diff --git a/.gitignore b/.gitignore index 0ead4b15e6..b792eb8f3b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ # pre-commit-hooks /.pre-commit-config.yaml + +# Python virtual env +.venv/ +venv/ diff --git a/Cargo.lock b/Cargo.lock index 0c4eb18001..842c6f4408 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da2d6f23ffea9d7e76c53eee25dfb67bcd8fde7f1198b0855350698c9f07c780" + [[package]] name = "inferno" version = "0.11.4" @@ -1695,6 +1701,74 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyckel" +version = "0.3.1" +dependencies = [ + "nickel-lang", + "pyo3", +] + +[[package]] +name = "pyo3" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "268be0c73583c183f2b14052337465768c07726936a260f480f0857cb95ba543" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot 0.12.1", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28fcd1e73f06ec85bf3280c48c67e731d8290ad3d730f8be9dc07946923005c8" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6cb136e222e49115b3c51c32792886defbfb0adead26a688142b346a0b9ffc" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94144a1266e236b1c932682136dc35a9dee8d3589728f68130c7c3861ef96b28" +dependencies = [ + "proc-macro2 1.0.39", + "pyo3-macros-backend", + "quote 1.0.18", + "syn 1.0.95", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8df9be978a2d2f0cdebabb03206ed73b11314701a5bfe71b0d753b81997777f" +dependencies = [ + "proc-macro2 1.0.39", + "quote 1.0.18", + "syn 1.0.95", +] + [[package]] name = "quick-xml" version = "0.23.0" @@ -2216,6 +2290,12 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "target-lexicon" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9410d0f6853b1d94f0e519fb95df60f29d2c1eff2d921ffdf01a4c8a3b54f12d" + [[package]] name = "tempfile" version = "3.3.0" @@ -2450,6 +2530,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "unindent" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" + [[package]] name = "url" version = "2.2.2" diff --git a/Cargo.toml b/Cargo.toml index c46f0e031e..d68dc0d934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ members = [ "lsp/nls", "utilities", "nickel-wasm-repl", + "pyckel", ] # Enable this to use flamegraphs diff --git a/flake.nix b/flake.nix index c27c587a9a..2f80039535 100644 --- a/flake.nix +++ b/flake.nix @@ -189,29 +189,38 @@ # Customize source filtering as Nickel uses non-standard-Rust files like `*.lalrpop`. src = filterNickelSrc craneLib.filterCargoSources; - # Args passed to all `cargo` invocations by Crane. - cargoExtraArgs = "--frozen --offline --workspace"; - in - rec { + # set of cargo args common to all builds + cargoBuildExtraArgs = "--frozen --offline"; + # Build *just* the cargo dependencies, so we can reuse all of that work (e.g. via cachix) when running in CI cargoArtifacts = craneLib.buildDepsOnly { - inherit - src - cargoExtraArgs; + inherit src; + cargoExtraArgs = "${cargoBuildExtraArgs} --workspace"; + # pyo3 needs a Python interpreter in the build environment + # https://pyo3.rs/v0.17.3/building_and_distribution#configuring-the-python-version + buildInputs = [ pkgs.python3 ]; }; - nickel = craneLib.buildPackage { - inherit - src - cargoExtraArgs - cargoArtifacts; - }; + buildPackage = packageName: + craneLib.buildPackage { + inherit + src + cargoArtifacts; + + cargoExtraArgs = "${cargoBuildExtraArgs} --package ${packageName}"; + }; + + + in + rec { + nickel = buildPackage "nickel-lang"; + lsp-nls = buildPackage "nickel-lang-lsp"; + nickel-wasm-repl = buildPackage "nickel-repl"; rustfmt = craneLib.cargoFmt { # Notice that unlike other Crane derivations, we do not pass `cargoArtifacts` to `cargoFmt`, because it does not need access to dependencies to format the code. inherit src; - # We don't reuse the `cargoExtraArgs` in scope because `cargo fmt` does not accept nor need any of `--frozen`, `--offline` or `--workspace` cargoExtraArgs = "--all"; # `-- --check` is automatically prepended by Crane @@ -221,12 +230,11 @@ clippy = craneLib.cargoClippy { inherit src - cargoExtraArgs cargoArtifacts; + cargoExtraArgs = cargoBuildExtraArgs; cargoClippyExtraArgs = "--all-targets -- --deny warnings --allow clippy::new-without-default --allow clippy::match_like_matches_macro"; }; - }; makeDevShell = { rust }: pkgs.mkShell { @@ -241,6 +249,7 @@ pkgs.nodejs pkgs.node2nix pkgs.nodePackages.markdownlint-cli + pkgs.python3 ]; shellHook = (pre-commit-builder { inherit rust; checkFormat = true; }).shellHook + '' @@ -395,6 +404,8 @@ checks = { inherit (mkCraneArtifacts { }) nickel + lsp-nls + nickel-wasm-repl clippy rustfmt; # An optimizing release build is long: eschew optimizations in checks by diff --git a/pyckel/Cargo.toml b/pyckel/Cargo.toml new file mode 100644 index 0000000000..9869bfcce9 --- /dev/null +++ b/pyckel/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pyckel" +version = "0.3.1" +authors = ["Nickel team"] +license = "MIT" +readme = "README.md" +description = "Python bindings for the Nickel programming language." +homepage = "https://nickel-lang.org" +repository = "https://github.com/tweag/nickel" +keywords = ["configuration", "language", "nix", "nickel"] +edition = "2018" + +[dependencies] +nickel-lang = {default-features = false, path = "../", version = "0.3.1" } +pyo3 = { version = "0.17.3", features = ["extension-module"] } + +[lib] +crate-type = ["cdylib", "rlib"] diff --git a/pyckel/README.md b/pyckel/README.md new file mode 100644 index 0000000000..233c1808bd --- /dev/null +++ b/pyckel/README.md @@ -0,0 +1,21 @@ +# pyckel + +Python bindings to use Nickel. + +## Install + +```shell +pip install . +``` + +## Use + +```python +import pyckel + +result = pyckel.run("let x = 1 in { y = x + 2 }") +print(result) +# { +# "y": 3 +# } +``` diff --git a/pyckel/pyproject.toml b/pyckel/pyproject.toml new file mode 100644 index 0000000000..58b2e62a23 --- /dev/null +++ b/pyckel/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = ["maturin>=0.14,<0.15"] +build-backend = "maturin" + +[tool.maturin] + diff --git a/pyckel/src/lib.rs b/pyckel/src/lib.rs new file mode 100644 index 0000000000..0fb180939a --- /dev/null +++ b/pyckel/src/lib.rs @@ -0,0 +1,68 @@ +use std::io::Cursor; + +use nickel_lang::{ + error::{Error, SerializationError}, + eval::cache::CBNCache, + program::Program, + serialize, +}; + +use pyo3::{create_exception, exceptions::PyException, prelude::*}; + +create_exception!(pyckel, NickelException, PyException); + +// see https://pyo3.rs/v0.17.3/function/error_handling.html#foreign-rust-error-types +struct NickelError(Error); +struct NickelSerializationError(SerializationError); + +impl From for NickelError { + fn from(value: Error) -> Self { + Self(value) + } +} + +impl std::convert::From for PyErr { + fn from(err: NickelError) -> PyErr { + match err { + // TODO better exceptions + NickelError(error) => NickelException::new_err(format!("{error:?}")), + } + } +} + +impl From for NickelSerializationError { + fn from(value: SerializationError) -> Self { + Self(value) + } +} + +impl std::convert::From for PyErr { + fn from(err: NickelSerializationError) -> PyErr { + match err { + // TODO better exceptions + NickelSerializationError(error) => NickelException::new_err(format!("{error:?}")), + } + } +} + +/// Evaluate from a Python str of a Nickel expression to a Python str of the resulting JSON. +#[pyfunction] +pub fn run(s: String) -> PyResult { + let mut program: Program = + Program::new_from_source(Cursor::new(s.to_string()), "python")?; + + let term = program.eval_full().map_err(NickelError)?; + + serialize::validate(serialize::ExportFormat::Json, &term).map_err(NickelSerializationError)?; + + let json_string = serialize::to_string(serialize::ExportFormat::Json, &term) + .map_err(NickelSerializationError)?; + + Ok(json_string) +} + +#[pymodule] +pub fn pyckel(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(run, m)?)?; + Ok(()) +}