feat: py bindings refactoring (#317)

* feat: py bindings refactoring

* unset version in pyproject.toml
This commit is contained in:
stefan-gorules
2025-02-16 20:19:02 +01:00
committed by GitHub
parent 2da4b69441
commit ff34aff821
11 changed files with 240 additions and 144 deletions
+43
View File
@@ -0,0 +1,43 @@
use anyhow::Context;
use pyo3::prelude::{PyAnyMethods, PyStringMethods};
use pyo3::types::PyString;
use pyo3::{pyclass, pymethods, Bound, FromPyObject, PyAny, PyResult};
use pythonize::depythonize;
use std::sync::Arc;
use zen_engine::model::DecisionContent;
#[pyclass]
#[pyo3(name = "ZenDecisionContent")]
pub struct PyZenDecisionContent(pub Arc<DecisionContent>);
#[pymethods]
impl PyZenDecisionContent {
#[new]
pub fn new(data: &str) -> PyResult<Self> {
let content = serde_json::from_str(data).context("Failed to parse JSON")?;
Ok(Self(Arc::new(content)))
}
}
pub struct PyZenDecisionContentJson(pub PyZenDecisionContent);
impl<'py> FromPyObject<'py> for PyZenDecisionContentJson {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
if let Ok(s) = ob.downcast::<PyZenDecisionContent>() {
let borrow_ref = s.borrow();
let content = borrow_ref.0.clone();
return Ok(Self(PyZenDecisionContent(content)));
}
if let Ok(b) = ob.downcast::<PyString>() {
let str = b.to_str()?;
let content = serde_json::from_str(str).context("Invalid JSON")?;
return Ok(Self(PyZenDecisionContent(Arc::new(content))));
}
let content = depythonize(ob)?;
Ok(Self(PyZenDecisionContent(Arc::new(content))))
}
}
+10 -22
View File
@@ -5,16 +5,14 @@ use crate::engine::PyZenEvaluateOptions;
use crate::loader::PyDecisionLoader;
use crate::mt::worker_pool;
use crate::value::PyValue;
use crate::variable::PyVariable;
use anyhow::{anyhow, Context};
use pyo3::types::PyDict;
use pyo3::{pyclass, pymethods, Bound, IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pyo3::{pyclass, pymethods, IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pyo3_async_runtimes::tokio;
use pyo3_async_runtimes::tokio::get_current_locals;
use pyo3_async_runtimes::tokio::re_exports::runtime::Runtime;
use pythonize::depythonize;
use serde_json::Value;
use zen_engine::{Decision, EvaluationOptions};
use zen_expression::Variable;
#[pyclass]
#[pyo3(name = "ZenDecision")]
@@ -32,22 +30,16 @@ impl PyZenDecision {
pub fn evaluate(
&self,
py: Python,
ctx: &Bound<'_, PyDict>,
opts: Option<&Bound<'_, PyDict>>,
ctx: PyVariable,
opts: Option<PyZenEvaluateOptions>,
) -> PyResult<Py<PyAny>> {
let context: Variable = depythonize(ctx).context("Failed to convert dict")?;
let options: PyZenEvaluateOptions = if let Some(op) = opts {
depythonize(op).context("Failed to convert dict")?
} else {
Default::default()
};
let options = opts.unwrap_or_default();
let decision = self.0.clone();
let rt = Runtime::new()?;
let result = rt
.block_on(decision.evaluate_with_opts(
context,
ctx.into_inner(),
EvaluationOptions {
max_depth: options.max_depth,
trace: options.trace,
@@ -65,15 +57,11 @@ impl PyZenDecision {
pub fn async_evaluate<'py>(
&'py self,
py: Python<'py>,
ctx: &Bound<'_, PyDict>,
opts: Option<&Bound<'_, PyDict>>,
ctx: PyValue,
opts: Option<PyZenEvaluateOptions>,
) -> PyResult<Py<PyAny>> {
let context: Value = depythonize(ctx).context("Failed to convert dict")?;
let options: PyZenEvaluateOptions = if let Some(op) = opts {
depythonize(op).context("Failed to convert dict")?
} else {
Default::default()
};
let context: Value = ctx.0;
let options = opts.unwrap_or_default();
let decision = self.0.clone();
let result = tokio::future_into_py_with_locals(py, get_current_locals(py)?, async move {
+27 -39
View File
@@ -1,22 +1,21 @@
use std::sync::Arc;
use crate::content::PyZenDecisionContentJson;
use crate::custom_node::PyCustomNode;
use crate::decision::PyZenDecision;
use crate::loader::PyDecisionLoader;
use crate::mt::{block_on, worker_pool};
use crate::value::PyValue;
use crate::variable::PyVariable;
use anyhow::{anyhow, Context};
use pyo3::prelude::PyDictMethods;
use pyo3::types::PyDict;
use pyo3::{pyclass, pymethods, Bound, IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pyo3::{pyclass, pymethods, Bound, FromPyObject, IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pyo3_async_runtimes::tokio::get_current_locals;
use pyo3_async_runtimes::{tokio, TaskLocals};
use pythonize::depythonize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use zen_engine::model::DecisionContent;
use zen_engine::{DecisionEngine, EvaluationOptions};
use zen_expression::Variable;
#[pyclass]
#[pyo3(name = "ZenEngine")]
@@ -24,7 +23,7 @@ pub struct PyZenEngine {
engine: Arc<DecisionEngine<PyDecisionLoader, PyCustomNode>>,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, FromPyObject)]
pub struct PyZenEvaluateOptions {
pub trace: Option<bool>,
pub max_depth: Option<u8>,
@@ -60,24 +59,26 @@ impl PyZenEngine {
};
let loader = match options.get_item("loader")? {
Some(loader) => Some(Python::with_gil(|py| loader.into_py_any(py))?),
Some(loader) => Some(loader.into_py_any(py)?),
None => None,
};
let custom_node = match options.get_item("customHandler")? {
Some(custom_node) => Some(Python::with_gil(|py| custom_node.into_py_any(py))?),
Some(custom_node) => Some(custom_node.into_py_any(py)?),
None => None,
};
let task_locals = TaskLocals::with_running_loop(py)
.ok()
.map(|s| s.copy_context(py).ok())
.flatten();
let make_locals = || {
TaskLocals::with_running_loop(py)
.ok()
.map(|s| s.copy_context(py).ok())
.flatten()
};
Ok(Self {
engine: Arc::new(DecisionEngine::new(
Arc::new(PyDecisionLoader::from(loader)),
Arc::new(PyCustomNode::new(custom_node, task_locals)),
Arc::new(PyDecisionLoader::new(loader, make_locals())),
Arc::new(PyCustomNode::new(custom_node, make_locals())),
)),
})
}
@@ -86,20 +87,14 @@ impl PyZenEngine {
pub fn evaluate(
&self,
py: Python,
key: String,
ctx: &Bound<'_, PyDict>,
opts: Option<&Bound<'_, PyDict>>,
key: &str,
ctx: PyVariable,
opts: Option<PyZenEvaluateOptions>,
) -> PyResult<Py<PyAny>> {
let context: Variable = depythonize(ctx).context("Failed to convert dict")?;
let options: PyZenEvaluateOptions = if let Some(op) = opts {
depythonize(op).context("Failed to convert dict")?
} else {
Default::default()
};
let options = opts.unwrap_or_default();
let result = block_on(self.engine.evaluate_with_opts(
key,
context,
ctx.into_inner(),
EvaluationOptions {
max_depth: options.max_depth,
trace: options.trace,
@@ -118,15 +113,11 @@ impl PyZenEngine {
&'py self,
py: Python<'py>,
key: String,
ctx: &Bound<'_, PyDict>,
opts: Option<&Bound<'_, PyDict>>,
ctx: PyValue,
opts: Option<PyZenEvaluateOptions>,
) -> PyResult<Py<PyAny>> {
let context: Value = depythonize(ctx).context("Failed to convert dict")?;
let options: PyZenEvaluateOptions = if let Some(op) = opts {
depythonize(op).context("Failed to convert dict")?
} else {
Default::default()
};
let context: Value = ctx.0;
let options: PyZenEvaluateOptions = opts.unwrap_or_default();
let engine = self.engine.clone();
let result = tokio::future_into_py_with_locals(py, get_current_locals(py)?, async move {
@@ -157,16 +148,13 @@ impl PyZenEngine {
Ok(result.unbind())
}
pub fn create_decision(&self, content: String) -> PyResult<PyZenDecision> {
let decision_content: DecisionContent =
serde_json::from_str(&content).context("Failed to serialize decision content")?;
let decision = self.engine.create_decision(decision_content.into());
pub fn create_decision(&self, content: PyZenDecisionContentJson) -> PyResult<PyZenDecision> {
let decision = self.engine.create_decision(content.0 .0);
Ok(PyZenDecision::from(decision))
}
pub fn get_decision<'py>(&'py self, _py: Python<'py>, key: String) -> PyResult<PyZenDecision> {
let decision = block_on(self.engine.get_decision(&key))
pub fn get_decision<'py>(&'py self, _py: Python<'py>, key: &str) -> PyResult<PyZenDecision> {
let decision = block_on(self.engine.get_decision(key))
.context("Failed to find decision with given key")?;
Ok(PyZenDecision::from(decision))
+21 -41
View File
@@ -1,15 +1,14 @@
use crate::variable::PyVariable;
use anyhow::{anyhow, Context};
use anyhow::anyhow;
use either::Either;
use pyo3::types::PyDict;
use pyo3::{pyclass, pyfunction, pymethods, Bound, IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pythonize::{depythonize, pythonize};
use pyo3::{pyclass, pyfunction, pymethods, IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pythonize::pythonize;
use zen_expression::expression::{Standard, Unary};
use zen_expression::{Expression, Variable};
#[pyfunction]
pub fn compile_expression(expression: String) -> PyResult<PyExpression> {
let expr = zen_expression::compile_expression(expression.as_str())
pub fn compile_expression(expression: &str) -> PyResult<PyExpression> {
let expr = zen_expression::compile_expression(expression)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(PyExpression {
@@ -18,8 +17,8 @@ pub fn compile_expression(expression: String) -> PyResult<PyExpression> {
}
#[pyfunction]
pub fn compile_unary_expression(expression: String) -> PyResult<PyExpression> {
let expr = zen_expression::compile_unary_expression(expression.as_str())
pub fn compile_unary_expression(expression: &str) -> PyResult<PyExpression> {
let expr = zen_expression::compile_unary_expression(expression)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(PyExpression {
@@ -31,42 +30,28 @@ pub fn compile_unary_expression(expression: String) -> PyResult<PyExpression> {
#[pyo3(signature = (expression, ctx=None))]
pub fn evaluate_expression(
py: Python,
expression: String,
ctx: Option<&Bound<'_, PyDict>>,
expression: &str,
ctx: Option<PyVariable>,
) -> PyResult<Py<PyAny>> {
let context = ctx
.map(|ctx| depythonize(ctx))
.transpose()
.context("Failed to convert context")?
.unwrap_or(Variable::Null);
let context = ctx.map(|c| c.into_inner()).unwrap_or(Variable::Null);
let result = zen_expression::evaluate_expression(expression.as_str(), context)
let result = zen_expression::evaluate_expression(expression, context)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
PyVariable(result).into_py_any(py)
}
#[pyfunction]
pub fn evaluate_unary_expression(expression: String, ctx: &Bound<'_, PyDict>) -> PyResult<bool> {
let context: Variable = depythonize(ctx).context("Failed to convert context")?;
let result = zen_expression::evaluate_unary_expression(expression.as_str(), context)
pub fn evaluate_unary_expression(expression: &str, ctx: PyVariable) -> PyResult<bool> {
let result = zen_expression::evaluate_unary_expression(expression, ctx.into_inner())
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(result)
}
#[pyfunction]
pub fn render_template(
py: Python,
template: String,
ctx: &Bound<'_, PyDict>,
) -> PyResult<Py<PyAny>> {
let context: Variable = depythonize(ctx)
.context("Failed to convert context")
.unwrap_or(Variable::Null);
let result = zen_tmpl::render(template.as_str(), context)
pub fn render_template(py: Python, template: &str, ctx: PyVariable) -> PyResult<Py<PyAny>> {
let result = zen_tmpl::render(template, ctx.into_inner())
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
PyVariable(result).into_py_any(py)
@@ -79,13 +64,8 @@ pub struct PyExpression {
#[pymethods]
impl PyExpression {
#[pyo3(signature = (ctx=None))]
pub fn evaluate(&self, py: Python, ctx: Option<&Bound<'_, PyDict>>) -> PyResult<Py<PyAny>> {
let context = ctx
.map(|ctx| depythonize(ctx))
.transpose()
.context("Failed to convert context")?
.unwrap_or(Variable::Null);
pub fn evaluate(&self, py: Python, ctx: Option<PyVariable>) -> PyResult<Py<PyAny>> {
let context = ctx.map(|c| c.into_inner()).unwrap_or(Variable::Null);
let maybe_result = match &self.expression {
Either::Left(standard) => standard.evaluate(context),
Either::Right(unary) => unary.evaluate(context).map(Variable::Bool),
@@ -99,8 +79,8 @@ impl PyExpression {
}
#[pyfunction]
pub fn validate_expression(py: Python, expression: String) -> PyResult<Option<Py<PyAny>>> {
let Err(error) = zen_expression::validate::validate_expression(expression.as_str()) else {
pub fn validate_expression(py: Python, expression: &str) -> PyResult<Option<Py<PyAny>>> {
let Err(error) = zen_expression::validate::validate_expression(expression) else {
return Ok(None);
};
@@ -108,8 +88,8 @@ pub fn validate_expression(py: Python, expression: String) -> PyResult<Option<Py
}
#[pyfunction]
pub fn validate_unary_expression(py: Python, expression: String) -> PyResult<Option<Py<PyAny>>> {
let Err(error) = zen_expression::validate::validate_expression(expression.as_str()) else {
pub fn validate_unary_expression(py: Python, expression: &str) -> PyResult<Option<Py<PyAny>>> {
let Err(error) = zen_expression::validate::validate_expression(expression) else {
return Ok(None);
};
+3
View File
@@ -1,3 +1,4 @@
use crate::content::PyZenDecisionContent;
use crate::decision::PyZenDecision;
use crate::engine::PyZenEngine;
use crate::expression::{
@@ -8,6 +9,7 @@ use pyo3::prelude::PyModuleMethods;
use pyo3::types::PyModule;
use pyo3::{pymodule, wrap_pyfunction, Bound, PyResult, Python};
mod content;
mod custom_node;
mod decision;
mod engine;
@@ -23,6 +25,7 @@ fn zen(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyZenEngine>()?;
m.add_class::<PyZenDecision>()?;
m.add_class::<PyExpression>()?;
m.add_class::<PyZenDecisionContent>()?;
m.add_function(wrap_pyfunction!(evaluate_expression, m)?)?;
m.add_function(wrap_pyfunction!(evaluate_unary_expression, m)?)?;
m.add_function(wrap_pyfunction!(render_template, m)?)?;
+45 -19
View File
@@ -1,46 +1,72 @@
use std::future::Future;
use std::sync::Arc;
use crate::content::PyZenDecisionContentJson;
use anyhow::anyhow;
use pyo3::{Py, PyAny, PyObject, Python};
use either::Either;
use pyo3::{IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pyo3_async_runtimes::TaskLocals;
use zen_engine::loader::{DecisionLoader, LoaderError, LoaderResponse};
use zen_engine::model::DecisionContent;
#[derive(Default)]
pub(crate) struct PyDecisionLoader(Option<Py<PyAny>>);
impl From<PyObject> for PyDecisionLoader {
fn from(value: PyObject) -> Self {
Self(Some(value))
}
pub(crate) struct PyDecisionLoader {
callback: Option<Py<PyAny>>,
task_locals: Option<TaskLocals>,
}
impl From<Option<Py<PyAny>>> for PyDecisionLoader {
fn from(value: Option<Py<PyAny>>) -> Self {
Self(value)
impl PyDecisionLoader {
pub fn new(callback: Option<Py<PyAny>>, task_locals: Option<TaskLocals>) -> Self {
Self {
callback,
task_locals,
}
}
}
impl PyDecisionLoader {
fn load_element(&self, key: &str) -> Result<Arc<DecisionContent>, anyhow::Error> {
let Some(object) = &self.0 else {
async fn load_element(&self, key: &str) -> Result<Arc<DecisionContent>, anyhow::Error> {
let Some(callable) = &self.callback else {
return Err(anyhow!("Loader is not defined"));
};
let content = Python::with_gil(|py| {
let result = object.call1(py, (key,))?;
result.extract::<String>(py)
})?;
let maybe_result: PyResult<_> = Python::with_gil(|py| {
let result = callable.call1(py, (key,))?;
let is_coroutine = result.getattr(py, "__await__").is_ok();
if !is_coroutine {
return Ok(Either::Left(
result.extract::<PyZenDecisionContentJson>(py)?,
));
}
Ok(serde_json::from_str::<DecisionContent>(&content)?.into())
let Some(task_locals) = &self.task_locals else {
Err(anyhow!("Task locals are required in async context"))?
};
let result_future = pyo3_async_runtimes::into_future_with_locals(
task_locals,
result.into_bound_py_any(py)?,
)?;
Ok(Either::Right(result_future))
});
match maybe_result? {
Either::Left(result) => Ok(result.0 .0),
Either::Right(future) => {
let result = future.await?;
let content =
Python::with_gil(|py| result.extract::<PyZenDecisionContentJson>(py))?;
Ok(content.0 .0)
}
}
}
}
impl DecisionLoader for PyDecisionLoader {
fn load<'a>(&'a self, key: &'a str) -> impl Future<Output = LoaderResponse> + 'a {
async move {
self.load_element(key).map_err(|e| {
self.load_element(key).await.map_err(|e| {
LoaderError::Internal {
source: e,
key: key.to_string(),
+4 -4
View File
@@ -60,11 +60,11 @@ impl PyNodeRequest {
#[pymethods]
impl PyNodeRequest {
fn get_field(&self, py: Python, path: String) -> PyResult<Py<PyAny>> {
fn get_field(&self, py: Python, path: &str) -> PyResult<Py<PyAny>> {
let node_config = &self.inner_node.config;
let selected_value: Value = node_config
.dot_get(path.as_str())
.dot_get(path)
.ok()
.flatten()
.context("Failed to find JSON path")?;
@@ -78,11 +78,11 @@ impl PyNodeRequest {
PyVariable(template_value).into_py_any(py)
}
fn get_field_raw(&self, py: Python, path: String) -> PyResult<Py<PyAny>> {
fn get_field_raw(&self, py: Python, path: &str) -> PyResult<Py<PyAny>> {
let node_config = &self.inner_node.config;
let selected_value: Value = node_config
.dot_get(path.as_str())
.dot_get(path)
.ok()
.flatten()
.context("Failed to find JSON path")?;
+26 -3
View File
@@ -1,6 +1,8 @@
use pyo3::prelude::{PyDictMethods, PyListMethods};
use pyo3::types::{PyDict, PyList};
use pyo3::{Bound, IntoPyObject, IntoPyObjectExt, PyAny, PyErr, PyResult, Python};
use anyhow::Context;
use pyo3::prelude::{PyAnyMethods, PyBytesMethods, PyDictMethods, PyListMethods, PyStringMethods};
use pyo3::types::{PyBytes, PyDict, PyList, PyString};
use pyo3::{Bound, FromPyObject, IntoPyObject, IntoPyObjectExt, PyAny, PyErr, PyResult, Python};
use pythonize::depythonize;
use serde_json::Value;
#[repr(transparent)]
@@ -46,3 +48,24 @@ impl<'py> IntoPyObject<'py> for PyValue {
value_to_object(py, &self.0)
}
}
impl<'py> FromPyObject<'py> for PyValue {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
if let Ok(s) = ob.downcast::<PyString>() {
let str_slice = s.to_str()?;
let var = serde_json::from_str(str_slice).context("Invalid JSON")?;
return Ok(PyValue(var));
}
if let Ok(b) = ob.downcast::<PyBytes>() {
let bytes = b.as_bytes();
let var = serde_json::from_slice(bytes).context("Invalid JSON")?;
return Ok(PyValue(var));
}
let var = depythonize(ob)?;
Ok(PyValue(var))
}
}
+32 -3
View File
@@ -1,6 +1,8 @@
use pyo3::prelude::{PyDictMethods, PyListMethods};
use pyo3::types::{PyDict, PyList};
use pyo3::{Bound, IntoPyObject, IntoPyObjectExt, PyAny, PyErr, PyResult, Python};
use anyhow::Context;
use pyo3::prelude::{PyAnyMethods, PyBytesMethods, PyDictMethods, PyListMethods, PyStringMethods};
use pyo3::types::{PyBytes, PyDict, PyList, PyString};
use pyo3::{Bound, FromPyObject, IntoPyObject, IntoPyObjectExt, PyAny, PyErr, PyResult, Python};
use pythonize::depythonize;
use rust_decimal::prelude::ToPrimitive;
use zen_expression::Variable;
@@ -8,6 +10,12 @@ use zen_expression::Variable;
#[derive(Clone, Debug)]
pub struct PyVariable(pub Variable);
impl PyVariable {
pub fn into_inner(self) -> Variable {
self.0
}
}
pub fn variable_to_object<'py>(py: Python<'py>, val: &Variable) -> PyResult<Bound<'py, PyAny>> {
match val {
Variable::Null => py.None().into_bound_py_any(py),
@@ -49,3 +57,24 @@ impl<'py> IntoPyObject<'py> for PyVariable {
variable_to_object(py, &self.0)
}
}
impl<'py> FromPyObject<'py> for PyVariable {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
if let Ok(s) = ob.downcast::<PyString>() {
let str_slice = s.to_str()?;
let var = serde_json::from_str(str_slice).context("Invalid JSON")?;
return Ok(PyVariable(var));
}
if let Ok(b) = ob.downcast::<PyBytes>() {
let bytes = b.as_bytes();
let var = serde_json::from_slice(bytes).context("Invalid JSON")?;
return Ok(PyVariable(var));
}
let var = depythonize(ob)?;
Ok(PyVariable(var))
}
}