diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml new file mode 100644 index 0000000..b8d3e3a --- /dev/null +++ b/.github/workflows/publish-python.yml @@ -0,0 +1,113 @@ +# This file is autogenerated by maturin v1.4.0 +# To update, run +# +# maturin generate-ci github --zig +# +name: publish-python + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter --zig + sccache: 'true' + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + windows: + runs-on: windows-latest + strategy: + matrix: + target: [x64, x86] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + macos: + runs-on: macos-latest + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v3 + with: + name: wheels + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing * diff --git a/.github/workflows/publish-wasm.yml b/.github/workflows/publish-wasm.yml index d7eb4b3..e8698f8 100644 --- a/.github/workflows/publish-wasm.yml +++ b/.github/workflows/publish-wasm.yml @@ -15,9 +15,17 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + # Setup .npmrc file to publish to npm + - uses: actions/setup-node@v3 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - name: Build run: wasm-pack build --target nodejs -r - name: Publish - run: # TODO + run: wasm-pack publish --target nodejs + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + diff --git a/Cargo.toml b/Cargo.toml index 9f73122..2bd61db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,10 @@ -workspace = { members = ["bindings/wasm"] } +[workspace] + +members = [ + "bindings/python", + "bindings/wasm" +] + [package] name = "regorus" description = "A fast, lightweight Rego (OPA policy language) interpreter" diff --git a/README.md b/README.md index fa91f26..50d33d0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ builtins. See [OPA Conformance](#opa-conformance) below. Regorus can be used from a variety of languages: -- Javascript (nodejs): Via npm package `regorus-wasm`. This package is Regorus compiled into WASM. +- Javascript: Via npm package `regorusjs`. This package is Regorus compiled into WASM. +- Python: Via `regorus` package. ## Getting Started diff --git a/bindings/python/.python-version b/bindings/python/.python-version new file mode 100644 index 0000000..4d11fba --- /dev/null +++ b/bindings/python/.python-version @@ -0,0 +1 @@ +pyo3 diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 0000000..2e58963 --- /dev/null +++ b/bindings/python/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "regoruspy" +version = "0.1.0" +edition = "2021" +repository = "https://github.com/microsoft/regorus/bindings/python" +description = "Python bindings for Regorus - a fast, lightweight Rego interpreter written in Rust" +keywords = ["interpreter", "opa", "policy-as-code", "rego"] + + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1.0.79" +ordered-float = "4.2.0" +pyo3 = {version = "0.20.2", features = ["anyhow", "extension-module"] } +regorus = { path = "../.." } +serde_json = "1.0.112" + diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml new file mode 100644 index 0000000..a6ec29e --- /dev/null +++ b/bindings/python/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.4,<2.0"] +build-backend = "maturin" + +[project] +name = "regorus" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 0000000..4762393 --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +use anyhow::{anyhow, Result}; +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use pyo3::types::*; + +use std::collections::{BTreeMap, BTreeSet}; + +use regorus::Value; + +/// Python wrapper for [`regorus::Engine`] +#[pyclass(unsendable)] +pub struct Engine { + engine: regorus::Engine, +} + +impl Default for Engine { + fn default() -> Self { + Self::new() + } +} + +impl Clone for Engine { + /// Clone a [`Engine`] + /// + /// To avoid having to parse same policy again, the engine can be cloned + /// after policies and data have been added. + fn clone(&self) -> Self { + Self { + engine: self.engine.clone(), + } + } +} + +fn from<'source>(ob: &'source PyAny) -> Result { + // dicts + Ok(if let Ok(dict) = ob.downcast::() { + let mut map = BTreeMap::new(); + for (k, v) in dict { + map.insert(from(k)?, from(v)?); + } + map.into() + } + // set + else if let Ok(pset) = ob.downcast::() { + let mut set = BTreeSet::new(); + for v in pset { + set.insert(from(v)?); + } + set.into() + } + // frozen set + else if let Ok(pfset) = ob.downcast::() { + // + let mut set = BTreeSet::new(); + for v in pfset { + set.insert(from(v)?); + } + set.into() + } + // lists and tuples + else if let Ok(plist) = ob.downcast::() { + let mut array = Vec::new(); + for v in plist { + array.push(from(v)?); + } + array.into() + } else if let Ok(ptuple) = ob.downcast::() { + let mut array = Vec::new(); + for v in ptuple { + array.push(from(v)?); + } + array.into() + } + // String + else if let Ok(s) = String::extract(ob) { + s.into() + } + // Numeric + else if let Ok(v) = i64::extract(ob) { + v.into() + } else if let Ok(v) = u64::extract(ob) { + v.into() + } else if let Ok(v) = f64::extract(ob) { + v.into() + } + // Boolean + else if let Ok(b) = bool::extract(ob) { + b.into() + } + // None + else if ob.downcast::().is_ok() { + Value::Null + } + // Anything that is a sequence + else if let Ok(pseq) = ob.downcast::() { + let mut array = Vec::new(); + for i in 0..pseq.len()? { + array.push(from(pseq.get_item(i)?)?); + } + array.into() + } + // Anything that is a map + else if let Ok(pmap) = ob.downcast::() { + let mut map = BTreeMap::new(); + let keys = pmap.keys()?; + let values = pmap.values()?; + for i in 0..keys.len()? { + let key = keys.get_item(i)?; + let value = values.get_item(i)?; + map.insert(from(key)?, from(value)?); + } + map.into() + } else { + return Err(PyErr::new::( + "object cannot be converted to RegoValue", + )); + }) +} + +fn to(mut v: Value, py: Python<'_>) -> Result { + Ok(match v { + Value::Null => None::.to_object(py), + + // TODO: Revisit this mapping + Value::Undefined => None::.to_object(py), + + Value::Bool(b) => b.to_object(py), + Value::String(s) => s.to_object(py), + + Value::Number(_) => { + if let Ok(f) = v.as_f64() { + f.to_object(py) + } else if let Ok(u) = v.as_u64() { + u.to_object(py) + } else { + v.as_i64()?.to_object(py) + } + } + + Value::Array(_) => { + let list = PyList::empty(py); + for v in std::mem::replace(v.as_array_mut()?, Vec::new()) { + list.append(to(v, py)?)?; + } + list.into() + } + + Value::Set(_) => { + let set = PySet::empty(py)?; + for v in std::mem::replace(v.as_set_mut()?, BTreeSet::new()) { + set.add(to(v, py)?)?; + } + set.into() + } + + Value::Object(_) => { + let dict = PyDict::new(py); + for (k, v) in std::mem::replace(v.as_object_mut()?, BTreeMap::new()) { + dict.set_item(to(k, py)?, to(v, py)?)?; + } + dict.into() + } + }) +} + +#[pymethods] +impl Engine { + /// Construct a new Engine + #[new] + pub fn new() -> Self { + Self { + engine: regorus::Engine::new(), + } + } + + /// Add a policy + /// + /// The policy is parsed into AST. + /// + /// * `path`: A filename to be associated with the policy. + /// * `rego`: Rego policy. + pub fn add_policy(&mut self, path: String, rego: String) -> Result<()> { + self.engine.add_policy(path, rego)?; + Ok(()) + } + + /// Add policy data. + /// + /// * `data`: Rego value. A Rego value is a number, bool, string, None + /// or a list/set/map whose items themselves are Rego values. + pub fn add_data(&mut self, data: &PyAny) -> Result<()> { + let data = from(data)?; + self.engine.add_data(data) + } + + /// Add policy data. + /// + /// * `data`: JSON encoded value to be used as policy data. + pub fn add_data_json(&mut self, data: String) -> Result<()> { + let data = regorus::Value::from_json_str(&data)?; + self.engine.add_data(data) + } + + /// Clear policy data. + pub fn clear_data(&mut self) -> Result<()> { + self.engine.clear_data(); + Ok(()) + } + + /// Set input. + /// + /// * `input`: Rego value. A Rego value is a number, bool, string, None + /// or a list/set/map whose items themselves are Rego values. + pub fn set_input(&mut self, input: &PyAny) -> Result<()> { + let input = from(input)?; + self.engine.set_input(input); + Ok(()) + } + + /// Set input. + /// + /// * `input`: JSON encoded value to be used as input to query. + pub fn set_input_json(&mut self, input: String) -> Result<()> { + let input = regorus::Value::from_json_str(&input)?; + self.engine.set_input(input); + Ok(()) + } + + /// Evaluate query. + /// + /// * `query`: Rego expression to be evaluate. + pub fn eval_query(&mut self, query: String, py: Python<'_>) -> Result { + let results = self.engine.eval_query(query, false)?; + + let rlist = PyList::empty(py); + for result in results.result.into_iter() { + let rdict = PyDict::new(py); + + let elist = PyList::empty(py); + for expr in result.expressions.into_iter() { + let edict = PyDict::new(py); + edict.set_item("value".to_object(py), to(expr.value, py)?)?; + edict.set_item("text".to_object(py), expr.text.as_ref().to_object(py))?; + + let ldict = PyDict::new(py); + ldict.set_item("row".to_object(py), expr.location.row.to_object(py))?; + ldict.set_item("col".to_object(py), expr.location.col.to_object(py))?; + + edict.set_item("location".to_object(py), ldict)?; + elist.append(edict)?; + } + + rdict.set_item("expressions".to_object(py), elist)?; + rdict.set_item("bindings".to_object(py), to(result.bindings, py)?)?; + rlist.append(rdict)?; + } + let dict = PyDict::new(py); + dict.set_item("result".to_object(py), rlist)?; + Ok(dict.into()) + } + + /// Evaluate query. Returns result as JSON. + /// + /// * `query`: Rego expression to be evaluate. + pub fn eval_query_as_json(&mut self, query: String) -> Result { + let results = self.engine.eval_query(query, false)?; + serde_json::to_string_pretty(&results).map_err(|e| anyhow!("{e}")) + } +} + +mod export { + use pyo3::prelude::*; + #[pymodule] + fn regorus(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_class::() + } +} diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 719546a..03ad835 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "regorus-wasm" +name = "regorusjs" version = "0.1.0" edition = "2021" repository = "https://github.com/microsoft/regorus/bindings/wasm" diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md index aadb1db..b0dbd66 100644 --- a/bindings/wasm/README.md +++ b/bindings/wasm/README.md @@ -1,4 +1,4 @@ -# regorus-wasm +# regorusjs **Regorus** is @@ -8,21 +8,70 @@ See [Repository](https://github.com/microsoft/regorus). -`regorus-wasm` is Regorus compiled into WASM. +`regorusjs` is Regorus compiled into WASM. ## Usage -In nodejs, +```javascript -``javascript - -var regorus = require('regorus-wasm') +var regorus = require('regorusjs') // Create an engine. var engine = new regorus.Engine(); // Add Rego policy. -engine.add_policy() +engine.add_policy( + // Associate this file name with policy + 'hello.rego', + + // Rego policy +` + package test + + # Join messages + message = concat(", ", [input.message, data.message]) +`) +// Set policy data +engine.add_data_json(` + { + "message" : "World!" + } +`) +// Set policy input +engine.set_input_json(` + { + "message" : "Hello" + } +`) + +// Eval query +results = engine.eval_query('data.test.message') + +// Display +console.log(results) +// { +// "result": [ +// { +// "expressions": [ +// { +// "value": "Hello, World!", +// "text": "data.test.message", +// "location": { +// "row": 1, +// "col": 1 +// } +// } +// ] +// } +// ] +// } + +// Convert results to object +results = JSON.parse(results) + +// Process result +console.log(results.result[0].expressions[0].value) +// Hello, World! ``` diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index c2ea9ed..68539aa 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -63,6 +63,12 @@ impl Engine { self.engine.add_data(data).map_err(error_to_jsvalue) } + /// Clear policy data. + pub fn clear_data(&mut self) -> Result<(), JsValue> { + self.engine.clear_data(); + Ok(()) + } + /// Set input. /// /// See https://docs.rs/regorus/0.1.0-alpha.2/regorus/struct.Engine.html#method.set_input diff --git a/src/engine.rs b/src/engine.rs index 784ec23..8877d63 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -13,7 +13,7 @@ use crate::QueryResults; use std::convert::AsRef; use std::path::Path; -use anyhow::Result; +use anyhow::{bail, Result}; /// The Rego evaluation engine. /// @@ -175,6 +175,9 @@ impl Engine { /// # } /// ``` pub fn add_data(&mut self, data: Value) -> Result<()> { + if data.as_object().is_err() { + bail!("data must be object"); + } self.prepared = false; self.interpreter.get_data_mut().merge(data) }