diff --git a/README.md b/README.md index 5b71f9e..7166c1c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,21 @@ cel 'age >= 18' --context '{"age": 25}' # true cel --interactive ``` +### Pre-compilation for Performance + +When evaluating the same expression multiple times with different contexts, use `compile()` for better performance: + +```python +from cel import compile + +# Compile once +program = compile("price * quantity > threshold") + +# Execute many times - much faster than repeated evaluate() calls +result1 = program.execute({"price": 10, "quantity": 5, "threshold": 40}) # True +result2 = program.execute({"price": 5, "quantity": 3, "threshold": 20}) # False +``` + ### Custom Functions ```python diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index f36c304..ce531f0 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -90,6 +90,25 @@ assert result == "No phone" # → "No phone" (safe field checking prevents erro print("✓ Context variables working correctly") ``` +## Pre-compilation for Performance + +Use `compile()` when evaluating the same expression many times with different contexts: + +```python +from cel import compile + +# Compile once, execute many times +program = compile("price * quantity > threshold") + +result1 = program.execute({"price": 10, "quantity": 5, "threshold": 40}) +assert result1 == True # → True (50 > 40) + +result2 = program.execute({"price": 5, "quantity": 3, "threshold": 20}) +assert result2 == False # → False (15 > 20) + +print("Pre-compilation working correctly") +``` + ## Ready for More? You've mastered the basics of CEL evaluation with dictionary context! For advanced features like custom Python functions, context objects, and production patterns, continue to the next guide. diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index 39a680d..674c0d1 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -6,8 +6,125 @@ Complete autogenerated reference for the Python CEL library. ::: cel.evaluate +### compile(expression: str) -> Program -## Classes +Compile a CEL expression into a reusable Program object. + +This function parses and compiles a CEL expression, returning a Program object that can be executed multiple times with different contexts. This is more efficient than calling `evaluate()` repeatedly with the same expression. + +**Parameters:** +- `expression`: The CEL expression to compile + +**Returns:** +- A compiled `Program` object + +**Raises:** +- `ValueError`: If the expression has syntax errors or is malformed + +**Example:** +```python +from cel import compile + +# Compile once +program = compile("x + y") + +# Execute many times with different contexts +result1 = program.execute({"x": 1, "y": 2}) +assert result1 == 3 # → 3 + +result2 = program.execute({"x": 10, "y": 20}) +assert result2 == 30 # → 30 +``` + +**When to use `compile()` vs `evaluate()`:** + +- Use `evaluate()` for one-time evaluation or interactive/REPL usage +- Use `compile()` + `execute()` when evaluating the same expression with many different contexts or in performance-critical loops + +## Classes + +### Program + +**A compiled CEL program that can be executed multiple times with different contexts.** + +The Program class represents a pre-compiled CEL expression. Use this when you need to evaluate the same expression many times with different variable bindings. Compiling once and executing multiple times is significantly faster than calling `evaluate()` repeatedly. + +```python +from cel import compile + +# Compile the expression once +program = compile("price * quantity > 100") + +# Execute many times with different contexts +result1 = program.execute({"price": 10, "quantity": 20}) +assert result1 == True # → True (200 > 100) + +result2 = program.execute({"price": 5, "quantity": 10}) +assert result2 == False # → False (50 > 100) +``` + +#### Methods + +##### execute(context=None) -> Any + +Execute the compiled program with the given context. + +**Parameters:** +- `context`: Optional evaluation context (dict or Context object) + +**Returns:** +- The result of the expression evaluation + +**Raises:** +- `RuntimeError`: If a variable or function is undefined +- `TypeError`: If there's a type mismatch during execution +- `ValueError`: If the context is an invalid type + +**Example with dict context:** +```python +from cel import compile + +program = compile("user.name + ' is ' + user.role") +result = program.execute({ + "user": {"name": "Alice", "role": "admin"} +}) +assert result == "Alice is admin" +``` + +**Example with Context object:** +```python +from cel import compile, Context + +program = compile("greet(name)") + +ctx = Context() +ctx.add_variable("name", "World") +ctx.add_function("greet", lambda x: f"Hello, {x}!") + +result = program.execute(ctx) +assert result == "Hello, World!" +``` + +**Performance pattern - compile once, execute many:** +```python +from cel import compile + +# Access control policy - compiled once at startup +policy = compile( + 'user.role == "admin" || resource.owner == user.id' +) + +# Evaluated many times per request +def check_access(user, resource): + return policy.execute({"user": user, "resource": resource}) + +# Fast repeated evaluation +assert check_access({"id": "alice", "role": "admin"}, {"owner": "bob"}) == True +assert check_access({"id": "bob", "role": "user"}, {"owner": "bob"}) == True +assert check_access({"id": "charlie", "role": "user"}, {"owner": "bob"}) == False +``` + +--- ### Context diff --git a/src/lib.rs b/src/lib.rs index a5c92b2..e1adc53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,88 @@ use std::error::Error; use std::fmt; use std::sync::Arc; +/// A compiled CEL program that can be executed multiple times with different contexts. +/// +/// This is useful when you need to evaluate the same expression many times with different +/// variable bindings. Compiling once and executing multiple times is significantly faster +/// than calling `evaluate()` repeatedly. +/// +/// # Example +/// +/// ```python +/// from cel import compile +/// +/// # Compile once +/// program = compile("price * quantity > 100") +/// +/// # Execute many times with different contexts +/// result1 = program.execute({"price": 10, "quantity": 20}) # True +/// result2 = program.execute({"price": 5, "quantity": 10}) # False +/// ``` +#[pyclass(name = "Program")] +struct PyProgram { + program: Program, + source: String, +} + +#[pymethods] +impl PyProgram { + /// Execute the compiled program with the given context. + /// + /// Args: + /// context: Optional evaluation context (dict or Context object) + /// + /// Returns: + /// The result of the expression evaluation + #[pyo3(signature = (context=None))] + fn execute(&self, context: Option<&Bound<'_, PyAny>>) -> PyResult> { + execute_compiled_program(&self.program, &self.source, context) + } + + fn __repr__(&self) -> String { + format!("Program({:?})", self.source) + } +} + +/// Compile a CEL expression into a reusable Program object. +/// +/// This function parses and compiles a CEL expression, returning a Program object +/// that can be executed multiple times with different contexts. This is more efficient +/// than calling `evaluate()` repeatedly with the same expression. +/// +/// Args: +/// expression: The CEL expression to compile +/// +/// Returns: +/// A compiled Program object +/// +/// Raises: +/// ValueError: If the expression has syntax errors or is malformed +/// +/// Example: +/// >>> from cel import compile +/// >>> program = compile("x + y") +/// >>> program.execute({"x": 1, "y": 2}) +/// 3 +/// >>> program.execute({"x": 10, "y": 20}) +/// 30 +#[pyfunction] +fn compile(expression: String) -> PyResult { + let program = panic::catch_unwind(|| Program::compile(&expression)) + .map_err(|_| { + warn!("CEL parser panic for expression: '{}'", expression); + PyValueError::new_err(format!( + "Failed to parse expression '{expression}': Invalid syntax or malformed string" + )) + })? + .map_err(|e| PyValueError::new_err(format!("Failed to parse expression '{expression}': {e}")))?; + + Ok(PyProgram { + program, + source: expression, + }) +} + #[derive(Debug)] struct RustyCelType(Value); @@ -516,11 +598,137 @@ fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyRes } } +/// Internal helper to execute a pre-compiled program with the given context. +/// Used by both `evaluate()` (after compiling) and `PyProgram.execute()`. +fn execute_compiled_program( + program: &Program, + src: &str, + evaluation_context: Option<&Bound<'_, PyAny>>, +) -> PyResult> { + let mut environment = CelContext::default(); + let mut ctx = context::Context::new(None, None)?; + let mut variables_for_env = HashMap::new(); + + // Process the evaluation context if provided + if let Some(evaluation_context) = evaluation_context { + // Attempt to extract directly as a Context object + if let Ok(py_context_ref) = evaluation_context.extract::>() { + // Clone variables and functions into our local Context + ctx.variables = py_context_ref.variables.clone(); + ctx.functions = py_context_ref.functions.clone(); + } else if let Ok(py_dict) = evaluation_context.cast::() { + // User passed in a dict - let's process variables and functions from the dict + ctx.update(py_dict)?; + } else { + return Err(PyValueError::new_err( + "evaluation_context must be a Context object or a dict", + )); + }; + + variables_for_env = ctx.variables.clone(); + } + + // Add variables and functions if we have a context + if evaluation_context.is_some() { + // Add any variables from the processed context + for (name, value) in &variables_for_env { + environment + .add_variable(name.clone(), value.clone()) + .map_err(|e| { + PyValueError::new_err(format!("Failed to add variable '{name}': {e}")) + })?; + } + + // Register Python functions + for (function_name, py_function) in ctx.functions.iter() { + // Create a wrapper function + let py_func_clone = Python::attach(|py| py_function.clone_ref(py)); + let func_name_clone = function_name.clone(); + + // Register a function that takes Arguments (variadic) and returns a Value + environment.add_function( + function_name, + move |args: ::cel::extractors::Arguments| -> Result { + let py_func = py_func_clone.clone(); + let func_name = func_name_clone.clone(); + + Python::attach(|py| { + // Convert CEL arguments to Python objects + let mut py_args = Vec::new(); + for cel_value in args.0.iter() { + let py_arg = RustyCelType(cel_value.clone()) + .into_pyobject(py) + .map_err(|e| ExecutionError::FunctionError { + function: func_name.clone(), + message: format!("Failed to convert argument to Python: {e}"), + })? + .into_any() + .unbind(); + py_args.push(py_arg); + } + + let py_args_tuple = PyTuple::new(py, py_args).map_err(|e| { + ExecutionError::FunctionError { + function: func_name.clone(), + message: format!("Failed to create arguments tuple: {e}"), + } + })?; + + // Call the Python function + let py_result = py_func.call1(py, py_args_tuple).map_err(|e| { + warn!("Python function '{}' failed: {}", func_name, e); + ExecutionError::FunctionError { + function: func_name.clone(), + message: format!("Python function call failed: {e}"), + } + })?; + + // Convert the result back to CEL Value + let py_result_ref = py_result.bind(py); + let cel_value = + RustyPyType(py_result_ref).try_into_value().map_err(|e| { + ExecutionError::FunctionError { + function: func_name.clone(), + message: format!( + "Failed to convert Python result to CEL value: {e}" + ), + } + })?; + + Ok(cel_value) + }) + }, + ); + } + } + + // Use panic::catch_unwind to handle execution panics gracefully + // AssertUnwindSafe is needed because the environment contains function closures + let result = + panic::catch_unwind(AssertUnwindSafe(|| program.execute(&environment))).map_err(|_| { + warn!("CEL execution panic for expression: '{}'", src); + PyValueError::new_err(format!( + "Failed to execute expression '{src}': Internal parser error" + )) + })?; + + match result { + Err(error) => Err(map_execution_error_to_python(&error)), + Ok(value) => Python::attach(|py| { + RustyCelType(value) + .into_pyobject(py) + .map(|obj| obj.unbind()) + }), + } +} + #[pymodule] fn cel(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { pyo3_log::init(); m.add_function(wrap_pyfunction!(evaluate, m)?)?; + m.add_function(wrap_pyfunction!(compile, m)?)?; m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/tests/test_compile.py b/tests/test_compile.py new file mode 100644 index 0000000..6d5c8c2 --- /dev/null +++ b/tests/test_compile.py @@ -0,0 +1,309 @@ +"""Tests for the compile() function and Program class. + +The compile() function pre-compiles a CEL expression into a Program object +that can be executed multiple times with different contexts, providing +significant performance benefits for repeated evaluation of the same expression. +""" + +import datetime + +import cel +from cel import Context +import pytest + + +class TestCompileBasics: + """Basic compilation and execution tests.""" + + def test_compile_simple_expression(self): + """Test compiling a simple arithmetic expression.""" + program = cel.compile("1 + 2") + result = program.execute() + assert result == 3 + + def test_compile_returns_program(self): + """Test that compile() returns a Program object.""" + program = cel.compile("true") + assert hasattr(program, "execute") + + def test_program_repr(self): + """Test Program __repr__ method.""" + program = cel.compile("x + y") + repr_str = repr(program) + assert "Program" in repr_str + assert "x + y" in repr_str + + def test_execute_without_context(self): + """Test executing a program without context.""" + program = cel.compile("42") + assert program.execute() == 42 + + def test_execute_with_none_context(self): + """Test executing with explicit None context.""" + program = cel.compile("true && false") + assert program.execute(None) is False + + +class TestCompileWithContext: + """Tests for compile/execute with various context types.""" + + def test_execute_with_dict_context(self): + """Test executing with a dictionary context.""" + program = cel.compile("x + y") + result = program.execute({"x": 10, "y": 20}) + assert result == 30 + + def test_execute_with_context_object(self): + """Test executing with a Context object.""" + program = cel.compile("name + ' is ' + string(age)") + ctx = Context() + ctx.add_variable("name", "Alice") + ctx.add_variable("age", 30) + result = program.execute(ctx) + assert result == "Alice is 30" + + def test_execute_same_program_different_contexts(self): + """Test executing the same program with different contexts.""" + program = cel.compile("price * quantity") + + result1 = program.execute({"price": 10, "quantity": 5}) + assert result1 == 50 + + result2 = program.execute({"price": 25, "quantity": 4}) + assert result2 == 100 + + result3 = program.execute({"price": 100, "quantity": 1}) + assert result3 == 100 + + def test_execute_with_nested_context(self): + """Test executing with nested dictionary context.""" + program = cel.compile("user.name + ' (' + user.role + ')'") + result = program.execute({ + "user": {"name": "Bob", "role": "admin"} + }) + assert result == "Bob (admin)" + + def test_execute_with_list_context(self): + """Test executing with list in context.""" + program = cel.compile("items[0] + items[1]") + result = program.execute({"items": [10, 20, 30]}) + assert result == 30 + + +class TestCompileWithFunctions: + """Tests for compile/execute with custom functions.""" + + def test_execute_with_custom_function(self): + """Test executing with a custom Python function.""" + program = cel.compile("double(x)") + ctx = Context() + ctx.add_function("double", lambda x: x * 2) + ctx.add_variable("x", 21) + result = program.execute(ctx) + assert result == 42 + + def test_execute_with_multiple_custom_functions(self): + """Test executing with multiple custom functions.""" + program = cel.compile("add(x, y) + multiply(x, y)") + ctx = Context() + ctx.add_function("add", lambda a, b: a + b) + ctx.add_function("multiply", lambda a, b: a * b) + ctx.add_variable("x", 3) + ctx.add_variable("y", 4) + result = program.execute(ctx) + assert result == 19 # (3+4) + (3*4) = 7 + 12 = 19 + + +class TestCompileTypes: + """Tests for various CEL types with compile/execute.""" + + def test_compile_boolean(self): + """Test compiling boolean expressions.""" + program = cel.compile("a > b && c") + result = program.execute({"a": 10, "b": 5, "c": True}) + assert result is True + + def test_compile_string(self): + """Test compiling string expressions.""" + program = cel.compile("greeting + ' ' + name") + result = program.execute({"greeting": "Hello", "name": "World"}) + assert result == "Hello World" + + def test_compile_list(self): + """Test compiling list expressions.""" + program = cel.compile("[a, b, c]") + result = program.execute({"a": 1, "b": 2, "c": 3}) + assert result == [1, 2, 3] + + def test_compile_map(self): + """Test compiling map expressions.""" + program = cel.compile("{'name': name, 'age': age}") + result = program.execute({"name": "Alice", "age": 30}) + assert result == {"name": "Alice", "age": 30} + + def test_compile_null(self): + """Test compiling null expressions.""" + program = cel.compile("null") + result = program.execute() + assert result is None + + def test_compile_bytes(self): + """Test compiling bytes expressions.""" + program = cel.compile("b'hello'") + result = program.execute() + assert result == b"hello" + + def test_compile_timestamp(self): + """Test compiling timestamp expressions.""" + program = cel.compile("timestamp('2024-01-01T00:00:00Z')") + result = program.execute() + assert isinstance(result, datetime.datetime) + assert result.year == 2024 + + def test_compile_duration(self): + """Test compiling duration expressions.""" + program = cel.compile("duration('1h30m')") + result = program.execute() + assert isinstance(result, datetime.timedelta) + assert result.total_seconds() == 5400 + + +class TestCompileErrors: + """Tests for error handling in compile/execute.""" + + def test_compile_invalid_syntax(self): + """Test that invalid syntax raises ValueError.""" + with pytest.raises(ValueError, match="Failed to parse"): + cel.compile("1 + + 2") + + def test_compile_empty_expression(self): + """Test that empty expression raises ValueError.""" + with pytest.raises(ValueError, match="Failed to parse"): + cel.compile("") + + def test_execute_undefined_variable(self): + """Test that undefined variable raises RuntimeError.""" + program = cel.compile("undefined_var + 1") + with pytest.raises(RuntimeError): + program.execute({}) + + def test_execute_type_error(self): + """Test that type errors are properly raised.""" + program = cel.compile("x + y") + with pytest.raises(TypeError): + # String + int should fail + program.execute({"x": "hello", "y": 42}) + + def test_execute_invalid_context_type(self): + """Test that invalid context type raises ValueError.""" + program = cel.compile("x + 1") + with pytest.raises(ValueError, match="must be a Context object or a dict"): + program.execute("invalid context") + + +class TestCompileRealWorldExamples: + """Real-world usage examples for compile/execute.""" + + def test_access_control_policy(self): + """Test access control policy evaluation.""" + policy = cel.compile( + 'user.role == "admin" || (resource.owner == user.id && action == "read")' + ) + + # Admin can do anything + assert policy.execute({ + "user": {"id": "alice", "role": "admin"}, + "resource": {"owner": "bob"}, + "action": "delete" + }) is True + + # Owner can read their own resource + assert policy.execute({ + "user": {"id": "bob", "role": "user"}, + "resource": {"owner": "bob"}, + "action": "read" + }) is True + + # Non-owner cannot read others' resources + assert policy.execute({ + "user": {"id": "charlie", "role": "user"}, + "resource": {"owner": "bob"}, + "action": "read" + }) is False + + def test_pricing_calculation(self): + """Test pricing calculation with discounts.""" + pricing = cel.compile( + "price * quantity * (1.0 - discount)" + ) + + # No discount + assert pricing.execute({ + "price": 100.0, "quantity": 2.0, "discount": 0.0 + }) == 200.0 + + # 10% discount + result = pricing.execute({ + "price": 100.0, "quantity": 2.0, "discount": 0.1 + }) + assert abs(result - 180.0) < 0.001 + + def test_validation_rules(self): + """Test validation rules.""" + age_check = cel.compile("age >= 18 && age <= 120") + + assert age_check.execute({"age": 25}) is True + assert age_check.execute({"age": 17}) is False + assert age_check.execute({"age": 121}) is False + + def test_data_filtering(self): + """Test data filtering expression.""" + filter_expr = cel.compile( + 'status == "active" && score >= min_score' + ) + + items = [ + {"status": "active", "score": 85}, + {"status": "inactive", "score": 90}, + {"status": "active", "score": 70}, + {"status": "active", "score": 95}, + ] + + filtered = [ + item for item in items + if filter_expr.execute({**item, "min_score": 80}) + ] + + assert len(filtered) == 2 + assert filtered[0]["score"] == 85 + assert filtered[1]["score"] == 95 + + +class TestCompilePerformancePattern: + """Tests demonstrating the performance benefit pattern.""" + + def test_compile_once_execute_many(self): + """Demonstrate compile-once-execute-many pattern.""" + # Compile the expression once + expr = cel.compile("x * x + y * y") + + # Execute many times with different values + results = [] + for i in range(100): + result = expr.execute({"x": i, "y": i + 1}) + results.append(result) + + # Verify some results + assert results[0] == 0 * 0 + 1 * 1 # 1 + assert results[1] == 1 * 1 + 2 * 2 # 5 + assert results[10] == 10 * 10 + 11 * 11 # 221 + + def test_reuse_compiled_program(self): + """Test that compiled programs can be reused safely.""" + program = cel.compile("value > threshold") + + # Multiple sequential executions + assert program.execute({"value": 10, "threshold": 5}) is True + assert program.execute({"value": 3, "threshold": 5}) is False + assert program.execute({"value": 100, "threshold": 50}) is True + assert program.execute({"value": 0, "threshold": 0}) is False