From e34dfa6faacc992168423e1e36f63c3d90c9f081 Mon Sep 17 00:00:00 2001 From: Timothy Moore Date: Thu, 13 Aug 2026 10:58:14 -0700 Subject: [PATCH] Adopt prepared values and strict reusable contexts --- CHANGELOG.md | 15 + Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 66 +- docs/contributing.md | 67 +- docs/cookbook.md | 75 +- docs/getting-started/installation.md | 35 +- docs/getting-started/quick-start.md | 155 ++-- docs/how-to-guides/access-control-policies.md | 233 ++--- .../business-logic-data-transformation.md | 279 +++--- docs/how-to-guides/cli-recipes.md | 2 +- docs/how-to-guides/dynamic-query-filters.md | 73 +- docs/how-to-guides/error-handling.md | 171 ++-- .../production-patterns-best-practices.md | 139 +-- docs/index.md | 103 ++- docs/reference/cel-compliance.md | 103 ++- docs/reference/cli-reference.md | 12 +- docs/reference/python-api.md | 523 ++--------- docs/tutorials/cel-language-basics.md | 40 +- docs/tutorials/extending-cel.md | 231 ++--- docs/tutorials/thinking-in-cel.md | 97 +- docs/tutorials/your-first-integration.md | 177 ++-- .../performance/compile_execute_benchmark.py | 107 +-- .../performance/prepared_context_benchmark.py | 109 +++ python/cel/cel.pyi | 62 +- python/cel/cli.py | 9 +- src/context.rs | 477 +++------- src/lib.rs | 850 +++++------------- tests/conftest.py | 27 +- tests/test_arithmetic.py | 34 +- tests/test_basics.py | 47 +- tests/test_boolean_coercion.py | 47 +- tests/test_compile.py | 404 +++------ tests/test_context.py | 174 ++-- tests/test_datetime.py | 77 +- tests/test_documentation.py | 25 +- tests/test_dual_mode_comprehensive.py | 30 +- tests/test_edge_cases.py | 9 +- tests/test_enhanced_error_handling.py | 69 +- tests/test_functions.py | 153 ++-- .../test_issue16_string_literal_regression.py | 30 +- tests/test_logical_operators.py | 92 +- tests/test_map_function.py | 16 +- tests/test_optional_values.py | 21 +- tests/test_parser_errors.py | 27 +- tests/test_performance_verification.py | 23 +- tests/test_prepare.py | 129 +++ tests/test_reduce.py | 2 +- tests/test_stdlib.py | 51 +- tests/test_types.py | 130 +-- tests/test_upstream_improvements.py | 182 ++-- 51 files changed, 2752 insertions(+), 3265 deletions(-) create mode 100644 examples/performance/prepared_context_benchmark.py create mode 100644 tests/test_prepare.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c7b4e..ef65a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `cel.prepare(value)` and opaque immutable `PreparedValue` handles for one-time Python-to-CEL conversion. +- Added direct reusable native contexts; prepared values can be shared across contexts and replaced with a cheap shared-handle insertion. + +### Changed + +- `Context()` now accepts no constructor arguments, and `Context.add_variable()` accepts only `PreparedValue`. +- `Program.execute(context)` and `evaluate(expression, context)` now require a `Context` and borrow its native CEL environment directly. +- Prepared values capture a snapshot of mutable Python inputs. Retain prepared objects used for hot-path replacement to avoid final-reference recursive destruction in the hot path. + +### Removed + +- Removed context constructor mappings, `Context.update()`, raw variable insertion, dictionary execution, and optional/omitted execution contexts. + ## [0.5.6] - 2026-02-07 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index aaa5f29..f0b242a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ dependencies = [ [[package]] name = "cel" -version = "0.5.6" +version = "0.6.0" dependencies = [ "cel 0.13.0", "chrono", @@ -124,7 +124,7 @@ dependencies = [ [[package]] name = "cel" version = "0.13.0" -source = "git+https://github.com/GeniosAI/cel-rust.git?rev=1f35435e5ad3261083cf7f337b922765d01dcb63#1f35435e5ad3261083cf7f337b922765d01dcb63" +source = "git+https://github.com/GeniosAI/cel-rust.git?rev=b9a85818c6f3d2b3098250bab8a3022154485895#b9a85818c6f3d2b3098250bab8a3022154485895" dependencies = [ "antlr4rust", "base64", diff --git a/Cargo.toml b/Cargo.toml index c48f919..dd9d262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cel" -version = "0.5.6" +version = "0.6.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -10,7 +10,7 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.27", features = ["chrono", "py-clone"]} -cel = { git = "https://github.com/GeniosAI/cel-rust.git", rev = "1f35435e5ad3261083cf7f337b922765d01dcb63", package = "cel", features = ["chrono", "json", "regex", "bytes"] } +cel = { git = "https://github.com/GeniosAI/cel-rust.git", rev = "b9a85818c6f3d2b3098250bab8a3022154485895", package = "cel", features = ["chrono", "json", "regex", "bytes"] } log = "0.4.27" pyo3-log = { git = "https://github.com/a1phyr/pyo3-log.git", branch = "pyo3_0.27" } chrono = { version = "0.4.42", features = ["serde"] } diff --git a/README.md b/README.md index c2837f4..3eb321a 100644 --- a/README.md +++ b/README.md @@ -36,21 +36,26 @@ After installation, both the Python library and the `cel` command-line tool will ### Python API -```python -from cel import evaluate +Python values are prepared explicitly and installed into a reusable native context: -# Simple expressions -result = evaluate("1 + 2") # 3 -result = evaluate("'Hello ' + 'World'") # "Hello World" -result = evaluate("age >= 18", {"age": 25}) # True +```python +import cel -# Complex expressions with context -result = evaluate( +context = cel.Context() +context.add_variable("age", cel.prepare(25)) +result = cel.evaluate("age >= 18", context) # True + +context.add_variable( + "user", + cel.prepare({"role": "admin"}), +) +context.add_variable( + "permissions", + cel.prepare(["read", "write", "delete"]), +) +result = cel.evaluate( 'user.role == "admin" && "write" in permissions', - { - "user": {"role": "admin"}, - "permissions": ["read", "write", "delete"] - } + context, ) # True ``` @@ -67,24 +72,34 @@ cel 'age >= 18' --context '{"age": 25}' # true cel --interactive ``` -### Pre-compilation for Performance +### Prepared Values and Reusable Contexts -When evaluating the same expression multiple times with different contexts, use `compile()` for better performance: +Prepare large values outside the hot path. Prepared values are immutable snapshots and can be shared by multiple contexts. Installing a retained prepared value only clones a shared handle: ```python import cel -# Compile once -program = cel.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 +data = { + "objects": [ + {"active": i % 2 == 0, "score": i, "profile": {"enabled": True}} + for i in range(500) + ] +} +prepared = cel.prepare(data) +context = cel.Context() +program = cel.compile("data.objects[3].profile.enabled") + +for _ in range(100_000): + context.add_variable("data", prepared) + result = program.execute(context) ``` +Retain prepared objects used for frequent replacement. If a context owns the final reference to a large prepared value, replacing or dropping it may recursively free the value and therefore take time proportional to its size. Returning a large map/list or passing one to a Python callback is also proportional to that result or argument size. + ### Custom Functions ```python +import cel from cel import Context, evaluate def calculate_discount(price, rate): @@ -92,7 +107,7 @@ def calculate_discount(price, rate): context = Context() context.add_function("calculate_discount", calculate_discount) -context.add_variable("price", 100) +context.add_variable("price", cel.prepare(100)) result = evaluate("price - calculate_discount(price, 0.1)", context) # 90.0 ``` @@ -100,6 +115,7 @@ result = evaluate("price - calculate_discount(price, 0.1)", context) # 90.0 ### Real-World Example ```python +import cel from cel import evaluate, Context # Access control policy @@ -109,11 +125,9 @@ user.role == "admin" || """ context = Context() -context.update({ - "user": {"id": "alice", "role": "user"}, - "resource": {"owner": "alice"}, - "current_hour": 14 # 2 PM -}) +context.add_variable("user", cel.prepare({"id": "alice", "role": "user"})) +context.add_variable("resource", cel.prepare({"owner": "alice"})) +context.add_variable("current_hour", cel.prepare(14)) access_granted = evaluate(policy, context) # True ``` diff --git a/docs/contributing.md b/docs/contributing.md index 7085ffd..b8aac21 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -14,27 +14,27 @@ flowchart LR subgraph Python["  šŸ Python Layer  "] API["  cel.evaluate()
  Context class
  CLI tool  "] end - + subgraph Rust["  šŸ¦€ Rust Wrapper (PyO3)  "] Wrapper["  Type conversion
  Error handling
  Function calls  "] end - + subgraph CEL["  āš” CEL Engine (upstream)  "] Engine["  CEL parser
  Expression evaluation
  Built-in functions  "] end - + Python --> Rust Rust --> CEL - + style Python fill:#e8f4f8,color:#2c3e50 - style Rust fill:#fdf2e9,color:#2c3e50 + style Rust fill:#fdf2e9,color:#2c3e50 style CEL fill:#f0f9ff,color:#2c3e50 ``` **Key Files:** - `src/lib.rs` - Main evaluation engine and type conversions -- `src/context.rs` - Context management and Python function integration +- `src/context.rs` - Context management and Python function integration - `python/cel/` - Python module structure and CLI - `tests/` - Comprehensive test suite with 300+ tests @@ -104,7 +104,7 @@ uv run pytest uv run pytest tests/test_basics.py # Core functionality # → ========================= 25 passed in 0.12s ========================= -uv run pytest tests/test_arithmetic.py # Math operations +uv run pytest tests/test_arithmetic.py # Math operations # → ========================= 42 passed in 0.18s ========================= uv run pytest tests/test_context.py # Variable handling @@ -142,20 +142,49 @@ We use a proactive detection system to monitor for upstream improvements: 2. **Positive Detection**: Expected failures (`@pytest.mark.xfail`) ready to pass when features arrive ```python +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + import pytest import cel -# Example: Detecting when string functions become available +# Example: Detecting when string functions become available def test_lower_ascii_not_implemented(self): """When this test starts failing, lowerAscii() has been implemented.""" with pytest.raises(RuntimeError, match="Undefined variable or function.*lowerAscii"): - cel.evaluate('"HELLO".lowerAscii()') + evaluate('"HELLO".lowerAscii()', cel.Context()) # → RuntimeError: Undefined variable or function 'lowerAscii' @pytest.mark.xfail(reason="String utilities not implemented in cel v0.11.1", strict=False) def test_lower_ascii_expected_behavior(self): """This test will pass when upstream implements lowerAscii().""" - result = cel.evaluate('"HELLO".lowerAscii()') + result = evaluate('"HELLO".lowerAscii()', cel.Context()) # → "hello" (when implemented) assert result == "hello" ``` @@ -189,7 +218,7 @@ uv run pytest tests/test_upstream_improvements.py -v --tb=no | grep -E "(XPASS|F **Interpreting Results:** - **PASSED** = Limitation still exists (expected) -- **XFAIL** = Expected failure (ready for when feature arrives) +- **XFAIL** = Expected failure (ready for when feature arrives) - **XPASS** = šŸŽ‰ Feature now available! (remove xfail marker) ### Dependency Update Process @@ -199,7 +228,7 @@ When updating the `cel` crate dependency: 1. **Run detection tests first** to identify new capabilities 2. **Update Cargo.toml** with new version 3. **Fix compilation issues** (API changes) -4. **Remove xfail markers** for now-passing tests +4. **Remove xfail markers** for now-passing tests 5. **Update documentation** to reflect new features 6. **Test thoroughly** to ensure no regressions @@ -226,22 +255,22 @@ from typing import Optional, Union, Dict, Any, Callable import cel # Type hints for public APIs -def evaluate(expression: str, context: Optional[Union[Dict[str, Any], 'Context']] = None) -> Any: - """Evaluate a CEL expression with optional context.""" +def evaluate(expression: str, context: Context) -> Any: + """Evaluate a CEL expression with a required Context.""" pass -# Comprehensive docstrings +# Comprehensive docstrings def add_function(self, name: str, func: Callable) -> None: """Add a Python function to the CEL evaluation context. - + Args: name: Function name to use in CEL expressions func: Python callable to invoke - + Example: >>> context = cel.Context() >>> context.add_function("double", lambda x: x * 2) - >>> cel.evaluate("double(21)", context) + >>> evaluate("double(21)", context) 42 """ ``` @@ -291,7 +320,7 @@ uv run pytest tests/test_failing.py -v -s uv run pytest tests/test_file.py::test_name --pdb # → ========================= test session starts ========================= # → >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> PDB set_trace >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -# → (Pdb) +# → (Pdb) ``` **Type Conversion Issues:** diff --git a/docs/cookbook.md b/docs/cookbook.md index 48d2a53..5be6edd 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -25,7 +25,7 @@ Build robust access control policies that are easy to understand and maintain. ### What You'll Learn - Role-based access control (RBAC) patterns -- Attribute-based access control (ABAC) implementations +- Attribute-based access control (ABAC) implementations - Time-based access restrictions - Multi-tenant authorization - Audit logging for access decisions @@ -34,15 +34,44 @@ Build robust access control policies that are easy to understand and maintain. **Role-based access:** ```python -from cel import evaluate +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter expression = 'user.role in ["admin", "editor"] && resource.type == "document"' context = { - "user": {"role": "editor", "id": "user123"}, + "user": {"role": "editor", "id": "user123"}, "resource": {"type": "document", "owner": "user456"} } -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → True ``` @@ -54,7 +83,7 @@ context = { "user": {"permissions": ["read", "write"], "active": True} } -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → True (user has read permission and is active) ``` @@ -81,7 +110,7 @@ Transform and validate data with declarative expressions that business users can **User data transformation:** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Transform user data into a structured format expression = '''{ @@ -92,14 +121,14 @@ expression = '''{ context = { "user": { - "first_name": "Alice", + "first_name": "Alice", "last_name": "Johnson", "age": 25, "spend": 1500 } } -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → {'name': 'Alice Johnson', 'can_vote': True, 'tier': 'gold'} ``` @@ -108,7 +137,7 @@ print(result) # → {'name': 'Alice Johnson', 'can_vote': True, 'tier': 'gold'} expression = 'email.matches(r"^[^@]+@[^@]+\\.[^@]+$") && size(email) <= 254' context = {"email": "user@company.com"} -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → True ``` @@ -133,7 +162,7 @@ Build flexible, secure query filters that adapt to user input while preventing i **Multi-field search:** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Search across multiple fields safely expression = '(name.contains(query) || description.contains(query)) && status == "active"' @@ -144,7 +173,7 @@ context = { "query": "Python" } -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → True (matches name field) ``` @@ -157,7 +186,7 @@ context = { "end_date": "2024-12-31T23:59:59Z" } -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → True (within date range) ``` @@ -184,19 +213,19 @@ Handle edge cases gracefully and provide meaningful error messages to users. **Safe property access:** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Safely check nested properties expression = 'has(user.profile) && user.profile.verified' # Test with complete data context = {"user": {"profile": {"verified": True}}} -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → True # Test with missing profile (won't error) context = {"user": {"email": "test@example.com"}} -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → False (safe fallback) ``` @@ -210,7 +239,7 @@ context = { } } -result = evaluate(expression, context) +result = evaluate(expression, as_context(context)) print(result) # → "alice@company.com" (fallback to email) ``` @@ -285,17 +314,17 @@ Learn battle-tested patterns for building robust, secure, and performant CEL app **Context validation pattern:** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def safe_evaluate(expression, context): # Validate context structure before evaluation if not isinstance(context, dict): raise ValueError("Context must be a dictionary") - + # Simple validation before evaluation if "user" not in context: return False - return evaluate(expression, context) + return evaluate(expression, as_context(context)) # Example usage result = safe_evaluate('user.role == "admin"', {"user": {"role": "admin"}}) @@ -311,7 +340,7 @@ def get_cached_evaluation(expression, context_tuple): # Cache results for identical expression + context combinations # Convert tuple back to dict for evaluation context = dict(context_tuple) - return evaluate(expression, context) + return evaluate(expression, as_context(context)) # Usage with hashable context context = {"user_role": "admin", "resource_type": "document"} @@ -319,7 +348,7 @@ result = get_cached_evaluation('user_role == "admin"', tuple(context.items())) print(result) # → True (cached on subsequent calls) ``` -> āš ļø **Security Best Practices**: +> āš ļø **Security Best Practices**: > - Always validate context data structure > - Use `has()` checks for optional fields > - Never trust user-provided expressions without sandboxing @@ -343,10 +372,10 @@ print(result) # → True (cached on subsequent calls) ## šŸ’” Can't Find What You're Looking For? - **Browse all tutorials**: [Learning CEL section](tutorials/thinking-in-cel.md) -- **Check the API**: [Python API Reference](reference/python-api.md) +- **Check the API**: [Python API Reference](reference/python-api.md) - **File an issue**: [GitHub Issues](https://github.com/hardbyte/python-common-expression-language/issues) - **Join discussions**: [GitHub Discussions](https://github.com/hardbyte/python-common-expression-language/discussions) --- -**šŸ’” Pro Tip**: Each guide includes copy-paste ready examples, real-world use cases, and links to related patterns. The examples are all tested and guaranteed to work with the current version. \ No newline at end of file +**šŸ’” Pro Tip**: Each guide includes copy-paste ready examples, real-world use cases, and links to related patterns. The examples are all tested and guaranteed to work with the current version. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index edf137c..c74325c 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -21,7 +21,7 @@ Getting Python CEL up and running is quick and easy. === "uv tool (CLI only)" Install the CLI tool globally: - + ```bash uv tool install common-expression-language # → Installed common-expression-language 0.11.0 @@ -45,7 +45,36 @@ After installation, you should have both the Python library and CLI tool availab ```python import cel -result = cel.evaluate("1 + 2") +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +import cel +result = evaluate("1 + 2", cel.Context()) # → 3 assert result == 3 print("āœ“ Basic evaluation working correctly") @@ -120,4 +149,4 @@ After installation, you get: - [**Quick Start**](quick-start.md) - Your first CEL expressions - [**Your First Integration**](../tutorials/your-first-integration.md) - Using the Python API -- [**Thinking in CEL**](../tutorials/thinking-in-cel.md) - Core concepts and philosophy \ No newline at end of file +- [**Thinking in CEL**](../tutorials/thinking-in-cel.md) - Core concepts and philosophy diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 6ff84e2..3b29d23 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -7,29 +7,58 @@ Get up and running with Python CEL in under 5 minutes. The simplest way to use CEL is with the `evaluate` function: ```python -from cel import evaluate +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter # Basic arithmetic -result = evaluate("1 + 2") +result = evaluate("1 + 2", cel.Context()) assert result == 3 # → 3 (CEL handles math naturally) # String operations -result = evaluate('"Hello " + "World"') +result = evaluate('"Hello " + "World"', cel.Context()) assert result == "Hello World" # → "Hello World" (string concatenation works intuitively) # Boolean logic -result = evaluate("5 > 3") +result = evaluate("5 > 3", cel.Context()) assert result == True # → True (comparison operators return clear boolean values) # Conditional expressions -result = evaluate('true ? "yes" : "no"') +result = evaluate('true ? "yes" : "no"', cel.Context()) assert result == "yes" # → "yes" (ternary operator for clean conditional logic) # Lists and maps -result = evaluate("[1, 2, 3]") +result = evaluate("[1, 2, 3]", cel.Context()) assert result == [1, 2, 3] # → [1, 2, 3] (native Python list creation) -result = evaluate('{"name": "Alice", "age": 30}') +result = evaluate('{"name": "Alice", "age": 30}', cel.Context()) assert result == {'name': 'Alice', 'age': 30} # → {'name': 'Alice', 'age': 30} (native Python dict) print("āœ“ Basic expressions working correctly") @@ -40,13 +69,13 @@ print("āœ“ Basic expressions working correctly") CEL expressions can use variables from context: ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Simple context variables -result = evaluate("age >= 18", {"age": 25}) +result = evaluate("age >= 18", as_context({"age": 25})) assert result == True # → True (age check with context variable) -result = evaluate("name + ' is awesome!'", {"name": "CEL"}) +result = evaluate("name + ' is awesome!'", as_context({"name": "CEL"})) assert result == "CEL is awesome!" # → "CEL is awesome!" (variable interpolation made easy) # Complex nested context @@ -60,31 +89,31 @@ user = { } } -# String concatenation with conditionals -adult_status = evaluate('user.age >= 18 ? "adult" : "minor"', {"user": user}) -result = evaluate('user.name + " is " + status', {"user": user, "status": adult_status}) +# String concatenation with conditionals +adult_status = evaluate('user.age >= 18 ? "adult" : "minor"', as_context({"user": user})) +result = evaluate('user.name + " is " + status', as_context({"user": user, "status": adult_status})) assert result == "Alice is adult" # → "Alice is adult" (nested objects with conditional logic) # Working with lists -result = evaluate('"admin" in user.roles', {"user": user}) +result = evaluate('"admin" in user.roles', as_context({"user": user})) assert result == True # → True (membership testing in arrays) # Nested object access -result = evaluate('user.profile.verified && user.profile.email.endsWith("@example.com")', {"user": user}) +result = evaluate('user.profile.verified && user.profile.email.endsWith("@example.com")', as_context({"user": user})) assert result == True # → True (deep object navigation with string methods) # Type conversions - CEL enforces type safety -result = evaluate('user.name + " is " + string(user.age) + " years old"', {"user": user}) +result = evaluate('user.name + " is " + string(user.age) + " years old"', as_context({"user": user})) assert result == "Alice is 30 years old" # → "Alice is 30 years old" (explicit type conversion with string()) # āŒ This would fail - no automatic type conversion between incompatible types: # evaluate('user.name + " is " + user.age') # TypeError: can't add string + int -# +# # āœ… Always use explicit conversion for mixed types: # string(), int(), float(), double() functions # Safe navigation with has() -result = evaluate('has(user.profile.phone) ? user.profile.phone : "No phone"', {"user": user}) +result = evaluate('has(user.profile.phone) ? user.profile.phone : "No phone"', as_context({"user": user})) assert result == "No phone" # → "No phone" (safe field checking prevents errors) print("āœ“ Context variables working correctly") @@ -100,10 +129,10 @@ import cel # Compile once, execute many times program = cel.compile("price * quantity > threshold") -result1 = program.execute({"price": 10, "quantity": 5, "threshold": 40}) +result1 = program.execute(as_context({"price": 10, "quantity": 5, "threshold": 40})) assert result1 == True # → True (50 > 40) -result2 = program.execute({"price": 5, "quantity": 3, "threshold": 20}) +result2 = program.execute(as_context({"price": 5, "quantity": 3, "threshold": 20})) assert result2 == False # → False (15 > 20) print("Pre-compilation working correctly") @@ -111,7 +140,7 @@ 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. +You've mastered the basics of CEL evaluation with prepared contexts! For advanced features like custom Python functions, context objects, and production patterns, continue to the next guide. ## CLI Quick Start @@ -148,7 +177,7 @@ cel --interactive The REPL provides: - šŸŽØ **Syntax highlighting** as you type -- šŸ“ **Auto-completion** for CEL functions and variables +- šŸ“ **Auto-completion** for CEL functions and variables - šŸ“š **Command history** with up/down arrows - šŸ”§ **Built-in commands**: `help`, `context`, `history`, `load` @@ -157,7 +186,7 @@ The REPL provides: ### Configuration Validation ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter config = { "database": { @@ -179,7 +208,7 @@ checks = [ ] for expression, message in checks: - result = evaluate(expression, config) + result = evaluate(expression, as_context(config)) assert result == True, f"Validation failed: {message}" # → True (each validation passes) print("āœ“ Configuration validation working correctly") @@ -188,7 +217,7 @@ print("āœ“ Configuration validation working correctly") ### Policy Evaluation ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def check_access_policy(user, resource, action): policy = """ @@ -196,14 +225,14 @@ def check_access_policy(user, resource, action): (user.role == "owner" && resource.owner == user.id) || (user.role == "member" && action == "read" && resource.public) """ - + context = { "user": user, - "resource": resource, + "resource": resource, "action": action } - - return evaluate(policy, context) + + return evaluate(policy, as_context(context)) # Example usage user = {"id": "alice", "role": "member"} @@ -212,7 +241,7 @@ resource = {"id": "doc1", "owner": "bob", "public": True} can_read = check_access_policy(user, resource, "read") assert can_read == True # → True (member can read public resources) -can_write = check_access_policy(user, resource, "write") +can_write = check_access_policy(user, resource, "write") assert can_write == False # → False (member cannot write to others' resources) print("āœ“ Policy evaluation working correctly") @@ -221,25 +250,25 @@ print("āœ“ Policy evaluation working correctly") ### Data Transformation ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def transform_user_data(users): """Transform and filter user data using CEL expressions.""" - + # Filter active adult users active_adults = [] for user in users: - if evaluate("user.active && user.age >= 18", {"user": user}): + if evaluate("user.active && user.age >= 18", as_context({"user": user})): active_adults.append(user) - + # Generate display names for user in active_adults: display_name = evaluate( 'user.first_name + " " + user.last_name + " (" + user.role + ")"', - {"user": user} + as_context({"user": user}) ) user["display_name"] = display_name - + return active_adults # Example data @@ -261,59 +290,59 @@ print("āœ“ Data transformation working correctly") CEL has a rich type system that maps naturally to Python: ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter from datetime import datetime, timedelta # Numbers with operations -result = evaluate("42") +result = evaluate("42", cel.Context()) assert result == 42 # → 42 (integers work naturally) assert isinstance(result, int) -result = evaluate("3.14 * double(2)") +result = evaluate("3.14 * double(2)", cel.Context()) assert result == 6.28 # → 6.28 (floating point arithmetic) assert isinstance(result, float) -result = evaluate("1u + 5u") +result = evaluate("1u + 5u", cel.Context()) assert result == 6 # → 6 (unsigned integers convert to regular int) # Strings with methods -result = evaluate('"hello world".size()') +result = evaluate('"hello world".size()', cel.Context()) assert result == 11 # → 11 (string length via size() method) # Note: String indexing like "hello"[1] is not supported in CEL # Use string methods instead: startsWith(), endsWith(), contains(), matches() -result = evaluate('"test".startsWith("te")') +result = evaluate('"test".startsWith("te")', cel.Context()) assert result == True # → True (rich string method support) # Bytes operations -result = evaluate("b'binary data'") +result = evaluate("b'binary data'", cel.Context()) assert result == b'binary data' # → b'binary data' (native bytes support) assert isinstance(result, bytes) -result = evaluate("b'hello'.size()") +result = evaluate("b'hello'.size()", cel.Context()) assert result == 5 # → 5 (bytes also have size() method) # Collections with operations -result = evaluate("[1, 2, 3] + [4, 5]") +result = evaluate("[1, 2, 3] + [4, 5]", cel.Context()) assert result == [1, 2, 3, 4, 5] # → [1, 2, 3, 4, 5] (list concatenation) -result = evaluate("[1, 2, 3].size()") +result = evaluate("[1, 2, 3].size()", cel.Context()) assert result == 3 # → 3 (list length) -result = evaluate('{"name": "Alice", "age": 30}') +result = evaluate('{"name": "Alice", "age": 30}', cel.Context()) assert result == {'name': 'Alice', 'age': 30} # → {'name': 'Alice', 'age': 30} (maps as dicts) assert isinstance(result, dict) -result = evaluate('{"a": 1, "b": 2}.size()') +result = evaluate('{"a": 1, "b": 2}.size()', cel.Context()) assert result == 2 # → 2 (map size) # Special types with operations -result = evaluate("null == null") +result = evaluate("null == null", cel.Context()) assert result == True # → True (null handling works correctly) # Timestamps -result = evaluate('timestamp("2024-01-01T12:00:00Z")') +result = evaluate('timestamp("2024-01-01T12:00:00Z")', cel.Context()) assert isinstance(result, datetime) # → datetime object (RFC3339 string parsing) assert result.year == 2024 assert result.month == 1 @@ -321,13 +350,13 @@ assert result.day == 1 assert result.hour == 12 # Durations -result = evaluate('duration("1h30m")') +result = evaluate('duration("1h30m")', cel.Context()) assert isinstance(result, timedelta) # → timedelta object (duration string parsing) assert result.total_seconds() == 5400.0 # → 5400.0 (1.5 hours in seconds) # Timestamp arithmetic context = {"now": datetime.now()} -result = evaluate('now + duration("2h")', context) +result = evaluate('now + duration("2h")', as_context(context)) assert isinstance(result, datetime) # → datetime object (time arithmetic works naturally) print("āœ“ Type system working correctly") @@ -338,18 +367,18 @@ print("āœ“ Type system working correctly") CEL expressions can fail for various reasons. Always handle errors appropriately: ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Most idiomatic: Let exceptions bubble up naturally def evaluate_expression(expression: str, context: dict = None): """Evaluate expression with proper exception handling.""" - return evaluate(expression, context or {}) + return evaluate(expression, as_context(context or {})) -# For cases where you need fallback values +# For cases where you need fallback values def evaluate_with_default(expression: str, context: dict = None, default = None): """Evaluate with fallback value on errors.""" try: - return evaluate(expression, context or {}) + return evaluate(expression, as_context(context or {})) except (ValueError, TypeError, RuntimeError): return default @@ -357,11 +386,11 @@ def evaluate_with_default(expression: str, context: dict = None, default = None) def safe_evaluate(expression: str, context: dict = None): """ Evaluate with detailed success/error information. - + Returns: (success: bool, result: Any, error_message: str) """ try: - result = evaluate(expression, context or {}) + result = evaluate(expression, as_context(context or {})) return (True, result, "") except ValueError as e: return (False, None, f"Syntax error: {e}") @@ -382,8 +411,8 @@ except (ValueError, TypeError, RuntimeError) as e: # Fallback pattern for non-critical features display_name = evaluate_with_default( - 'user.display_name', - {"user": {"first_name": "John"}}, + 'user.display_name', + {"user": {"first_name": "John"}}, default="Unknown User" ) assert display_name == "Unknown User" # → "Unknown User" (missing field) @@ -394,7 +423,7 @@ assert success == False assert result is None assert "Runtime error" in error -success, result, error = safe_evaluate("age * 2", context) +success, result, error = safe_evaluate("age * 2", context) assert success == True assert result == 50 assert error == "" @@ -415,7 +444,7 @@ user = {"age": 25, "role": "member", "verified": True} business_rules = [ "age >= 18", # Valid rule "role == 'admin'", # Valid rule (false result) - "verified && age > 21", # Valid rule + "verified && age > 21", # Valid rule "invalid_syntax + +", # Invalid syntax ] @@ -430,7 +459,7 @@ print("āœ“ Idiomatic error handling working correctly") ## What's Next? -Congratulations! You've mastered basic CEL evaluation with dictionary context. Now choose your learning path: +Congratulations! You've mastered basic CEL evaluation with prepared contexts. Now choose your learning path: **šŸš€ Start Building Real Applications (Recommended):** - **[Your First Integration](../tutorials/your-first-integration.md)** - Learn Context objects and custom Python functions through practical examples diff --git a/docs/how-to-guides/access-control-policies.md b/docs/how-to-guides/access-control-policies.md index aad6e39..bcc0fbb 100644 --- a/docs/how-to-guides/access-control-policies.md +++ b/docs/how-to-guides/access-control-policies.md @@ -8,7 +8,7 @@ Your application needs sophisticated access control that goes beyond simple role - Time of day restrictions - Resource ownership -- Collaboration permissions +- Collaboration permissions - Context-sensitive rules Hard-coding these rules makes them difficult to update and test. @@ -20,15 +20,44 @@ Instead of complex if/else chains in your application code, define access polici CEL enables sophisticated, multi-factor access control policies that handle complex business rules: ```python -from cel import evaluate +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter from datetime import datetime def check_advanced_access_policy(user, resource, action, current_time=None): """Enterprise-grade multi-factor access control policy.""" - + if current_time is None: current_time = datetime.now() - + # Advanced policy with multiple business rules: # 1. Admins can do anything, anytime # 2. Resource owners have full access during business hours @@ -37,32 +66,32 @@ def check_advanced_access_policy(user, resource, action, current_time=None): # 5. Compliance: audit logs required for financial data access policy = """ (user.role == "admin") || - (resource.owner == user.id && user.verified && + (resource.owner == user.id && user.verified && (action != "delete" || user.department == resource.department)) || (user.department == resource.department && user.clearance_level >= resource.sensitivity_level && action in ["read", "comment"] && is_business_hours(current_hour)) || (user.role == "external" && user.id in resource.approved_external_users && action == "read" && resource.external_access_allowed) || - (action == "read" && resource.public && + (action == "read" && resource.public && (user.role != "guest" || is_business_hours(current_hour))) """ - + def is_business_hours(hour): return 9 <= hour <= 17 - + context = { "user": user, - "resource": resource, + "resource": resource, "action": action, "current_hour": current_time.hour, "is_business_hours": is_business_hours } - - return evaluate(policy, context) + + return evaluate(policy, as_context(context)) # Example: Financial data access financial_user = { - "id": "analyst1", + "id": "analyst1", "role": "analyst", "department": "finance", "clearance_level": 3, @@ -72,7 +101,7 @@ financial_user = { financial_resource = { "id": "q4_report", "owner": "cfo", - "department": "finance", + "department": "finance", "sensitivity_level": 3, "external_access_allowed": False, "approved_external_users": [], @@ -103,15 +132,15 @@ print("āœ“ Advanced access control policies working correctly") ```python def check_hierarchical_access(user, resource, action): """Implement role hierarchy where higher roles inherit lower permissions.""" - + role_hierarchy = { "guest": 0, - "user": 1, + "user": 1, "member": 2, "manager": 3, "admin": 4 } - + policy = """ user.role_level >= required_level && ( @@ -121,15 +150,15 @@ def check_hierarchical_access(user, resource, action): (action in ["read", "write", "delete"] && user.role_level >= 3) ) """ - + context = { "user": {**user, "role_level": role_hierarchy.get(user["role"], 0)}, "resource": resource, "action": action, "required_level": 0 # Minimum level to access system } - - return evaluate(policy, context) + + return evaluate(policy, as_context(context)) # Test the hierarchical access control guest_user = {"role": "guest", "id": "guest1"} @@ -143,7 +172,7 @@ private_resource = {"public": False, "owner": "user1", "collaborators": ["guest1 result = check_hierarchical_access(guest_user, public_resource, "read") assert result == True # → Access GRANTED: Public resources accessible to all authenticated users -# Test 2: Guest accessing private resource (denied) +# Test 2: Guest accessing private resource (denied) result = check_hierarchical_access(guest_user, private_resource, "write") assert result == False # → Access DENIED: Insufficient role level - guests cannot write to private resources @@ -167,10 +196,10 @@ print("āœ“ Hierarchical access control working correctly") ```python def check_time_based_access(user, resource, action, current_time=None): """Implement time-based access restrictions.""" - + if current_time is None: current_time = datetime.now() - + policy = """ user.role == "admin" || ( @@ -182,7 +211,7 @@ def check_time_based_access(user, resource, action, current_time=None): ) ) """ - + context = { "user": user, "resource": resource, @@ -190,8 +219,8 @@ def check_time_based_access(user, resource, action, current_time=None): "hour": current_time.hour, "day_of_week": current_time.weekday() } - - return evaluate(policy, context) + + return evaluate(policy, as_context(context)) # Test time-based access control standard_user = {"role": "user", "schedule": "standard"} @@ -226,7 +255,7 @@ print("āœ“ Time-based access control working correctly") ```python def check_resource_specific_access(user, resource, action): """Different rules for different resource types.""" - + policies = { "document": """ user.role == "admin" || @@ -234,29 +263,29 @@ def check_resource_specific_access(user, resource, action): (resource.public && action == "read") || (user.id in resource.collaborators && action in ["read", "comment"]) """, - + "database": """ user.role == "admin" || (user.role == "developer" && action in ["read", "write"]) || (user.role == "analyst" && action == "read") """, - + "system": """ user.role == "admin" || (user.role == "operator" && action in ["read", "restart"]) || (user.role == "monitor" && action == "read") """ } - + policy = policies.get(resource.get("type", "document"), policies["document"]) - + context = { "user": user, "resource": resource, "action": action } - - return evaluate(policy, context) + + return evaluate(policy, as_context(context)) # Test resource-specific access control developer = {"role": "developer", "id": "dev1"} @@ -302,15 +331,15 @@ One of the most common real-world applications of CEL is in Kubernetes Validatin ### ValidatingAdmissionPolicy Examples ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter import json def validate_kubernetes_pod(pod_spec, policy_expression): """Validate a Kubernetes Pod specification using CEL expressions.""" - + # Normalize the pod spec to ensure consistent structure for policy evaluation normalized_spec = normalize_pod_spec(pod_spec) - + context = { "object": normalized_spec, "request": { @@ -321,9 +350,9 @@ def validate_kubernetes_pod(pod_spec, policy_expression): } } } - + try: - return evaluate(policy_expression, context) + return evaluate(policy_expression, as_context(context)) except Exception as e: print(f"Policy validation failed: {e}") return False @@ -331,15 +360,15 @@ def validate_kubernetes_pod(pod_spec, policy_expression): def normalize_pod_spec(pod_spec): """Normalize pod spec to ensure consistent structure.""" normalized = pod_spec.copy() - + # Ensure securityContext exists with defaults if "securityContext" not in normalized["spec"]: normalized["spec"]["securityContext"] = {} - + # Set default runAsUser if not specified (1000 = non-root) if "runAsUser" not in normalized["spec"]["securityContext"]: normalized["spec"]["securityContext"]["runAsUser"] = 1000 - + return normalized # Example 1: Security Policy - Require non-root containers @@ -367,7 +396,7 @@ assert validate_kubernetes_pod(secure_pod, pod_security_policy) == True # → S # Invalid pod - runs as root insecure_pod = { - "apiVersion": "v1", + "apiVersion": "v1", "kind": "Pod", "metadata": {"name": "insecure-app"}, "spec": { @@ -385,7 +414,7 @@ assert validate_kubernetes_pod(insecure_pod, pod_security_policy) == False # # Pod with no security context - should default to non-root and pass default_pod = { "apiVersion": "v1", - "kind": "Pod", + "kind": "Pod", "metadata": {"name": "default-app"}, "spec": { "containers": [{ @@ -406,7 +435,7 @@ print("āœ“ Kubernetes pod security validation working correctly") ```python def validate_resource_limits(workload_spec): """Enforce resource limits and requests for production workloads.""" - + # Policy: All containers must have CPU and memory limits set # and requests must be at least 50% of limits resource_policy = """ @@ -420,14 +449,14 @@ def validate_resource_limits(workload_spec): has(container.resources.requests.memory) ) """ - + context = {"object": workload_spec} - return evaluate(resource_policy, context) + return evaluate(resource_policy, as_context(context)) # Valid deployment with proper resource management deployment_with_limits = { "apiVersion": "apps/v1", - "kind": "Deployment", + "kind": "Deployment", "metadata": {"name": "web-app"}, "spec": { "containers": [{ @@ -452,22 +481,22 @@ print("āœ“ Kubernetes resource limit validation working correctly") ```python def validate_network_policy(network_policy_spec): """Validate NetworkPolicy configurations for security compliance.""" - + # Policy: Ensure network policies have both ingress and egress rules # and don't allow unrestricted access network_security_policy = """ has(object.spec.ingress) && size(object.spec.ingress) > 0 && has(object.spec.egress) && size(object.spec.egress) > 0 && - object.spec.ingress.all(rule, + object.spec.ingress.all(rule, !has(rule.from) || size(rule.from) > 0 ) && object.spec.egress.all(rule, !has(rule.to) || size(rule.to) > 0 ) """ - + context = {"object": network_policy_spec} - return evaluate(network_security_policy, context) + return evaluate(network_security_policy, as_context(context)) # Valid network policy with restricted access secure_network_policy = { @@ -498,19 +527,19 @@ print("āœ“ Kubernetes network policy validation working correctly") ```python def validate_custom_resource(custom_resource_spec, crd_validation_rules): """Validate custom resources using CEL expressions.""" - + # Example: Validate a custom Application resource app_validation_policy = """ has(object.spec.replicas) && object.spec.replicas >= 1 && has(object.spec.image) && object.spec.image.contains(':') && !object.spec.image.endsWith(':latest') && - has(object.spec.environment) && + has(object.spec.environment) && object.spec.environment in ['dev', 'staging', 'prod'] && (object.spec.environment == 'prod' ? object.spec.replicas >= 3 : true) """ - + context = {"object": custom_resource_spec} - return evaluate(app_validation_policy, context) + return evaluate(app_validation_policy, as_context(context)) # Valid production application production_app = { @@ -524,10 +553,10 @@ production_app = { } } -# Valid development application +# Valid development application development_app = { "apiVersion": "platform.company.com/v1", - "kind": "Application", + "kind": "Application", "metadata": {"name": "test-service"}, "spec": { "replicas": 1, # Dev can have 1 replica @@ -546,20 +575,20 @@ print("āœ“ Kubernetes custom resource validation working correctly") ### Production Kubernetes Policy Engine ```python -from cel import evaluate, Context +# Context/evaluate are provided by the documentation adapter from datetime import datetime import re class KubernetesPolicyEngine: """Production-grade policy engine for Kubernetes admission control.""" - + def __init__(self): self.policies = {} self.load_default_policies() - + def load_default_policies(self): """Load standard security and compliance policies.""" - + self.policies = { "pod-security": { "expression": """ @@ -567,7 +596,7 @@ class KubernetesPolicyEngine: """, "message": "Pods must not run as root user" }, - + "resource-quotas": { "expression": """ object.spec.containers.all(container, @@ -577,7 +606,7 @@ class KubernetesPolicyEngine: """, "message": "All containers must specify resource limits and requests" }, - + "image-policy": { "expression": """ object.spec.containers.all(container, @@ -588,39 +617,39 @@ class KubernetesPolicyEngine: """, "message": "Images must be from company registry with semantic versioning" }, - + "namespace-compliance": { "expression": """ has(object.metadata.namespace) && object.metadata.namespace != 'default' && - (object.metadata.namespace.startsWith('prod-') ? + (object.metadata.namespace.startsWith('prod-') ? (has(object.metadata.labels) && 'compliance.company.com/approved' in object.metadata.labels) : true) """, "message": "Production namespaces require compliance approval labels" } } - + def normalize_resource_spec(self, resource_spec): """Normalize resource spec to ensure consistent structure for policy evaluation.""" normalized = resource_spec.copy() - + # Ensure spec exists if "spec" not in normalized: normalized["spec"] = {} - + # For Pods, ensure securityContext with defaults if normalized.get("kind") == "Pod": if "securityContext" not in normalized["spec"]: normalized["spec"]["securityContext"] = {} - + # Set default runAsUser if not specified (1000 = non-root) if "runAsUser" not in normalized["spec"]["securityContext"]: normalized["spec"]["securityContext"]["runAsUser"] = 1000 - + # Ensure containers list exists if "containers" not in normalized["spec"]: normalized["spec"]["containers"] = [] - + # Normalize container resources for container in normalized["spec"]["containers"]: if "resources" not in container: @@ -629,56 +658,56 @@ class KubernetesPolicyEngine: container["resources"]["limits"] = {} if "requests" not in container["resources"]: container["resources"]["requests"] = {} - + # Ensure metadata and labels exist if "metadata" not in normalized: normalized["metadata"] = {} if "labels" not in normalized["metadata"]: normalized["metadata"]["labels"] = {} - + return normalized - + def validate_admission(self, resource_spec, operation="CREATE", user_info=None): """Validate a Kubernetes resource admission request.""" - + if user_info is None: user_info = {"username": "system", "groups": ["system:authenticated"]} - + # Normalize the resource to ensure consistent structure for policy evaluation normalized_spec = self.normalize_resource_spec(resource_spec) - + context = Context() - context.add_variable("object", normalized_spec) - context.add_variable("operation", operation) - context.add_variable("userInfo", user_info) - context.add_variable("timestamp", datetime.now().isoformat()) - + context.add_variable("object", cel.prepare(normalized_spec)) + context.add_variable("operation", cel.prepare(operation)) + context.add_variable("userInfo", cel.prepare(user_info)) + context.add_variable("timestamp", cel.prepare(datetime.now().isoformat())) + results = [] - + for policy_name, policy_config in self.policies.items(): try: # Skip certain policies for system users - if (user_info.get("username", "").startswith("system:") and + if (user_info.get("username", "").startswith("system:") and policy_name == "image-policy"): continue - - result = evaluate(policy_config["expression"], context) + + result = evaluate(policy_config["expression"], as_context(context)) results.append({ "policy": policy_name, "allowed": result, "message": policy_config["message"] if not result else "Policy passed" }) - + except Exception as e: results.append({ "policy": policy_name, "allowed": False, "message": f"Policy evaluation error: {e}" }) - + # Overall admission decision admission_allowed = all(r["allowed"] for r in results) - + return { "allowed": admission_allowed, "message": "Admission approved" if admission_allowed else "Admission denied", @@ -712,7 +741,7 @@ compliant_pod = { # Test admission result = policy_engine.validate_admission( - compliant_pod, + compliant_pod, operation="CREATE", user_info={"username": "developer@company.com", "groups": ["developers"]} ) @@ -739,15 +768,15 @@ print("\nāœ“ Kubernetes production policy engine working correctly") ```python import pytest -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def test_kubernetes_pod_security_policies(): """Comprehensive test suite for Kubernetes pod security policies.""" - + def check_pod_security(pod_spec): policy = """ - (!has(object.spec.securityContext) || - !has(object.spec.securityContext.runAsUser) || + (!has(object.spec.securityContext) || + !has(object.spec.securityContext.runAsUser) || object.spec.securityContext.runAsUser != 0) && object.spec.containers.all(container, !has(container.securityContext) || @@ -755,8 +784,8 @@ def test_kubernetes_pod_security_policies(): container.securityContext.privileged == false ) """ - return evaluate(policy, {"object": pod_spec}) - + return evaluate(policy, as_context({"object": pod_spec})) + # Test case 1: Secure pod should pass secure_pod = { "spec": { @@ -765,7 +794,7 @@ def test_kubernetes_pod_security_policies(): } } assert check_pod_security(secure_pod) == True # → SECURITY VALID: Non-root user and no privileged containers - + # Test case 2: Root user should fail root_pod = { "spec": { @@ -774,20 +803,20 @@ def test_kubernetes_pod_security_policies(): } } assert check_pod_security(root_pod) == False # → SECURITY VIOLATION: Root user (UID 0) poses container escape risk - + # Test case 3: Privileged container should fail privileged_pod = { "spec": { "securityContext": {"runAsUser": 1000}, "containers": [{ - "name": "app", + "name": "app", "image": "nginx", "securityContext": {"privileged": True} }] } } assert check_pod_security(privileged_pod) == False # → SECURITY VIOLATION: Privileged containers bypass kernel security - + # Test case 4: Missing security context should pass (default behavior) default_pod = { "spec": { @@ -805,7 +834,7 @@ These Kubernetes examples demonstrate CEL's real-world power in: - **ValidatingAdmissionPolicies**: Prevent insecure or non-compliant resources - **Resource Management**: Enforce CPU/memory limits and requests -- **Security Compliance**: Block privileged containers and root users +- **Security Compliance**: Block privileged containers and root users - **Network Security**: Validate NetworkPolicy configurations - **Custom Resources**: Validate application-specific requirements - **Production Workflows**: Complete policy engines with multiple validation rules @@ -819,7 +848,7 @@ The Python CEL library is perfect for: ## Why This Works - **Readable**: Business stakeholders can understand the policy -- **Testable**: Each condition can be tested independently +- **Testable**: Each condition can be tested independently - **Flexible**: New rules can be added without code changes - **Safe**: No risk of infinite loops or side effects - **Auditable**: Policy changes are visible and trackable @@ -835,4 +864,4 @@ The Python CEL library is perfect for: ## Related Topics - [Business Logic & Data Transformation](business-logic-data-transformation.md) - Validate access control settings and transform user/resource data for policies -- [Production Patterns & Best Practices](production-patterns-best-practices.md) - Security and performance patterns \ No newline at end of file +- [Production Patterns & Best Practices](production-patterns-best-practices.md) - Security and performance patterns diff --git a/docs/how-to-guides/business-logic-data-transformation.md b/docs/how-to-guides/business-logic-data-transformation.md index 8636b3d..4d2eb3e 100644 --- a/docs/how-to-guides/business-logic-data-transformation.md +++ b/docs/how-to-guides/business-logic-data-transformation.md @@ -13,12 +13,41 @@ Your application has complex business rules that change frequently based on mark Implement a configurable business rules engine where rules are defined as CEL expressions that business users can understand and modify: ```python -from cel import evaluate, Context +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter from datetime import datetime, timedelta class BusinessRulesEngine: """Execute configurable business rules using CEL.""" - + def __init__(self): self.rules = { # Insurance pricing rules @@ -28,46 +57,46 @@ class BusinessRulesEngine: vehicle.type == "truck" ? 1200 : 1000 """, - + "age_multiplier": """ driver.age < 25 ? 1.5 : driver.age < 35 ? 1.2 : driver.age < 60 ? 1.0 : 1.1 """, - + "experience_discount": """ driver.years_experience >= 10 ? 0.9 : driver.years_experience >= 5 ? 0.95 : 1.0 """, - + "safety_features_discount": """ vehicle.anti_theft ? 0.95 : 1.0 """, - + "claims_penalty": """ driver.claims_count == 0 ? 0.9 : driver.claims_count == 1 ? 1.0 : driver.claims_count == 2 ? 1.2 : 1.4 """, - + # Loan eligibility rules "credit_score_eligible": "applicant.credit_score >= 650", - + "income_sufficient": """ loan.monthly_payment <= (double(applicant.monthly_income) * 0.28) """, - + "debt_to_income_acceptable": """ (applicant.existing_debt + loan.monthly_payment) <= (double(applicant.monthly_income) * 0.36) """, - + "employment_stable": """ applicant.employment_months >= 24 || applicant.employment_type == "self_employed" """, - + # Shipping cost rules "shipping_base_cost": """ package.weight <= 1 ? 5.99 : @@ -75,85 +104,85 @@ class BusinessRulesEngine: package.weight <= 20 ? 15.99 : double(package.weight) * 1.2 """, - + "shipping_distance_multiplier": """ shipping.distance <= 50 ? 1.0 : shipping.distance <= 200 ? 1.2 : shipping.distance <= 1000 ? 1.5 : 2.0 """, - + "express_shipping_multiplier": "shipping.express ? 2.0 : 1.0", - + "free_shipping_eligible": """ order.total >= 100 || customer.premium_member """ } - + def calculate_insurance_premium(self, driver, vehicle): """Calculate insurance premium using business rules.""" context = Context() - context.add_variable("driver", driver) - context.add_variable("vehicle", vehicle) - + context.add_variable("driver", cel.prepare(driver)) + context.add_variable("vehicle", cel.prepare(vehicle)) + # Calculate each component - base_premium = evaluate(self.rules["base_premium"], context) - age_multiplier = evaluate(self.rules["age_multiplier"], context) - experience_discount = evaluate(self.rules["experience_discount"], context) - safety_discount = evaluate(self.rules["safety_features_discount"], context) - claims_penalty = evaluate(self.rules["claims_penalty"], context) - + base_premium = evaluate(self.rules["base_premium"], as_context(context)) + age_multiplier = evaluate(self.rules["age_multiplier"], as_context(context)) + experience_discount = evaluate(self.rules["experience_discount"], as_context(context)) + safety_discount = evaluate(self.rules["safety_features_discount"], as_context(context)) + claims_penalty = evaluate(self.rules["claims_penalty"], as_context(context)) + # Final calculation - premium = (base_premium * - age_multiplier * - experience_discount * - safety_discount * + premium = (base_premium * + age_multiplier * + experience_discount * + safety_discount * claims_penalty) - + return round(premium, 2) - + def check_loan_eligibility(self, applicant, loan): """Check loan eligibility using business rules.""" context = Context() - context.add_variable("applicant", applicant) - context.add_variable("loan", loan) - + context.add_variable("applicant", cel.prepare(applicant)) + context.add_variable("loan", cel.prepare(loan)) + # Check each eligibility criterion criteria = { - "credit_score": evaluate(self.rules["credit_score_eligible"], context), - "income": evaluate(self.rules["income_sufficient"], context), - "debt_to_income": evaluate(self.rules["debt_to_income_acceptable"], context), - "employment": evaluate(self.rules["employment_stable"], context) + "credit_score": evaluate(self.rules["credit_score_eligible"], as_context(context)), + "income": evaluate(self.rules["income_sufficient"], as_context(context)), + "debt_to_income": evaluate(self.rules["debt_to_income_acceptable"], as_context(context)), + "employment": evaluate(self.rules["employment_stable"], as_context(context)) } - + # All criteria must pass eligible = all(criteria.values()) - + return { "eligible": eligible, "criteria": criteria, "reasons": [k for k, v in criteria.items() if not v] } - + def calculate_shipping_cost(self, package, shipping, order, customer): """Calculate shipping cost using business rules.""" context = Context() - context.add_variable("package", package) - context.add_variable("shipping", shipping) - context.add_variable("order", order) - context.add_variable("customer", customer) - + context.add_variable("package", cel.prepare(package)) + context.add_variable("shipping", cel.prepare(shipping)) + context.add_variable("order", cel.prepare(order)) + context.add_variable("customer", cel.prepare(customer)) + # Check if free shipping applies - if evaluate(self.rules["free_shipping_eligible"], context): + if evaluate(self.rules["free_shipping_eligible"], as_context(context)): return 0.0 - + # Calculate shipping cost - base_cost = evaluate(self.rules["shipping_base_cost"], context) - distance_multiplier = evaluate(self.rules["shipping_distance_multiplier"], context) - express_multiplier = evaluate(self.rules["express_shipping_multiplier"], context) - + base_cost = evaluate(self.rules["shipping_base_cost"], as_context(context)) + distance_multiplier = evaluate(self.rules["shipping_distance_multiplier"], as_context(context)) + express_multiplier = evaluate(self.rules["express_shipping_multiplier"], as_context(context)) + total_cost = base_cost * distance_multiplier * express_multiplier - + return round(total_cost, 2) # Example usage @@ -227,11 +256,11 @@ You need to transform data from various sources into a consistent format. The tr Use CEL expressions to define transformation rules that can be easily understood and modified: ```python -from cel import evaluate, Context +# Context/evaluate are provided by the documentation adapter class DataTransformationPipeline: """Transform data using configurable CEL expressions.""" - + def __init__(self): # Define transformation rules as CEL expressions self.transformations = { @@ -264,7 +293,7 @@ class DataTransformationPipeline: "unknown" """ }, - + # Calculate derived fields "calculate_metrics": { "engagement_score": """ @@ -285,41 +314,41 @@ class DataTransformationPipeline: """ } } - + def transform_user_data(self, input_data, current_year=2024): """Transform user data using CEL expressions.""" context = Context() - context.add_variable("input", input_data) - context.add_variable("current_year", current_year) - + context.add_variable("input", cel.prepare(input_data)) + context.add_variable("current_year", cel.prepare(current_year)) + # Add helper functions context.add_function("grade_to_score", self._grade_to_score) - + # Apply normalization transformations normalized = {} for field, expression in self.transformations["normalize_user"].items(): try: - result = evaluate(expression, context) + result = evaluate(expression, as_context(context)) if result is not None: normalized[field] = result except Exception as e: # Handle transformation errors gracefully normalized[field] = None - + # Add normalized data to context for metric calculations - context.add_variable("user", normalized) - + context.add_variable("user", cel.prepare(normalized)) + # Calculate derived metrics for field, expression in self.transformations["calculate_metrics"].items(): try: - result = evaluate(expression, context) + result = evaluate(expression, as_context(context)) normalized[field] = result except Exception as e: # Handle calculation errors gracefully normalized[field] = None - + return normalized - + def _grade_to_score(self, grade): """Convert letter grade to numeric score.""" grade_map = {"A": 95, "B": 85, "C": 75, "D": 65, "F": 50} @@ -331,7 +360,7 @@ pipeline = DataTransformationPipeline() # Data source 1: Has first_name, last_name, age source1_data = { "first_name": "John", - "last_name": "Doe", + "last_name": "Doe", "age": 30, "email": "JOHN.DOE@EXAMPLE.COM", "rating": 4, # 1-5 scale @@ -360,7 +389,7 @@ source2_data = { # Transform both data sources result1 = pipeline.transform_user_data(source1_data) result2 = pipeline.transform_user_data(source2_data) -# → result1: {"full_name": "John Doe", "email": "JOHN.DOE@EXAMPLE.COM", "age": 30, "score": 80.0, "status": "active", +# → result1: {"full_name": "John Doe", "email": "JOHN.DOE@EXAMPLE.COM", "age": 30, "score": 80.0, "status": "active", # "engagement_score": 85, "risk_level": "low", "subscription_tier": "platinum"} # → result2: {"full_name": "Jane Smith", "email": "jane.smith@example.com", "age": 34, "score": 85, "status": "ACTIVE", # "engagement_score": 50, "risk_level": "medium", "subscription_tier": "silver"} @@ -372,7 +401,7 @@ assert "engagement_score" in result1 # Verify transformed data from source 2 assert "full_name" in result2 -assert "email" in result2 +assert "email" in result2 assert "engagement_score" in result2 # Both results now have consistent structure: @@ -394,10 +423,10 @@ assert "email" in result1 and "email" in result2 ```python class ComposableRulesEngine(BusinessRulesEngine): """Rules engine with rule composition and inheritance.""" - + def __init__(self): super().__init__() - + # Define rule hierarchies self.rule_hierarchies = { "discount_rules": { @@ -407,7 +436,7 @@ class ComposableRulesEngine(BusinessRulesEngine): "seasonal_discount": "is_holiday_season() ? 0.15 : 0.0", "combined_discount": "min(base_discount + volume_discount + loyalty_discount + seasonal_discount, 0.5)" }, - + "risk_assessment": { "financial_risk": "applicant.debt_ratio > 0.4 ? 0.3 : (applicant.debt_ratio > 0.2 ? 0.1 : 0.0)", "credit_risk": "applicant.credit_score < 600 ? 0.4 : (applicant.credit_score < 700 ? 0.2 : 0.0)", @@ -415,36 +444,36 @@ class ComposableRulesEngine(BusinessRulesEngine): "total_risk": "min(financial_risk + credit_risk + employment_risk, 1.0)" } } - + def evaluate_rule_hierarchy(self, hierarchy_name, context_data): """Evaluate all rules in a hierarchy.""" if hierarchy_name not in self.rule_hierarchies: return {} - + context = Context() for key, value in context_data.items(): - context.add_variable(key, value) - + context.add_variable(key, cel.prepare(value)) + # Add helper functions context.add_function("is_holiday_season", self._is_holiday_season) context.add_function("min", min) context.add_function("max", max) - + hierarchy = self.rule_hierarchies[hierarchy_name] results = {} - + # Evaluate rules in order, making previous results available for rule_name, rule_expression in hierarchy.items(): try: - result = evaluate(rule_expression, context) + result = evaluate(rule_expression, as_context(context)) results[rule_name] = result - context.add_variable(rule_name, result) # Make available to subsequent rules + context.add_variable(rule_name, cel.prepare(result)) # Make available to subsequent rules except Exception as e: # Handle rule evaluation error gracefully results[rule_name] = None - + return results - + def _is_holiday_season(self): """Check if current date is in holiday season.""" now = datetime.now() @@ -480,7 +509,7 @@ assert discount_results["volume_discount"] == 0.05, "Volume discount should be 5 assert discount_results["loyalty_discount"] == 0.05, "Loyalty discount should be 5% for 2-4 years" # Verify seasonal discount (behavior depends on actual date) -seasonal_discount = discount_results["seasonal_discount"] +seasonal_discount = discount_results["seasonal_discount"] assert seasonal_discount >= 0.0, "Seasonal discount should be non-negative" print(f"Seasonal discount: {seasonal_discount} ({'holiday season' if seasonal_discount > 0 else 'regular season'})") # → Seasonal discount: 0.15 (holiday season) # or 0.0 (regular season) depending on current date @@ -535,7 +564,7 @@ print(f"āœ“ Risk assessment working: {risk_results['total_risk']} total risk") ```python def create_conditional_transformer(): """Transform data with conditional field mapping.""" - + mapping_rules = { "phone": """ has(input.phone) ? format_phone(input.phone) : @@ -543,16 +572,16 @@ def create_conditional_transformer(): has(input.telephone) ? format_phone(input.telephone) : null """, - + "address": """ has(input.address) ? input.address : - (has(input.street) && has(input.city)) ? - input.street + ", " + input.city + + (has(input.street) && has(input.city)) ? + input.street + ", " + input.city + (has(input.state) ? ", " + input.state : "") + (has(input.zip) ? " " + string(input.zip) : "") : null """, - + "full_address": """ has(user.address) ? user.address : join_address_parts([ @@ -563,7 +592,7 @@ def create_conditional_transformer(): ]) """ } - + def format_phone(phone): """Format phone number consistently.""" digits = "".join(filter(str.isdigit, str(phone))) @@ -572,17 +601,17 @@ def create_conditional_transformer(): elif len(digits) == 11 and digits[0] == "1": return f"+1 ({digits[1:4]}) {digits[4:7]}-{digits[7:]}" return phone - + def get_field(path, default=""): """Safely get nested field value.""" # This is a placeholder - in real use, would get from current context return default - + def join_address_parts(parts): """Join non-empty address parts.""" non_empty = [p for p in parts if p and p.strip()] return ", ".join(non_empty) if non_empty else "" - + return mapping_rules, { "format_phone": format_phone, "get_field": get_field, @@ -602,11 +631,11 @@ assert "format_phone" in funcs ```python class DynamicRulesEngine: """Rules engine that loads rules from external sources.""" - + def __init__(self): self.rules = {} self.rule_metadata = {} - + def load_rules_from_config(self, rules_config): """Load rules from configuration dictionary.""" for rule_name, rule_data in rules_config.items(): @@ -618,7 +647,7 @@ class DynamicRulesEngine: "author": rule_data.get("author", "system"), "tags": rule_data.get("tags", []) } - + def validate_rule(self, rule_expression, test_context=None): """Validate a rule expression.""" if test_context is None: @@ -629,55 +658,55 @@ class DynamicRulesEngine: "test_list": [1, 2, 3], "test_object": {"field": "value"} } - + try: - result = evaluate(rule_expression, test_context) + result = evaluate(rule_expression, as_context(test_context)) return True, result, None except Exception as e: return False, None, str(e) - + def update_rule(self, rule_name, new_expression, metadata=None, validation_context=None): """Update a rule with validation.""" is_valid, test_result, error = self.validate_rule(new_expression, validation_context) - + if not is_valid: raise ValueError(f"Invalid rule expression: {error}") - + # Backup old rule if rule_name in self.rules: old_rule = self.rules[rule_name] old_metadata = self.rule_metadata.get(rule_name, {}) # Rule backed up (in real implementation, save to backup storage) - + # Update rule self.rules[rule_name] = new_expression - + if metadata: self.rule_metadata[rule_name] = { **self.rule_metadata.get(rule_name, {}), **metadata, "last_modified": datetime.now().isoformat() } - + return True - + def execute_rule(self, rule_name, context): """Execute a specific rule.""" if rule_name not in self.rules: raise KeyError(f"Rule not found: {rule_name}") - + rule_expression = self.rules[rule_name] - + try: - return evaluate(rule_expression, context) + return evaluate(rule_expression, as_context(context)) except Exception as e: raise RuntimeError(f"Error executing rule {rule_name}: {e}") - + def get_rule_info(self, rule_name): """Get information about a rule.""" if rule_name not in self.rules: return None - + return { "name": rule_name, "expression": self.rules[rule_name], @@ -701,7 +730,7 @@ rules_config = { "author": "business_team", "tags": ["customer", "segmentation"] }, - + "fraud_score": { "expression": """ double(transaction.amount > double(customer.avg_transaction) * 5.0 ? 0.3 : 0.0) + @@ -729,7 +758,7 @@ customer_data = { }, "transaction": { "amount": 500, - "location": "NY", + "location": "NY", "time_hour": 14 } } @@ -759,7 +788,7 @@ except ValueError as e: # Test rule validation with valid business rule expression # Provide validation context that matches the rule's expected variables validation_context = {"customer": {"annual_spend": 5000}} -success = dynamic_engine.update_rule("test_rule", "customer.annual_spend > 1000", +success = dynamic_engine.update_rule("test_rule", "customer.annual_spend > 1000", validation_context=validation_context) # → True # Rule validation passed: expression is syntactically correct and executes successfully assert success == True, "Should accept valid business rule" @@ -800,42 +829,42 @@ print(f"āœ“ Customer tier calculation: bronze($500), gold($7500), platinum($1500 ```python def transform_batch_with_filters(data_list, transformation_config): """Transform a batch of records with filtering and validation.""" - + def transform_record(record): context = Context() - context.add_variable("input", record) - context.add_variable("current_timestamp", datetime.now().isoformat()) - + context.add_variable("input", cel.prepare(record)) + context.add_variable("current_timestamp", cel.prepare(datetime.now().isoformat())) + # Add transformation functions for func_name, func in transformation_config.get("functions", {}).items(): context.add_function(func_name, func) - + # Apply filters first for filter_expr in transformation_config.get("filters", []): try: - if not evaluate(filter_expr, context): + if not evaluate(filter_expr, as_context(context)): return None # Record filtered out except Exception: return None # Filter evaluation failed - + # Apply transformations transformed = {} for field, expr in transformation_config.get("transformations", {}).items(): try: - result = evaluate(expr, context) + result = evaluate(expr, as_context(context)) transformed[field] = result except Exception as e: # Handle transformation failure gracefully transformed[field] = None - + return transformed - + results = [] for record in data_list: transformed = transform_record(record) if transformed is not None: results.append(transformed) - + return results # Example batch transformation configuration @@ -940,4 +969,4 @@ print("āœ“ Batch transformation with filtering working correctly") - [Access Control Policies](access-control-policies.md) - User-specific business rules - [Dynamic Query Filters](dynamic-query-filters.md) - Query-based rule applications - [Production Patterns & Best Practices](production-patterns-best-practices.md) - Security and performance patterns -- [Error Handling](error-handling.md) - Robust error handling for rule execution \ No newline at end of file +- [Error Handling](error-handling.md) - Robust error handling for rule execution diff --git a/docs/how-to-guides/cli-recipes.md b/docs/how-to-guides/cli-recipes.md index d2a56e5..c208aab 100644 --- a/docs/how-to-guides/cli-recipes.md +++ b/docs/how-to-guides/cli-recipes.md @@ -581,4 +581,4 @@ fi cel "$base_policy && $security_policy" --context-file user_context.json ``` -For more advanced usage patterns, see the [Python API documentation](../reference/python-api.md) and other how-to guides in this section. \ No newline at end of file +For more advanced usage patterns, see the [Python API documentation](../reference/python-api.md) and other how-to guides in this section. diff --git a/docs/how-to-guides/dynamic-query-filters.md b/docs/how-to-guides/dynamic-query-filters.md index 8272baa..f8333be 100644 --- a/docs/how-to-guides/dynamic-query-filters.md +++ b/docs/how-to-guides/dynamic-query-filters.md @@ -23,12 +23,41 @@ Your application needs to build database queries dynamically based on user input Use CEL to build safe, dynamic filters that combine user criteria with security constraints: ```python +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + import json -from cel import evaluate, Context +# Context/evaluate are provided by the documentation adapter class DynamicQueryBuilder: """Build database queries dynamically using CEL expressions.""" - + def __init__(self): self.base_security_filters = { "admin": "true", # Admins see everything @@ -36,7 +65,7 @@ class DynamicQueryBuilder: "user": "record.user_id == user.id", "guest": "record.public == true" } - + def _format_value(self, value): """Format values correctly for CEL expressions.""" if isinstance(value, str): @@ -47,20 +76,20 @@ class DynamicQueryBuilder: return "null" else: return str(value) # Numbers - + def build_filter(self, user, user_filters): """Build a filter that combines security and user criteria.""" - + # Get base security filter for user's role security_filter = self.base_security_filters.get(user["role"], "false") - + # Build user filter from criteria user_filter_parts = [] for criterion in user_filters: field = criterion["field"] operator = criterion["operator"] value = criterion["value"] - + # Build CEL expression based on operator if operator == "equals": user_filter_parts.append(f'record.{field} == {self._format_value(value)}') @@ -74,29 +103,29 @@ class DynamicQueryBuilder: # value should be a list value_list = ', '.join(self._format_value(v) for v in value) user_filter_parts.append(f'record.{field} in [{value_list}]') - + # Combine user filters with AND user_filter = " && ".join(user_filter_parts) if user_filter_parts else "true" - + # Combine security filter with user filter combined_filter = f"({security_filter}) && ({user_filter})" - + return combined_filter - + def test_filter(self, filter_expression, user, sample_records): """Test filter against sample records.""" context = Context() - context.add_variable("user", user) - + context.add_variable("user", cel.prepare(user)) + matching_records = [] for record in sample_records: - context.add_variable("record", record) + context.add_variable("record", cel.prepare(record)) try: - if evaluate(filter_expression, context): + if evaluate(filter_expression, as_context(context)): matching_records.append(record) except Exception as e: print(f"Error evaluating filter for record {record.get('id', 'unknown')}: {e}") - + return matching_records # Example usage @@ -139,7 +168,7 @@ print("User filter:", user_filter) # Test filters admin_results = query_builder.test_filter(admin_filter, admin_user, sample_records) -# → [{'id': '1', 'user_id': 'user1', 'department': 'Sales', 'amount': 1500, 'status': 'active', 'public': False}, +# → [{'id': '1', 'user_id': 'user1', 'department': 'Sales', 'amount': 1500, 'status': 'active', 'public': False}, # {'id': '5', 'user_id': 'user4', 'department': 'Sales', 'amount': 1800, 'status': 'active', 'public': True}] manager_results = query_builder.test_filter(manager_filter, manager_user, sample_records) @@ -158,12 +187,12 @@ print(f"User sees {len(user_results)} records") # Verify expected results assert len(admin_results) == 2 # Admin sees all matching records -assert len(manager_results) == 2 # Manager sees Sales records +assert len(manager_results) == 2 # Manager sees Sales records assert len(user_results) == 1 # User sees only their own record assert user_results[0]["user_id"] == "user1" # → All assertions pass -# Verify the filter expressions are constructed correctly +# Verify the filter expressions are constructed correctly assert "(true)" in admin_filter # Admin has no restrictions assert "record.department == user.department" in manager_filter # Manager restricted by department assert "record.user_id == user.id" in user_filter # User restricted to own records @@ -183,7 +212,7 @@ filter_expr = query_builder.build_filter(admin_user, mixed_filters) # → "(true) && (record.active == true && record.score > 85.5 && record.category in [\"urgent\", \"sales\"] && record.notes == null)" # Individual parts: # record.active == true -# record.score > 85.5 +# record.score > 85.5 # record.category in ["urgent", "sales"] # record.notes == null @@ -194,7 +223,7 @@ print("āœ“ Dynamic query filters working correctly") ## Why This Works - **Secure**: Security constraints are always applied regardless of user input -- **Flexible**: Users can build complex queries within their permissions +- **Flexible**: Users can build complex queries within their permissions - **Safe**: CEL prevents injection attacks and ensures expressions terminate - **Testable**: Filters can be tested against sample data before deployment - **Maintainable**: Query logic is separated from application code @@ -239,4 +268,4 @@ This ensures security constraints cannot be circumvented by user input. - [Access Control Policies](access-control-policies.md) - User permission patterns - [Business Logic & Data Transformation](business-logic-data-transformation.md) - Validate filter configurations -- [Production Patterns & Best Practices](production-patterns-best-practices.md) - Security and performance patterns \ No newline at end of file +- [Production Patterns & Best Practices](production-patterns-best-practices.md) - Security and performance patterns diff --git a/docs/how-to-guides/error-handling.md b/docs/how-to-guides/error-handling.md index 90a86c7..d92e076 100644 --- a/docs/how-to-guides/error-handling.md +++ b/docs/how-to-guides/error-handling.md @@ -11,17 +11,46 @@ The library raises specific exception types based on the underlying error type. Raised when the CEL expression has invalid syntax, is empty, or fails to compile: ```python -from cel import evaluate +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter try: - evaluate("1 + + 2") # Invalid syntax + evaluate("1 + + 2", cel.Context()) # Invalid syntax assert False, "Expected ValueError" except ValueError as e: assert "Failed to parse expression" in str(e) # → ValueError: Failed to parse expression (graceful failure) try: - evaluate("") # Empty expression + evaluate("", cel.Context()) # Empty expression assert False, "Expected ValueError" except ValueError as e: assert "Failed to parse expression" in str(e) @@ -34,21 +63,21 @@ Raised for undefined variables/functions and function execution errors: ```python try: - evaluate("undefined_var", {}) # Variable not in context + evaluate("undefined_var", as_context({})) # Variable not in context assert False, "Expected RuntimeError" except RuntimeError as e: assert "Undefined variable or function" in str(e) # → RuntimeError: Undefined variable 'undefined_var' try: - evaluate("missing_func()", {}) # Function doesn't exist - assert False, "Expected RuntimeError" + evaluate("missing_func()", as_context({})) # Function doesn't exist + assert False, "Expected RuntimeError" except RuntimeError as e: assert "Undefined variable or function" in str(e) # → RuntimeError: Undefined function 'missing_func' try: - evaluate("user.missing_field", {"user": {"name": "alice"}}) # Field access error + evaluate("user.missing_field", as_context({"user": {"name": "alice"}})) # Field access error assert False, "Expected ValueError" except ValueError as e: assert "No such key" in str(e) @@ -61,24 +90,24 @@ Raised when operations are performed on incompatible types: ```python try: - evaluate("1 + 2u") # Mixed signed/unsigned arithmetic + evaluate("1 + 2u", cel.Context()) # Mixed signed/unsigned arithmetic assert False, "Expected TypeError" -except TypeError as e: - assert "Cannot mix signed and unsigned" in str(e) +except (TypeError, ValueError) as e: + assert "No such overload" in str(e) or "Cannot mix signed and unsigned" in str(e) # → TypeError: Cannot mix signed and unsigned integers try: - evaluate('"hello" && true') # String in logical operation + evaluate('"hello" && true', cel.Context()) # String in logical operation assert False, "Expected ValueError" -except ValueError as e: +except (TypeError, ValueError) as e: assert "No such overload" in str(e) - # → ValueError: No such overload for mixed-type logical operations + # → No such overload for mixed-type logical operations try: - evaluate("[1, 2, 3].map(x, x * 2.0)") # Mixed arithmetic in map + evaluate("[1, 2, 3].map(x, x * 2.0)", cel.Context()) # Mixed arithmetic in map assert False, "Expected TypeError" -except TypeError as e: - assert "operation" in str(e) +except (TypeError, ValueError) as e: + assert "operation" in str(e) or "No such overload" in str(e) # → TypeError: Unsupported operation between types ``` @@ -93,17 +122,17 @@ except TypeError as e: **Examples that now raise clean errors:** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter try: - evaluate("'unclosed quote", {}) + evaluate("'unclosed quote", as_context({})) assert False, "Should have raised ValueError" except ValueError as e: assert "Failed to parse expression" in str(e) # → ValueError: Malformed input handled safely (no crash) try: - evaluate('"mixed quotes\'', {}) + evaluate('"mixed quotes\'', as_context({})) assert False, "Should have raised ValueError" except ValueError as e: assert "Failed to parse expression" in str(e) @@ -120,22 +149,22 @@ The library now safely handles all malformed input by raising appropriate except Create a wrapper function that handles all CEL exceptions gracefully: ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter from typing import Any, Optional, Dict import logging def safe_evaluate(expression: str, context: Optional[Dict[str, Any]] = None) -> Optional[Any]: """ Safely evaluate a CEL expression with comprehensive error handling. - + Returns None if evaluation fails for any reason. """ try: - return evaluate(expression, context) + return evaluate(expression, as_context(context)) except ValueError as e: logging.warning(f"CEL parse error: {e}") return None - except TypeError as e: + except (TypeError, ValueError) as e: logging.warning(f"CEL type error: {e}") return None except RuntimeError as e: @@ -170,12 +199,12 @@ def validate_nested_field(context: Dict[str, Any], field_path: str) -> bool: """Check if a nested field exists (e.g., 'user.profile.verified').""" keys = field_path.split('.') current = context - + for key in keys: if not isinstance(current, dict) or key not in current: return False current = current[key] - + return True def safe_policy_evaluation(policy: str, context: Dict[str, Any]) -> bool: @@ -183,14 +212,14 @@ def safe_policy_evaluation(policy: str, context: Dict[str, Any]) -> bool: try: # Validate required top-level fields validate_context(context, ["user", "resource"]) - + # Validate specific nested fields used in policy if not validate_nested_field(context, "user.id"): raise ValueError("Missing required field: user.id") - - result = evaluate(policy, context) + + result = evaluate(policy, as_context(context)) return bool(result) if result is not None else False - + except Exception as e: logging.error(f"Policy evaluation failed: {e}") return False # Deny access on any error @@ -252,7 +281,7 @@ from typing import List, Optional class CELValidator: """Validator for CEL expressions from untrusted sources.""" - + # Patterns that are commonly malformed and raise ValueError DANGEROUS_PATTERNS = [ r"'[^']*$", # Unclosed single quote @@ -260,58 +289,58 @@ class CELValidator: r"'[^']*\"", # Mixed quotes: single -> double r'"[^"]*\'', # Mixed quotes: double -> single ] - + # Maximum expression length to prevent DoS MAX_EXPRESSION_LENGTH = 1000 - + def validate_expression(self, expression: str) -> List[str]: """ Validate a CEL expression for common issues. - + Returns list of validation errors (empty if valid). """ errors = [] - + # Check length if len(expression) > self.MAX_EXPRESSION_LENGTH: errors.append(f"Expression too long (max {self.MAX_EXPRESSION_LENGTH} chars)") - + # Check for dangerous patterns for pattern in self.DANGEROUS_PATTERNS: if re.search(pattern, expression): errors.append("Expression contains potentially problematic syntax") break - + # Check balanced quotes if not self._quotes_balanced(expression): errors.append("Unbalanced quotes detected") - + return errors - + def _quotes_balanced(self, expression: str) -> bool: """Check if quotes are properly balanced.""" single_quotes = expression.count("'") double_quotes = expression.count('"') - + # Simple check - both should be even (assuming no escaping) return single_quotes % 2 == 0 and double_quotes % 2 == 0 def safe_user_expression_eval(user_expression: str, context: Dict[str, Any]) -> tuple[bool, Optional[Any], List[str]]: """ Safely evaluate a user-provided CEL expression. - + Returns (success, result, errors). """ validator = CELValidator() - + # Validate expression first validation_errors = validator.validate_expression(user_expression) if validation_errors: return False, None, validation_errors - + # Attempt evaluation try: - result = evaluate(user_expression, context) + result = evaluate(user_expression, as_context(context)) return True, result, [] except Exception as e: return False, None, [f"Evaluation error: {str(e)}"] @@ -327,7 +356,7 @@ if success: else: assert False, f"Validation should not have failed: {errors}" -# Test 2: Invalid expression (accessing nonexistent field) +# Test 2: Invalid expression (accessing nonexistent field) dangerous_input = 'user.nonexistent_field' success, result, errors = safe_user_expression_eval(dangerous_input, context) assert success == False, "Expression with nonexistent field should be blocked" @@ -368,14 +397,14 @@ risky_expr = 'user.profile.settings.theme == "dark"' # āœ… Safe - check existence first safe_expr = ''' - has(user.profile) && - has(user.profile.settings) && - has(user.profile.settings.theme) && + has(user.profile) && + has(user.profile.settings) && + has(user.profile.settings.theme) && user.profile.settings.theme == "dark" ''' # āœ… Even safer - use defaults (with has() checks) -safe_with_defaults = '''has(user.profile) && has(user.profile.settings) && +safe_with_defaults = '''has(user.profile) && has(user.profile.settings) && (has(user.profile.settings.theme) ? user.profile.settings.theme : "light") == "dark"''' # Test both approaches @@ -432,28 +461,28 @@ from datetime import datetime, timezone def evaluate_with_logging(expression: str, context: Dict[str, Any], operation_id: str = None) -> Any: """Evaluate with comprehensive logging for production debugging.""" - + start_time = datetime.now(timezone.utc) - + log_context = { "operation_id": operation_id, "expression": expression, "context_keys": list(context.keys()) if context else [], "timestamp": start_time.isoformat() } - + try: - result = evaluate(expression, context) - + result = evaluate(expression, as_context(context)) + # Log successful evaluation logging.info("CEL evaluation succeeded", extra={ **log_context, "result_type": type(result).__name__, "duration_ms": (datetime.now(timezone.utc) - start_time).total_seconds() * 1000 }) - + return result - + except Exception as e: # Log detailed error information logging.error("CEL evaluation failed", extra={ @@ -478,9 +507,9 @@ def check_access(user_id: str, resource_id: str, policy: str) -> bool: "user": get_user(user_id), "resource": get_resource(resource_id) } - + operation_id = f"access_check_{user_id}_{resource_id}" - + try: result = evaluate_with_logging(policy, context, operation_id) return bool(result) @@ -501,18 +530,18 @@ assert result is True Write comprehensive tests for your error handling: ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter from typing import Any, Optional, Dict import logging def safe_evaluate(expression: str, context: Optional[Dict[str, Any]] = None) -> Optional[Any]: """Safely evaluate a CEL expression with comprehensive error handling.""" try: - return evaluate(expression, context) + return evaluate(expression, as_context(context)) except ValueError as e: logging.warning(f"CEL parse error: {e}") return None - except TypeError as e: + except (TypeError, ValueError) as e: logging.warning(f"CEL type error: {e}") return None except RuntimeError as e: @@ -524,34 +553,34 @@ def safe_evaluate(expression: str, context: Optional[Dict[str, Any]] = None) -> def test_error_handling(): """Test various error scenarios.""" - + # Test parse errors try: - evaluate("1 + + 2") + evaluate("1 + + 2", cel.Context()) assert False, "Should have raised ValueError" except ValueError: pass # Expected # → ValueError caught (syntax error handled gracefully) - - # Test runtime errors + + # Test runtime errors try: - evaluate("unknown_var", {}) + evaluate("unknown_var", as_context({})) assert False, "Should have raised RuntimeError" except RuntimeError: pass # Expected # → RuntimeError caught (undefined variable blocked safely) - + # Test type errors try: - evaluate("1 + 2u") # Mixed signed/unsigned arithmetic + evaluate("1 + 2u", cel.Context()) # Mixed signed/unsigned arithmetic assert False, "Should have raised TypeError" except (TypeError, ValueError): # May be TypeError or ValueError depending on operation - pass # Expected + pass # Expected # → Type error caught (incompatible types handled safely) def test_safe_evaluation(): """Test safe evaluation wrapper.""" - + # Should return None for invalid expressions assert safe_evaluate("1 + + 2") is None # → None (parse error handled gracefully) @@ -559,7 +588,7 @@ def test_safe_evaluation(): # → None (runtime error converted to safe None) assert safe_evaluate("undefined_field", {}) is None # → None (undefined variable error handled without crash) - + # Should work for valid expressions assert safe_evaluate("1 + 2") == 3 # → 3 (valid expression evaluates correctly) @@ -584,4 +613,4 @@ print("āœ“ Error handling test examples working correctly") 7. **Handle malformed input** with proper exception handling 8. **Fail safely** - deny access on evaluation errors -Remember: CEL is designed to be safe, but your application's error handling determines how gracefully it handles edge cases and malicious input. \ No newline at end of file +Remember: CEL is designed to be safe, but your application's error handling determines how gracefully it handles edge cases and malicious input. diff --git a/docs/how-to-guides/production-patterns-best-practices.md b/docs/how-to-guides/production-patterns-best-practices.md index 717750f..91078ad 100644 --- a/docs/how-to-guides/production-patterns-best-practices.md +++ b/docs/how-to-guides/production-patterns-best-practices.md @@ -12,7 +12,7 @@ This guide serves as your comprehensive hub for production CEL patterns, summari # āœ… Safe - won't crash if profile is missing has("user.profile") && user.profile.verified -# āœ… Safe - with fallback value +# āœ… Safe - with fallback value user.profile.verified if has("user.profile") else false ``` @@ -25,7 +25,36 @@ user.profile.verified if has("user.profile") else false **Key Practice**: Don't trust input data - validate it first. ```python -from cel import evaluate +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter def safe_policy_evaluation(policy, context): # Validate required fields exist @@ -33,7 +62,7 @@ def safe_policy_evaluation(policy, context): for field in required_fields: if field not in context: raise ValueError(f"Missing required field: {field}") - return evaluate(policy, context) + return evaluate(policy, as_context(context)) # Test the function context = {"user": {"id": "alice"}, "resource": {"type": "file"}, "action": "read"} @@ -84,7 +113,7 @@ result = decorated_func() **Core Components**: - **Context Builders**: Create consistent CEL contexts from Flask requests -- **Policy Decorators**: Apply access control policies to routes +- **Policy Decorators**: Apply access control policies to routes - **Error Handling**: Graceful policy evaluation failure handling **Implementation Details**: This involves several patterns including request context building, policy decorator implementation, and error handling. The complete Flask integration requires ~200 lines of production-ready code. @@ -172,12 +201,12 @@ response = edit_view(MockRequest(), "123") **Key Practice**: Design flat, efficient context structures. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # āœ… Efficient - flat structure context_flat = { "user_role": "admin", - "resource_type": "database", + "resource_type": "database", "action": "delete" } @@ -189,9 +218,9 @@ context_nested = { } # Test both contexts work -result1 = evaluate("user_role == 'admin'", context_flat) +result1 = evaluate("user_role == 'admin'", as_context(context_flat)) # → True (fast evaluation: ~5μs with flat structure) -result2 = evaluate("request.user.profile.role == 'admin'", context_nested) +result2 = evaluate("request.user.profile.role == 'admin'", as_context(context_nested)) # → True (slower evaluation: ~15μs with nested structure) ``` @@ -205,13 +234,13 @@ result2 = evaluate("request.user.profile.role == 'admin'", context_nested) ```python from functools import lru_cache -from cel import evaluate +# Context/evaluate are provided by the documentation adapter class PolicyEngine: @lru_cache(maxsize=1000) def _evaluate_cached(self, policy, user_role, resource_public): context = {"user": {"role": user_role}, "resource": {"public": resource_public}} - return evaluate(policy, context) + return evaluate(policy, as_context(context)) # Test the cached evaluation engine = PolicyEngine() @@ -244,10 +273,10 @@ ALLOWED_PATTERN = re.compile(r'^[a-zA-Z0-9_\s\.\(\)\[\]\{\}\+\-\*\/\<\>\=\!\&\|\ def sanitize_expression(expression): if len(expression) > MAX_EXPRESSION_LENGTH: raise ValueError("Expression too long") - + if not ALLOWED_PATTERN.match(expression): raise ValueError("Expression contains invalid characters") - + return expression # Test the sanitization function @@ -277,7 +306,7 @@ except ValueError as e: **Key Practice**: Only include necessary, safe data in CEL contexts. ```python -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter def create_isolated_context(user_data, resource_data): # Only include explicitly allowed fields @@ -286,7 +315,7 @@ def create_isolated_context(user_data, resource_data): "role": user_data.get("role"), "verified": user_data.get("verified", False) } - return Context({"user": safe_user}) + return make_context({"user": safe_user}) # Test the isolation function user_data = {"id": "alice", "role": "admin", "password": "secret", "verified": True} @@ -294,16 +323,16 @@ resource_data = {"type": "file"} context = create_isolated_context(user_data, resource_data) # Verify only safe fields are included by testing evaluation -assert evaluate("user.id", context) == "alice" +assert evaluate("user.id", as_context(context)) == "alice" # → "alice" (safe field accessible in isolated context) -assert evaluate("user.role", context) == "admin" +assert evaluate("user.role", as_context(context)) == "admin" # → "admin" (role information safely exposed for authorization) -assert evaluate("user.verified", context) is True +assert evaluate("user.verified", as_context(context)) is True # → True (verification status available for security decisions) # Verify password is not accessible (this would fail if password was included) try: - evaluate("user.password", context) + evaluate("user.password", as_context(context)) # → Exception (sensitive data successfully isolated from CEL context) assert False, "Password should not be accessible" except Exception: @@ -321,19 +350,19 @@ except Exception: **Key Practice**: Treat CEL expressions as code - write comprehensive tests. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def test_admin_access_policy(): context = {"user": {"role": "admin"}} policy = "user.role == 'admin'" - result = evaluate(policy, context) + result = evaluate(policy, as_context(context)) # → True (admin access policy correctly grants permission) assert result == True def test_missing_context_handled_safely(): context = {"user": {"id": "alice"}} # No role safe_policy = 'has(user.role) && user.role == "admin"' - result = evaluate(safe_policy, context) + result = evaluate(safe_policy, as_context(context)) # → False (defensive policy safely handles missing role field) assert result == False @@ -371,13 +400,13 @@ class MockClient: def test_protected_route_access(): client = MockClient() - + # Test admin access - response = client.get('/admin/users', + response = client.get('/admin/users', headers={'Authorization': 'Bearer admin_token'}) # → 200 (admin successfully granted access to protected route) assert response.status_code == 200 - + # Test user denial response = client.get('/admin/users', headers={'Authorization': 'Bearer user_token'}) @@ -402,7 +431,7 @@ test_protected_route_access() ```python import logging -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Configure logger logger = logging.getLogger(__name__) @@ -410,7 +439,7 @@ logger.setLevel(logging.INFO) def evaluate_with_logging(expression, context, description=""): try: - result = evaluate(expression, context) + result = evaluate(expression, as_context(context)) logger.info(f"CEL evaluation {description}: '{expression}' -> {result}") return result except Exception as e: @@ -438,7 +467,7 @@ result = evaluate_with_logging("user.role == 'admin'", context, "test") ```python import time import logging -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Configure logger logger = logging.getLogger(__name__) @@ -448,7 +477,7 @@ class MonitoredPolicyEngine: def evaluate_monitored(self, expression, context): start_time = time.perf_counter() try: - result = evaluate(expression, context) + result = evaluate(expression, as_context(context)) return result finally: duration = time.perf_counter() - start_time @@ -464,7 +493,7 @@ result = engine.evaluate_monitored("user.role == 'admin'", context) # Test with different expressions to verify monitoring test_expressions = [ ("user.role == 'admin'", True), - ("user.role == 'user'", False), + ("user.role == 'user'", False), ("has(user.permissions) && 'admin' in user.permissions", False), ("user.role in ['admin', 'manager', 'user']", True) ] @@ -515,11 +544,11 @@ Run this benchmark to understand CEL performance on your hardware: ```python import time -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def benchmark_cel_performance(): """Comprehensive CEL performance benchmark matching documented claims.""" - + # Test scenarios matching the performance table test_cases = [ { @@ -530,12 +559,12 @@ def benchmark_cel_performance(): "iterations": 10000 }, { - "name": "Complex expressions", + "name": "Complex expressions", "expression": "user.active && user.role in ['admin', 'editor'] && has(user.permissions) && user.permissions.size() > 0", "context": { "user": { "active": True, - "role": "admin", + "role": "admin", "permissions": ["read", "write", "delete"] } }, @@ -544,43 +573,43 @@ def benchmark_cel_performance(): }, { "name": "Function calls", - "expression": "double(x) + square(y)", + "expression": "double(x) + double(square(y))", "context": { "x": 5, "y": 3, "double": lambda x: x * 2, "square": lambda x: x * x }, - "expected": 19, # double(5) + square(3) = 10 + 9 + "expected": 14.0, # CEL double(5) + Python square(3) = 5.0 + 9 "iterations": 3000 } ] - + results = [] - + for test_case in test_cases: print(f"\nBenchmarking: {test_case['name']}") - + # Verify the expression works correctly - result = evaluate(test_case["expression"], test_case["context"]) + result = evaluate(test_case["expression"], as_context(test_case["context"])) # → Expected result (validates benchmark test case correctness) assert result == test_case["expected"], f"Expected {test_case['expected']}, got {result}" - + # Warmup for _ in range(100): - evaluate(test_case["expression"], test_case["context"]) - + evaluate(test_case["expression"], as_context(test_case["context"])) + # Benchmark start_time = time.perf_counter() for _ in range(test_case["iterations"]): - evaluate(test_case["expression"], test_case["context"]) + evaluate(test_case["expression"], as_context(test_case["context"])) end_time = time.perf_counter() - + # Calculate metrics total_time = end_time - start_time avg_time_us = (total_time / test_case["iterations"]) * 1_000_000 throughput = test_case["iterations"] / total_time - + result_data = { "name": test_case["name"], "avg_time_us": avg_time_us, @@ -588,10 +617,10 @@ def benchmark_cel_performance(): "iterations": test_case["iterations"] } results.append(result_data) - + print(f" Average time: {avg_time_us:.1f} μs") print(f" Throughput: {throughput:,.0f} ops/sec") - + return results # Run the benchmark and display results @@ -600,7 +629,7 @@ if __name__ == "__main__": print("=" * 40) results = benchmark_cel_performance() # → Comprehensive performance metrics for production capacity planning - + print("\nSummary:") print("-" * 40) for result in results: @@ -611,7 +640,7 @@ if __name__ == "__main__": **Expected Results**: - **Simple expressions**: 5-15 μs per evaluation, 50,000+ ops/sec -- **Complex expressions**: 15-40 μs per evaluation, 25,000+ ops/sec +- **Complex expressions**: 15-40 μs per evaluation, 25,000+ ops/sec - **Function calls**: 20-50 μs per evaluation, 20,000+ ops/sec **Learn More**: See [Performance Benchmarking Examples](https://github.com/hardbyte/python-common-expression-language/tree/main/examples/performance) for comprehensive benchmarking scripts. @@ -623,7 +652,7 @@ if __name__ == "__main__": **Key Practice**: Use CEL expressions to validate application configuration. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter validation_rules = [ { @@ -632,7 +661,7 @@ validation_rules = [ "message": "Database port must be between 1 and 65535" }, { - "field": "ssl_required", + "field": "ssl_required", "expression": 'config.ssl_enabled || env == "development"', "message": "SSL must be enabled in production" } @@ -649,7 +678,7 @@ config_context = { # Validate all rules for rule in validation_rules: - result = evaluate(rule["expression"], config_context) + result = evaluate(rule["expression"], as_context(config_context)) # → True (configuration validation passed: system is properly configured) assert result is True, f"Validation failed: {rule['message']}" @@ -663,7 +692,7 @@ invalid_context = { } port_rule = validation_rules[0] -port_valid = evaluate(port_rule["expression"], invalid_context) +port_valid = evaluate(port_rule["expression"], as_context(invalid_context)) # → False (invalid configuration detected: prevents deployment of misconfigured system) assert port_valid is False ``` @@ -700,7 +729,7 @@ assert port_valid is False ## Related Guides - **[Error Handling](error-handling.md)** - Comprehensive error handling strategies -- **[Business Logic & Data Transformation](business-logic-data-transformation.md)** - Complex business rules and data processing +- **[Business Logic & Data Transformation](business-logic-data-transformation.md)** - Complex business rules and data processing - **[Access Control Policies](access-control-policies.md)** - User permission and authorization patterns - **[Dynamic Query Filters](dynamic-query-filters.md)** - Database query construction and filtering -- **[CLI Usage Recipes](cli-recipes.md)** - Command-line tool integration patterns \ No newline at end of file +- **[CLI Usage Recipes](cli-recipes.md)** - Command-line tool integration patterns diff --git a/docs/index.md b/docs/index.md index 87bfa94..adb3e06 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,19 +13,48 @@ providing fast and safe CEL expression evaluation with seamless Python integrati **Simple evaluation** ```python - from cel import evaluate - - result = evaluate("age > 21", {"age": 25}) + import cel + Context = cel.Context + + + def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + + def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + + def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + + def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + + # Context/evaluate are provided by the documentation adapter + + result = evaluate("age > 21", as_context({"age": 25})) assert result == True # → True (age check passes) ``` - + **Policy checks** ```python policy = "user.role == 'admin' || resource.public" - result = evaluate(policy, {"user": {"role": "guest"}, "resource": {"public": True}}) + result = evaluate(policy, as_context({"user": {"role": "guest"}, "resource": {"public": True}})) assert result == True # → True (public resource access allowed) ``` - + **Nested data** ```python user_data = { @@ -34,15 +63,15 @@ providing fast and safe CEL expression evaluation with seamless Python integrati "profile": {"verified": True, "role": "admin"} } } - + # Access nested fields and business logic - name_check = evaluate("user.name == 'Alice'", user_data) + name_check = evaluate("user.name == 'Alice'", as_context(user_data)) assert name_check == True # → True (name matches) - + policy = "user.profile.verified && user.profile.role == 'admin'" - admin_access = evaluate(policy, user_data) + admin_access = evaluate(policy, as_context(user_data)) assert admin_access == True # → True (verified admin user) - + print("āœ“ Basic CEL evaluation working correctly") # → āœ“ Basic CEL evaluation working correctly ``` @@ -53,10 +82,10 @@ providing fast and safe CEL expression evaluation with seamless Python integrati cel '1 + 2' # → 3 cel '"Hello " + "World"' # → Hello World cel '[1, 2, 3].size()' # → 3 - + # With context cel 'age >= 21' --context '{"age": 25}' # → true - + # Interactive REPL cel --interactive ``` @@ -69,32 +98,32 @@ providing fast and safe CEL expression evaluation with seamless Python integrati "hello" + " " + "world" // → "hello world" [1, 2, 3][1] // → 2 {"name": "Alice"}.name // → "Alice" - + // Conditionals and logic age >= 18 ? "adult" : "minor" has(user.email) && user.email.endsWith("@company.com") - + // Collection operations users.filter(u, u.active).all(u, u.verified) emails.exists(e, e.endsWith("@company.com")) - + // Built-in functions size([1, 2, 3]) // → 3 timestamp("2024-01-01T00:00:00Z") duration("1h30m") ``` - + **[šŸ“– Complete Syntax Reference →](tutorials/cel-language-basics.md)** ## Key Features -āœ… **80% CEL spec compliance** -āœ… **200+ tests** -āœ… **CLI + Python API** -āœ… **Safe by design** (Rust core) -āœ… **Ready for production** -āœ… **No GIL-blocking, safe concurrent evaluation** -āœ… **Strict type safety** (CEL-compliant type system) +āœ… **80% CEL spec compliance** +āœ… **200+ tests** +āœ… **CLI + Python API** +āœ… **Safe by design** (Rust core) +āœ… **Ready for production** +āœ… **No GIL-blocking, safe concurrent evaluation** +āœ… **Strict type safety** (CEL-compliant type system) ## Why Python CEL? @@ -131,28 +160,28 @@ graph LR A[Python Application] --> B[python-cel Package] B --> C[PyO3 Boundary] C --> D[cel Rust Crate] - + subgraph PL ["Python Layer"] B E[evaluate function] F[Context class] G[Type conversion] end - + subgraph RL ["Rust Layer"] D H[CEL Parser] I[Expression Evaluator] J[Type System] end - + B --> E B --> F B --> G D --> H D --> I D --> J - + style A fill:#3776ab,color:#fff style D fill:#ce422b,color:#fff style C fill:#f39c12,color:#fff @@ -161,10 +190,10 @@ graph LR **Why This Architecture?** - **šŸš€ Speed**: Rust's zero-cost abstractions deliver microsecond-level performance -- **šŸ›”ļø Safety**: Memory-safe Rust prevents crashes and security vulnerabilities +- **šŸ›”ļø Safety**: Memory-safe Rust prevents crashes and security vulnerabilities - **šŸ”§ Ergonomics**: PyO3 provides seamless Python integration with automatic type conversion -- **šŸ“¦ Distribution**: Single wheel package with no external dependencies -- **⚔ Concurrency**: No GIL-blocking — safe concurrent evaluation across threads +- **šŸ“¦ Distribution**: Single wheel package with no external dependencies +- **⚔ Concurrency**: No GIL-blocking — safe concurrent evaluation across threads ## Installation @@ -177,11 +206,11 @@ After installation, both the Python library and the `cel` command-line tool will ## Real-World Example: Access Control ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Multi-factor access control policy policy = """ - user.verified && + user.verified && (user.role == "admin" || resource.owner == user.id || resource.public) """ @@ -190,9 +219,9 @@ admin_user = {"user": {"role": "admin", "verified": True, "id": "admin1"}, "reso owner_user = {"user": {"role": "user", "verified": True, "id": "alice"}, "resource": {"owner": "alice", "public": False}} guest_user = {"user": {"role": "guest", "verified": True, "id": "guest1"}, "resource": {"owner": "bob", "public": True}} -assert evaluate(policy, admin_user) == True # → True (admin access granted) -assert evaluate(policy, owner_user) == True # → True (owner access granted) -assert evaluate(policy, guest_user) == True # → True (public resource access) +assert evaluate(policy, as_context(admin_user)) == True # → True (admin access granted) +assert evaluate(policy, as_context(owner_user)) == True # → True (owner access granted) +assert evaluate(policy, as_context(guest_user)) == True # → True (public resource access) print("āœ“ Access control policies working correctly") # → āœ“ Access control policies working correctly ``` @@ -222,4 +251,4 @@ Simple, readable policies that handle complex business logic. --- -*Built with ā¤ļø using [PyO3](https://pyo3.rs/) and [cel](https://crates.io/crates/cel)* \ No newline at end of file +*Built with ā¤ļø using [PyO3](https://pyo3.rs/) and [cel](https://crates.io/crates/cel)* diff --git a/docs/reference/cel-compliance.md b/docs/reference/cel-compliance.md index fec250b..cb59279 100644 --- a/docs/reference/cel-compliance.md +++ b/docs/reference/cel-compliance.md @@ -61,7 +61,7 @@ This implementation correctly follows the CEL specification where maps can have ### āœ… Core Data Types - **Integers**: Full support for 64-bit signed integers (`int`) - **Unsigned Integers**: Support for 64-bit unsigned integers (`uint`) with `u` suffix -- **Floats**: IEEE 64-bit double precision floating-point +- **Floats**: IEEE 64-bit double precision floating-point - **Booleans**: Standard true/false values - **Strings**: Unicode string support with concatenation - **Bytes**: Byte sequence support (no concatenation) @@ -75,12 +75,12 @@ This implementation correctly follows the CEL specification where maps can have #### Arithmetic Operators - `+` (addition) - Integers, floats, strings -- `-` (subtraction) - Integers, floats +- `-` (subtraction) - Integers, floats - `*` (multiplication) - Integers, floats - `/` (division) - Integers, floats - `%` (remainder/modulo) - Integers only -#### Comparison Operators +#### Comparison Operators - `==` (equal) - All types - `!=` (not equal) - All types - `<`, `>`, `<=`, `>=` - Numbers, strings (lexicographic) @@ -157,7 +157,36 @@ This section focuses on what you need to know to use CEL effectively in your app This library provides Python implementations of missing CEL functions: ```python -from cel import Context, evaluate +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter from cel.stdlib import add_stdlib_to_context # Add all standard library functions at once @@ -165,8 +194,8 @@ context = Context() add_stdlib_to_context(context) # substring() is now available as a function (not a method) -result = evaluate('substring("hello world", 0, 5)', context) # → "hello" -result = evaluate('substring("hello world", 6)', context) # → "world" +result = evaluate('substring("hello world", 0, 5)', as_context(context)) # → "hello" +result = evaluate('substring("hello world", 6)', as_context(context)) # → "world" # Note: Use function syntax, not method syntax # āœ… substring("hello", 2, 4) - correct @@ -178,7 +207,7 @@ result = evaluate('substring("hello world", 6)', context) # → "world" You can also add your own custom functions: ```python -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter # Add custom functions for missing CEL features context = Context() @@ -187,38 +216,38 @@ context.add_function("upper", str.upper) context.add_function("find", str.find) # Add variables to the context -context.add_variable("name", "ALICE") -context.add_variable("text", "hello world") +context.add_variable("name", cel.prepare("ALICE")) +context.add_variable("text", cel.prepare("hello world")) # Use Python functions in CEL expressions -result = evaluate('lower(name)', context) # → "alice" -result = evaluate('find(text, "world")', context) # → 6 +result = evaluate('lower(name)', as_context(context)) # → "alice" +result = evaluate('find(text, "world")', as_context(context)) # → 6 ``` #### Type Safety Best Practices ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # āœ… SAFE: Explicit type conversions for mixed arithmetic -result = evaluate("int(value) + 1", {"value": "42"}) # → 43 +result = evaluate("int(value) + 1", as_context({"value": "42"})) # → 43 # āš ļø RISKY: Mixed int/uint arithmetic - use explicit conversion # evaluate("1 + 2u") # This will fail -result = evaluate("1 + int(2u)") # → 3 (safe alternative) +result = evaluate("1 + int(2u)", cel.Context()) # → 3 (safe alternative) # āœ… SAFE: Use has() checks for optional fields safe_expr = 'has(user.profile) && user.profile.verified' -result = evaluate(safe_expr, {"user": {}}) # → False (graceful handling) +result = evaluate(safe_expr, as_context({"user": {}})) # → False (graceful handling) ``` #### Production-Safe Error Handling ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def safe_evaluate(expression, context): """Wrapper for production CEL evaluation with proper error handling.""" try: - return evaluate(expression, context) + return evaluate(expression, as_context(context)) except ValueError as e: # Parse/syntax errors - log and return safe default print(f"CEL syntax error: {e}") @@ -267,7 +296,7 @@ This section covers upstream work, detection strategies, and contribution opport - **Detection**: āœ… Comprehensive detection for all missing functions - **Missing functions**: - `lowerAscii()` - lowercase conversion - - `upperAscii()` - uppercase conversion + - `upperAscii()` - uppercase conversion - `indexOf(substring)` - find position in strings - `lastIndexOf(substring)` - find last occurrence - `substring(start, end)` - extract substring @@ -279,18 +308,18 @@ This section covers upstream work, detection strategies, and contribution opport ```cel // Should work but doesn't: "Hello".lowerAscii() // case conversion -"hello world".indexOf("world") // substring search +"hello world".indexOf("world") // substring search "hello,world".split(",") // string splitting ``` **Impact**: Medium - useful for string processing **Recommendation**: Contribute to cel crate upstream -#### 2. Mixed Signed/Unsigned Integer Arithmetic -- **Status**: Partially supported +#### 2. Mixed Signed/Unsigned Integer Arithmetic +- **Status**: Partially supported - **Detection**: āœ… Comprehensive detection for mixed operations - **CEL Spec**: Supports both `int` and `uint` types with `u` suffix (`1u`, `42u`) -- **Our Implementation**: +- **Our Implementation**: - āœ… Unsigned literals work: `1u`, `42u` → Python `int` - āœ… Pure unsigned arithmetic: `1u + 2u` → `3` - āŒ Mixed arithmetic fails: `1 + 2u` throws "Unsupported binary operator" @@ -334,19 +363,19 @@ This section covers upstream work, detection strategies, and contribution opport - **Detection**: āœ… Full detection with expected behavior tests **Missing features**: - `optional.of(value)` - create optional -- `optional.orValue(default)` - unwrap with default +- `optional.orValue(default)` - unwrap with default - `?` suffix for optional chaining **Recent Progress**: Upstream has introduced optional type infrastructure, suggesting these features may be implemented in future releases. -### āš ļø Behavioral Differences +### āš ļø Behavioral Differences #### 1. OR Operator Behavior (CRITICAL ISSUE) - **Detection**: āœ… We monitor for when this behavior gets fixed upstream - **Status**: JavaScript-like behavior instead of CEL spec compliance - **Upstream Priority**: **CRITICAL** - This affects specification conformance -#### 2. Type Coercion in Logical Operations +#### 2. Type Coercion in Logical Operations - **Our Implementation**: Performs Python-like truthiness evaluation - **CEL Spec**: May have different rules for type coercion - **Example**: Empty strings, zero values treated as falsy @@ -366,7 +395,7 @@ The underlying cel-rust implementation continues to evolve with improvements tha ### **Potential Future Features** ```cel // May be available in future releases -type(42) // → "int" +type(42) // → "int" type("hello") // → "string" type([1, 2, 3]) // → "list" @@ -416,16 +445,16 @@ All malformed syntax is now handled gracefully with proper Python exceptions: **Examples of safe error handling:** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # All of these now raise clean ValueError exceptions: try: - evaluate("'unclosed quote", {}) + evaluate("'unclosed quote", as_context({})) except ValueError as e: print(f"Parse error: {e}") try: - evaluate('"mixed quotes\'', {}) + evaluate('"mixed quotes\'', as_context({})) except ValueError as e: print(f"Parse error: {e}") ``` @@ -465,13 +494,13 @@ Both the CLI tool and the core `evaluate()` function now handle all malformed in - Impact: **MEDIUM** - Widely used in string processing applications - Contribution path: cel crate standard library expansion -2. **OR operator CEL spec compliance** - āœ… **Detection Ready** +2. **OR operator CEL spec compliance** - āœ… **Detection Ready** - Issue: Returns original values instead of booleans - Impact: **HIGH** - Breaks specification conformance - Contribution path: Core logical operation fixes 3. **Type introspection function** - āœ… **Detection Ready** (`test_upstream_detection.py`) - - Function: `type()` for runtime type checking + - Function: `type()` for runtime type checking - Impact: **MEDIUM** - Useful for dynamic expressions - Contribution path: Leverage existing type system infrastructure @@ -497,16 +526,16 @@ Both the CLI tool and the core `evaluate()` function now handle all malformed in - Impact: **LOW** - Can be implemented via Python context - Contribution path: Standard library expansion -8. **Optional value handling** - āœ… **Detection Ready** +8. **Optional value handling** - āœ… **Detection Ready** - Features: `optional.of()`, `.orValue()`, `?` chaining - Impact: **LOW** - Alternative patterns exist - Contribution path: Type system extensions -### šŸ”§ Local Improvement Opportunities +### šŸ”§ Local Improvement Opportunities #### High Impact (Python Library) 1. **Enhanced error handling** - Better Python exception mapping and messages -2. **Performance benchmarking** - Systematic performance testing and optimization +2. **Performance benchmarking** - Systematic performance testing and optimization 3. **Comprehensive testing** - Cover newly discovered working features #### Medium Impact (Documentation & Tooling) @@ -517,7 +546,7 @@ Both the CLI tool and the core `evaluate()` function now handle all malformed in ### šŸŽ¬ Immediate Actions for Contributors 1. āœ… **Monitoring system active** - All issues have upstream detection -2. šŸ”„ **Priority: OR operator fix** - Most critical specification compliance issue +2. šŸ”„ **Priority: OR operator fix** - Most critical specification compliance issue 3. šŸ“ **Priority: String utilities** - High-value, lower-risk contribution opportunity 4. šŸš€ **Engage upstream** - Discuss contribution strategy with cel crate maintainers @@ -534,6 +563,6 @@ When adding new features or fixing compliance issues: ## Related Resources - **CEL Specification**: https://github.com/google/cel-spec -- **cel crate**: https://crates.io/crates/cel +- **cel crate**: https://crates.io/crates/cel - **CEL Language Definition**: https://github.com/google/cel-spec/blob/master/doc/langdef.md -- **CEL Homepage**: https://cel.dev/ \ No newline at end of file +- **CEL Homepage**: https://cel.dev/ diff --git a/docs/reference/cli-reference.md b/docs/reference/cli-reference.md index 14bdcaf..b137464 100644 --- a/docs/reference/cli-reference.md +++ b/docs/reference/cli-reference.md @@ -43,7 +43,7 @@ cel --help cel -h ``` -#### `--version`, `-v` +#### `--version`, `-v` Show version information and exit. ```bash @@ -87,7 +87,7 @@ cel 'config.valid' -f config.json ``` **Format**: Path to valid JSON file -**Special values**: +**Special values**: - `/dev/stdin` - Read from standard input - `-` - Read from standard input (shorthand) @@ -121,7 +121,7 @@ cel 'user.name' --format raw --context-file user.json **Values**: - `auto` (default) - Automatically detect best format - `json` - JSON format -- `yaml` - YAML format +- `yaml` - YAML format - `raw` - Raw string output (no quotes for strings) - `pretty` - Pretty-printed format @@ -179,7 +179,7 @@ Display current context variables in a formatted table. ``` CEL> context - Context Variables + Context Variables ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”³ā”ā”ā”ā”ā”ā”ā”³ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”“ ā”ƒ Variable ā”ƒ Type ā”ƒ Value ā”ƒ └━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ @@ -230,7 +230,7 @@ Show help message. ``` CEL> help - REPL Commands + REPL Commands ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”³ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”ā”“ ā”ƒ Command ā”ƒ Description ā”ƒ └━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ @@ -285,4 +285,4 @@ cel --interactive When using `--exit-status`, codes are: - 0: Expression evaluated to truthy value -- 1: Expression evaluated to falsy value \ No newline at end of file +- 1: Expression evaluated to falsy value diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index aa03439..509f5ca 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -1,520 +1,103 @@ # Python API Reference -Complete autogenerated reference for the Python CEL library. +The public API uses one explicit pipeline: -## Functions - -::: cel.evaluate - -### compile(expression: str) -> Program - -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 -import cel - -# Compile once -program = cel.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 +```text +Python value -> cel.prepare -> Context.add_variable -> Program.execute(Context) ``` -**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 +## `prepare(value) -> PreparedValue` -**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. +Prepare a supported Python value once. The result is an opaque immutable snapshot that can be reused and shared. Calling `prepare` on a prepared value is idempotent and cheap. ```python import cel -# Compile the expression once -program = cel.compile("price * quantity > 100") - -# Execute many times with different contexts -result1 = program.execute({"price": 10, "quantity": 20}) -assert result1 == True # → True (200 > 100) +source = {"profile": {"enabled": True}} +prepared = cel.prepare(source) +source["profile"]["enabled"] = False -result2 = program.execute({"price": 5, "quantity": 10}) -assert result2 == False # → False (50 > 100) +context = cel.Context() +context.add_variable("user", prepared) +assert cel.compile("user.profile.enabled").execute(context) is True ``` -#### Methods - -##### execute(context=None) -> Any - -Execute the compiled program with the given context. +Supported values include `None`, booleans, signed and unsigned-range integers, floats, strings, bytes, lists, tuples, mappings, timezone-aware and naive datetimes, timedeltas, optional values, and nested combinations. Unsupported values fail during preparation. `PreparedValue` has no public constructor and does not render its payload. -**Parameters:** -- `context`: Optional evaluation context (dict or Context object) +## `Context` -**Returns:** -- The result of the expression evaluation +`Context()` accepts no arguments and owns one persistent native CEL context. -**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 import cel -program = cel.compile("user.name + ' is ' + user.role") -result = program.execute({ - "user": {"name": "Alice", "role": "admin"} -}) -assert result == "Alice is admin" +context = cel.Context() +context.add_variable("name", cel.prepare("Alice")) +context.add_function("greet", lambda name: f"Hello, {name}!") +assert cel.evaluate("greet(name)", context) == "Hello, Alice!" ``` -**Example with Context object:** -```python -import cel -from cel import Context +### `add_variable(name, value)` -program = cel.compile("greet(name)") +`value` must be a `PreparedValue`; raw Python values raise `TypeError`. Adding a name again replaces its binding. No implicit preparation occurs. -ctx = Context() -ctx.add_variable("name", "World") -ctx.add_function("greet", lambda x: f"Hello, {x}!") +### `add_function(name, function)` -result = program.execute(ctx) -assert result == "Hello, World!" -``` - -**Performance pattern - compile once, execute many:** -```python -import cel +Register a callable directly in the native context. The callback adapter is created once and reused by later executions. -# Access control policy - compiled once at startup -policy = cel.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` intentionally has no constructor mappings, `update`, public variable/function dictionaries, mapping behavior, copying, serialization, or introspection API. -### OptionalValue +## `compile(expression) -> Program` -**Wrapper for CEL optional values.** +Compile an expression once and execute it against a `Context`: -CEL optional values preserve the distinction between "no value" and "a value that is null". -The Python wrapper keeps that distinction intact. - -```python -import cel - -opt = cel.evaluate("optional.of(42)") -assert isinstance(opt, cel.OptionalValue) -assert opt.has_value() is True -assert opt.value() == 42 -assert opt.or_value(0) == 42 - -none_opt = cel.evaluate("optional.none()") -assert none_opt.has_value() is False -assert none_opt.or_value("default") == "default" -``` - -**Distinguishing `optional.none()` from `optional.of(null)`:** ```python import cel -opt_null = cel.evaluate("optional.of(null)") -assert opt_null.has_value() is True -assert opt_null.value() is None - -opt_none = cel.evaluate("optional.none()") -assert opt_none.has_value() is False -``` - -**Passing OptionalValue into evaluation contexts:** -```python -import cel - -opt = cel.OptionalValue.of(123) -assert cel.evaluate("opt.orValue(0)", {"opt": opt}) == 123 - -opt_none = cel.OptionalValue.none() -assert cel.evaluate("opt.orValue(7)", {"opt": opt_none}) == 7 -``` - -#### Methods - -##### of(value) -> OptionalValue - -Create an optional value containing `value`. - -##### none() -> OptionalValue - -Create an empty optional value. - -##### has_value() -> bool - -Return `True` when the optional contains a value. - -##### value() -> Any - -Return the contained value or raise `ValueError` for `optional.none()`. - -##### or_value(default) -> Any - -Return the contained value if present, otherwise `default`. - -##### or_optional(other) -> OptionalValue - -Return `self` if it has a value, otherwise return `other`. - ---- - -### Context - -**A class for managing evaluation context with variables and custom functions.** - -The Context class provides more control over the evaluation environment than simple dictionary context. It allows you to: - -- Add variables with type checking -- Register custom Python functions -- Manage complex evaluation scenarios - -```python -from cel import evaluate, Context - -# Basic usage -context = Context() -context.add_variable("name", "Alice") -context.add_variable("age", 30) - -result = evaluate("name + ' is ' + string(age)", context) -# → "Alice is 30" -assert result == "Alice is 30" -``` - -#### Methods - -##### add_variable(name: str, value: Any) -> None - -Add a variable to the context. - -**Parameters:** -- `name`: Variable name (must be valid CEL identifier) -- `value`: Variable value (will be converted to appropriate CEL type) - -**Example:** -```python -from cel import Context, evaluate - -context = Context() -context.add_variable("user_id", "123") -context.add_variable("permissions", ["read", "write"]) -context.add_variable("config", {"debug": True, "port": 8080}) - -# Verify the variables are accessible -evaluate("user_id", context) -# → "123" -evaluate("size(permissions)", context) -# → 2 -evaluate("config.debug", context) -# → True -assert evaluate("user_id", context) == "123" -assert evaluate("size(permissions)", context) == 2 -assert evaluate("config.debug", context) == True -``` - -##### update(variables: Dict[str, Any]) -> None - -Add multiple variables at once. - -**Parameters:** -- `variables`: Dictionary of variable names to values - -**Example:** -```python -from cel import Context, evaluate - -context = Context() -context.update({ - "user_id": "123", - "role": "admin", - "permissions": ["read", "write", "delete"] +prepared = cel.prepare({ + "objects": [ + {"active": i % 2 == 0, "score": i} + for i in range(500) + ] }) +context = cel.Context() +program = cel.compile("data.objects[3].score >= 3") -# Verify the batch update worked -evaluate("user_id", context) -# → "123" -evaluate("role", context) -# → "admin" -evaluate("size(permissions)", context) -# → 3 -assert evaluate("user_id", context) == "123" -assert evaluate("role", context) == "admin" -assert evaluate("size(permissions)", context) == 3 +for _ in range(100_000): + context.add_variable("data", prepared) + assert program.execute(context) is True ``` -##### add_function(name: str, func: Callable) -> None - -Register a Python function for use in CEL expressions. +`Program.execute(context)` requires exactly one concrete `Context`. Dictionaries, `None`, prepared values, omitted arguments, and other representations are rejected. Execution borrows the native context and does not rebuild it or reconvert bound values. -**Parameters:** -- `name`: Function name as it will appear in CEL expressions -- `func`: Python function to register +## `evaluate(expression, context) -> Any` -**Requirements for functions:** -- Should handle type conversions appropriately -- Should raise meaningful exceptions for invalid inputs -- Must be callable from the Python environment +`evaluate` also requires a `Context`: -**Example:** ```python -from cel import Context, evaluate - -def validate_email(email): - import re - pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - return re.match(pattern, email) is not None - -context = Context() -context.add_function("validate_email", validate_email) - -evaluate('validate_email("user@example.com")', context) -# → True - -# Test invalid email -evaluate('validate_email("invalid-email")', context) -# → False -result = evaluate('validate_email("user@example.com")', context) -assert result == True - -result = evaluate('validate_email("invalid-email")', context) -assert result == False -``` - ---- - -## Type System - -### CEL to Python Type Mapping - -This table shows how CEL types are converted to Python types when expressions are evaluated: - -| CEL Type | CEL Spec | Python Type | Example CEL | Python Result | -|----------|----------|-------------|-------------|---------------| -| `int` | 64-bit signed integers | `int` | `42` | `42` | -| `uint` | 64-bit unsigned integers | `int` | `42u` | `42` | -| `double` | 64-bit IEEE floating-point | `float` | `3.14` | `3.14` | -| `bool` | Boolean values | `bool` | `true` | `True` | -| `string` | Unicode code point sequences | `str` | `"hello"` | `"hello"` | -| `bytes` | Byte sequences | `bytes` | `b"data"` | `b"data"` | -| `null_type` | Null value | `NoneType` | `null` | `None` | -| `list` | Ordered sequences | `list` | `[1, 2, 3]` | `[1, 2, 3]` | -| `map` | Key-value collections | `dict` | `{"key": "value"}` | `{"key": "value"}` | -| `timestamp` | Protocol buffer timestamps | `datetime.datetime` | `timestamp("2024-01-01T00:00:00Z")` | `datetime(2024, 1, 1, tzinfo=timezone.utc)` | -| `duration` | Protocol buffer durations | `datetime.timedelta` | `duration("1h30m")` | `timedelta(hours=1, minutes=30)` | - -#### Map Type Constraints - -**āœ… FULLY COMPLIANT** with CEL specification: - -- **Key Types**: Restricted to `int`, `uint`, `bool`, and `string` as per CEL spec -- **Value Types**: Support heterogeneous values (mixed types) as allowed by CEL spec -- **Runtime Behavior**: Maps can contain `dyn` types for mixed-value collections - -**Examples:** -```cel -// āœ… Valid key types -{1: "int key", "str": "string key", true: "bool key"} - -// āœ… Mixed value types (CEL compliant) -{"name": "Alice", "age": 30, "verified": true, "score": 95.5} - -// āœ… Nested heterogeneous structures -{"users": [{"name": "Alice"}, {"name": "Bob"}], "count": 2} -``` - -### Python to CEL Type Mapping - -When passing Python objects as context: - -| Python Type | CEL Type | Notes | -|-------------|----------|-------| -| `int` | `int` | Direct mapping | -| `float` | `double` | Direct mapping | -| `str` | `string` | Direct mapping | -| `bool` | `bool` | Direct mapping | -| `None` | `null` | Direct mapping | -| `list` | `list(T)` | Element types preserved | -| `dict` | `map(K, V)` | Key/value types preserved | -| `bytes` | `bytes` | Direct mapping | -| `datetime.datetime` | `timestamp` | Timezone info preserved | -| `datetime.timedelta` | `duration` | Direct mapping | - ---- - -## Error Handling - -### Exception Types - -The library raises specific exception types for different error conditions based on the underlying error type: - -#### `ValueError` - Parse and Compilation Errors - -Raised when the CEL expression has invalid syntax, is empty, or fails to compile: - -```python -from cel import evaluate - -# Invalid syntax raises ValueError -try: - evaluate("1 + + 2") # Invalid syntax - # → ValueError: Failed to parse expression: ... - assert False, "Should have raised ValueError" -except ValueError as e: - assert "Failed to parse expression" in str(e) - -# Empty expression raises ValueError -try: - evaluate("") - # → ValueError: Failed to parse expression - assert False, "Should have raised ValueError" -except ValueError as e: - assert "Failed to parse expression" in str(e) -``` - -#### `RuntimeError` - Variable and Function Errors - -Raised for undefined variables or functions, and function execution errors: +import cel -```python -from cel import evaluate - -# Undefined variables raise RuntimeError -try: - evaluate("unknown_variable + 1", {}) - # → RuntimeError: Undefined variable 'unknown_variable' - assert False, "Should have raised RuntimeError" -except RuntimeError as e: - assert "Undefined variable" in str(e) - -# Undefined functions raise RuntimeError -try: - evaluate("unknownFunction(42)", {}) - # → RuntimeError: Undefined function 'unknownFunction' - assert False, "Should have raised RuntimeError" -except RuntimeError as e: - assert "Undefined" in str(e) and "function" in str(e) - -# Function execution errors raise RuntimeError -from cel import Context -def failing_function(): - raise Exception("Something went wrong") - -context = Context() -context.add_function("fail", failing_function) - -try: - evaluate("fail()", context) - # → RuntimeError: Function 'fail' error: Something went wrong - assert False, "Should have raised RuntimeError" -except RuntimeError as e: - assert "Function 'fail' error" in str(e) +context = cel.Context() +context.add_variable("answer", cel.prepare(42)) +assert cel.evaluate("answer", context) == 42 ``` -#### `TypeError` - Type Compatibility Errors +## `OptionalValue` -Raised when operations are performed on incompatible types: +`OptionalValue.of(value)` and `OptionalValue.none()` expose CEL optional values to Python. Prepare them before binding: ```python -from cel import evaluate - -# String + int operations raise TypeError -try: - evaluate('"hello" + 42') # String + int - # → TypeError: Unsupported addition operation between string and int - assert False, "Should have raised TypeError" -except TypeError as e: - assert "Unsupported addition operation" in str(e) - -# Mixed signed/unsigned int operations raise TypeError -try: - evaluate("1u + 2") # Mixed signed/unsigned int - # → TypeError: Cannot mix signed and unsigned integers - assert False, "Should have raised TypeError" -except TypeError as e: - assert "Cannot mix signed and unsigned integers" in str(e) - -# Unsupported multiplication raises TypeError -try: - evaluate('"text" * "more"') # String multiplication - # → TypeError: Unsupported multiplication operation between strings - assert False, "Should have raised TypeError" -except TypeError as e: - assert "Unsupported multiplication operation" in str(e) -``` - -#### Mixed Type Arithmetic Errors - -**Mixed numeric types raise TypeError:** +import cel -```python -from cel import evaluate - -# Mixed numeric types in expressions -try: - evaluate("1 + 2.5") # int + double -except TypeError as e: - assert "Unsupported addition operation" in str(e) - print(f"Mixed arithmetic error: {e}") - -# Mixed types from context -context = {"int_val": 10, "float_val": 2.5} -try: - evaluate("int_val * float_val", context) -except TypeError as e: - assert "Unsupported multiplication operation" in str(e) - print(f"Context type mixing error: {e}") - -# To fix mixed arithmetic, use consistent types: -result = evaluate("1.0 + 2.5") # → 3.5 (both doubles) -result = evaluate("1 + 2") # → 3 (both ints) +context = cel.Context() +context.add_variable("value", cel.prepare(cel.OptionalValue.of(42))) +context.add_variable("missing", cel.prepare(cel.OptionalValue.none())) +assert cel.compile("value.orValue(0)").execute(context) == 42 +assert cel.compile("missing.orValue(7)").execute(context) == 7 ``` -### Production Error Handling +## Performance and lifetime notes -For comprehensive error handling patterns, safety guidelines, and production best practices, see the **[Error Handling How-To Guide](../how-to-guides/error-handling.md)** which covers: +For a retained prepared value, replacing a binding clones only a shared handle. Fixed field/index paths returning primitive values do not clone unrelated maps, lists, or objects. Expressions that inspect, return, or pass large values remain proportional to the data examined or materialized. -- Safe handling of malformed expressions and untrusted input -- Safe evaluation wrappers and best practices -- Context validation patterns -- Defensive expression techniques -- Logging and monitoring -- Testing error scenarios +Retain prepared objects used for hot-path replacement. If a context owns the final reference to a large prepared value, replacing or dropping that final reference can recursively free the value and may take time proportional to its size. diff --git a/docs/tutorials/cel-language-basics.md b/docs/tutorials/cel-language-basics.md index 1163569..7da238f 100644 --- a/docs/tutorials/cel-language-basics.md +++ b/docs/tutorials/cel-language-basics.md @@ -12,17 +12,17 @@ This comprehensive guide covers all CEL syntax, operators, and built-in function Python CEL implements a comprehensive subset of the CEL specification: -āœ… **Core CEL Types**: Integers (signed/unsigned), floats, booleans, strings, bytes, lists, maps, null -āœ… **Arithmetic Operations**: `+`, `-`, `*`, `/`, `%` (strict type matching required) -āœ… **Comparison Operations**: `==`, `!=`, `<`, `>`, `<=`, `>=` -āœ… **Logical Operations**: `&&`, `||`, `!` with short-circuit evaluation -āœ… **String Operations**: Concatenation, indexing, `startsWith()`, `endsWith()`, `contains()`, `size()` -āœ… **Collection Operations**: List/map indexing, `size()`, `.all()`, `.exists()`, `.filter()` -āœ… **Datetime Support**: `timestamp()` and `duration()` functions with arithmetic -āœ… **Member Access**: Dot notation, bracket notation, safe access patterns -āœ… **Ternary Operator**: `condition ? true_value : false_value` -āœ… **Type Functions**: `has()`, conversion functions -āœ… **Python Integration**: Custom functions, Python ↔ CEL type conversion +āœ… **Core CEL Types**: Integers (signed/unsigned), floats, booleans, strings, bytes, lists, maps, null +āœ… **Arithmetic Operations**: `+`, `-`, `*`, `/`, `%` (strict type matching required) +āœ… **Comparison Operations**: `==`, `!=`, `<`, `>`, `<=`, `>=` +āœ… **Logical Operations**: `&&`, `||`, `!` with short-circuit evaluation +āœ… **String Operations**: Concatenation, indexing, `startsWith()`, `endsWith()`, `contains()`, `size()` +āœ… **Collection Operations**: List/map indexing, `size()`, `.all()`, `.exists()`, `.filter()` +āœ… **Datetime Support**: `timestamp()` and `duration()` functions with arithmetic +āœ… **Member Access**: Dot notation, bracket notation, safe access patterns +āœ… **Ternary Operator**: `condition ? true_value : false_value` +āœ… **Type Functions**: `has()`, conversion functions +āœ… **Python Integration**: Custom functions, Python ↔ CEL type conversion See [CEL Compliance](../reference/cel-compliance.md) for detailed feature status. @@ -31,7 +31,7 @@ See [CEL Compliance](../reference/cel-compliance.md) for detailed feature status ### Numbers ```cel 42 // Integer -42u // Unsigned integer +42u // Unsigned integer 3.14 // Double/float -17 // Negative numbers 1e6 // Scientific notation (1,000,000) @@ -157,7 +157,7 @@ has(obj.field) // Field existence → true/false timestamp("2024-01-01T00:00:00Z") // From ISO string timestamp("2024-01-01T00:00:00-05:00") // With timezone -// Create durations +// Create durations duration("1h") // 1 hour duration("30m") // 30 minutes duration("1h30m") // 1 hour 30 minutes @@ -174,7 +174,7 @@ timestamp("2024-01-01T14:00:00Z") - duration("1h") // Subtract duration // Check all items meet condition [1, 2, 3].all(x, x > 0) // → true -// Check any item meets condition +// Check any item meets condition [1, 2, 3].exists(x, x == 2) // → true // Filter items by condition @@ -226,7 +226,7 @@ age >= 0 && age <= 120 // Required field validation has(user.name) && user.name != "" -// Numeric validation +// Numeric validation has(user.age) && user.age > 0 ``` @@ -235,7 +235,7 @@ has(user.age) && user.age > 0 // Role-based access user.role == "admin" -// Multi-role access +// Multi-role access user.role in ["admin", "moderator"] // Permission-based access @@ -266,7 +266,7 @@ hour >= 9 && hour <= 17 // Business hours users.filter(u, u.active) // Find admin users -users.filter(u, u.role == "admin") +users.filter(u, u.role == "admin") // Complex filtering orders.filter(o, o.total > 100 && o.status == "paid") @@ -290,7 +290,7 @@ email.contains("@") && email.endsWith(".com") {"status": active ? "enabled" : "disabled"} ``` -### List Construction +### List Construction ```cel // Dynamic lists from filtering users.filter(u, u.active).map(u, u.name) // Names of active users @@ -300,7 +300,7 @@ users.filter(u, u.active).map(u, u.name) // Names of active users ### Supported Types - **int**: 64-bit signed integers -- **uint**: 64-bit unsigned integers +- **uint**: 64-bit unsigned integers - **double**: 64-bit floating point - **string**: UTF-8 strings - **bool**: true/false @@ -366,4 +366,4 @@ Now that you've learned the complete CEL syntax, choose your next path based on **šŸ’” Pro Tip:** If you're new to CEL, we recommend: **Language Basics → [Your First Integration](your-first-integration.md) → [Access Control Policies](../how-to-guides/access-control-policies.md)** -Remember: CEL is **non-Turing complete** by design. No loops, no function definitions, no side effects. This makes it safe, predictable, and perfect for configuration, policies, and business rules! \ No newline at end of file +Remember: CEL is **non-Turing complete** by design. No loops, no function definitions, no side effects. This makes it safe, predictable, and perfect for configuration, policies, and business rules! diff --git a/docs/tutorials/extending-cel.md b/docs/tutorials/extending-cel.md index 1f8cf23..16dbce3 100644 --- a/docs/tutorials/extending-cel.md +++ b/docs/tutorials/extending-cel.md @@ -8,38 +8,67 @@ You've learned the basics in [Your First Integration](your-first-integration.md) ## The Context Class -While dictionary context works well for simple cases, the `Context` class provides more control and features for complex applications. +Use `cel.prepare()` and a reusable `Context` for all application data; this keeps conversion outside the execution path and supports custom functions. ### Basic Context Usage ```python -from cel import evaluate, Context +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter # Create a context object context = Context() # Add variables one by one -context.add_variable("user_name", "Alice") -context.add_variable("user_age", 30) -context.add_variable("permissions", ["read", "write"]) +context.add_variable("user_name", cel.prepare("Alice")) +context.add_variable("user_age", cel.prepare(30)) +context.add_variable("permissions", cel.prepare(["read", "write"])) # Use the context -result = evaluate("user_name + ' is ' + string(user_age)", context) +result = evaluate("user_name + ' is ' + string(user_age)", as_context(context)) assert result == "Alice is 30" # → String concatenation with type conversion -result = evaluate('"write" in permissions', context) +result = evaluate('"write" in permissions', as_context(context)) assert result == True # → List membership check for permissions ``` ### Adding Multiple Variables ```python -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter context = Context() # Add multiple variables at once -context.update({ +add_variables(context, { "user": { "id": "user123", "name": "Bob", @@ -55,12 +84,12 @@ context.update({ # Complex expressions with nested data policy = """ - user.verified && + user.verified && environment == "production" && user.email.endsWith("@example.com") """ -result = evaluate(policy, context) +result = evaluate(policy, as_context(context)) assert result == True # → Complex multi-condition policy evaluation ``` @@ -80,8 +109,8 @@ assert result == True # → Complex multi-condition policy evaluation **Step 1: Simple Dictionary Example** ```python # Simple case - dictionary is fine -result = evaluate("x + y", {"x": 10, "y": 20}) -assert result == 30 # → Basic arithmetic with dictionary context +result = evaluate("x + y", as_context({"x": 10, "y": 20})) +assert result == 30 # → Basic arithmetic with explicit prepared contexts ``` **Step 2: Define Custom Functions** @@ -107,10 +136,10 @@ context.add_function("check_permissions", check_permissions) **Step 4: Add Variables and Evaluate** ```python -context.add_variable("base_config", {"database": {"host": "localhost", "port": 5432}}) -context.add_variable("user", {"email": "test@example.com"}) +context.add_variable("base_config", cel.prepare({"database": {"host": "localhost", "port": 5432}})) +context.add_variable("user", cel.prepare({"email": "test@example.com"})) -result = evaluate("validate_email(user.email) && check_permissions()", context) +result = evaluate("validate_email(user.email) && check_permissions()", as_context(context)) assert result == True # → Custom function orchestration for business logic ``` @@ -122,7 +151,7 @@ One of CEL's most powerful features is the ability to call Python functions from **Step 1: Define Your Functions** ```python -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter def calculate_tax(income, rate=0.1): """Calculate tax based on income and rate.""" @@ -142,16 +171,16 @@ tax_context.add_function("is_valid_email", is_valid_email) **Step 3: Add Variables** ```python -tax_context.add_variable("user_income", 50000) -tax_context.add_variable("user_email", "alice@example.com") +tax_context.add_variable("user_income", cel.prepare(50000)) +tax_context.add_variable("user_email", cel.prepare("alice@example.com")) ``` **Step 4: Evaluate Expressions** ```python -tax_result = evaluate("calculate_tax(user_income, 0.15)", tax_context) +tax_result = evaluate("calculate_tax(user_income, 0.15)", as_context(tax_context)) assert tax_result == 7500.0 # → Function with parameters: 50000 * 0.15 -email_result = evaluate("is_valid_email(user_email)", tax_context) +email_result = evaluate("is_valid_email(user_email)", as_context(tax_context)) assert email_result == True # → Validation function returns boolean ``` @@ -159,7 +188,7 @@ assert email_result == True # → Validation function returns boolean **Step 1: Define Complex Business Functions** ```python -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter def score_calculation(base_score, bonus_multiplier): """Calculate final score with bonus.""" @@ -183,13 +212,13 @@ def format_user_info(name, age, department): ```python demo_context = Context() demo_context.add_function("score_calculation", score_calculation) -demo_context.add_function("is_prime", is_prime) +demo_context.add_function("is_prime", is_prime) demo_context.add_function("format_user_info", format_user_info) ``` **Step 3: Add Test Data** ```python -demo_context.update({ +add_variables(demo_context, { "employee": { "name": "Alice", "age": 25, @@ -205,25 +234,25 @@ demo_context.update({ **Step 4: Test Individual Functions** ```python -calc_result = evaluate("score_calculation(employee.base_score, config.multiplier)", demo_context) +calc_result = evaluate("score_calculation(employee.base_score, config.multiplier)", as_context(demo_context)) assert calc_result == 102.0 # → Mathematical function: 85 * 1.2 -prime_check = evaluate("is_prime(employee.age)", demo_context) +prime_check = evaluate("is_prime(employee.age)", as_context(demo_context)) assert prime_check == False # → Algorithmic function: 25 is not prime -info_text = evaluate('format_user_info(employee.name, employee.age, employee.department)', demo_context) +info_text = evaluate('format_user_info(employee.name, employee.age, employee.department)', as_context(demo_context)) assert info_text == "Alice (25) from Engineering" # → String formatting function ``` **Step 5: Combine Functions in Complex Rules** ```python business_rule = """ - config.bonus_active && + config.bonus_active && score_calculation(employee.base_score, config.multiplier) > 100 && employee.age >= 18 """ -final_result = evaluate(business_rule, demo_context) +final_result = evaluate(business_rule, as_context(demo_context)) assert final_result == True # → Complex business rule combining multiple functions print("āœ“ Complex validation functions working correctly") @@ -235,7 +264,7 @@ Now let's see how to combine custom functions for a real-world application - a b **Step 1: Define Business Validation Functions** ```python -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter import re from datetime import datetime, timedelta @@ -272,12 +301,12 @@ def user_has_permission(user_id, permission, permissions_db): def create_business_rules_context(): """Create a context with business validation functions.""" context = Context() - + # Register all functions context.add_function("validate_password", validate_password) context.add_function("days_until_expiry", days_until_expiry) context.add_function("user_has_permission", user_has_permission) - + return context business_context = create_business_rules_context() @@ -285,7 +314,7 @@ business_context = create_business_rules_context() **Step 3: Add Business Data** ```python -business_context.update({ +add_variables(business_context, { "user": { "id": "user123", "password": "MySecure123", @@ -316,21 +345,21 @@ admin_actions_rule = """ **Step 5: Evaluate and Test Rules** ```python # Test valid user -can_access_account = evaluate(account_access_rule, business_context) -can_perform_admin_actions = evaluate(admin_actions_rule, business_context) +can_access_account = evaluate(account_access_rule, as_context(business_context)) +can_perform_admin_actions = evaluate(admin_actions_rule, as_context(business_context)) assert can_access_account == True # → Enterprise access control validation assert can_perform_admin_actions == True # → Admin privilege verification # Test with invalid user -business_context.add_variable("user", { - "id": "user456", +business_context.add_variable("user", cel.prepare({ + "id": "user456", "password": "weak", # Fails password validation "subscription_expires": "2023-01-01T00:00:00Z", # Expired "verified": False -}) +})) -expired_user_access = evaluate(account_access_rule, business_context) +expired_user_access = evaluate(account_access_rule, as_context(business_context)) assert expired_user_access == False # → Security policy correctly denies access print("āœ“ Business rules engine working correctly") @@ -371,25 +400,25 @@ error_context = Context() error_context.add_function("check_user_exists", check_user_exists) error_context.add_function("get_user_status", get_user_status) error_context.add_function("safe_divide", safe_divide) -error_context.add_variable("users_db", { +error_context.add_variable("users_db", cel.prepare({ "user123": {"name": "Alice", "status": "active"} -}) +})) ``` **Step 3: Test Error Handling** ```python # Test individual safety functions -exists_check = evaluate('check_user_exists("user123", users_db)', error_context) +exists_check = evaluate('check_user_exists("user123", users_db)', as_context(error_context)) assert exists_check == True # → Database existence check with error safety -status_check = evaluate('get_user_status("user123", users_db) == "active"', error_context) +status_check = evaluate('get_user_status("user123", users_db) == "active"', as_context(error_context)) assert status_check == True # → Status validation with fallback handling # Test combined safety check safety_result = evaluate(""" - check_user_exists("user123", users_db) && + check_user_exists("user123", users_db) && get_user_status("user123", users_db) == "active" -""", error_context) +""", as_context(error_context)) assert safety_result == True # → Chained safety validations for robustness ``` @@ -407,15 +436,15 @@ def format_currency(amount, currency="USD"): ```python currency_context = Context() currency_context.add_function("format_currency", format_currency) -currency_context.add_variable("price", 29.99) +currency_context.add_variable("price", cel.prepare(29.99)) ``` **Step 3: Test Pure Function** ```python -currency_result = evaluate('format_currency(price)', currency_context) +currency_result = evaluate('format_currency(price)', as_context(currency_context)) assert currency_result == "USD 29.99" # → Pure function with default parameter -eur_result = evaluate('format_currency(price, "EUR")', currency_context) +eur_result = evaluate('format_currency(price, "EUR")', as_context(currency_context)) assert eur_result == "EUR 29.99" # → Pure function with explicit parameter print("āœ“ Pure functions working correctly") @@ -431,70 +460,70 @@ These patterns provide the foundation for production-ready systems: **Complete PolicyContext Implementation** ```python -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter from datetime import datetime class PolicyContext: """Reusable context builder for policy evaluation.""" - + def __init__(self): self.context = Context() self._setup_common_functions() - + def _setup_common_functions(self): """Set up commonly used functions.""" def current_time(): return datetime.now() - + def is_business_hours(): # For testing purposes, always return True # In production, use: datetime.now().hour to check 9 <= hour <= 17 return True - + def contains_any(text, keywords): """Check if text contains any of the keywords.""" return any(keyword.lower() in text.lower() for keyword in keywords) - + self.context.add_function("current_time", current_time) self.context.add_function("is_business_hours", is_business_hours) self.context.add_function("contains_any", contains_any) - + def add_user(self, user_data): """Add user information to context.""" - self.context.add_variable("user", { + self.context.add_variable("user", cel.prepare({ "id": user_data.get("id"), "name": user_data.get("name"), "email": user_data.get("email"), "roles": user_data.get("roles", []), "verified": user_data.get("verified", False), "department": user_data.get("department", "unknown") - }) + })) return self - + def add_resource(self, resource_data): """Add resource information to context.""" - self.context.add_variable("resource", { + self.context.add_variable("resource", cel.prepare({ "id": resource_data.get("id"), "type": resource_data.get("type"), "owner": resource_data.get("owner"), "public": resource_data.get("public", False), "tags": resource_data.get("tags", []) - }) + })) return self - + def add_request_info(self, method, path, ip_address): """Add request information to context.""" - self.context.add_variable("request", { + self.context.add_variable("request", cel.prepare({ "method": method, "path": path, "ip": ip_address, "time": datetime.now().isoformat() - }) + })) return self - + def evaluate_policy(self, policy_expression): """Evaluate a policy expression with this context.""" - return evaluate(policy_expression, self.context) + return evaluate(policy_expression, as_context(self.context)) ``` **Using the Context Builder** @@ -503,7 +532,7 @@ policy_ctx = PolicyContext() policy_ctx.add_user({ "id": "alice", "name": "Alice Smith", - "email": "alice@company.com", + "email": "alice@company.com", "roles": ["user", "developer"], "verified": True, "department": "engineering" @@ -533,26 +562,26 @@ assert access_granted == True # → Enterprise policy with reusable context bui **Step 1: Create Base Context Class** ```python -from cel import Context +# Context/evaluate are provided by the documentation adapter class BaseContext: """Base context with common functions.""" - + def __init__(self): self.context = Context() self._add_base_functions() - + def _add_base_functions(self): def string_length(s): return len(str(s)) - + def is_empty(value): if value is None: return True if isinstance(value, (str, list, dict)): return len(value) == 0 return False - + self.context.add_function("string_length", string_length) self.context.add_function("is_empty", is_empty) ``` @@ -561,21 +590,21 @@ class BaseContext: ```python class WebAppContext(BaseContext): """Extended context for web applications.""" - + def __init__(self): super().__init__() self._add_web_functions() - + def _add_web_functions(self): def is_safe_redirect(url): """Check if URL is safe for redirects.""" dangerous_schemes = ["javascript:", "data:", "vbscript:"] return not any(url.lower().startswith(scheme) for scheme in dangerous_schemes) - + def extract_domain(email): """Extract domain from email address.""" return email.split("@")[-1] if "@" in email else "" - + self.context.add_function("is_safe_redirect", is_safe_redirect) self.context.add_function("extract_domain", extract_domain) ``` @@ -583,15 +612,15 @@ class WebAppContext(BaseContext): **Step 3: Use Inherited Context** ```python web_context = WebAppContext() -web_context.context.update({ +add_variables(web_context.context, { "redirect_url": "https://example.com/dashboard", "user_email": "alice@company.com" }) safety_check = evaluate(""" - is_safe_redirect(redirect_url) && + is_safe_redirect(redirect_url) && extract_domain(user_email) == "company.com" -""", web_context.context) +""", as_context(web_context.context)) assert safety_check == True # → Inherited context with specialized web functions ``` @@ -602,45 +631,45 @@ Always test your custom functions thoroughly: ```python import pytest -from cel import Context, evaluate +# Context/evaluate are provided by the documentation adapter def test_custom_functions(): """Test custom function behavior.""" - + def divide_safely(a, b): if b == 0: return float('inf') return a / b - + context = Context() context.add_function("divide_safely", divide_safely) - + # Test normal division - result = evaluate("divide_safely(10, 2)", context) + result = evaluate("divide_safely(10, 2)", as_context(context)) assert result == 5.0 # → Safe division function handles normal cases - + # Test division by zero - result = evaluate("divide_safely(10, 0)", context) + result = evaluate("divide_safely(10, 0)", as_context(context)) assert result == float('inf') # → Graceful error handling returns infinity - + # Test with context variables - context.add_variable("numerator", 15) - context.add_variable("denominator", 3) - result = evaluate("divide_safely(numerator, denominator)", context) + context.add_variable("numerator", cel.prepare(15)) + context.add_variable("denominator", cel.prepare(3)) + result = evaluate("divide_safely(numerator, denominator)", as_context(context)) assert result == 5.0 # → Function integration with context variables def test_context_isolation(): """Test that contexts don't interfere with each other.""" - + context1 = Context() - context1.add_variable("value", 10) - + context1.add_variable("value", cel.prepare(10)) + context2 = Context() - context2.add_variable("value", 20) - - result1 = evaluate("value * 2", context1) - result2 = evaluate("value * 2", context2) - + context2.add_variable("value", cel.prepare(20)) + + result1 = evaluate("value * 2", as_context(context1)) + result2 = evaluate("value * 2", as_context(context2)) + assert result1 == 20 # → First context: 10 * 2 assert result2 == 40 # → Second context: 20 * 2, isolated state @@ -658,10 +687,10 @@ else: You now have the advanced skills needed for production CEL implementations: -āœ… **Advanced Context Management** - Context builders, inheritance, and composition patterns -āœ… **Production-Quality Functions** - Error handling, pure functions, and comprehensive testing -āœ… **Scalable Architectures** - Reusable context builders for complex applications -āœ… **Testing Strategies** - Isolated testing and validation patterns +āœ… **Advanced Context Management** - Context builders, inheritance, and composition patterns +āœ… **Production-Quality Functions** - Error handling, pure functions, and comprehensive testing +āœ… **Scalable Architectures** - Reusable context builders for complex applications +āœ… **Testing Strategies** - Isolated testing and validation patterns ## Ready for Production? diff --git a/docs/tutorials/thinking-in-cel.md b/docs/tutorials/thinking-in-cel.md index 2053f51..c770817 100644 --- a/docs/tutorials/thinking-in-cel.md +++ b/docs/tutorials/thinking-in-cel.md @@ -28,10 +28,39 @@ Before diving deeper into CEL, let's step back and understand what makes CEL fun CEL is intentionally **not** a general-purpose programming language. You can't write loops, define functions, or perform I/O operations. This limitation is actually CEL's greatest strength. ```python -from cel import evaluate +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter # āœ… This works - safe expression evaluation -result = evaluate("user.age >= 18 && user.verified", {"user": {"age": 25, "verified": True}}) +result = evaluate("user.age >= 18 && user.verified", as_context({"user": {"age": 25, "verified": True}})) assert result == True # → True (adult verified user) # āŒ This is impossible - no loops or side effects @@ -53,7 +82,7 @@ assert result == True # → True (adult verified user) CEL expressions describe **what** you want, not **how** to compute it. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Declarative: "I want users who are adults and verified" user_filter = "user.age >= 18 && user.verified" @@ -66,7 +95,7 @@ test_cases = [ ] for context, expected in test_cases: - result = evaluate(user_filter, context) + result = evaluate(user_filter, as_context(context)) assert result == expected ``` @@ -83,7 +112,7 @@ This declarative nature makes CEL expressions: CEL expressions always return the same result given the same input. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # This expression will ALWAYS return the same result for the same user policy = "user.role == 'admin' || (user.department == 'IT' && user.yearsOfService > 2)" @@ -97,7 +126,7 @@ test_scenarios = [ ] for user_data, expected in test_scenarios: - result = evaluate(policy, {"user": user_data}) + result = evaluate(policy, as_context({"user": user_data})) assert result == expected # → Results match expected access levels ``` @@ -109,23 +138,23 @@ for user_data, expected in test_scenarios: **Policy and Rules Engines** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Business pricing with multiple factors pricing_rule = "base_price * (double(1) + tax_rate) * double(premium_customer ? 0.9 : 1.0)" -result = evaluate(pricing_rule, { +result = evaluate(pricing_rule, as_context({ "base_price": 100.0, "tax_rate": 0.08, "premium_customer": True -}) +})) # → 97.2 (premium customer gets 10% discount) assert result == 97.2 # Testing for illustration - not required in your code # Multi-tier access control access_policy = "user.role == 'admin' || (resource.owner == user.id && action in ['read', 'update']) || (resource.public && action == 'read')" -result = evaluate(access_policy, { +result = evaluate(access_policy, as_context({ "user": {"role": "admin", "id": "user1"}, "resource": {"owner": "user2", "public": False}, "action": "delete" -}) +})) # → True (admin role grants access to any action) assert result == True # Testing for illustration - not required in your code ``` @@ -134,7 +163,7 @@ assert result == True # Testing for illustration - not required in your code **Configuration Validation** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Business rule validation table validation_rules = { @@ -152,13 +181,13 @@ config = { # Validate all rules for description, rule in validation_rules.items(): - result = evaluate(rule, config) + result = evaluate(rule, as_context(config)) assert result == True, f"Failed: {description}" ``` **Data Filtering and Transformation** ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Dynamic API filters filters = { @@ -167,7 +196,7 @@ filters = { } for name, (expr, ctx, expected) in filters.items(): - result = evaluate(expr, ctx) + result = evaluate(expr, as_context(ctx)) assert result == expected # → Results match expected filter outcomes ``` @@ -265,7 +294,7 @@ Use Python for stateful operations: class RateLimiter: def __init__(self): self.requests = {} # Persistent state - + def is_allowed(self, user_id, max_requests=100): # Track request counts over time current_count = self.requests.get(user_id, 0) @@ -288,16 +317,16 @@ assert rate_limiter.is_allowed("user1", max_requests=2) == False # → False (l CEL expressions should be readable by non-programmers. Business users should be able to understand and potentially modify them. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # āœ… GOOD: Clear and readable clear_rule = "order.total > 100 && customer.loyalty_tier == 'gold'" -result = evaluate(clear_rule, {"order": {"total": 150}, "customer": {"loyalty_tier": "gold"}}) +result = evaluate(clear_rule, as_context({"order": {"total": 150}, "customer": {"loyalty_tier": "gold"}})) assert result == True # → True (gold customer with large order) -# āŒ BAD: Too cryptic - avoid this style +# āŒ BAD: Too cryptic - avoid this style cryptic_rule = "o.t > 1e2 && c.lt == 'g'" -result = evaluate(cryptic_rule, {"o": {"t": 150}, "c": {"lt": "g"}}) +result = evaluate(cryptic_rule, as_context({"o": {"t": 150}, "c": {"lt": "g"}})) assert result == True # → True (works but unreadable) ``` @@ -311,7 +340,7 @@ assert result == True # → True (works but unreadable) **Why readable names matter:** - Business users can review and suggest changes -- Debugging is faster when expressions are self-documenting +- Debugging is faster when expressions are self-documenting - Code reviews focus on logic, not deciphering abbreviations **šŸ’” Takeaway: Use readable identifiers so policies are self-documenting.** @@ -321,7 +350,7 @@ assert result == True # → True (works but unreadable) Provide clean, well-structured data to your expressions. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # āœ… Clean, structured context context = { @@ -331,7 +360,7 @@ context = { } policy = "user.role == 'admin' || (resource.owner == user.id && 'delete' in user.permissions)" -result = evaluate(policy, context) +result = evaluate(policy, as_context(context)) assert result == True ``` @@ -344,7 +373,7 @@ assert result == True CEL expressions are code - treat them as such with proper testing. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Compact test scenarios test_cases = [ @@ -354,7 +383,7 @@ test_cases = [ ] for name, policy, context, expected in test_cases: - result = evaluate(policy, context) + result = evaluate(policy, as_context(context)) assert result == expected # → Results match expected access decisions ``` @@ -365,7 +394,7 @@ for name, policy, context, expected in test_cases: Always check for field existence when dealing with optional data. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # āœ… Safe patterns with has() checks safety_examples = [ @@ -375,7 +404,7 @@ safety_examples = [ ] for name, expr, context, expected in safety_examples: - result = evaluate(expr, context) + result = evaluate(expr, as_context(context)) assert result == expected # → Results show safe handling of optional fields ``` @@ -386,7 +415,7 @@ for name, expr, context, expected in safety_examples: Make it clear what data your expressions expect. ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Expected context schema: # { @@ -404,7 +433,7 @@ test_context = { "action": "read" } -result = evaluate(access_policy, test_context) +result = evaluate(access_policy, as_context(test_context)) assert result == True # → True (owner access granted) ``` @@ -421,24 +450,24 @@ Think of CEL as a very smart calculator that can work with complex data structur 3. **Get a result** (always the same for the same inputs) ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter # Like a calculator, but for complex logic expression = "price * double(quantity) * (double(1) + tax_rate) * double(customer.vip ? 0.9 : 1.0)" context = { "price": 29.99, - "quantity": 2, + "quantity": 2, "tax_rate": 0.08, "customer": {"vip": True} } -total = evaluate(expression, context) # 58.38 (with VIP discount) +total = evaluate(expression, as_context(context)) # 58.38 (with VIP discount) assert abs(total - 58.3006) < 0.001 # 29.99 * 2 * 1.08 * 0.9 ``` This mental model helps you understand CEL's boundaries: - Calculators don't send emails → CEL doesn't do I/O -- Calculators don't remember previous calculations → CEL doesn't have state +- Calculators don't remember previous calculations → CEL doesn't have state - Calculators always give the same answer → CEL is deterministic ## Understanding CEL's Place in Your Architecture @@ -469,4 +498,4 @@ Choose your path based on your current experience and goals: - **Have CEL experience:** Use this as a design reference when building complex applications - **Evaluating CEL:** This tutorial + [CEL Compliance](../reference/cel-compliance.md) will help you decide if CEL fits your needs -Armed with these concepts, you're ready to build safe, maintainable, and powerful expression-based systems! \ No newline at end of file +Armed with these concepts, you're ready to build safe, maintainable, and powerful expression-based systems! diff --git a/docs/tutorials/your-first-integration.md b/docs/tutorials/your-first-integration.md index 2eb2846..a21a06b 100644 --- a/docs/tutorials/your-first-integration.md +++ b/docs/tutorials/your-first-integration.md @@ -2,39 +2,68 @@ Now that you understand the basics from [Quick Start](../getting-started/quick-start.md), let's dive deeper into CEL's powerful Python integration features. You'll learn to use the Context class for better control and add custom Python functions to create domain-specific expressions. -> **Prerequisites:** Complete the [Quick Start Guide](../getting-started/quick-start.md) to understand basic CEL evaluation with dictionary context. If you want to understand CEL's design philosophy first, read [Thinking in CEL](thinking-in-cel.md). +> **Prerequisites:** Complete the [Quick Start Guide](../getting-started/quick-start.md) to understand basic CEL evaluation with prepared contexts. If you want to understand CEL's design philosophy first, read [Thinking in CEL](thinking-in-cel.md). ## What You'll Learn By the end of this tutorial, you'll be able to: - āœ… Use the Context class for advanced variable management -- āœ… Register and call custom Python functions from CEL expressions +- āœ… Register and call custom Python functions from CEL expressions - āœ… Build practical business policies that combine CEL expressions with Python logic - āœ… Handle errors gracefully in production scenarios - āœ… Apply common patterns for access control, validation, and business rules ## The Context Class -While dictionary context is convenient for simple use cases, the `Context` class provides more control and enables advanced features like custom Python functions: +Use a reusable `Context` with explicitly prepared values; it also enables advanced features like custom Python functions: ```python -from cel import evaluate, Context +import cel +Context = cel.Context + + +def add_variables(context, values): + for name, value in values.items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def make_context(values=None): + context = cel.Context() + if values: + add_variables(context, values) + return context + + +def as_context(value=None): + if isinstance(value, cel.Context): + return value + return make_context(value) + + +def evaluate(expression, context=None): + return cel.evaluate(expression, as_context(context)) + +# Context/evaluate are provided by the documentation adapter # Create a context object context = Context() # Add variables -context.add_variable("name", "Alice") -context.add_variable("age", 30) -context.add_variable("roles", ["user", "admin"]) +context.add_variable("name", cel.prepare("Alice")) +context.add_variable("age", cel.prepare(30)) +context.add_variable("roles", cel.prepare(["user", "admin"])) # Use the context in evaluations -result = evaluate("name + ' is ' + string(age)", context) +result = evaluate("name + ' is ' + string(age)", as_context(context)) # → "Alice is 30" assert result == "Alice is 30" -result = evaluate('"admin" in roles', context) +result = evaluate('"admin" in roles', as_context(context)) # → True assert result == True @@ -47,17 +76,17 @@ Add multiple variables at once using `update()`: ```python context2 = Context() -context2.update({ +add_variables(context2, { "user": { "name": "Bob", - "email": "bob@example.com", + "email": "bob@example.com", "profile": {"verified": True, "department": "engineering"} }, "current_time": "2024-01-15T10:30:00Z", "permissions": ["read", "write"] }) -result = evaluate("user.profile.verified && 'write' in permissions", context2) +result = evaluate("user.profile.verified && 'write' in permissions", as_context(context2)) # → True (verified user with write permission) assert result == True @@ -69,7 +98,7 @@ print("āœ“ Batch context updates working correctly") The Context class enables you to call Python functions from CEL expressions, opening up unlimited possibilities for domain-specific logic: ```python -from cel import evaluate, Context +# Context/evaluate are provided by the documentation adapter import re import hashlib from datetime import datetime @@ -100,12 +129,12 @@ def calculate_discount(price, customer_type, quantity=1): # Set up context with variables and functions context3 = Context() -context3.add_variable("income", 50000) -context3.add_variable("user_email", "alice@example.com") -context3.add_variable("today", "saturday") -context3.add_variable("price", 100.0) -context3.add_variable("customer", "vip") -context3.add_variable("quantity", 15) +context3.add_variable("income", cel.prepare(50000)) +context3.add_variable("user_email", cel.prepare("alice@example.com")) +context3.add_variable("today", cel.prepare("saturday")) +context3.add_variable("price", cel.prepare(100.0)) +context3.add_variable("customer", cel.prepare("vip")) +context3.add_variable("quantity", cel.prepare(15)) context3.add_function("calculate_tax", calculate_tax) context3.add_function("is_weekend", is_weekend) @@ -114,37 +143,37 @@ context3.add_function("hash_password", hash_password) context3.add_function("calculate_discount", calculate_discount) # Use functions in expressions -tax = evaluate("calculate_tax(income, 0.15)", context3) +tax = evaluate("calculate_tax(income, 0.15)", as_context(context3)) # → 7500.0 (50000 * 0.15) assert abs(tax - 7500.0) < 0.01, f"Expected ~7500.0, got {tax}" # Test weekend detection -weekend = evaluate('is_weekend(today)', context3) +weekend = evaluate('is_weekend(today)', as_context(context3)) # → True (saturday is a weekend) assert weekend == True # Validate email -email_valid = evaluate('validate_email(user_email)', context3) +email_valid = evaluate('validate_email(user_email)', as_context(context3)) # → True (alice@example.com is valid) assert email_valid == True # Calculate discount with volume bonus -discount = evaluate('calculate_discount(price, customer, quantity)', context3) +discount = evaluate('calculate_discount(price, customer, quantity)', as_context(context3)) # → 25.0 (20% VIP discount + 5% volume discount on $100) assert abs(discount - 25.0) < 0.01, f"Expected ~25.0, got {discount}" # 20% VIP + 5% volume # Complex expressions combining multiple functions -final_price = evaluate('price - calculate_discount(price, customer, quantity)', context3) +final_price = evaluate('price - calculate_discount(price, customer, quantity)', as_context(context3)) # → 75.0 ($100 - $25 discount) assert abs(final_price - 75.0) < 0.01, f"Expected ~75.0, got {final_price}" # Conditional logic with functions -weekend_greeting = evaluate('is_weekend(today) ? "Have a great weekend!" : "Have a productive day!"', context3) +weekend_greeting = evaluate('is_weekend(today) ? "Have a great weekend!" : "Have a productive day!"', as_context(context3)) # → "Have a great weekend!" (today is saturday) assert weekend_greeting == "Have a great weekend!" # Hash password (showing first 16 chars for brevity) -password_hash = evaluate('hash_password("secret123")', context3) +password_hash = evaluate('hash_password("secret123")', as_context(context3)) # → "fcf730b6d95236ec..." (SHA-256 hash) assert password_hash.startswith("fcf730b6d95236ec") @@ -189,18 +218,18 @@ user_db = { "bob": {"permissions": ["read"]} } -context4.add_variable("users", user_db) +context4.add_variable("users", cel.prepare(user_db)) # Use functions with safe patterns -result = evaluate('safe_divide(100, 0) == null', context4) +result = evaluate('safe_divide(100, 0) == null', as_context(context4)) # → True (division by zero returns null) assert result == True -result = evaluate('check_permission("alice", "admin", users)', context4) +result = evaluate('check_permission("alice", "admin", users)', as_context(context4)) # → True (alice has admin permission) assert result == True -result = evaluate('format_currency(29.99, "EUR")', context4) +result = evaluate('format_currency(29.99, "EUR")', as_context(context4)) # → "€29.99" (formatted with Euro symbol) assert result == "€29.99" @@ -218,20 +247,20 @@ Let's build from simple rules to sophisticated access control - each example tea Start with basic business logic to get comfortable with policy patterns: ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def check_discount_eligibility(customer): """Simple business rule for customer discounts.""" - - # Business rule: Customers get discounts if they are verified + + # Business rule: Customers get discounts if they are verified # and have either premium status OR made 5+ orders discount_policy = """ - customer.verified && + customer.verified && (customer.premium || customer.order_count >= 5) """ - + discount_context = {"customer": customer} - return evaluate(discount_policy, discount_context) + return evaluate(discount_policy, as_context(discount_context)) # Test different customer scenarios premium_customer = {"verified": True, "premium": True, "order_count": 2} @@ -252,26 +281,26 @@ from datetime import datetime def check_order_approval(order, current_time=None): """Multi-factor approval policy for orders.""" - + if current_time is None: current_time = datetime.now() - + # Business rule: Orders are auto-approved if: # 1. Amount is under $1000, OR - # 2. Customer is premium AND amount under $5000, OR + # 2. Customer is premium AND amount under $5000, OR # 3. During business hours AND amount under $2500 approval_policy = """ order.amount < 1000 || (order.customer.premium && order.amount < 5000) || (current_hour >= 9 && current_hour <= 17 && order.amount < 2500) """ - + approval_context = { "order": order, "current_hour": current_time.hour } - - return evaluate(approval_policy, approval_context) + + return evaluate(approval_policy, as_context(approval_context)) # Test scenarios small_order = {"amount": 500, "customer": {"premium": False}} @@ -292,28 +321,28 @@ Now apply these patterns to access control - the foundation of secure applicatio ```python def check_resource_access(user, resource, action, current_time=None): """Production-ready access control policy.""" - + if current_time is None: current_time = datetime.now() - + # Access control policy with multiple authorization paths: # 1. Admins can always access anything - # 2. Resource owners can read/write their own resources + # 2. Resource owners can read/write their own resources # 3. Public resources are readable by anyone access_policy = """ user.role == "admin" || (resource.owner == user.id && action in ["read", "write"]) || (resource.public && action == "read") """ - + access_context = { "user": user, "resource": resource, "action": action, "current_hour": current_time.hour } - - return evaluate(access_policy, access_context) + + return evaluate(access_policy, as_context(access_context)) # Test realistic scenarios alice = {"id": "alice", "role": "user", "team": "engineering"} @@ -321,7 +350,7 @@ bob = {"id": "bob", "role": "admin", "team": "security"} project_doc = { "id": "project_plan", - "owner": "alice", + "owner": "alice", "team": "engineering", "public": False } @@ -343,7 +372,7 @@ print("āœ“ Policy progression examples working correctly") **Key Learning Points:** - **Start Simple**: Begin with straightforward business rules before adding complexity -- **Layer Complexity**: Add factors like time, user attributes, and resource properties incrementally +- **Layer Complexity**: Add factors like time, user attributes, and resource properties incrementally - **Test Scenarios**: Each policy should handle multiple real-world scenarios - **Clear Intent**: Write policies that business stakeholders can understand and verify @@ -356,16 +385,16 @@ These patterns scale from simple validation to enterprise access control systems score_context = {"score": 85, "threshold": 80} # Numeric comparisons -result = evaluate("score > threshold", score_context) +result = evaluate("score > threshold", as_context(score_context)) # → True (85 > 80) assert result == True -result = evaluate("score >= 90", score_context) +result = evaluate("score >= 90", as_context(score_context)) # → False (85 < 90) assert result == False -# String comparisons +# String comparisons status_context = {"status": "active"} -result = evaluate('status == "active"', status_context) +result = evaluate('status == "active"', as_context(status_context)) # → True (exact string match) assert result == True ``` @@ -378,17 +407,17 @@ logic_context = { } # AND logic -result = evaluate("user.verified && feature_enabled", logic_context) +result = evaluate("user.verified && feature_enabled", as_context(logic_context)) # → True (both conditions are true) assert result == True -# OR logic -result = evaluate("user.age < 18 || user.verified", logic_context) +# OR logic +result = evaluate("user.age < 18 || user.verified", as_context(logic_context)) # → True (user is verified, even though age >= 18) assert result == True # NOT logic -result = evaluate("!user.verified", logic_context) +result = evaluate("!user.verified", as_context(logic_context)) # → False (user.verified is True) assert result == False ``` @@ -401,18 +430,18 @@ list_context = { } # Check membership -result = evaluate('"write" in permissions', list_context) +result = evaluate('"write" in permissions', as_context(list_context)) # → True ("write" is in ["read", "write"]) assert result == True -result = evaluate('"admin" in permissions', list_context) +result = evaluate('"admin" in permissions', as_context(list_context)) # → False ("admin" is not in ["read", "write"]) assert result == False # List operations -result = evaluate("numbers.size()", list_context) +result = evaluate("numbers.size()", as_context(list_context)) # → 5 (length of [1, 2, 3, 4, 5]) assert result == 5 -result = evaluate("numbers[0]", list_context) +result = evaluate("numbers[0]", as_context(list_context)) # → 1 (first element) assert result == 1 ``` @@ -423,12 +452,12 @@ assert result == 1 safe_context = {"user": {"name": "Charlie"}} # No "age" field # Check if field exists before using it -result = evaluate('has(user.age) && user.age > 18', safe_context) +result = evaluate('has(user.age) && user.age > 18', as_context(safe_context)) # → False (user.age field doesn't exist) assert result == False # Use has() for safe access with fallback -result = evaluate('has(user.age) ? user.age >= 18 : false', safe_context) +result = evaluate('has(user.age) ? user.age >= 18 : false', as_context(safe_context)) # → False (user.age doesn't exist, fallback to false) assert result == False ``` @@ -438,17 +467,17 @@ assert result == False CEL expressions can fail for various reasons. Handle errors gracefully: ```python -from cel import evaluate +# Context/evaluate are provided by the documentation adapter def safe_evaluate(expression, context): """ Evaluate expression with proper error handling using Result-like pattern. - + Returns: (success: bool, result: Any, error_message: str) """ try: - result = evaluate(expression, context) + result = evaluate(expression, as_context(context)) return (True, result, "") except ValueError as e: return (False, None, f"Syntax error: {e}") @@ -481,7 +510,7 @@ assert "Runtime error" in error # Alternatively, let exceptions bubble up (most idiomatic): def evaluate_with_context(expression, context): """Most idiomatic approach - let callers handle exceptions.""" - return evaluate(expression, context) + return evaluate(expression, as_context(context)) # Let exceptions bubble up naturally try: @@ -511,7 +540,7 @@ rules = [ ] for rule in rules: - result = evaluate(rule, {"config": config}) + result = evaluate(rule, as_context({"config": config})) # → True (all config values meet validation criteria) assert result == True, f"Config validation failed: {rule}" ``` @@ -525,8 +554,8 @@ user_context = { # Check if user should see new UI show_new_ui = evaluate( - "feature_flags.new_ui && user.beta_tester", - user_context + "feature_flags.new_ui && user.beta_tester", + as_context(user_context) ) # → True (feature enabled AND user is beta tester) assert show_new_ui == True @@ -541,8 +570,8 @@ form_data = { } # Validate form input - demonstrate basic validation patterns -email_valid = evaluate('email.contains("@")', form_data) -terms_valid = evaluate('terms_accepted == true', form_data) +email_valid = evaluate('email.contains("@")', as_context(form_data)) +terms_valid = evaluate('terms_accepted == true', as_context(form_data)) age_valid = form_data["age"] >= 18 and form_data["age"] <= 120 # Simple Python check all_valid = email_valid and terms_valid and age_valid @@ -575,4 +604,4 @@ Congratulations! You've mastered the Context class and custom Python functions. 3. **For Advanced Usage:** Read [Extending CEL](extending-cel.md) - Learn advanced patterns and best practices -You're now ready to handle thousands of policies in production systems! \ No newline at end of file +You're now ready to handle thousands of policies in production systems! diff --git a/examples/performance/compile_execute_benchmark.py b/examples/performance/compile_execute_benchmark.py index f8a2f12..5906f27 100644 --- a/examples/performance/compile_execute_benchmark.py +++ b/examples/performance/compile_execute_benchmark.py @@ -1,86 +1,65 @@ -"""Compare evaluate() vs compile()+execute() performance. +"""Small compile/execute benchmark using the strict prepared-context API. -Run: - uv run python examples/performance/compile_execute_benchmark.py +For the full 50/500/5,000-object benchmark, run +`prepared_context_benchmark.py` in this directory. """ import json import statistics import time -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import cel -def bench_case( - func: Callable[[], Any], iterations: int = 5000, repeats: int = 3 -) -> dict[str, float | int]: - times = [] - for _ in range(repeats): - func() - start = time.perf_counter() - for _i in range(iterations): - func() - end = time.perf_counter() - times.append((end - start) / iterations * 1_000_000) # us - avg = statistics.mean(times) - stdev = statistics.stdev(times) if len(times) > 1 else 0.0 - return { - "avg_us": avg, - "stdev_us": stdev, - "min_us": min(times), - "max_us": max(times), - "iterations": iterations, - "repeats": repeats, - } +def bench(operation: Callable[[], Any], iterations: int = 5_000) -> dict[str, float]: + samples = [] + for _ in range(3): + operation() + started = time.perf_counter_ns() + for _ in range(iterations): + operation() + samples.append((time.perf_counter_ns() - started) / iterations) + return {"median_ns": statistics.median(samples), "min_ns": min(samples)} -def measure_compile(expr: str) -> tuple[cel.Program, float]: - start = time.perf_counter() - program = cel.compile(expr) - end = time.perf_counter() - return program, (end - start) * 1_000_000 +def make_context(**values: Any) -> cel.Context: + context = cel.Context() + for name, value in values.items(): + context.add_variable(name, cel.prepare(value)) + return context def main() -> None: - results: dict[str, dict[str, Any]] = {} - - cases: list[tuple[str, str, Any]] = [] - - ctx_simple: dict[str, Any] = {"x": 10, "y": 20} - ctx_str: dict[str, Any] = {"greet": "hello", "name": "world"} - ctx_list: dict[str, Any] = {"items": list(range(1000))} - ctx_map: dict[str, Any] = {"user": {"role": "admin", "active": True}} - - cases.append(("simple_arithmetic", "x + y * 2", ctx_simple)) - cases.append(("string_concat", "greet + ' ' + name", ctx_str)) - cases.append(("list_size", "size(items)", ctx_list)) - cases.append( + cases = [ + ("simple_arithmetic", "x + y * 2", make_context(x=10, y=20)), + ("string_concat", "greet + ' ' + name", make_context(greet="hello", name="world")), + ("list_size", "size(items)", make_context(items=list(range(1_000)))), ( "map_lookup_bool", "user.role == 'admin' && user.active", - ctx_map, - ) - ) - - ctx_func = cel.Context() - ctx_func.add_function("double", lambda x: x * 2) - ctx_func.add_variable("x", 21) - cases.append(("python_function", "double(x)", ctx_func)) - - for name, expr, ctx in cases: - program, compile_us = measure_compile(expr) - - eval_bench = bench_case(lambda expr=expr, ctx=ctx: cel.evaluate(expr, ctx)) - exec_bench = bench_case(lambda program=program, ctx=ctx: program.execute(ctx)) - - speedup = eval_bench["avg_us"] / exec_bench["avg_us"] if exec_bench["avg_us"] > 0 else None - + make_context(user={"role": "admin", "active": True}), + ), + ] + + callback_context = make_context(x=21) + callback_context.add_function("double_value", lambda value: value * 2) + cases.append(("python_function", "double_value(x)", callback_context)) + + results = {} + for name, expression, context in cases: + started = time.perf_counter_ns() + program = cel.compile(expression) + compile_ns = time.perf_counter_ns() - started results[name] = { - "compile_time_us": compile_us, - "evaluate": eval_bench, - "compiled_execute": exec_bench, - "speedup_eval_over_execute": speedup, + "compile_ns": compile_ns, + "compiled_execute": bench( + lambda program=program, context=context: program.execute(context) + ), + "evaluate": bench( + lambda expression=expression, context=context: cel.evaluate(expression, context) + ), } print(json.dumps(results, indent=2, sort_keys=True)) diff --git a/examples/performance/prepared_context_benchmark.py b/examples/performance/prepared_context_benchmark.py new file mode 100644 index 0000000..4826c73 --- /dev/null +++ b/examples/performance/prepared_context_benchmark.py @@ -0,0 +1,109 @@ +"""Benchmark preparation, prepared binding, and direct context execution. + +Build the extension in release mode first: + + uv run maturin develop --release + uv run python examples/performance/prepared_context_benchmark.py +""" + +import json +import statistics +import time +from collections.abc import Callable +from typing import Any + +import cel + +OBJECT_COUNTS = (50, 500, 5_000) +ITERATIONS = 100_000 +EXPRESSIONS = { + "nested_bool": "data.objects[3].profile.enabled", + "primitive_predicate": "data.objects[3].active && data.objects[3].score >= 3", +} + + +def fixture(object_count: int) -> dict[str, Any]: + return { + "objects": [ + { + "id": index, + "active": index % 2 == 0, + "score": index, + "profile": {"enabled": True, "padding": list(range(20))}, + } + for index in range(object_count) + ] + } + + +def benchmark(operation: Callable[[], Any], iterations: int, repeats: int = 5) -> dict[str, float]: + samples = [] + for _ in range(repeats): + for _ in range(1_000): + operation() + started = time.perf_counter_ns() + for _ in range(iterations): + operation() + samples.append((time.perf_counter_ns() - started) / iterations) + return { + "median_ns": statistics.median(samples), + "min_ns": min(samples), + "max_ns": max(samples), + } + + +def main() -> None: + report: dict[str, Any] = {} + for object_count in OBJECT_COUNTS: + source = fixture(object_count) + + started = time.perf_counter_ns() + prepared = cel.prepare(source) + preparation_ns = time.perf_counter_ns() - started + + context = cel.Context() + started = time.perf_counter_ns() + context.add_variable("data", prepared) + first_insertion_ns = time.perf_counter_ns() - started + + case: dict[str, Any] = { + "preparation_ns": preparation_ns, + "first_insertion_ns": first_insertion_ns, + "replacement": benchmark( + lambda context=context, prepared=prepared: context.add_variable("data", prepared), + ITERATIONS, + ), + "expressions": {}, + } + for name, expression in EXPRESSIONS.items(): + program = cel.compile(expression) + case["expressions"][name] = { + "execute": benchmark( + lambda program=program, context=context: program.execute(context), ITERATIONS + ), + "replace_and_execute": benchmark( + lambda context=context, prepared=prepared, program=program: ( + context.add_variable("data", prepared), + program.execute(context), + ), + ITERATIONS, + ), + } + + callback_context = cel.Context() + callback_context.add_variable("data", prepared) + callback_context.add_function("enabled", lambda value: value) + callback_program = cel.compile("enabled(data.objects[3].profile.enabled)") + case["selected_primitive_callback"] = benchmark( + lambda callback_program=callback_program, callback_context=callback_context: ( + callback_program.execute(callback_context) + ), + ITERATIONS, + ) + report[str(object_count)] = case + + print(json.dumps(report, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/python/cel/cel.pyi b/python/cel/cel.pyi index b2d0582..c84da18 100644 --- a/python/cel/cel.pyi +++ b/python/cel/cel.pyi @@ -1,45 +1,21 @@ -""" -Type stubs for the CEL Rust extension module. -""" +"""Type stubs for the CEL Rust extension module.""" -from typing import Any, Callable, Dict, Literal, Optional, Union, overload +from typing import Any, Callable + +class PreparedValue: + """Opaque immutable CEL value created by :func:`prepare`.""" class Context: - """CEL evaluation context for variables and functions.""" + """Reusable native CEL context for prepared values and Python functions.""" - @overload def __init__(self) -> None: ... - @overload - def __init__(self, variables: Dict[str, Any]) -> None: ... - @overload - def __init__( - self, - variables: Optional[Dict[str, Any]] = None, - *, - functions: Optional[Dict[str, Callable[..., Any]]] = None, - ) -> None: ... - def add_variable(self, name: str, value: Any) -> None: - """Add a variable to the context.""" - ... - - def add_function(self, name: str, func: Callable[..., Any]) -> None: - """Add a function to the context.""" - ... - - def update(self, variables: Dict[str, Any]) -> None: - """Update context with variables from a dictionary.""" - ... + def add_variable(self, name: str, value: PreparedValue) -> None: ... + def add_function(self, name: str, function: Callable[..., Any]) -> None: ... class Program: - """Compiled CEL program that can be executed multiple times.""" + """Compiled CEL program.""" - def execute(self, context: Optional[Union[Dict[str, Any], Context]] = None) -> Any: - """Execute the compiled program with an optional context.""" - ... - -def compile(expression: str) -> Program: - """Compile a CEL expression into a reusable Program object.""" - ... + def execute(self, context: Context) -> Any: ... class OptionalValue: """Wrapper for CEL optional values.""" @@ -53,18 +29,6 @@ class OptionalValue: def or_value(self, default: Any) -> Any: ... def or_optional(self, other: OptionalValue) -> OptionalValue: ... -def evaluate( - expression: str, - context: Optional[Union[Dict[str, Any], Context]] = None, -) -> Any: - """ - Evaluate a CEL expression. - - Args: - expression: The CEL expression to evaluate - context: Optional context with variables and functions - - Returns: - The result of evaluating the expression - """ - ... +def prepare(value: Any) -> PreparedValue: ... +def compile(expression: str) -> Program: ... +def evaluate(expression: str, context: Context) -> Any: ... diff --git a/python/cel/cli.py b/python/cel/cli.py index 4a790c2..9d6579e 100644 --- a/python/cel/cli.py +++ b/python/cel/cli.py @@ -37,7 +37,7 @@ from rich.table import Table # Import directly from relative modules to avoid circular imports -from .cel import Context, evaluate +from .cel import Context, evaluate, prepare from .stdlib import add_stdlib_to_context # Initialize Rich console @@ -195,10 +195,9 @@ def __init__(self, context: Optional[Dict[str, Any]] = None): def _update_cel_context(self): """Update the internal CEL context object.""" - if self.context: - self._cel_context = Context(self.context) - else: - self._cel_context = Context() + self._cel_context = Context() + for name, value in self.context.items(): + self._cel_context.add_variable(name, prepare(value)) # Always add stdlib functions to the context add_stdlib_to_context(self._cel_context) diff --git a/src/context.rs b/src/context.rs index de09bd8..28d8b21 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,393 +1,124 @@ +use ::cel::extractors::Arguments; use ::cel::objects::TryIntoValue; -use ::cel::Value; -use pyo3::exceptions::PyValueError; +use ::cel::{Context as CelContext, ExecutionError, PreparedValue, Value}; +use pyo3::exceptions::PyTypeError; use pyo3::prelude::*; -use pyo3::types::PyDict; -use std::collections::HashMap; +use pyo3::types::PyTuple; -#[pyo3::pyclass] -/// Manages the evaluation environment for CEL expressions. -/// -/// The `Context` class provides a structured, efficient way to handle variables -/// and custom functions for CEL expression evaluation. It is the recommended -/// approach for managing complex evaluation environments and offers better -/// performance than using dictionaries for repeated evaluations. -/// -/// Key Benefits: -/// - **Type Safety**: Automatic conversion and validation of Python types to CEL types -/// - **Performance**: Optimized for reuse across multiple evaluations -/// - **Flexibility**: Support for both variables and custom Python functions -/// - **Memory Efficiency**: Shared context reduces overhead for multiple expressions -/// -/// Use this class when you need to: -/// - Register custom Python functions for use in CEL expressions -/// - Build a reusable context for multiple evaluations with the same variables -/// - Dynamically add, update, or manage variables and functions -/// - Ensure type safety and proper error handling for context data -/// - Optimize performance for applications with frequent CEL evaluations -/// -/// Attributes: -/// variables (dict): A dictionary mapping variable names (str) to their -/// values (automatically converted to appropriate CEL types). -/// functions (dict): A dictionary mapping function names (str) to their -/// corresponding Python callable objects. -/// -/// Thread Safety: -/// Context objects are not thread-safe. Create separate Context instances -/// for concurrent use or implement your own synchronization. +use crate::{RustyCelType, RustyPyType}; + +/// An opaque, immutable CEL value prepared for inexpensive context binding. +#[pyclass(name = "PreparedValue", frozen)] +pub struct PyPreparedValue { + pub(crate) inner: PreparedValue, +} + +impl PyPreparedValue { + pub(crate) fn new(inner: PreparedValue) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyPreparedValue { + fn __repr__(&self) -> String { + format!("PreparedValue(type='{}')", self.inner.type_name()) + } +} + +/// Context is a reusable native CEL evaluation context for expressions. /// -/// Performance Tips: -/// - Reuse Context objects for multiple evaluations when possible -/// - Pre-populate Context with all needed variables and functions -/// - Avoid frequent add_variable/add_function calls in hot code paths +/// Its variables must be converted with `cel.prepare()` before they are added, +/// and its functions are registered as persistent Python callback adapters. +/// The context stores prepared values and Python function adapters directly; +/// executing an expression borrows this native context without rebuilding it. +#[pyclass] pub struct Context { - pub variables: HashMap, - pub functions: HashMap>, + pub(crate) inner: CelContext<'static>, } -#[pyo3::pymethods] +#[pymethods] impl Context { #[new] - #[pyo3(signature = (variables=None, functions=None))] - /// Creates a new `Context` object. - /// - /// Initializes a CEL evaluation context with optional variables and functions. - /// This constructor provides a convenient way to set up a complete evaluation - /// environment in a single call. - /// - /// Args: - /// variables (Optional[dict]): A dictionary of initial variables to - /// populate the context with. Keys must be strings (variable names), - /// and values can be any Python type supported by CEL (bool, int, - /// float, str, list, dict, datetime, bytes). Values are automatically - /// converted to their corresponding CEL types. - /// functions (Optional[dict]): A dictionary of initial custom functions - /// to register. Keys are the function names as they will appear in - /// CEL expressions (must be strings), and values are the corresponding - /// Python callable objects (functions, methods, or any callable). - /// - /// Raises: - /// ValueError: If variable names are not strings, or if variable values - /// cannot be converted to supported CEL types. - /// - /// Examples: - /// Creating an empty context: - /// - /// >>> from cel import Context - /// >>> context = Context() - /// - /// Creating a context with variables: - /// - /// >>> context = Context(variables={ - /// ... "user_id": 123, - /// ... "user_name": "alice", - /// ... "permissions": ["read", "write"], - /// ... "metadata": {"created": "2023-01-01", "active": True} - /// ... }) - /// - /// Creating a context with custom functions: - /// - /// >>> def greet(name): - /// ... return f"Hello, {name}!" - /// >>> def calculate_tax(amount, rate=0.1): - /// ... return amount * rate - /// >>> - /// >>> context = Context(functions={ - /// ... "greet": greet, - /// ... "tax": calculate_tax - /// ... }) - /// - /// Creating a complete context with both variables and functions: - /// - /// >>> context = Context( - /// ... variables={ - /// ... "product_price": 99.99, - /// ... "tax_rate": 0.08, - /// ... "user_name": "Bob" - /// ... }, - /// ... functions={ - /// ... "greet": lambda name: f"Hi {name}!", - /// ... "format_currency": lambda x: f"${x:.2f}" - /// ... } - /// ... ) - /// >>> from cel import evaluate - /// >>> evaluate("greet(user_name)", context) - /// 'Hi Bob!' - /// >>> evaluate("format_currency(product_price * (1 + tax_rate))", context) - /// '$107.99' - pub fn new( - variables: Option<&Bound<'_, PyDict>>, - functions: Option<&Bound<'_, PyDict>>, - ) -> PyResult { - let mut context = Context { - variables: HashMap::new(), - functions: HashMap::new(), - }; - - if let Some(variables) = variables { - //context.variables.extend(variables.clone()); - for (k, v) in variables { - let key = k - .extract::() - .map_err(|_| PyValueError::new_err("Variable name must be strings")); - key.map(|key| context.add_variable(key, &v))??; - } - }; - - if let Some(functions) = functions { - context.update(functions)?; - }; - - Ok(context) + pub fn new() -> Self { + Self { + inner: CelContext::default(), + } } - /// Registers a Python function for use within CEL expressions. - /// - /// The registered function becomes available as a native CEL function and can - /// be called with the same syntax as built-in CEL functions. Function arguments - /// are automatically converted from CEL types to Python types, and return values - /// are converted back to CEL types. - /// - /// Function Requirements: - /// - Must be a Python callable (function, method, lambda, or callable object) - /// - Arguments should accept CEL-compatible Python types - /// - Return value must be convertible to a CEL type - /// - Should handle potential type conversion errors gracefully - /// - /// Args: - /// name (str): The name of the function as it will be called from CEL - /// expressions. Must be a valid CEL identifier (alphanumeric and - /// underscores, starting with a letter or underscore). - /// function (Callable): The Python function or callable to register. - /// Can be a function, method, lambda, or any callable object. - /// - /// Examples: - /// Registering built-in Python functions: - /// - /// >>> from cel import Context, evaluate - /// >>> context = Context() - /// >>> context.add_function("string_length", len) - /// >>> context.add_function("absolute_value", abs) - /// >>> evaluate('string_length("hello world")', context) - /// 11 - /// >>> evaluate('absolute_value(-42)', context) - /// 42 - /// - /// Registering custom functions: - /// - /// >>> def is_valid_email(email): - /// ... return "@" in email and "." in email - /// >>> def calculate_discount(price, percentage): - /// ... return price * (percentage / 100.0) - /// >>> - /// >>> context.add_function("is_email", is_valid_email) - /// >>> context.add_function("discount", calculate_discount) - /// >>> evaluate('is_email("user@example.com")', context) - /// True - /// >>> evaluate('discount(100.0, 15)', context) - /// 15.0 - /// - /// Registering lambda functions: + /// Add or replace the prepared variable binding identified by `name`. /// - /// >>> context.add_function("square", lambda x: x * x) - /// >>> context.add_function("greeting", lambda name: f"Welcome, {name}!") - /// >>> evaluate('square(7)', context) - /// 49 - /// - /// Registering methods from objects: - /// - /// >>> import re - /// >>> context.add_function("regex_match", re.match) - /// >>> # Note: This would need proper error handling in practice - fn add_function(&mut self, name: String, function: Py) { - self.functions.insert(name, function); + /// The value must be returned by `cel.prepare`; raw Python values are not + /// converted implicitly. + pub fn add_variable(&mut self, name: String, value: PyRef<'_, PyPreparedValue>) { + self.inner.add_prepared_variable(name, value.inner.clone()); } - /// Adds a variable to the context. - /// - /// Variables added to the context become available for use in CEL expressions. - /// The value is automatically converted from Python types to the corresponding - /// CEL types. If a variable with the same name already exists, it will be - /// overwritten with the new value. - /// - /// Supported Python Types and Their CEL Equivalents: - /// - bool → CEL bool - /// - int → CEL int (signed 64-bit) - /// - float → CEL double - /// - str → CEL string - /// - list/tuple → CEL list - /// - dict → CEL map - /// - datetime.datetime → CEL timestamp - /// - datetime.timedelta → CEL duration - /// - bytes/bytearray → CEL bytes - /// - None → CEL null - /// - /// Args: - /// name (str): The name of the variable as it will be used in CEL - /// expressions. Must be a valid CEL identifier (alphanumeric - /// characters and underscores, starting with a letter or underscore). - /// value (Any): The Python value of the variable. Must be one of the - /// supported Python types listed above. - /// - /// Raises: - /// ValueError: If the value cannot be converted to a supported CEL type, - /// or if the variable name is not a string. - /// - /// Examples: - /// Adding basic data types: - /// - /// >>> from cel import Context, evaluate - /// >>> context = Context() - /// >>> context.add_variable("user_id", 123) - /// >>> context.add_variable("username", "alice") - /// >>> context.add_variable("is_active", True) - /// >>> evaluate("username + ' (ID: ' + string(user_id) + ')'", context) - /// 'alice (ID: 123)' - /// - /// Adding collections: - /// - /// >>> context.add_variable("permissions", ["read", "write", "admin"]) - /// >>> context.add_variable("user_data", { - /// ... "name": "Alice", - /// ... "department": "Engineering", - /// ... "level": 5 - /// ... }) - /// >>> evaluate("'admin' in permissions", context) - /// True - /// >>> evaluate("user_data.department", context) - /// 'Engineering' - /// - /// Adding datetime objects: - /// - /// >>> from datetime import datetime, timedelta - /// >>> context.add_variable("now", datetime.now()) - /// >>> context.add_variable("one_hour", timedelta(hours=1)) - /// - /// Overwriting existing variables: - /// - /// >>> context.add_variable("counter", 1) - /// >>> evaluate("counter", context) - /// 1 - /// >>> context.add_variable("counter", 2) # Overwrites previous value - /// >>> evaluate("counter", context) - /// 2 - pub fn add_variable(&mut self, name: String, value: &Bound<'_, PyAny>) -> PyResult<()> { - let value = crate::RustyPyType(value).try_into_value().map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!( - "Failed to convert variable '{name}': {e}" - )) - })?; - self.variables.insert(name, value); - Ok(()) - } + /// Register the callable `function` under the CEL function `name`. + /// + /// The Python-to-CEL adapter is installed once and reused by every + /// expression execution. + pub fn add_function( + &mut self, + py: Python<'_>, + name: String, + function: Py, + ) -> PyResult<()> { + if !function.bind(py).is_callable() { + return Err(PyTypeError::new_err("function must be callable")); + } - /// Updates the context from a dictionary of variables and functions. - /// - /// This method provides a convenient way to populate the context from a single - /// dictionary. It automatically distinguishes between variables and functions - /// based on whether values are callable. Non-callable values are added as - /// variables, while callable values are registered as functions. - /// - /// This is particularly useful for: - /// - Bulk updates to context data - /// - Dynamic context construction from configuration - /// - Integration with existing codebases that use dictionaries - /// - Merging multiple data sources into a single context - /// - /// Behavior: - /// - Callable values (functions, lambdas, methods) → registered as functions - /// - Non-callable values → added as variables - /// - Existing variables/functions with the same names are overwritten - /// - Keys must be strings (valid CEL identifiers) - /// - /// Args: - /// variables (dict): A dictionary where keys are strings representing - /// names for variables or functions. Values can be either: - /// - Data values (for variables): any CEL-compatible Python type - /// - Callable objects (for functions): functions, methods, lambdas - /// - /// Raises: - /// ValueError: If any key is not a string, or if a non-callable value - /// cannot be converted to a supported CEL type. - /// - /// Examples: - /// Basic mixed update with variables and functions: - /// - /// >>> from cel import Context, evaluate - /// >>> context = Context() - /// >>> def say_hi(name): - /// ... return f"Hi, {name}!" - /// >>> def calculate_total(price, tax_rate=0.1): - /// ... return price * (1 + tax_rate) - /// >>> - /// >>> context.update({ - /// ... "user_name": "Alice", - /// ... "user_id": 12345, - /// ... "is_premium": True, - /// ... "greet": say_hi, - /// ... "total": calculate_total - /// ... }) - /// >>> evaluate('greet(user_name)', context) - /// 'Hi, Alice!' - /// >>> evaluate('total(99.99)', context) - /// 109.989 - /// - /// Updating with built-in functions: - /// - /// >>> context.update({ - /// ... "numbers": [1, -2, 3, -4, 5], - /// ... "text": "Hello World", - /// ... "length": len, - /// ... "abs_value": abs, - /// ... "upper": str.upper - /// ... }) - /// >>> evaluate('length(text)', context) - /// 11 - /// >>> evaluate('abs_value(-42)', context) - /// 42 - /// - /// Dynamic context from configuration: - /// - /// >>> config = { - /// ... "api_endpoint": "https://api.example.com", - /// ... "timeout": 30, - /// ... "retries": 3, - /// ... "format_url": lambda base, path: f"{base}/{path.strip('/')}" - /// ... } - /// >>> context.update(config) - /// >>> evaluate('format_url(api_endpoint, "/users/123")', context) - /// 'https://api.example.com/users/123' - /// - /// Merging multiple data sources: - /// - /// >>> user_data = {"name": "Bob", "age": 30} - /// >>> system_config = {"debug": True, "version": "1.0"} - /// >>> utilities = {"join": "-".join, "format": "{:.2f}".format} - /// >>> - /// >>> context.update({**user_data, **system_config, **utilities}) - /// >>> evaluate('join(["user", name, string(age)])', context) - /// 'user-Bob-30' - pub fn update(&mut self, variables: &Bound<'_, PyDict>) -> PyResult<()> { - for (key, value) in variables { - // Attempt to extract the key as a String - let key = key - .extract::() - .map_err(|_| PyValueError::new_err("Keys must be strings"))?; + let function_name = name.clone(); + self.inner.add_function( + &name, + move |Arguments(args): Arguments| -> Result { + Python::attach(|py| { + let py_args = args + .iter() + .map(|value| { + RustyCelType(value.clone()) + .into_pyobject(py) + .map(Bound::unbind) + .map_err(|error| ExecutionError::FunctionError { + function: function_name.clone(), + message: format!( + "failed to convert argument to Python: {error}" + ), + }) + }) + .collect::, _>>()?; + let py_args = PyTuple::new(py, py_args).map_err(|error| { + ExecutionError::FunctionError { + function: function_name.clone(), + message: format!("failed to create argument tuple: {error}"), + } + })?; + let result = function.call1(py, py_args).map_err(|error| { + ExecutionError::FunctionError { + function: function_name.clone(), + message: format!("Python function call failed: {error}"), + } + })?; - if value.is_callable() { - // Value is a function, add it to the functions hashmap - let py_function = value.unbind(); - self.functions.insert(key, py_function); - } else { - // Value is a variable, add it to the variables hashmap - let value = crate::RustyPyType(&value) - .try_into_value() - .map_err(|e| PyValueError::new_err(e.to_string()))?; + RustyPyType(result.bind(py)) + .try_into_value() + .map_err(|error| ExecutionError::FunctionError { + function: function_name.clone(), + message: format!("failed to convert Python result to CEL: {error}"), + }) + }) + }, + ); + Ok(()) + } - self.variables.insert(key, value); - } - } + fn __repr__(&self) -> &'static str { + "Context()" + } +} - Ok(()) +impl Default for Context { + fn default() -> Self { + Self::new() } } diff --git a/src/lib.rs b/src/lib.rs index 2cd6efa..bd48f6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,40 +1,21 @@ mod context; use ::cel::objects::{Key, OptionalValue, TryIntoValue}; -use ::cel::{Context as CelContext, ExecutionError, Program, Value}; +use ::cel::{ExecutionError, PreparedValue, Program, Value}; +use chrono::{DateTime, Duration as ChronoDuration, Offset, TimeZone}; +use context::{Context, PyPreparedValue}; use log::warn; use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; use pyo3::prelude::*; -use pyo3::BoundObject; -use std::panic::{self, AssertUnwindSafe}; - -use chrono::{DateTime, Duration as ChronoDuration, Offset, TimeZone}; use pyo3::types::{PyBool, PyBytes, PyDict, PyList, PyMapping, PyTuple, PyType, PyTypeMethods}; -use pyo3::PyTypeInfo; - +use pyo3::{BoundObject, PyTypeInfo}; use std::collections::HashMap; use std::error::Error; use std::fmt; +use std::panic::{self, AssertUnwindSafe}; 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 -/// ``` +/// A compiled CEL program that can be executed repeatedly against reusable contexts. #[pyclass(name = "Program")] struct PyProgram { program: Program, @@ -43,16 +24,10 @@ struct PyProgram { #[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) + /// Execute the compiled program against a native `Context` by reference. + fn execute(&self, context: PyRef<'_, Context>, py: Python<'_>) -> PyResult> { + let result = execute_program(&self.program, &self.source, &context.inner)?; + RustyCelType(result).into_pyobject(py).map(Bound::unbind) } fn __repr__(&self) -> String { @@ -81,7 +56,7 @@ impl PyOptionalValue { fn of(_cls: &Bound<'_, PyType>, value: &Bound<'_, PyAny>) -> PyResult { let value = RustyPyType(value) .try_into_value() - .map_err(|e| PyValueError::new_err(e.to_string()))?; + .map_err(|error| PyValueError::new_err(error.to_string()))?; Ok(Self { value: Some(value) }) } @@ -98,7 +73,7 @@ impl PyOptionalValue { match &self.value { Some(value) => RustyCelType(value.clone()) .into_pyobject(py) - .map(|obj| obj.unbind()), + .map(Bound::unbind), None => Err(PyValueError::new_err("optional.none() dereference")), } } @@ -107,20 +82,14 @@ impl PyOptionalValue { match &self.value { Some(value) => RustyCelType(value.clone()) .into_pyobject(py) - .map(|obj| obj.unbind()), + .map(Bound::unbind), None => Ok(default.clone().unbind()), } } fn or_optional(&self, other: PyRef<'_, PyOptionalValue>) -> PyOptionalValue { - if self.value.is_some() { - PyOptionalValue { - value: self.value.clone(), - } - } else { - PyOptionalValue { - value: other.value.clone(), - } + PyOptionalValue { + value: self.value.clone().or_else(|| other.value.clone()), } } @@ -130,51 +99,63 @@ impl PyOptionalValue { fn __repr__(&self) -> String { match &self.value { - Some(value) => format!("OptionalValue({value:?})"), + Some(_) => "OptionalValue(...)".to_string(), None => "OptionalValue.none()".to_string(), } } } -/// 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 +/// Compile a CEL expression into a reusable program. #[pyfunction] fn compile(expression: String) -> PyResult { - let program = panic::catch_unwind(|| Program::compile(&expression)) + let program = compile_program(&expression)?; + Ok(PyProgram { + program, + source: expression, + }) +} + +/// Convert a supported Python value into an immutable reusable CEL value. +#[pyfunction] +fn prepare(value: &Bound<'_, PyAny>) -> PyResult { + if let Ok(prepared) = value.extract::>() { + return Ok(PyPreparedValue::new(prepared.inner.clone())); + } + + let value = RustyPyType(value) + .try_into_value() + .map_err(|error| PyValueError::new_err(format!("Failed to prepare value: {error}")))?; + let prepared = PreparedValue::try_from_value(value) + .map_err(|error| PyValueError::new_err(format!("Failed to prepare value: {error}")))?; + Ok(PyPreparedValue::new(prepared)) +} + +fn compile_program(source: &str) -> PyResult { + panic::catch_unwind(|| Program::compile(source)) .map_err(|_| { - warn!("CEL parser panic for expression: '{}'", expression); + warn!("CEL parser panic for expression: '{source}'"); PyValueError::new_err(format!( - "Failed to parse expression '{expression}': Invalid syntax or malformed string" + "Failed to parse expression '{source}': invalid syntax or malformed string" )) })? - .map_err(|e| { - PyValueError::new_err(format!("Failed to parse expression '{expression}': {e}")) - })?; + .map_err(|error| { + PyValueError::new_err(format!("Failed to parse expression '{source}': {error}")) + }) +} - Ok(PyProgram { - program, - source: expression, - }) +fn execute_program( + program: &Program, + source: &str, + context: &::cel::Context<'_>, +) -> PyResult { + let result = + panic::catch_unwind(AssertUnwindSafe(|| program.execute(context))).map_err(|_| { + warn!("CEL execution panic for expression: '{source}'"); + PyValueError::new_err(format!( + "Failed to execute expression '{source}': internal evaluator error" + )) + })?; + result.map_err(|error| map_execution_error_to_python(&error)) } #[derive(Debug)] @@ -186,72 +167,52 @@ impl<'py> IntoPyObject<'py> for RustyCelType { type Error = PyErr; fn into_pyobject(self, py: Python<'py>) -> Result { - let obj = match self { - // Primitive Types - RustyCelType(Value::Null) => py.None().into_bound(py), - RustyCelType(Value::Bool(b)) => PyBool::new(py, b).into_bound().into_any(), - RustyCelType(Value::Int(i64)) => i64.into_pyobject(py)?.into_any(), - RustyCelType(Value::UInt(u64)) => u64.into_pyobject(py)?.into_any(), - RustyCelType(Value::Float(f)) => f.into_pyobject(py)?.into_any(), - RustyCelType(Value::Timestamp(ts)) => ts.into_pyobject(py)?.into_any(), - RustyCelType(Value::Duration(d)) => d.into_pyobject(py)?.into_any(), - RustyCelType(Value::String(s)) => s.as_ref().to_string().into_pyobject(py)?.into_any(), - RustyCelType(Value::List(val)) => { + let object = match self.0 { + Value::Null => py.None().into_bound(py), + Value::Bool(value) => PyBool::new(py, value).into_bound().into_any(), + Value::Int(value) => value.into_pyobject(py)?.into_any(), + Value::UInt(value) => value.into_pyobject(py)?.into_any(), + Value::Float(value) => value.into_pyobject(py)?.into_any(), + Value::Timestamp(value) => value.into_pyobject(py)?.into_any(), + Value::Duration(value) => value.into_pyobject(py)?.into_any(), + Value::String(value) => value.as_ref().to_string().into_pyobject(py)?.into_any(), + Value::Bytes(value) => PyBytes::new(py, &value).into_any(), + Value::List(values) => { let list = PyList::empty(py); - for v in val.as_ref().iter() { - let item = RustyCelType(v.clone()).into_pyobject(py)?; - list.append(&item)?; + for value in values.iter() { + list.append(RustyCelType(value.clone()).into_pyobject(py)?)?; } list.into_any() } - RustyCelType(Value::Bytes(val)) => PyBytes::new(py, &val).into_any(), - - RustyCelType(Value::Map(val)) => { - // Create a PyDict with the converted Python key and values. - let python_dict = PyDict::new(py); - - for (k, v) in val.map.as_ref().iter() { - // Key is an enum with String, Uint, Int and Bool variants. Value is any RustyCelType - let key = match k { - Key::String(s) => s.as_ref().into_pyobject(py)?.into_any(), - Key::Uint(u64) => u64.into_pyobject(py)?.into_any(), - Key::Int(i64) => i64.into_pyobject(py)?.into_any(), - Key::Bool(b) => PyBool::new(py, *b).into_bound().into_any(), + Value::Map(value) => { + let dictionary = PyDict::new(py); + for (key, value) in value.map.iter() { + let key = match key { + Key::String(value) => value.as_ref().into_pyobject(py)?.into_any(), + Key::Uint(value) => value.into_pyobject(py)?.into_any(), + Key::Int(value) => value.into_pyobject(py)?.into_any(), + Key::Bool(value) => PyBool::new(py, *value).into_bound().into_any(), }; - let value = RustyCelType(v.clone()).into_pyobject(py)?; - python_dict.set_item(&key, &value)?; + dictionary.set_item(key, RustyCelType(value.clone()).into_pyobject(py)?)?; } - - python_dict.into_any() + dictionary.into_any() } - - RustyCelType(Value::Opaque(opaque)) => { - if opaque.runtime_type_name() == "optional_type" { - if let Some(optional) = opaque.downcast_ref::() { - Py::new( - py, - PyOptionalValue { - value: optional.value().cloned(), - }, - )? - .into_bound(py) - .into_any() - } else { - format!("{:?}", Value::Opaque(opaque.clone())) - .into_pyobject(py)? - .into_any() - } - } else { - format!("{:?}", Value::Opaque(opaque.clone())) - .into_pyobject(py)? - .into_any() - } + Value::Opaque(opaque) if opaque.runtime_type_name() == "optional_type" => { + let optional = opaque + .downcast_ref::() + .ok_or_else(|| PyValueError::new_err("invalid CEL optional value"))?; + Py::new( + py, + PyOptionalValue { + value: optional.value().cloned(), + }, + )? + .into_bound(py) + .into_any() } - - // Turn everything else into a String: - nonprimitive => format!("{nonprimitive:?}").into_pyobject(py)?.into_any(), + other => format!("{other:?}").into_pyobject(py)?.into_any(), }; - Ok(obj) + Ok(object) } } @@ -264,33 +225,34 @@ pub enum CelError { } impl fmt::Display for CelError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - CelError::ConversionError(msg) => write!(f, "Conversion Error: {msg}"), + CelError::ConversionError(message) => write!(formatter, "Conversion Error: {message}"), } } } + impl Error for CelError {} -impl<'a> RustyPyType<'a> { +impl RustyPyType<'_> { fn key_from_py(key: &Bound<'_, PyAny>) -> Result { if key.is_none() { return Err(CelError::ConversionError( - "None cannot be used as a key in dictionaries".to_string(), + "None cannot be used as a dictionary key".to_string(), )); } - - if let Ok(k) = key.extract::() { - Ok(Key::Int(k)) - } else if let Ok(k) = key.extract::() { - Ok(Key::Uint(k)) - } else if let Ok(k) = key.extract::() { - Ok(Key::Bool(k)) - } else if let Ok(k) = key.extract::() { - Ok(Key::String(k.into())) + // `bool` is a subclass of `int` in Python, so it must be checked first. + if let Ok(value) = key.extract::() { + Ok(Key::Bool(value)) + } else if let Ok(value) = key.extract::() { + Ok(Key::Int(value)) + } else if let Ok(value) = key.extract::() { + Ok(Key::Uint(value)) + } else if let Ok(value) = key.extract::() { + Ok(Key::String(value.into())) } else { Err(CelError::ConversionError( - "Failed to convert Python mapping key to Key".to_string(), + "failed to convert Python mapping key".to_string(), )) } } @@ -298,494 +260,154 @@ impl<'a> RustyPyType<'a> { fn mapping_to_value(mapping: &Bound<'_, PyMapping>) -> Result { let keys = mapping .keys() - .map_err(|e| CelError::ConversionError(format!("Failed to read mapping keys: {e}")))?; - - let mut map: HashMap = HashMap::new(); + .map_err(|error| CelError::ConversionError(format!("failed to read keys: {error}")))?; + let mut result = HashMap::new(); for key in keys.iter() { - let key_converted = Self::key_from_py(&key)?; - let value = mapping.get_item(&key).map_err(|e| { - CelError::ConversionError(format!("Failed to read mapping item: {e}")) + let converted_key = Self::key_from_py(&key)?; + let value = mapping.get_item(&key).map_err(|error| { + CelError::ConversionError(format!("failed to read mapping item: {error}")) })?; - let value_converted = RustyPyType(&value).try_into_value().map_err(|e| { - CelError::ConversionError(format!("Failed to convert mapping value: {e}")) - })?; - map.insert(key_converted, value_converted); + result.insert(converted_key, RustyPyType(&value).try_into_value()?); } - - Ok(Value::Map(map.into())) + Ok(Value::Map(result.into())) } } -/// Build a CEL execution environment from an optional evaluation context. -/// -/// This consolidates the shared logic used by `evaluate()` and `Program.execute()` -/// to keep behavior consistent between the two entrypoints. -fn build_environment( - evaluation_context: Option<&Bound<'_, PyAny>>, - environment: &mut CelContext<'_>, -) -> PyResult<()> { - let mut ctx = context::Context::new(None, None)?; - - // 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", - )); - }; +impl TryIntoValue for RustyPyType<'_> { + type Error = CelError; - // Add any variables from the processed context - for (name, value) in &ctx.variables { - environment - .add_variable(name.clone(), value.clone()) - .map_err(|e| { - PyValueError::new_err(format!("Failed to add variable '{name}': {e}")) + fn try_into_value(self) -> Result { + let object = self.0; + if let Ok(optional) = object.extract::>() { + Ok(optional.to_cel_value()) + } else if object.is_none() { + Ok(Value::Null) + } else if let Ok(value) = object.extract::() { + Ok(Value::Bool(value)) + } else if let Ok(value) = object.extract::() { + Ok(Value::Int(value)) + } else if let Ok(value) = object.extract::() { + Ok(Value::UInt(value)) + } else if let Ok(value) = object.extract::() { + Ok(Value::Float(value)) + } else if let Ok(value) = object.extract::>() { + Ok(Value::Timestamp(value)) + } else if let Ok(value) = object.extract::() { + let local = chrono::Local + .from_local_datetime(&value) + .single() + .ok_or_else(|| { + CelError::ConversionError("ambiguous or invalid local datetime".to_string()) })?; - } - - // 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) - }) - }, - ); - } - } - - Ok(()) -} - -/// Enhanced error handling that maps CEL execution errors to appropriate Python exceptions -fn map_execution_error_to_python(error: &ExecutionError) -> PyErr { - match error { - ExecutionError::UndeclaredReference(name) => { - PyRuntimeError::new_err(format!( - "Undefined variable or function: '{name}'. Check that the variable is defined in the context or that the function name is spelled correctly." - )) - }, - ExecutionError::UnsupportedBinaryOperator(op, left_type, right_type) => { - let left_type_str = format!("{left_type:?}"); - let right_type_str = format!("{right_type:?}"); - match *op { - "add" => { - if (left_type_str.contains("Int") && right_type_str.contains("UInt")) || - (left_type_str.contains("UInt") && right_type_str.contains("Int")) { - PyTypeError::new_err(format!( - "Cannot mix signed and unsigned integers in arithmetic: {left_type:?} + {right_type:?}. Use explicit conversion: int(value) or uint(value)" - )) - } else { - PyTypeError::new_err(format!( - "Unsupported addition operation: {left_type:?} + {right_type:?}. Check that both operands are compatible types (int+int, double+double, string+string, etc.)" - )) - } - }, - "mul" => { - PyTypeError::new_err(format!( - "Unsupported multiplication operation: {left_type:?} * {right_type:?}. Ensure both operands are numeric and of compatible types. Use explicit conversion if needed: double(value)*double(value)" - )) - }, - "sub" => { - PyTypeError::new_err(format!( - "Unsupported subtraction operation: {left_type:?} - {right_type:?}. Ensure both operands are numeric and of compatible types." - )) - }, - "div" => { - PyTypeError::new_err(format!( - "Unsupported division operation: {left_type:?} / {right_type:?}. Ensure both operands are numeric and of compatible types." - )) - }, - _ => { - PyTypeError::new_err(format!( - "Unsupported operation '{op}' between {left_type:?} and {right_type:?}. Check the CEL specification for supported operations between these types." - )) + Ok(Value::Timestamp(local.with_timezone(&local.offset().fix()))) + } else if let Ok(value) = object.extract::() { + Ok(Value::Duration(value)) + } else if let Ok(value) = object.extract::() { + Ok(Value::String(value.into())) + } else if let Ok(value) = object.cast::() { + value + .iter() + .map(|item| RustyPyType(&item).try_into_value()) + .collect::, _>>() + .map(|values| Value::List(Arc::new(values))) + } else if let Ok(value) = object.cast::() { + value + .iter() + .map(|item| RustyPyType(&item).try_into_value()) + .collect::, _>>() + .map(|values| Value::List(Arc::new(values))) + } else if let Ok(value) = object.cast::() { + let is_exact_dict = + object.get_type().as_type_ptr() == PyDict::type_object(object.py()).as_type_ptr(); + if is_exact_dict { + let mut result = HashMap::new(); + for (key, value) in value { + result.insert( + Self::key_from_py(&key)?, + RustyPyType(&value).try_into_value()?, + ); } - } - }, - ExecutionError::FunctionError { function, message } => { - PyRuntimeError::new_err(format!( - "Function '{function}' error: {message}. Check function arguments and their types." - )) - }, - _ => { - // Fallback for any other execution errors - provide helpful message based on error content - let error_str = format!("{error:?}"); - if error_str.contains("UndeclaredReference") { - PyRuntimeError::new_err(format!( - "Undefined variable or function. Check that all variables are defined in the context and function names are spelled correctly. Error: {error}" - )) - } else if error_str.contains("UnsupportedBinaryOperator") { - PyTypeError::new_err(format!( - "Unsupported operation between incompatible types. Check the CEL specification for supported operations. Error: {error}" - )) + Ok(Value::Map(result.into())) } else { - PyValueError::new_err(format!( - "CEL execution error: {error}. This may indicate an unsupported operation or invalid expression." - )) + let mapping = object.cast::().map_err(|error| { + CelError::ConversionError(format!("failed to read dict subclass: {error}")) + })?; + Self::mapping_to_value(mapping) } + } else if let Ok(mapping) = object.cast::() { + Self::mapping_to_value(mapping) + } else if let Ok(value) = object.extract::>() { + Ok(Value::Bytes(value.into())) + } else { + let type_name = object + .get_type() + .name() + .map(|name| name.to_string()) + .unwrap_or_else(|_| "".into()); + Err(CelError::ConversionError(format!( + "failed to convert Python object of type {type_name}" + ))) } } } -/// We can't implement TryIntoValue for PyAny, so we implement for our wrapper RustyPyType -impl TryIntoValue for RustyPyType<'_> { - type Error = CelError; - - fn try_into_value(self) -> Result { - let val = match self { - RustyPyType(pyobject) => { - if let Ok(py_optional) = pyobject.extract::>() { - Ok(py_optional.to_cel_value()) - } else if pyobject.is_none() { - Ok(Value::Null) - } else if let Ok(value) = pyobject.extract::() { - Ok(Value::Bool(value)) - } else if let Ok(value) = pyobject.extract::() { - Ok(Value::Int(value)) - } else if let Ok(value) = pyobject.extract::() { - Ok(Value::UInt(value)) - } else if let Ok(value) = pyobject.extract::() { - Ok(Value::Float(value)) - } else if let Ok(value) = pyobject.extract::>() { - Ok(Value::Timestamp(value)) - } else if let Ok(value) = pyobject.extract::() { - // Handle naive datetime - assuming the naive datetime is in local time - let local_timezone = chrono::Local; - if let Some(datetime_local) = - local_timezone.from_local_datetime(&value).single() - { - let datetime_fixed: DateTime = - datetime_local.with_timezone(&datetime_local.offset().fix()); - Ok(Value::Timestamp(datetime_fixed)) - } else { - // Ambiguous or invalid local datetime - Err(CelError::ConversionError( - "Ambiguous or invalid local datetime".to_string(), - )) - } - } else if let Ok(value) = pyobject.extract::() { - Ok(Value::Duration(value)) - } else if let Ok(value) = pyobject.extract::() { - Ok(Value::String(value.into())) - } else if let Ok(value) = pyobject.cast::() { - let list = value - .iter() - .map(|item| RustyPyType(&item).try_into_value()) - .collect::, Self::Error>>(); - list.map(|v| Value::List(Arc::new(v))) - } else if let Ok(value) = pyobject.cast::() { - let list = value - .iter() - .map(|item| RustyPyType(&item).try_into_value()) - .collect::, Self::Error>>(); - list.map(|v| Value::List(Arc::new(v))) - } else if let Ok(value) = pyobject.cast::() { - let py = pyobject.py(); - let is_exact_dict = - pyobject.get_type().as_type_ptr() == PyDict::type_object(py).as_type_ptr(); - - if is_exact_dict { - let mut map: HashMap = HashMap::new(); - for (key, value) in value.into_iter() { - let key = Self::key_from_py(&key)?; - let dict_value = RustyPyType(&value).try_into_value().map_err(|e| { - CelError::ConversionError(format!( - "Failed to convert PyDict value to Value: {e}" - )) - })?; - map.insert(key, dict_value); - } - Ok(Value::Map(map.into())) - } else { - let mapping = pyobject.cast::().map_err(|e| { - CelError::ConversionError(format!( - "Failed to cast dict subclass to mapping: {e}" - )) - })?; - Self::mapping_to_value(mapping) - } - } else if let Ok(mapping) = pyobject.cast::() { - Self::mapping_to_value(mapping) - } else if let Ok(value) = pyobject.extract::>() { - Ok(Value::Bytes(value.into())) - } else { - let type_name = pyobject - .get_type() - .name() - .map(|ps| ps.to_string()) - .unwrap_or("".into()); - Err(CelError::ConversionError(format!( - "Failed to convert Python object of type {type_name} to Value" - ))) - } +fn map_execution_error_to_python(error: &ExecutionError) -> PyErr { + match error { + ExecutionError::UndeclaredReference(name) => PyRuntimeError::new_err(format!( + "Undefined variable or function: '{name}'. Check that the variable is defined in the context and that the function name is spelled correctly." + )), + ExecutionError::UnsupportedBinaryOperator(operator, left, right) => { + let left_type = format!("{:?}", left.type_of()); + let right_type = format!("{:?}", right.type_of()); + let is_signed_unsigned = + (left_type == "Int" && right_type == "UInt") + || (left_type == "UInt" && right_type == "Int"); + if is_signed_unsigned { + return PyTypeError::new_err(format!( + "Cannot mix signed and unsigned integers in {operator} operation: {left_type} and {right_type}. Use explicit conversion: int(value) or uint(value)." + )); } - }; - val - } -} - -/// Evaluate a Common Expression Language (CEL) expression. -/// -/// This is the main entry point for the CEL library. It parses, compiles, and -/// evaluates a CEL expression within an optional context, returning the result -/// as a native Python type. -/// -/// CEL expressions support a wide range of operations including arithmetic, -/// logical operations, string manipulation, list/map operations, and custom -/// function calls. For detailed language reference, see the CEL specification -/// documentation. -/// -/// Args: -/// src (str): The CEL expression to evaluate. Must be a valid CEL expression -/// according to the CEL language specification. -/// evaluation_context (Optional[Union[cel.Context, dict]]): An optional -/// context for the evaluation. This can be either: -/// - A `cel.Context` object (recommended for reusable contexts) -/// - A standard Python dictionary containing variables and functions -/// - None (for expressions that don't require external variables) -/// -/// Returns: -/// Union[bool, int, float, str, list, dict, datetime.datetime, bytes, None]: -/// The result of the expression, automatically converted to the appropriate -/// Python type. Common return types include: -/// - bool: For logical expressions (e.g., "1 < 2") -/// - int/float: For arithmetic expressions -/// - str: For string operations -/// - list: For list expressions and operations -/// - dict: For map/object expressions -/// - datetime.datetime: For timestamp operations -/// - bytes: For byte array operations -/// - None: For null values -/// -/// Raises: -/// ValueError: If the expression has a syntax error, fails to parse, or -/// is malformed. This includes issues such as: -/// - Unclosed quotes or parentheses -/// - Invalid CEL syntax -/// - Empty expressions -/// TypeError: If an operation is attempted on incompatible types, such as: -/// - Adding incompatible types (e.g., string + int without conversion) -/// - Mixing signed and unsigned integers in arithmetic -/// - Using unsupported operators between specific types -/// RuntimeError: For evaluation errors that occur during execution: -/// - Referencing undefined variables or functions -/// - Errors from custom Python functions -/// - Internal evaluation failures -/// -/// Performance Notes: -/// - For multiple evaluations with the same context, use a `cel.Context` -/// object for better performance and memory efficiency. -/// - Complex expressions are compiled once and can be cached internally. -/// -/// Examples: -/// Basic arithmetic and logical operations: -/// -/// >>> from cel import evaluate -/// >>> evaluate("1 + 2 * 3") -/// 7 -/// >>> evaluate("'Hello' + ' ' + 'World'") -/// 'Hello World' -/// >>> evaluate("[1, 2, 3].size() > 2") -/// True -/// -/// Using variables from a dictionary context: -/// -/// >>> user_data = {"name": "Alice", "age": 30, "roles": ["admin", "user"]} -/// >>> evaluate("name + ' is ' + string(age) + ' years old'", user_data) -/// 'Alice is 30 years old' -/// >>> evaluate("'admin' in roles", user_data) -/// True -/// -/// Working with nested data structures: -/// -/// >>> context = { -/// ... "user": {"profile": {"name": "Bob", "verified": True}}, -/// ... "settings": {"theme": "dark", "notifications": False} -/// ... } -/// >>> evaluate("user.profile.verified && settings.theme == 'dark'", context) -/// True -/// -/// Using custom Python functions: -/// -/// >>> def calculate_discount(price, percentage): -/// ... return price * (1 - percentage / 100) -/// >>> context = { -/// ... "price": 100.0, -/// ... "discount_rate": 15, -/// ... "calculate_discount": calculate_discount -/// ... } -/// >>> evaluate("calculate_discount(price, discount_rate)", context) -/// 85.0 -/// -/// Error handling example: -/// -/// >>> try: -/// ... evaluate("undefined_variable + 5") -/// ... except RuntimeError as e: -/// ... print(f"Error: {e}") -/// Error: Undefined variable or function: 'undefined_variable'... -/// -/// Using Context object for reusable evaluations: -/// -/// >>> from cel import Context -/// >>> context = Context( -/// ... variables={"base_url": "https://api.example.com"}, -/// ... functions={"len": len} -/// ... ) -/// >>> evaluate("base_url + '/users'", context) -/// 'https://api.example.com/users' -/// >>> evaluate("len('hello world')", context) -/// 11 -/// -/// Type safety and error handling: -/// -/// >>> # Strict CEL mode enforces type compatibility -/// >>> evaluate("1.0 + 2.5") # Same type - works -/// 3.5 -/// >>> try: -/// ... evaluate("1 + 2.5") # Mixed types - fails -/// ... except TypeError as e: -/// ... print("Type error:", e) -/// Type error: Unsupported addition operation: Int + Double... -/// -/// >>> # Use explicit conversion for mixed arithmetic -/// >>> evaluate("double(1) + 2.5") -/// 3.5 -/// -/// See Also: -/// - cel.Context: For managing reusable evaluation contexts -/// - CEL Language Guide: For comprehensive language documentation -/// - Python API Reference: For detailed API documentation -#[pyfunction(signature = (src, evaluation_context=None))] -fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyResult { - let mut environment = CelContext::default(); - build_environment(evaluation_context, &mut environment)?; - - // Use panic::catch_unwind to handle parser panics gracefully - let program = panic::catch_unwind(|| Program::compile(&src)) - .map_err(|_| { - warn!("CEL parser panic for expression: '{}'", src); - PyValueError::new_err(format!( - "Failed to parse expression '{src}': Invalid syntax or malformed string" - )) - })? - .map_err(|e| PyValueError::new_err(format!("Failed to parse expression '{src}': {e}")))?; - // 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" + let operation = match *operator { + "add" => "addition", + "sub" => "subtraction", + "mul" => "multiplication", + "div" => "division", + "rem" => "remainder", + other => other, + }; + PyTypeError::new_err(format!( + "Unsupported {operation} operation between {left_type} and {right_type}. Check that both operands are compatible types; use explicit conversion if needed: double(value)." )) - })?; - - match result { - Err(error) => Err(map_execution_error_to_python(&error)), - Ok(value) => Ok(RustyCelType(value)), + } + ExecutionError::NoSuchOverload => PyTypeError::new_err("No such overload"), + ExecutionError::FunctionError { function, message } => { + PyRuntimeError::new_err(format!("Function '{function}' error: {message}")) + } + other => PyValueError::new_err(format!("CEL execution error: {other}")), } } -/// 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(); - build_environment(evaluation_context, &mut environment)?; - - // 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()) - }), - } +/// Compile and evaluate a CEL expression against a reusable native Context. +/// +/// The context is required and is borrowed directly for the duration of +/// evaluation; dictionaries and implicit contexts are not accepted. +#[pyfunction] +fn evaluate(src: String, context: PyRef<'_, Context>, py: Python<'_>) -> PyResult> { + let program = compile_program(&src)?; + let result = execute_program(&program, &src, &context.inner)?; + RustyCelType(result).into_pyobject(py).map(Bound::unbind) } #[pymodule] -fn cel(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { +fn cel(_py: Python<'_>, module: &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::()?; - m.add_class::()?; + module.add_function(wrap_pyfunction!(evaluate, module)?)?; + module.add_function(wrap_pyfunction!(compile, module)?)?; + module.add_function(wrap_pyfunction!(prepare, module)?)?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; Ok(()) } diff --git a/tests/conftest.py b/tests/conftest.py index 666fc1e..a92ab66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,8 @@ +"""Shared test fixtures for the strict prepared-context API.""" + +from __future__ import annotations + +import cel import pytest expressions = [ @@ -20,7 +25,6 @@ ] -# Valid expressions fixture @pytest.fixture(params=expressions) def valid_simple_expression(request): return request.param @@ -36,7 +40,26 @@ def valid_simple_expression(request): ] -# Valid expressions with context fixture @pytest.fixture(params=expression_context_pairs) def expression_context_result(request): return request.param + + +def make_context(values: dict[str, object] | None = None) -> cel.Context: + """Create a strict Context from test data, preparing each variable explicitly.""" + context = cel.Context() + for name, value in (values or {}).items(): + if callable(value): + context.add_function(name, value) + else: + context.add_variable(name, cel.prepare(value)) + return context + + +def evaluate( + expression: str, + values: dict[str, object] | cel.Context | None = None, +): + """Legacy-test helper routed through the public strict API.""" + context = values if isinstance(values, cel.Context) else make_context(values) + return cel.evaluate(expression, context) diff --git a/tests/test_arithmetic.py b/tests/test_arithmetic.py index 483a6ed..535579e 100644 --- a/tests/test_arithmetic.py +++ b/tests/test_arithmetic.py @@ -11,6 +11,7 @@ import cel import pytest +from conftest import evaluate class TestBasicArithmetic: @@ -18,31 +19,31 @@ class TestBasicArithmetic: def test_basic_addition(self): """Test basic integer addition.""" - assert cel.evaluate("1 + 1") == 2 + assert evaluate("1 + 1") == 2 def test_basic_subtraction(self): """Test basic integer subtraction.""" - assert cel.evaluate("5 - 3") == 2 + assert evaluate("5 - 3") == 2 def test_basic_multiplication(self): """Test basic integer multiplication.""" - assert cel.evaluate("3 * 4") == 12 + assert evaluate("3 * 4") == 12 def test_basic_division(self): """Test basic division.""" - assert cel.evaluate("10 / 2") == 5.0 + assert evaluate("10 / 2") == 5.0 def test_integer_modulo(self): """Test integer modulo operation.""" - assert cel.evaluate("10 % 3") == 1 + assert evaluate("10 % 3") == 1 def test_string_concatenation(self): """Test string concatenation with + operator.""" - assert cel.evaluate("'Hello ' + name", {"name": "World"}) == "Hello World" + assert evaluate("'Hello ' + name", {"name": "World"}) == "Hello World" def test_complex_string_concatenation(self): """Test complex string concatenation from context.""" - result = cel.evaluate( + result = evaluate( 'resource.name.startsWith("/groups/" + claim.group)', {"resource": {"name": "/groups/hardbyte"}, "claim": {"group": "hardbyte"}}, ) @@ -55,7 +56,7 @@ class TestArithmeticWithContext: def test_datetime_arithmetic_context(self): """Test datetime arithmetic operations with context.""" now = datetime.datetime.now(datetime.timezone.utc) - result = cel.evaluate("start_time + duration('1h')", {"start_time": now}) + result = evaluate("start_time + duration('1h')", {"start_time": now}) expected = now + datetime.timedelta(hours=1) assert result == expected @@ -65,20 +66,20 @@ class TestArithmeticEdgeCases: def test_no_preprocessing_for_pure_int_operations(self): """Test that pure integer operations are not modified.""" - result = cel.evaluate("5 + 3") + result = evaluate("5 + 3") assert result == 8 assert isinstance(result, int) def test_no_preprocessing_for_pure_float_operations(self): """Test that pure float operations are not modified.""" - result = cel.evaluate("5.5 + 3.2") + result = evaluate("5.5 + 3.2") assert result == 8.7 assert isinstance(result, float) def test_invalid_expression_raises_parse_value_error(self): """Test that invalid arithmetic expressions raise proper ValueError.""" with pytest.raises(ValueError, match="Failed to parse expression"): - cel.evaluate("1 +") + evaluate("1 +") class TestBytesArithmetic: @@ -91,20 +92,21 @@ def test_bytes_concatenation_context(self): """CEL spec requires bytes concatenation with + operator, but cel-interpreter 0.10.0 doesn't implement it.""" part1 = b"hello" part2 = b"world" - result = cel.evaluate("part1 + b' ' + part2", {"part1": part1, "part2": part2}) + result = evaluate("part1 + b' ' + part2", {"part1": part1, "part2": part2}) assert result == b"hello world" + @pytest.mark.xfail(reason="cel-rust currently supports bytes concatenation") def test_bytes_concatenation_not_supported(self): - """Test direct bytes concatenation (CEL spec requires this but cel-interpreter 0.10.0 doesn't support it).""" - with pytest.raises(TypeError, match="Unsupported addition operation"): - cel.evaluate("b'hello' + b'world'") + """Track the upstream bytes concatenation behavior.""" + with pytest.raises(TypeError, match="No such overload"): + evaluate("b'hello' + b'world'") def test_bytes_concatenation_workaround(self): """Test bytes concatenation workaround using string conversion.""" part1 = b"hello" part2 = b"world" # Workaround: convert to strings, concatenate, then convert back to bytes - result = cel.evaluate( + result = evaluate( 'bytes(string(part1) + " " + string(part2))', {"part1": part1, "part2": part2} ) assert result == b"hello world" diff --git a/tests/test_basics.py b/tests/test_basics.py index 0a12dab..5709407 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -2,33 +2,34 @@ import cel import pytest +from conftest import evaluate def test_readme_example(): - assert cel.evaluate( + assert evaluate( 'resource.name.startsWith("/groups/" + claim.group)', {"resource": {"name": "/groups/hardbyte"}, "claim": {"group": "hardbyte"}}, ) def test_return_bool(): - assert cel.evaluate("1 == 1") + assert evaluate("1 == 1") def test_return_list(): - assert cel.evaluate("[1, 1]") == [1, 1] + assert evaluate("[1, 1]") == [1, 1] def test_return_dict(): - assert cel.evaluate("foo", {"foo": {"bar": 2}}) == {"bar": 2} + assert evaluate("foo", {"foo": {"bar": 2}}) == {"bar": 2} def test_return_null(): - assert cel.evaluate("null") is None + assert evaluate("null") is None def test_timestamp(): - assert cel.evaluate("timestamp('1996-12-19T16:39:57-08:00')") == datetime.datetime( + assert evaluate("timestamp('1996-12-19T16:39:57-08:00')") == datetime.datetime( 1996, 12, 19, @@ -40,43 +41,43 @@ def test_timestamp(): def test_timestamp_utc(): - result = cel.evaluate("timestamp('1996-12-19T16:39:57-08:00')") + result = evaluate("timestamp('1996-12-19T16:39:57-08:00')") expected = datetime.datetime(1996, 12, 20, 0, 39, 57, tzinfo=datetime.timezone.utc) assert result == expected def test_duration(): - assert cel.evaluate("duration('24h')") == datetime.timedelta(hours=24) + assert evaluate("duration('24h')") == datetime.timedelta(hours=24) def test_timestamp_context_with_timezone(): now = datetime.datetime.now(datetime.timezone.utc) - assert cel.evaluate("now", {"now": now}) == now + assert evaluate("now", {"now": now}) == now def test_timestamp_add_duration(): now = datetime.datetime.now(datetime.timezone.utc) - result = cel.evaluate("start_time + duration('1h')", {"start_time": now}) + result = evaluate("start_time + duration('1h')", {"start_time": now}) assert result == now + datetime.timedelta(hours=1) def test_timestamp_context_without_timezone(): now = datetime.datetime.now() - assert cel.evaluate("now", {"now": now}) + assert evaluate("now", {"now": now}) def test_size(): - assert cel.evaluate("size([1, 2, 3])") == 3 + assert evaluate("size([1, 2, 3])") == 3 def test_basic_expressions_evaluate(valid_simple_expression): - result = cel.evaluate(valid_simple_expression) + result = evaluate(valid_simple_expression) assert type(result) in (int, float, str, bytes, bool, list, dict, type(None), datetime.datetime) def test_expressions_with_context(expression_context_result): expression, context, expected_result = expression_context_result - result = cel.evaluate(expression, context) + result = evaluate(expression, context) assert result == expected_result @@ -86,46 +87,46 @@ def test_expressions_with_context(expression_context_result): ) def test_str_context_expression(): """Test string indexing - currently not supported by cel-interpreter.""" - result = cel.evaluate("word[1]", {"word": "hello"}) + result = evaluate("word[1]", {"word": "hello"}) assert result == "e" def test_list_context_expression(): - result = cel.evaluate("foo[1]", {"foo": [1, 2, 3]}) + result = evaluate("foo[1]", {"foo": [1, 2, 3]}) assert result == 2 def test_dict_context_expression(): - result = cel.evaluate("foo['bar']", {"foo": {"bar": 2}}) + result = evaluate("foo['bar']", {"foo": {"bar": 2}}) assert result == 2 def test_tuple_context_expression(): - result = cel.evaluate("foo[1]", {"foo": (2, 3, 4)}) + result = evaluate("foo[1]", {"foo": (2, 3, 4)}) assert result == 3 def test_bytes_size(): - result = cel.evaluate("size(b'hello')") + result = evaluate("size(b'hello')") assert result == 5 def test_bytes_inequality(): - result = cel.evaluate("b'hello' != b'world'") + result = evaluate("b'hello' != b'world'") assert result def test_bytes_equality_via_context(): - result = cel.evaluate("b'hello' == foo", {"foo": b"hello"}) + result = evaluate("b'hello' == foo", {"foo": b"hello"}) assert result def test_bytes_string_conversion(): """Test bytes <-> string conversion functions that ARE supported by CEL""" # Convert string to bytes - result = cel.evaluate('bytes("hello")') + result = evaluate('bytes("hello")') assert result == b"hello" # Convert bytes to string - result = cel.evaluate('string(b"hello")') + result = evaluate('string(b"hello")') assert result == "hello" diff --git a/tests/test_boolean_coercion.py b/tests/test_boolean_coercion.py index 4ba903e..7a208c3 100644 --- a/tests/test_boolean_coercion.py +++ b/tests/test_boolean_coercion.py @@ -6,7 +6,7 @@ """ import pytest -from cel import evaluate +from conftest import evaluate class TestCelCompliantBooleanOperations: @@ -20,31 +20,31 @@ def test_not_operator_with_boolean(self): def test_not_operator_with_non_boolean_fails(self): """Test that NOT operator correctly fails with non-boolean operands.""" # Numbers should fail - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("!0") - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("!1") - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("!42") # Strings should fail - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("!''") - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("!'hello'") # Collections should fail - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("![]") - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("!{}") # Null should fail - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("!null") def test_logical_and_with_boolean_operands(self): @@ -56,13 +56,12 @@ def test_logical_and_with_boolean_operands(self): def test_logical_and_with_mixed_types_fails(self): """Test that AND operator correctly fails with mixed-type operands.""" - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("'string' && true") - with pytest.raises(ValueError, match="No such overload"): - evaluate("42 && false") + assert evaluate("42 && false") is False - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("true && 1") def test_logical_or_with_boolean_operands(self): @@ -74,10 +73,13 @@ def test_logical_or_with_boolean_operands(self): def test_logical_or_special_cel_behavior(self): """Test OR operator's special CEL behavior with boolean first operand.""" - # When first operand is boolean false, returns second operand - assert evaluate("false || 99") == 99 - assert evaluate("false || 'text'") == "text" - assert evaluate("false || null") is None + # This implementation requires the second operand to be boolean. + with pytest.raises(TypeError, match="No such overload"): + evaluate("false || 99") + with pytest.raises(TypeError, match="No such overload"): + evaluate("false || 'text'") + with pytest.raises(TypeError, match="No such overload"): + evaluate("false || null") # When first operand is boolean true, short-circuits to true assert evaluate("true || 99") is True @@ -85,13 +87,12 @@ def test_logical_or_special_cel_behavior(self): def test_logical_or_with_non_boolean_first_operand_fails(self): """Test that OR operator correctly fails when first operand is not boolean.""" - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("42 || false") - with pytest.raises(ValueError, match="No such overload"): - evaluate("'string' || true") + assert evaluate("'string' || true") is True - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("0 || 'default'") def test_ternary_operator_requires_boolean_condition(self): @@ -101,10 +102,10 @@ def test_ternary_operator_requires_boolean_condition(self): assert evaluate("false ? 'yes' : 'no'") == "no" # Non-boolean conditions should fail - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("42 ? 'yes' : 'no'") - with pytest.raises(ValueError, match="No such overload"): + with pytest.raises(TypeError, match="No such overload"): evaluate("'string' ? 'yes' : 'no'") def test_boolean_comparisons_work_correctly(self): diff --git a/tests/test_compile.py b/tests/test_compile.py index a5be111..7e16057 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -1,311 +1,171 @@ -"""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 import pytest -from cel import Context -class TestCompileBasics: - """Basic compilation and execution tests.""" +def context(**variables): + result = cel.Context() + for name, value in variables.items(): + result.add_variable(name, cel.prepare(value)) + return result + - def test_compile_simple_expression(self): - """Test compiling a simple arithmetic expression.""" +class TestCompileBasics: + def test_compile_and_execute(self): program = cel.compile("1 + 2") - result = program.execute() - assert result == 3 + assert program.execute(context()) == 3 def test_compile_returns_program(self): - """Test that compile() returns a Program object.""" - program = cel.compile("true") - assert hasattr(program, "execute") + assert isinstance(cel.compile("true"), cel.Program) 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 + assert repr(cel.compile("x + y")) == 'Program("x + y")' - def test_execute_with_none_context(self): - """Test executing with explicit None context.""" - program = cel.compile("true && false") - assert program.execute(None) is False + @pytest.mark.parametrize("argument", [None, {}, cel.prepare(1), object()]) + def test_execute_rejects_non_context_arguments(self, argument): + with pytest.raises(TypeError): + cel.compile("42").execute(argument) + def test_execute_requires_exactly_one_argument(self): + program = cel.compile("42") + with pytest.raises(TypeError): + program.execute() + with pytest.raises(TypeError): + program.execute(context(), context()) -class TestCompileWithContext: - """Tests for compile/execute with various context types.""" + def test_program_exposes_one_execution_method(self): + program = cel.compile("42") + execution_methods = [name for name in dir(program) if name.startswith("execute")] + assert execution_methods == ["execute"] - 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.""" +class TestCompileWithContext: + def test_execute_with_variables(self): 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 + assert program.execute(context(name="Alice", age=30)) == "Alice is 30" - 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_reuse_program_with_multiple_contexts(self): + program = cel.compile("price * quantity") + assert program.execute(context(price=10, quantity=5)) == 50 + assert program.execute(context(price=25, quantity=4)) == 100 + + def test_nested_dot_and_index_selection(self): + ctx = context( + data={ + "objects": [ + {"active": False, "profile": {"enabled": False}}, + {"active": True, "profile": {"enabled": True}}, + ] + } + ) + assert cel.compile("data.objects[1].profile.enabled").execute(ctx) is True + assert cel.compile("data['objects'][1].active").execute(ctx) is True - 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 + def test_fixed_index_primitive_predicate(self): + objects = [ + {"active": index % 2 == 0, "score": index, "padding": list(range(100))} + for index in range(10) + ] + assert ( + cel.compile("data.objects[3].active || data.objects[3].score >= 3").execute( + context(data={"objects": objects}) + ) + is True + ) 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() + def test_selected_primitive_arguments(self): + ctx = context(data={"left": 20, "right": 22}) + ctx.add_function("add", lambda left, right: left + right) + assert cel.compile("add(data.left, data.right)").execute(ctx) == 42 + + def test_multiple_functions(self): + ctx = context(x=3, y=4) 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 + assert cel.compile("add(x, y) + multiply(x, y)").execute(ctx) == 19 + + +class TestCompileResults: + @pytest.mark.parametrize( + ("expression", "expected"), + [ + ("true", True), + ("42", 42), + ("3.5", 3.5), + ("'hello'", "hello"), + ("null", None), + ("b'hello'", b"hello"), + ("[1, 2, 3]", [1, 2, 3]), + ("{'answer': 42}", {"answer": 42}), + ], + ) + def test_result_types(self, expression, expected): + assert cel.compile(expression).execute(context()) == expected + + def test_timestamp_and_duration(self): + timestamp = cel.compile("timestamp('2024-01-01T00:00:00Z')").execute(context()) + duration = cel.compile("duration('1h30m')").execute(context()) + assert isinstance(timestamp, datetime.datetime) + assert duration == datetime.timedelta(seconds=5400) + + def test_returning_prepared_map_and_list_remains_correct(self): + data = {"map": {"answer": 42}, "list": [1, 2, 3]} + ctx = context(data=data) + assert cel.compile("data.map").execute(ctx) == data["map"] + assert cel.compile("data.list").execute(ctx) == data["list"] + + +class TestSelectionSemantics: + def test_missing_key_and_out_of_range(self): + ctx = context(data={"items": [1], "record": {"present": True}}) + with pytest.raises(ValueError, match="No such key"): + cel.compile("data.record.missing").execute(ctx) + with pytest.raises(ValueError, match="Index out of bounds"): + cel.compile("data.items[5]").execute(ctx) + + def test_has(self): + ctx = context(data={"record": {"present": True}}) + assert cel.compile("has(data.record.present)").execute(ctx) is True + assert cel.compile("has(data.record.missing)").execute(ctx) is False + + def test_optional_values_remain_usable_with_prepared_contexts(self): + ctx = context(value=cel.OptionalValue.of(42), missing=cel.OptionalValue.none()) + assert cel.compile("value.orValue(0)").execute(ctx) == 42 + assert cel.compile("missing.orValue(0)").execute(ctx) == 0 + + def test_selection_from_owned_temporary(self): + ctx = context() + assert cel.compile("{'profile': {'enabled': true}}.profile.enabled").execute(ctx) is True + assert cel.compile("[{'enabled': true}][0].enabled").execute(ctx) is True 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.""" + @pytest.mark.parametrize("expression", ["1 + + 2", ""]) + def test_compile_invalid_syntax(self, expression): with pytest.raises(ValueError, match="Failed to parse"): - cel.compile("") + cel.compile(expression) def test_execute_undefined_variable(self): - """Test that undefined variable raises RuntimeError.""" - program = cel.compile("undefined_var + 1") with pytest.raises(RuntimeError): - program.execute({}) + cel.compile("undefined_var + 1").execute(context()) 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.""" + cel.compile("x + y").execute(context(x="hello", y=42)) - 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 +class TestEvaluate: + def test_evaluate_uses_context(self): + assert cel.evaluate("x + y", context(x=20, y=22)) == 42 - # 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") + @pytest.mark.parametrize("argument", [None, {}, cel.prepare(1), object()]) + def test_evaluate_rejects_non_context_arguments(self, argument): + with pytest.raises(TypeError): + cel.evaluate("42", argument) - # 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 + def test_evaluate_requires_context(self): + with pytest.raises(TypeError): + cel.evaluate("42") diff --git a/tests/test_context.py b/tests/test_context.py index d1da03c..4940084 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,109 +1,141 @@ +import datetime + import cel import pytest -def test_create_empty_context(): - cel.Context() - +def bind(context, name, value): + context.add_variable(name, cel.prepare(value)) -def test_context_vars_explicit(): - context = cel.Context(variables={"a": 10}) - assert cel.evaluate("a", context) == 10 +def test_context_is_empty_and_accepts_no_constructor_arguments(): + context = cel.Context() + assert repr(context) == "Context()" -def test_context_vars_implicit(): - context = cel.Context({"a": 10}) - assert cel.evaluate("a", context) == 10 + with pytest.raises(TypeError): + cel.Context({"a": 1}) + with pytest.raises(TypeError): + cel.Context(variables={"a": 1}) + with pytest.raises(TypeError): + cel.Context(functions={"f": lambda: None}) -def test_context_vars_none_value(): - context = cel.Context({"a": None}) - assert cel.evaluate("a", context) is None +def test_context_exposes_only_narrow_mutation_api(): + context = cel.Context() + assert not hasattr(context, "variables") + assert not hasattr(context, "functions") + assert not hasattr(context, "update") + with pytest.raises(TypeError): + len(context) -def test_adding_to_context(): +def test_add_and_replace_prepared_variable(): context = cel.Context() + program = cel.compile("data.value") - with pytest.raises( - RuntimeError - ): # Enhanced error handling now raises RuntimeError for undefined variables - assert cel.evaluate("a + 2", context) == 4 + bind(context, "data", {"value": 1}) + assert program.execute(context) == 1 - context.add_variable("a", 2) - assert cel.evaluate("a + 2", context) == 4 + bind(context, "data", {"value": 2}) + assert program.execute(context) == 2 -def test_explicit_context(): +def test_repeated_and_alternating_prepared_bindings(): context = cel.Context() - context.add_variable("a", 2) - assert cel.evaluate("a + 2", context) == 4 + one = cel.prepare({"value": 1}) + two = cel.prepare({"value": 2}) + program = cel.compile("data.value") + for prepared, expected in [(one, 1), (one, 1), (two, 2), (one, 1), (two, 2)]: + context.add_variable("data", prepared) + assert program.execute(context) == expected -def test_custom_function_init_context(): - def custom_function(a, b): - return a + b - context = cel.Context(functions={"f": custom_function}) +def test_replacing_one_variable_preserves_others(): + context = cel.Context() + bind(context, "a", 1) + bind(context, "b", 2) + bind(context, "a", 40) + assert cel.compile("a + b").execute(context) == 42 + + +@pytest.mark.parametrize( + "raw_value", + [ + 1, + True, + 1.5, + "value", + b"value", + [1], + (1,), + {"value": 1}, + datetime.datetime.now(), + datetime.timedelta(seconds=1), + cel.OptionalValue.none(), + ], +) +def test_add_variable_rejects_raw_python_values(raw_value): + with pytest.raises(TypeError): + cel.Context().add_variable("value", raw_value) + + +def test_undefined_variable_is_an_execution_error(): + context = cel.Context() + with pytest.raises(RuntimeError, match="Undefined variable"): + cel.compile("missing").execute(context) - assert cel.evaluate("f(1, 2)", context) == 3 +def test_function_registration_and_repeated_execution(): + calls = [] -def test_context_init_vars_and_funcs(): - def custom_function(a, b): + def add(a, b): + calls.append((a, b)) return a + b - context = cel.Context({"a": 10}, functions={"f": custom_function}) + context = cel.Context() + context.add_function("add", add) + bind(context, "data", {"left": 20, "right": 22}) + program = cel.compile("add(data.left, data.right)") - assert cel.evaluate("f(a, 2)", context) == 12 + assert program.execute(context) == 42 + assert program.execute(context) == 42 + assert calls == [(20, 22), (20, 22)] -def test_custom_function_with_explicit_context(): - def custom_function(a, b): - return a + b +def test_function_must_be_callable(): + with pytest.raises(TypeError, match="callable"): + cel.Context().add_function("not_callable", 42) + +def test_function_results_are_converted_to_cel(): context = cel.Context() - context.add_function("custom_function", custom_function) - assert cel.evaluate("custom_function(1, 2)", context) == 3 + context.add_function("make", lambda: {"answer": [40, 42]}) + assert cel.compile("make().answer[1]").execute(context) == 42 -def test_updating_explicit_context(): - def custom_function(a, b): - return a + b +def test_function_exceptions_become_execution_errors(): + def fail(): + raise ValueError("useful callback failure") context = cel.Context() - context.update( - { - "custom_function": custom_function, - "a": 40, - "b": 2, - } - ) - assert cel.evaluate("custom_function(a, b)", context) == 42 + context.add_function("fail", fail) + with pytest.raises(RuntimeError, match="useful callback failure"): + cel.compile("fail()").execute(context) -def test_nested_context_none(): - """Test that nested context with None values works correctly""" - context = { - "spec": { - "type": "dns", - "nameserver": None, - "host": "github.com", - "timeout": 30.0, - }, - "data": { - "canonical_name": "github.com.", - "expiration": 1732097106.7902246, - "A": ["4.237.22.38"], - "response-code": "NOERROR", - "startTimestamp": "2024-11-20T10:04:59.789017+00:00", - "endTimestamp": "2024-11-20T10:04:59.790298+00:00", +def test_nested_data_and_none(): + context = cel.Context() + bind( + context, + "data", + { + "spec": {"nameserver": None, "host": "github.com"}, + "response": {"response-code": "NOERROR", "addresses": ["4.237.22.38"]}, }, - } - - cel_context = cel.Context(variables=context) + ) - # Test that we can access nested values and None - assert cel.evaluate("spec.nameserver", cel_context) is None - assert cel.evaluate("spec.host", cel_context) == "github.com" - assert cel.evaluate("data['response-code']", cel_context) == "NOERROR" - assert cel.evaluate("size(data.A)", cel_context) == 1 + assert cel.compile("data.spec.nameserver").execute(context) is None + assert cel.compile("data.spec.host").execute(context) == "github.com" + assert cel.compile("data.response['response-code']").execute(context) == "NOERROR" + assert cel.compile("size(data.response.addresses)").execute(context) == 1 diff --git a/tests/test_datetime.py b/tests/test_datetime.py index c7f7e63..8e67104 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -12,6 +12,7 @@ import cel import pytest +from conftest import evaluate class TestDatetimeBasics: @@ -22,28 +23,28 @@ def test_datetime_with_different_timezones(self): # UTC timezone utc_time = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": utc_time}) + result = evaluate("dt", {"dt": utc_time}) assert result == utc_time assert result.tzinfo == datetime.timezone.utc # Fixed offset timezone (+5 hours) offset_tz = datetime.timezone(datetime.timedelta(hours=5)) offset_time = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=offset_tz) - result = cel.evaluate("dt", {"dt": offset_time}) + result = evaluate("dt", {"dt": offset_time}) assert result == offset_time assert result.tzinfo == offset_tz # Fixed offset timezone (-8 hours) negative_offset_tz = datetime.timezone(datetime.timedelta(hours=-8)) negative_offset_time = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=negative_offset_tz) - result = cel.evaluate("dt", {"dt": negative_offset_time}) + result = evaluate("dt", {"dt": negative_offset_time}) assert result == negative_offset_time assert result.tzinfo == negative_offset_tz def test_naive_datetime_conversion(self): """Test that naive datetimes are properly converted to timezone-aware.""" naive_time = datetime.datetime(2024, 1, 1, 12, 0, 0) - result = cel.evaluate("dt", {"dt": naive_time}) + result = evaluate("dt", {"dt": naive_time}) # Should convert to timezone-aware datetime assert isinstance(result, datetime.datetime) @@ -62,7 +63,7 @@ def test_datetime_microseconds(self): dt_with_microseconds = datetime.datetime( 2024, 1, 1, 12, 0, 0, 123456, tzinfo=datetime.timezone.utc ) - result = cel.evaluate("dt", {"dt": dt_with_microseconds}) + result = evaluate("dt", {"dt": dt_with_microseconds}) assert result == dt_with_microseconds assert result.microsecond == 123456 @@ -72,14 +73,14 @@ def test_datetime_type_consistency(self): delta = datetime.timedelta(hours=1) # Verify types are preserved - dt_result = cel.evaluate("dt", {"dt": dt}) + dt_result = evaluate("dt", {"dt": dt}) assert isinstance(dt_result, datetime.datetime) - delta_result = cel.evaluate("delta", {"delta": delta}) + delta_result = evaluate("delta", {"delta": delta}) assert isinstance(delta_result, datetime.timedelta) # Arithmetic should return correct types - add_result = cel.evaluate("dt + delta", {"dt": dt, "delta": delta}) + add_result = evaluate("dt + delta", {"dt": dt, "delta": delta}) assert isinstance(add_result, datetime.datetime) @@ -92,12 +93,12 @@ def test_datetime_arithmetic(self): one_hour = datetime.timedelta(hours=1) # Test datetime addition - result = cel.evaluate("dt + duration", {"dt": base_time, "duration": one_hour}) + result = evaluate("dt + duration", {"dt": base_time, "duration": one_hour}) expected = base_time + one_hour assert result == expected # Test datetime subtraction - result = cel.evaluate("dt - duration", {"dt": base_time, "duration": one_hour}) + result = evaluate("dt - duration", {"dt": base_time, "duration": one_hour}) expected = base_time - one_hour assert result == expected @@ -107,11 +108,11 @@ def test_datetime_arithmetic_edge_cases(self): # Add zero duration zero_delta = datetime.timedelta(0) - result = cel.evaluate("dt + delta", {"dt": base_dt, "delta": zero_delta}) + result = evaluate("dt + delta", {"dt": base_dt, "delta": zero_delta}) assert result == base_dt # Subtract zero duration - result = cel.evaluate("dt - delta", {"dt": base_dt, "delta": zero_delta}) + result = evaluate("dt - delta", {"dt": base_dt, "delta": zero_delta}) assert result == base_dt def test_nested_datetime_operations(self): @@ -123,11 +124,11 @@ def test_nested_datetime_operations(self): context = {"dt1": dt1, "dt2": dt2, "delta": delta} # Complex datetime expression - result = cel.evaluate("(dt1 + delta) == dt2", context) + result = evaluate("(dt1 + delta) == dt2", context) assert result is True # Nested comparison - result = cel.evaluate("dt1 < dt2 && (dt1 + delta) == dt2", context) + result = evaluate("dt1 < dt2 && (dt1 + delta) == dt2", context) assert result is True @@ -140,15 +141,15 @@ def test_datetime_comparisons(self): dt2 = datetime.datetime(2024, 1, 1, 13, 0, 0, tzinfo=datetime.timezone.utc) # dt1 < dt2 - result = cel.evaluate("dt1 < dt2", {"dt1": dt1, "dt2": dt2}) + result = evaluate("dt1 < dt2", {"dt1": dt1, "dt2": dt2}) assert result is True # dt1 == dt1 - result = cel.evaluate("dt1 == dt1", {"dt1": dt1}) + result = evaluate("dt1 == dt1", {"dt1": dt1}) assert result is True # dt2 > dt1 - result = cel.evaluate("dt2 > dt1", {"dt1": dt1, "dt2": dt2}) + result = evaluate("dt2 > dt1", {"dt1": dt1, "dt2": dt2}) assert result is True def test_timezone_awareness_mixed(self): @@ -159,10 +160,10 @@ def test_timezone_awareness_mixed(self): context = {"utc_dt": utc_time, "naive_dt": naive_time} # Both should be accessible - result_utc = cel.evaluate("utc_dt", context) + result_utc = evaluate("utc_dt", context) assert result_utc == utc_time - result_naive = cel.evaluate("naive_dt", context) + result_naive = evaluate("naive_dt", context) assert isinstance(result_naive, datetime.datetime) assert result_naive.tzinfo is not None # Should be converted to timezone-aware @@ -175,27 +176,27 @@ def test_timedelta_operations(self): # Various timedelta units microseconds_delta = datetime.timedelta(microseconds=123456) - result = cel.evaluate("delta", {"delta": microseconds_delta}) + result = evaluate("delta", {"delta": microseconds_delta}) assert result == microseconds_delta seconds_delta = datetime.timedelta(seconds=45) - result = cel.evaluate("delta", {"delta": seconds_delta}) + result = evaluate("delta", {"delta": seconds_delta}) assert result == seconds_delta minutes_delta = datetime.timedelta(minutes=30) - result = cel.evaluate("delta", {"delta": minutes_delta}) + result = evaluate("delta", {"delta": minutes_delta}) assert result == minutes_delta hours_delta = datetime.timedelta(hours=6) - result = cel.evaluate("delta", {"delta": hours_delta}) + result = evaluate("delta", {"delta": hours_delta}) assert result == hours_delta days_delta = datetime.timedelta(days=7) - result = cel.evaluate("delta", {"delta": days_delta}) + result = evaluate("delta", {"delta": days_delta}) assert result == days_delta weeks_delta = datetime.timedelta(weeks=2) - result = cel.evaluate("delta", {"delta": weeks_delta}) + result = evaluate("delta", {"delta": weeks_delta}) assert result == weeks_delta def test_timedelta_edge_cases(self): @@ -203,17 +204,17 @@ def test_timedelta_edge_cases(self): # Maximum timedelta max_delta = datetime.timedelta(days=999999999, seconds=86399, microseconds=999999) - result = cel.evaluate("delta", {"delta": max_delta}) + result = evaluate("delta", {"delta": max_delta}) assert result == max_delta # Minimum (negative) timedelta min_delta = datetime.timedelta(days=-999999999) - result = cel.evaluate("delta", {"delta": min_delta}) + result = evaluate("delta", {"delta": min_delta}) assert result == min_delta # Zero timedelta zero_delta = datetime.timedelta(0) - result = cel.evaluate("delta", {"delta": zero_delta}) + result = evaluate("delta", {"delta": zero_delta}) assert result == zero_delta @@ -225,35 +226,35 @@ def test_datetime_edge_cases(self): # Year 1 (minimum year) min_dt = datetime.datetime(1, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": min_dt}) + result = evaluate("dt", {"dt": min_dt}) assert result == min_dt # Year 9999 (maximum year) max_dt = datetime.datetime(9999, 12, 31, 23, 59, 59, 999999, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": max_dt}) + result = evaluate("dt", {"dt": max_dt}) assert result == max_dt # Leap year February 29th leap_dt = datetime.datetime(2024, 2, 29, 12, 0, 0, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": leap_dt}) + result = evaluate("dt", {"dt": leap_dt}) assert result == leap_dt def test_datetime_near_epoch(self): """Test datetime values near Unix epoch.""" # Unix epoch start epoch = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": epoch}) + result = evaluate("dt", {"dt": epoch}) assert result == epoch # Just before epoch pre_epoch = datetime.datetime(1969, 12, 31, 23, 59, 59, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": pre_epoch}) + result = evaluate("dt", {"dt": pre_epoch}) assert result == pre_epoch def test_datetime_with_extreme_microseconds(self): """Test datetime with maximum microseconds.""" extreme_dt = datetime.datetime(2024, 1, 1, 12, 0, 0, 999999, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": extreme_dt}) + result = evaluate("dt", {"dt": extreme_dt}) assert result == extreme_dt assert result.microsecond == 999999 @@ -262,7 +263,7 @@ def test_datetime_string_representations(self): dt = datetime.datetime(2024, 6, 15, 14, 30, 45, 123456, tzinfo=datetime.timezone.utc) # Pass through CEL evaluation - result = cel.evaluate("dt", {"dt": dt}) + result = evaluate("dt", {"dt": dt}) # Verify all components are preserved assert result.year == dt.year @@ -283,7 +284,7 @@ def test_ambiguous_local_datetime(self): # For now, test with a normal naive datetime to ensure the conversion works naive_dt = datetime.datetime(2024, 1, 1, 2, 30, 0) # No DST ambiguity in January - result = cel.evaluate("dt", {"dt": naive_dt}) + result = evaluate("dt", {"dt": naive_dt}) assert isinstance(result, datetime.datetime) assert result.tzinfo is not None @@ -294,12 +295,12 @@ def test_dst_transition_dates(self): # Spring forward date (would be 2 AM -> 3 AM in DST zones) spring_forward = datetime.datetime(2024, 3, 10, 2, 30, 0, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": spring_forward}) + result = evaluate("dt", {"dt": spring_forward}) assert result == spring_forward # Fall back date (would be 2 AM -> 1 AM in DST zones) fall_back = datetime.datetime(2024, 11, 3, 1, 30, 0, tzinfo=datetime.timezone.utc) - result = cel.evaluate("dt", {"dt": fall_back}) + result = evaluate("dt", {"dt": fall_back}) assert result == fall_back @pytest.mark.parametrize( diff --git a/tests/test_documentation.py b/tests/test_documentation.py index ec5bfee..7cf436d 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -53,10 +53,8 @@ def test_context_methods_have_docstrings(): assert "function" in add_function_doc.lower() assert "name" in add_function_doc.lower() - # Check update method - update_doc = cel.Context.update.__doc__ - assert update_doc is not None - assert "dictionary" in update_doc.lower() or "variables" in update_doc.lower() + # The narrow API intentionally has no update method. + assert not hasattr(cel.Context, "update") def test_context_constructor_signature(): @@ -64,13 +62,8 @@ def test_context_constructor_signature(): sig = inspect.signature(cel.Context) params = list(sig.parameters.keys()) - # Should accept variables and functions parameters - assert "variables" in params - assert "functions" in params - - # Both should be optional (have defaults) - assert sig.parameters["variables"].default is None - assert sig.parameters["functions"].default is None + # The narrow API intentionally exposes a no-argument constructor. + assert params == [] def test_evaluate_function_signature(): @@ -82,9 +75,9 @@ def test_evaluate_function_signature(): assert "src" in params assert sig.parameters["src"].default == inspect.Parameter.empty - # Should have optional evaluation_context - assert "evaluation_context" in params - assert sig.parameters["evaluation_context"].default is None + # The execution context is required and concrete. + assert params == ["src", "context"] + assert sig.parameters["context"].default is inspect.Parameter.empty def test_help_text_contains_examples(): @@ -129,4 +122,6 @@ def test_api_discoverability(): context_attrs = dir(cel.Context) assert "add_variable" in context_attrs assert "add_function" in context_attrs - assert "update" in context_attrs + assert "update" not in context_attrs + assert "prepare" in dir(cel) + assert "PreparedValue" in dir(cel) diff --git a/tests/test_dual_mode_comprehensive.py b/tests/test_dual_mode_comprehensive.py index 1ace6d8..5643c8c 100644 --- a/tests/test_dual_mode_comprehensive.py +++ b/tests/test_dual_mode_comprehensive.py @@ -8,7 +8,7 @@ import cel import pytest -from cel import Context, evaluate +from conftest import evaluate, make_context class TestStrictModeEvaluation: @@ -28,7 +28,7 @@ def test_string_literals_preserved_in_strict_mode(self): ] # Context with floats - ctx = Context({"value": 0.4, "rate": 3.14}) + ctx = {"value": 0.4, "rate": 3.14} for expr in test_cases: # Remove quotes and handle escaped quotes properly @@ -55,7 +55,7 @@ def test_string_comparisons_work_in_strict_mode(self): ] for expr, ctx, expected in test_cases: - context = Context(ctx) + context = ctx result = evaluate(expr, context) @@ -73,10 +73,12 @@ def test_mixed_arithmetic_fails_in_strict_mode(self): ] for expr, ctx in test_cases: - context = Context(ctx) if ctx else None + context = ctx # Should fail in Strict mode - with pytest.raises(TypeError, match="Unsupported.*operation"): + with pytest.raises( + (TypeError, ValueError), match="No such overload|Unsupported.*operation" + ): evaluate(expr, context) def test_same_type_arithmetic_works_in_strict_mode(self): @@ -92,7 +94,7 @@ def test_same_type_arithmetic_works_in_strict_mode(self): ] for expr, ctx, expected in test_cases: - context = Context(ctx) if ctx else None + context = ctx result = evaluate(expr, context) @@ -109,7 +111,7 @@ def test_array_indexing_works_in_strict_mode(self): ] for expr, ctx, expected in test_cases: - context = Context(ctx) if ctx else None + context = ctx result = evaluate(expr, context) @@ -124,7 +126,7 @@ def test_integer_arithmetic_stays_integer_in_strict_mode(self): ] for expr, ctx, expected in test_cases: - context = Context(ctx) + context = ctx result = evaluate(expr, context) @@ -153,7 +155,9 @@ def test_comprehensions_with_explicit_mixed_types_fail(self): for expr in mixed_type_arithmetic_comprehensions: # Should fail due to mixed arithmetic inside comprehension - with pytest.raises(TypeError, match="Unsupported.*operation"): + with pytest.raises( + (TypeError, ValueError), match="No such overload|Unsupported.*operation" + ): evaluate(expr) def test_comprehensions_with_mixed_comparisons_work(self): @@ -177,7 +181,7 @@ def test_complex_expressions_with_parentheses(self): ] for expr, ctx, expected in test_cases: - context = Context(ctx) if ctx else None + context = ctx result = evaluate(expr, context) @@ -192,7 +196,7 @@ def test_string_functions_preserve_strings(self): ] for expr, ctx, expected in test_cases: - context = Context(ctx) + context = ctx result = evaluate(expr, context) @@ -210,7 +214,7 @@ def test_edge_cases_strict_mode(self): ] for expr, ctx, expected in test_cases: - context = Context(ctx) if ctx else None + context = ctx result = evaluate(expr, context) @@ -224,7 +228,7 @@ def test_github_issue_16_regression(self): """ # The original issue report case record = {"var": "epa1", "var_2": 10, "var_3": 0.4} - ctx = Context(record) + ctx = record # Test 1: String comparison should work result = evaluate('var == "epa1"', ctx) diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 7412b9e..8fcdd16 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -4,11 +4,12 @@ import cel import pytest +from conftest import evaluate def test_boolean_edge_cases(): """Test boolean edge cases""" - assert not cel.evaluate("true && false", {}) - assert cel.evaluate("true || false", {}) - assert not cel.evaluate("!true", {}) - assert cel.evaluate("!false", {}) + assert not evaluate("true && false", {}) + assert evaluate("true || false", {}) + assert not evaluate("!true", {}) + assert evaluate("!false", {}) diff --git a/tests/test_enhanced_error_handling.py b/tests/test_enhanced_error_handling.py index 12b4cab..4b61986 100644 --- a/tests/test_enhanced_error_handling.py +++ b/tests/test_enhanced_error_handling.py @@ -6,6 +6,7 @@ import cel import pytest +from conftest import evaluate class TestEnhancedErrorHandling: @@ -14,7 +15,7 @@ class TestEnhancedErrorHandling: def test_undefined_variable_runtime_error(self): """Test that undefined variables raise RuntimeError with helpful message.""" with pytest.raises(RuntimeError) as exc_info: - cel.evaluate("undefined_var + 1", {}) + evaluate("undefined_var + 1", {}) error_msg = str(exc_info.value) assert "Undefined variable or function: 'undefined_var'" in error_msg @@ -23,7 +24,7 @@ def test_undefined_variable_runtime_error(self): def test_undefined_function_runtime_error(self): """Test that undefined functions raise RuntimeError with helpful message.""" with pytest.raises(RuntimeError) as exc_info: - cel.evaluate("unknownFunction(42)", {}) + evaluate("unknownFunction(42)", {}) error_msg = str(exc_info.value) assert "Undefined variable or function: 'unknownFunction'" in error_msg @@ -31,29 +32,31 @@ def test_undefined_function_runtime_error(self): def test_mixed_int_uint_arithmetic_type_error(self): """Test that mixed signed/unsigned arithmetic raises TypeError with solution.""" - with pytest.raises(TypeError) as exc_info: - cel.evaluate("1 + 2u", {}) + with pytest.raises((TypeError, ValueError)) as exc_info: + evaluate("1 + 2u", {}) error_msg = str(exc_info.value) - assert "Cannot mix signed and unsigned integers" in error_msg assert ( - "Use explicit conversion: int(" in error_msg - or "Use explicit conversion: uint(" in error_msg + "No such overload" in error_msg + or "Cannot mix signed and unsigned integers" in error_msg ) + assert "No such overload" in error_msg or "Use explicit conversion" in error_msg def test_unsupported_multiplication_type_error(self): """Test multiplication type errors provide conversion suggestions.""" - with pytest.raises(TypeError) as exc_info: - cel.evaluate("[1,2,3].map(x, x * 2.0)", {}) + with pytest.raises((TypeError, ValueError)) as exc_info: + evaluate("[1,2,3].map(x, x * 2.0)", {}) error_msg = str(exc_info.value) - assert "Unsupported multiplication operation" in error_msg - assert "Use explicit conversion if needed: double(" in error_msg + assert ( + "No such overload" in error_msg or "Unsupported multiplication operation" in error_msg + ) + assert "No such overload" in error_msg or "Use explicit conversion if needed" in error_msg def test_unsupported_addition_type_error(self): """Test addition type errors for incompatible types.""" with pytest.raises(TypeError) as exc_info: - cel.evaluate("'hello' + 42", {}) + evaluate("'hello' + 42", {}) error_msg = str(exc_info.value) assert "Unsupported addition operation" in error_msg @@ -69,7 +72,7 @@ def failing_function(x): context.add_function("failing_func", failing_function) with pytest.raises(RuntimeError) as exc_info: - cel.evaluate("failing_func(42)", context) + evaluate("failing_func(42)", context) error_msg = str(exc_info.value) assert "failing_func" in error_msg @@ -78,7 +81,7 @@ def failing_function(x): def test_empty_expression_parse_error(self): """Test that empty expressions raise parse errors.""" with pytest.raises(ValueError) as exc_info: - cel.evaluate("", {}) + evaluate("", {}) error_msg = str(exc_info.value) assert "Failed to parse expression" in error_msg @@ -86,7 +89,7 @@ def test_empty_expression_parse_error(self): def test_whitespace_only_expression_parse_error(self): """Test that whitespace-only expressions raise parse errors.""" with pytest.raises(ValueError) as exc_info: - cel.evaluate(" ", {}) + evaluate(" ", {}) error_msg = str(exc_info.value) assert "Failed to parse expression" in error_msg @@ -98,7 +101,7 @@ class TestErrorMessageQuality: def test_missing_string_function_helpful_message(self): """Test that missing string functions provide helpful error messages.""" with pytest.raises(RuntimeError) as exc_info: - cel.evaluate('"hello".lowerAscii()', {}) + evaluate('"hello".lowerAscii()', {}) error_msg = str(exc_info.value) assert "lowerAscii" in error_msg @@ -107,7 +110,7 @@ def test_missing_string_function_helpful_message(self): def test_missing_type_function_helpful_message(self): """Test that missing type() function provides helpful error message.""" with pytest.raises(RuntimeError) as exc_info: - cel.evaluate("type(42)", {}) + evaluate("type(42)", {}) error_msg = str(exc_info.value) assert "type" in error_msg @@ -115,8 +118,8 @@ def test_missing_type_function_helpful_message(self): def test_mixed_arithmetic_provides_conversion_examples(self): """Test that mixed arithmetic errors show conversion syntax.""" - with pytest.raises(TypeError) as exc_info: - cel.evaluate("1u + 2", {}) + with pytest.raises((TypeError, ValueError)) as exc_info: + evaluate("1u + 2", {}) error_msg = str(exc_info.value) assert "int(" in error_msg or "uint(" in error_msg @@ -131,12 +134,16 @@ def test_detailed_operation_error_messages(self): ] for expr, expected_op, expected_guidance in test_cases: - with pytest.raises(TypeError) as exc_info: - cel.evaluate(expr, {}) + with pytest.raises((TypeError, ValueError)) as exc_info: + evaluate(expr, {}) error_msg = str(exc_info.value) - assert expected_op in error_msg.lower() - assert expected_guidance in error_msg.lower() + assert expected_op in error_msg.lower() or "no such overload" in error_msg.lower() + assert ( + expected_guidance in error_msg.lower() + or "compatible types" in error_msg.lower() + or "no such overload" in error_msg.lower() + ) class TestExceptionTypes: @@ -145,17 +152,17 @@ class TestExceptionTypes: def test_runtime_error_for_undefined_references(self): """RuntimeError should be raised for undefined variables/functions.""" with pytest.raises(RuntimeError): - cel.evaluate("undefined_var", {}) + evaluate("undefined_var", {}) def test_type_error_for_incompatible_operations(self): """TypeError should be raised for incompatible type operations.""" - with pytest.raises(TypeError): - cel.evaluate("1 + 'hello'", {}) + with pytest.raises((TypeError, ValueError)): + evaluate("1 + 'hello'", {}) def test_value_error_for_invalid_expressions(self): """ValueError should be raised for invalid expressions.""" with pytest.raises(ValueError): - cel.evaluate("", {}) + evaluate("", {}) def test_fallback_to_value_error(self): """Unknown errors should fallback to ValueError.""" @@ -170,22 +177,22 @@ class TestBackwardCompatibility: def test_basic_evaluation_still_works(self): """Basic expressions should still work normally.""" - result = cel.evaluate("1 + 2", {}) + result = evaluate("1 + 2", {}) assert result == 3 def test_context_variables_still_work(self): """Context variables should still work normally.""" - result = cel.evaluate("x + y", {"x": 10, "y": 5}) + result = evaluate("x + y", {"x": 10, "y": 5}) assert result == 15 def test_functions_still_work(self): """Built-in functions should still work normally.""" - result = cel.evaluate('size("hello")', {}) + result = evaluate('size("hello")', {}) assert result == 5 def test_complex_expressions_still_work(self): """Complex expressions should still work normally.""" - result = cel.evaluate("[1,2,3].all(x, x > 0)", {}) + result = evaluate("[1,2,3].all(x, x > 0)", {}) assert result is True diff --git a/tests/test_functions.py b/tests/test_functions.py index 39e1bff..3867c86 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -4,37 +4,47 @@ import cel import pytest +from conftest import evaluate class TestBuiltInCollectionFunctions: - """Test built-in collection functions that work in CEL.""" + """Test built-in collection functions registered explicitly in a Context.""" + + @staticmethod + def function_context(): + context = cel.Context() + context.add_function("min", min) + context.add_function("max", max) + return context def test_min_function_works(self): """Test that min() function works correctly.""" - assert cel.evaluate("min([3, 1, 4, 1, 5])") == 1 - assert cel.evaluate("min([1.5, 2.3, 0.8])") == 0.8 - assert cel.evaluate("min(['banana', 'apple', 'cherry'])") == "apple" + context = self.function_context() + assert evaluate("min(3, 1, 4, 1, 5)", context) == 1 + assert evaluate("min(1.5, 2.3, 0.8)", context) == 0.8 + assert evaluate("min('banana', 'apple', 'cherry')", context) == "apple" def test_max_function_works(self): """Test that max() function works correctly.""" - assert cel.evaluate("max([3, 1, 4, 1, 5])") == 5 - assert cel.evaluate("max([1.5, 2.3, 0.8])") == 2.3 - assert cel.evaluate("max(['banana', 'apple', 'cherry'])") == "cherry" + context = self.function_context() + assert evaluate("max(3, 1, 4, 1, 5)", context) == 5 + assert evaluate("max(1.5, 2.3, 0.8)", context) == 2.3 + assert evaluate("max('banana', 'apple', 'cherry')", context) == "cherry" def test_custom_function(): def custom_function(a, b): return a + b - assert cel.evaluate("custom_function(1, 2)", {"custom_function": custom_function}) == 3 + assert evaluate("custom_function(1, 2)", {"custom_function": custom_function}) == 3 def test_readme_custom_function_example(): def is_adult(age): return age > 21 - assert not cel.evaluate("is_adult(age)", {"is_adult": is_adult, "age": 18}) - assert cel.evaluate("is_adult(age)", {"is_adult": is_adult, "age": 32}) + assert not evaluate("is_adult(age)", {"is_adult": is_adult, "age": 18}) + assert evaluate("is_adult(age)", {"is_adult": is_adult, "age": 32}) class TestPythonExceptionPropagation: @@ -49,11 +59,11 @@ def raise_value_error(x): return x * 2 # Should work normally - assert cel.evaluate("double_positive(5)", {"double_positive": raise_value_error}) == 10 + assert evaluate("double_positive(5)", {"double_positive": raise_value_error}) == 10 # Should propagate ValueError as RuntimeError with pytest.raises(RuntimeError, match="Value must be non-negative"): - cel.evaluate("double_positive(-1)", {"double_positive": raise_value_error}) + evaluate("double_positive(-1)", {"double_positive": raise_value_error}) def test_type_error_propagation(self): """Test TypeError from custom function is propagated as RuntimeError.""" @@ -64,11 +74,11 @@ def strict_math(a, b): return a + b # Should work normally - assert cel.evaluate("math(1, 2.5)", {"math": strict_math}) == 3.5 + assert evaluate("math(1, 2.5)", {"math": strict_math}) == 3.5 # Should propagate TypeError as RuntimeError with pytest.raises(RuntimeError, match="Arguments must be numeric"): - cel.evaluate( + evaluate( "math('hello', 'world')", {"math": strict_math, "str1": "hello", "str2": "world"} ) @@ -85,13 +95,13 @@ def validate_email(email): # Should work normally assert ( - cel.evaluate("validate('test@example.com')", {"validate": validate_email}) + evaluate("validate('test@example.com')", {"validate": validate_email}) == "test@example.com" ) # Should propagate custom exception as RuntimeError with pytest.raises(RuntimeError, match="Invalid email format"): - cel.evaluate("validate('invalid-email')", {"validate": validate_email}) + evaluate("validate('invalid-email')", {"validate": validate_email}) def test_zero_division_error_propagation(self): """Test ZeroDivisionError from custom function is propagated.""" @@ -102,11 +112,11 @@ def safe_divide(a, b): return a / b # Should work normally - assert cel.evaluate("divide(10, 2)", {"divide": safe_divide}) == 5.0 + assert evaluate("divide(10, 2)", {"divide": safe_divide}) == 5.0 # Should propagate ZeroDivisionError as RuntimeError with pytest.raises(RuntimeError, match="Cannot divide by zero"): - cel.evaluate("divide(10, 0)", {"divide": safe_divide}) + evaluate("divide(10, 0)", {"divide": safe_divide}) class TestFunctionSignatures: @@ -119,8 +129,7 @@ def get_current_time(): return "2024-01-01T00:00:00Z" assert ( - cel.evaluate("current_time()", {"current_time": get_current_time}) - == "2024-01-01T00:00:00Z" + evaluate("current_time()", {"current_time": get_current_time}) == "2024-01-01T00:00:00Z" ) def test_single_argument_function(self): @@ -129,8 +138,8 @@ def test_single_argument_function(self): def square(x): return x * x - assert cel.evaluate("square(5)", {"square": square}) == 25 - assert cel.evaluate("square(2.5)", {"square": square}) == 6.25 + assert evaluate("square(5)", {"square": square}) == 25 + assert evaluate("square(2.5)", {"square": square}) == 6.25 def test_multiple_arguments_function(self): """Test function with multiple arguments.""" @@ -139,14 +148,14 @@ def calculate_area(length, width, height=1): return length * width * height # Test with required arguments - assert cel.evaluate("area(5, 3)", {"area": calculate_area}) == 15 + assert evaluate("area(5, 3)", {"area": calculate_area}) == 15 # Note: CEL doesn't support default arguments directly, # so we test the Python function behavior when called from CEL def area_with_default(length, width): return calculate_area(length, width) # Uses default height=1 - assert cel.evaluate("area_2d(4, 6)", {"area_2d": area_with_default}) == 24 + assert evaluate("area_2d(4, 6)", {"area_2d": area_with_default}) == 24 def test_variadic_arguments_simulation(self): """Test function that handles variable number of arguments via list.""" @@ -158,10 +167,10 @@ def sum_all(numbers): return sum(numbers) # Single number - assert cel.evaluate("sum_numbers(42)", {"sum_numbers": sum_all}) == 42 + assert evaluate("sum_numbers(42)", {"sum_numbers": sum_all}) == 42 # List of numbers - assert cel.evaluate("sum_numbers([1, 2, 3, 4, 5])", {"sum_numbers": sum_all}) == 15 + assert evaluate("sum_numbers([1, 2, 3, 4, 5])", {"sum_numbers": sum_all}) == 15 def test_keyword_arguments_simulation(self): """Test function that handles keyword-like arguments via dict.""" @@ -178,13 +187,13 @@ def format_person(person_dict): # Test with different combinations basic_context = {"format": format_person, "person": {"name": "Alice", "age": 30}} - assert cel.evaluate("format(person)", basic_context) == "Alice (age 30)" + assert evaluate("format(person)", basic_context) == "Alice (age 30)" title_context = { "format": format_person, "person": {"name": "Bob", "age": 45, "title": "Dr."}, } - assert cel.evaluate("format(person)", title_context) == "Dr. Bob (age 45)" + assert evaluate("format(person)", title_context) == "Dr. Bob (age 45)" class TestComplexTypeHandling: @@ -205,12 +214,12 @@ def list_stats(numbers): # Test list filtering context = {"filter_even": filter_even_numbers, "numbers": [1, 2, 3, 4, 5, 6]} - result = cel.evaluate("filter_even(numbers)", context) + result = evaluate("filter_even(numbers)", context) assert result == [2, 4, 6] # Test list statistics stats_context = {"stats": list_stats, "data": [1, 2, 3, 4, 5]} - result = cel.evaluate("stats(data)", stats_context) + result = evaluate("stats(data)", stats_context) assert result == {"count": 5, "sum": 15, "avg": 3.0} def test_dict_input_and_output(self): @@ -228,7 +237,7 @@ def extract_keys(dictionary): # Test dictionary merging merge_context = {"merge": merge_dicts, "dict1": {"a": 1, "b": 2}, "dict2": {"c": 3, "d": 4}} - result = cel.evaluate("merge(dict1, dict2)", merge_context) + result = evaluate("merge(dict1, dict2)", merge_context) assert result == {"a": 1, "b": 2, "c": 3, "d": 4} # Test key extraction @@ -236,7 +245,7 @@ def extract_keys(dictionary): "get_keys": extract_keys, "data": {"name": "Alice", "age": 30, "city": "NYC"}, } - result = cel.evaluate("get_keys(data)", keys_context) + result = evaluate("get_keys(data)", keys_context) assert set(result) == {"name", "age", "city"} # Order may vary def test_nested_data_structures(self): @@ -264,7 +273,7 @@ def count_nested_items(data): {"id": 3, "name": "Charlie", "role": "moderator"}, ] find_context = {"find_user": find_user_by_id, "users": users_data} - result = cel.evaluate("find_user(users, 2)", find_context) + result = evaluate("find_user(users, 2)", find_context) assert result == {"id": 2, "name": "Bob", "role": "user"} # Test nested counting @@ -274,7 +283,7 @@ def count_nested_items(data): "clothes": {"items": ["shirt", "pants", "shoes", "hat"]}, } count_context = {"count_items": count_nested_items, "inventory": nested_data} - result = cel.evaluate("count_items(inventory)", count_context) + result = evaluate("count_items(inventory)", count_context) assert result == 9 def test_datetime_handling(self): @@ -302,19 +311,19 @@ def create_datetime_from_string(date_string): # Test datetime formatting test_dt = datetime.datetime(2024, 1, 15, 14, 30, 0) format_context = {"format_dt": format_datetime, "dt": test_dt} - result = cel.evaluate("format_dt(dt)", format_context) + result = evaluate("format_dt(dt)", format_context) assert result == "2024-01-15 14:30:00" # Test datetime difference dt1 = datetime.datetime(2024, 1, 1) dt2 = datetime.datetime(2024, 1, 15) diff_context = {"days_between": datetime_diff_days, "start": dt1, "end": dt2} - result = cel.evaluate("days_between(start, end)", diff_context) + result = evaluate("days_between(start, end)", diff_context) assert result == 14 # Test datetime creation create_context = {"parse_dt": create_datetime_from_string} - result = cel.evaluate("parse_dt('2024-01-01T12:00:00Z')", create_context) + result = evaluate("parse_dt('2024-01-01T12:00:00Z')", create_context) assert isinstance(result, datetime.datetime) assert result.year == 2024 assert result.month == 1 @@ -341,17 +350,17 @@ def bytes_length(data): # Test string encoding encode_context = {"encode": encode_string} - result = cel.evaluate("encode('hello world')", encode_context) + result = evaluate("encode('hello world')", encode_context) assert result == b"hello world" # Test bytes decoding decode_context = {"decode": decode_bytes, "data": b"hello world"} - result = cel.evaluate("decode(data)", decode_context) + result = evaluate("decode(data)", decode_context) assert result == "hello world" # Test bytes length length_context = {"byte_len": bytes_length, "data": b"hello"} - result = cel.evaluate("byte_len(data)", length_context) + result = evaluate("byte_len(data)", length_context) assert result == 5 @@ -369,14 +378,14 @@ def simple_add(a, b): # Warm up for _ in range(100): - cel.evaluate(expression, context) + evaluate(expression, context) # Measure performance start_time = time.perf_counter() iterations = 10000 for _ in range(iterations): - result = cel.evaluate(expression, context) + result = evaluate(expression, context) assert result == 3 end_time = time.perf_counter() @@ -408,14 +417,14 @@ def complex_calculation(data): # Warm up for _ in range(10): - cel.evaluate(expression, context) + evaluate(expression, context) # Measure performance start_time = time.perf_counter() iterations = 1000 for _ in range(iterations): - result = cel.evaluate(expression, context) + result = evaluate(expression, context) assert result == 9900 # Sum of 0*2 + 1*2 + ... + 99*2 end_time = time.perf_counter() @@ -440,7 +449,7 @@ def process_large_list(items): # Measure performance start_time = time.perf_counter() - result = cel.evaluate(expression, context) + result = evaluate(expression, context) end_time = time.perf_counter() # Verify correctness @@ -465,10 +474,10 @@ def maybe_return_value(condition): return None # Test None return - assert cel.evaluate("get_value(false)", {"get_value": maybe_return_value}) is None + assert evaluate("get_value(false)", {"get_value": maybe_return_value}) is None # Test non-None return - assert cel.evaluate("get_value(true)", {"get_value": maybe_return_value}) == "value" + assert evaluate("get_value(true)", {"get_value": maybe_return_value}) == "value" def test_function_with_empty_collections(self): """Test function behavior with empty collections.""" @@ -479,10 +488,10 @@ def process_collection(items): return {"count": len(items), "first": items[0]} # Test empty list - assert cel.evaluate("process([])", {"process": process_collection}) == {"empty": True} + assert evaluate("process([])", {"process": process_collection}) == {"empty": True} # Test non-empty list - result = cel.evaluate("process([1, 2, 3])", {"process": process_collection}) + result = evaluate("process([1, 2, 3])", {"process": process_collection}) assert result == {"count": 3, "first": 1} def test_function_with_recursive_data(self): @@ -507,13 +516,13 @@ def _traverse(obj, depth): # Test normal nested structure nested_data = {"level1": {"level2": {"level3": "value"}}} context = {"traverse": safe_traverse, "data": nested_data} - result = cel.evaluate("traverse(data)", context) + result = evaluate("traverse(data)", context) assert result == {"level1": {"level2": {"level3": "value"}}} # Test very deep structure (would hit depth limit) very_deep = {"a": {"b": {"c": {"d": {"e": {"f": {"g": "too_deep"}}}}}}} deep_context = {"traverse": safe_traverse, "data": very_deep} - result = cel.evaluate("traverse(data)", deep_context) + result = evaluate("traverse(data)", deep_context) # Should contain MAX_DEPTH_REACHED somewhere in the result assert "MAX_DEPTH_REACHED" in str(result) @@ -534,11 +543,11 @@ def handle_special_values(value): return f"normal:{value}" # Test None - assert cel.evaluate("handle(null)", {"handle": handle_special_values}) == "null" + assert evaluate("handle(null)", {"handle": handle_special_values}) == "null" # Test normal values - assert cel.evaluate("handle(42)", {"handle": handle_special_values}) == "normal:42" - assert cel.evaluate("handle('test')", {"handle": handle_special_values}) == "normal:test" + assert evaluate("handle(42)", {"handle": handle_special_values}) == "normal:42" + assert evaluate("handle('test')", {"handle": handle_special_values}) == "normal:test" class TestFunctionIntegrationWithCELFeatures: @@ -556,13 +565,13 @@ def get_domain(email): context = {"is_valid": is_valid_email, "domain": get_domain, "email": "user@example.com"} # Use function in conditional - result = cel.evaluate("is_valid(email) ? domain(email) : 'invalid'", context) + result = evaluate("is_valid(email) ? domain(email) : 'invalid'", context) assert result == "example.com" # Test with invalid email invalid_context = context.copy() invalid_context["email"] = "invalid-email" - result = cel.evaluate("is_valid(email) ? domain(email) : 'invalid'", invalid_context) + result = evaluate("is_valid(email) ? domain(email) : 'invalid'", invalid_context) assert result == "invalid" def test_function_with_list_operations(self): @@ -574,16 +583,16 @@ def multiply_by_two(x): def is_even(x): return x % 2 == 0 - context = {"double": multiply_by_two, "even": is_even, "numbers": [1, 2, 3, 4, 5]} + context = {"double_value": multiply_by_two, "even": is_even, "numbers": [1, 2, 3, 4, 5]} # Note: CEL's map() might not work directly with custom functions # due to type system limitations, but we can test other combinations # Test function with list filtering (conceptual - may need adaptation) # This tests the function itself, integration with CEL macros may vary - assert cel.evaluate("double(5)", context) == 10 - assert cel.evaluate("even(4)", context) - assert not cel.evaluate("even(3)", context) + assert evaluate("double_value(5)", context) == 10 + assert evaluate("even(4)", context) + assert not evaluate("even(3)", context) def test_function_with_map_operations(self): """Test custom functions with CEL map operations.""" @@ -605,9 +614,9 @@ def has_property(obj, prop): } # Test nested access - assert cel.evaluate("get(user, 'name')", context) == "Alice" - assert cel.evaluate("has_prop(user, 'profile')", context) - assert not cel.evaluate("has_prop(user, 'missing')", context) + assert evaluate("get(user, 'name')", context) == "Alice" + assert evaluate("has_prop(user, 'profile')", context) + assert not evaluate("has_prop(user, 'missing')", context) def test_function_chaining(self): """Test chaining multiple custom functions.""" @@ -629,7 +638,7 @@ def string_length(s): } # Test function chaining - result = cel.evaluate("length(upper(replace(text, 'world', 'CEL')))", context) + result = evaluate("length(upper(replace(text, 'world', 'CEL')))", context) assert result == len("HELLO CEL") @@ -646,15 +655,15 @@ def greet(name): return f"Hello, {name}!" context = cel.Context() - context.add_variable("x", 5) - context.add_variable("y", 3) + context.add_variable("x", cel.prepare(5)) + context.add_variable("y", cel.prepare(3)) context.add_function("multiply", multiply) context.add_function("greet", greet) - context.add_variable("name", "Alice") + context.add_variable("name", cel.prepare("Alice")) # Test function calls with Context class - assert cel.evaluate("multiply(x, y)", context) == 15 - assert cel.evaluate("greet(name)", context) == "Hello, Alice!" + assert evaluate("multiply(x, y)", context) == 15 + assert evaluate("greet(name)", context) == "Hello, Alice!" def test_mixed_context_and_functions(self): """Test mixing variables and functions in context.""" @@ -666,11 +675,11 @@ def format_currency(amount): return f"${amount:.2f}" context = cel.Context() - context.add_variable("price", 100.0) - context.add_variable("tax_rate", 0.08) + context.add_variable("price", cel.prepare(100.0)) + context.add_variable("tax_rate", cel.prepare(0.08)) context.add_function("calc_tax", calculate_tax) context.add_function("format", format_currency) # Test complex expression with functions and variables - result = cel.evaluate("format(price + calc_tax(price, tax_rate))", context) + result = evaluate("format(price + calc_tax(price, tax_rate))", context) assert result == "$108.00" diff --git a/tests/test_issue16_string_literal_regression.py b/tests/test_issue16_string_literal_regression.py index 646531e..e0101b7 100644 --- a/tests/test_issue16_string_literal_regression.py +++ b/tests/test_issue16_string_literal_regression.py @@ -7,7 +7,7 @@ """ import pytest -from cel import Context, evaluate +from conftest import evaluate, make_context class TestIssue16StringLiteralRegression: @@ -16,21 +16,21 @@ class TestIssue16StringLiteralRegression: def test_string_comparison_with_float_context(self): """Test that string comparisons work correctly with floats in context.""" record = {"var": "epa1", "var_2": 10, "var_3": 0.4} - ctx = Context(record) + ctx = record result = evaluate('var == "epa1"', ctx) assert result is True, "String comparison should work with floats in context" def test_string_literal_with_number_suffix(self): """Test that string literals ending with numbers are not modified.""" - ctx = Context({"value": 0.4}) # Float in context + ctx = {"value": 0.4} # Float in context result = evaluate('"epa1"', ctx) assert result == "epa1", f"String literal should be unchanged, got {result}" def test_string_literal_with_embedded_numbers(self): """Test that string literals with numbers in the middle are not modified.""" - ctx = Context({"value": 0.4}) # Float in context + ctx = {"value": 0.4} # Float in context test_cases = [ '"abc123def"', @@ -48,7 +48,7 @@ def test_string_literal_with_embedded_numbers(self): def test_string_literal_pure_numbers(self): """Test that string literals that look like pure numbers are not modified.""" - ctx = Context({"value": 0.4}) # Float in context + ctx = {"value": 0.4} # Float in context test_cases = [ '"123"', @@ -66,14 +66,14 @@ def test_string_literal_pure_numbers(self): def test_string_function_with_numeric_strings(self): """Test that the string() function works correctly with numeric strings.""" - ctx = Context({"value": 0.4}) # Float in context + ctx = {"value": 0.4} # Float in context result = evaluate('string("epa1")', ctx) assert result == "epa1", f"string() function should return unchanged string, got {result}" def test_single_quote_strings(self): """Test that single-quoted strings are also handled correctly.""" - ctx = Context({"value": 0.4}) # Float in context + ctx = {"value": 0.4} # Float in context test_cases = [ "'epa1'", @@ -90,7 +90,7 @@ def test_single_quote_strings(self): def test_escaped_quotes_in_strings(self): """Test that strings with escaped quotes are handled correctly.""" - ctx = Context({"value": 0.4}) # Float in context + ctx = {"value": 0.4} # Float in context # Test escaped double quotes result = evaluate('"He said \\"hello123\\""', ctx) @@ -102,26 +102,26 @@ def test_escaped_quotes_in_strings(self): def test_control_case_without_floats(self): """Control test: verify behavior without floats in context.""" - ctx = Context({"var": "epa1", "var_2": 10}) # No floats + ctx = {"var": "epa1", "var_2": 10} # No floats result = evaluate('var == "epa1"', ctx) assert result is True, "Control test should pass without floats in context" def test_mixed_expressions_with_actual_numbers(self): """Test that mixed arithmetic fails appropriately in strict mode.""" - ctx = Context({"value": 0.4}) # Float in context + ctx = {"value": 0.4} # Float in context # Mixed arithmetic should fail in strict mode - with pytest.raises(TypeError, match="Unsupported.*operation"): + with pytest.raises(TypeError, match="No such overload|Unsupported.*operation"): evaluate("1 + 2.5", ctx) # Mixed type with context variables should also fail - with pytest.raises(TypeError, match="Unsupported.*operation"): + with pytest.raises(TypeError, match="No such overload|Unsupported.*operation"): evaluate("value + 1", ctx) # 0.4 + 1 should fail in strict mode def test_complex_expressions_with_strings_and_numbers(self): """Test complex expressions mixing strings and numbers.""" - ctx = Context({"name": "test123", "value": 0.5}) + ctx = {"name": "test123", "value": 0.5} # String comparison should work result = evaluate('name == "test123" && value > 0.4', ctx) @@ -133,7 +133,7 @@ def test_complex_expressions_with_strings_and_numbers(self): def test_edge_case_empty_strings(self): """Test edge cases with empty strings.""" - ctx = Context({"value": 0.4}) + ctx = {"value": 0.4} result = evaluate('""', ctx) assert result == "", "Empty string should remain empty" @@ -141,7 +141,7 @@ def test_edge_case_empty_strings(self): def test_issue_specific_reproduction(self): """Direct reproduction of the original issue report.""" record = {"var": "epa1", "var_2": 10, "var_3": 0.4} - ctx = Context(record) + ctx = record # Test 1: The main issue - string comparison result = evaluate('var == "epa1"', ctx) diff --git a/tests/test_logical_operators.py b/tests/test_logical_operators.py index 1651fa2..3cc8271 100644 --- a/tests/test_logical_operators.py +++ b/tests/test_logical_operators.py @@ -7,6 +7,7 @@ import cel import pytest +from conftest import evaluate class TestLogicalOperators: @@ -14,25 +15,25 @@ class TestLogicalOperators: def test_logical_and_basic(self): """Test basic AND operator functionality.""" - assert cel.evaluate("true && true") is True - assert cel.evaluate("true && false") is False - assert cel.evaluate("false && true") is False - assert cel.evaluate("false && false") is False + assert evaluate("true && true") is True + assert evaluate("true && false") is False + assert evaluate("false && true") is False + assert evaluate("false && false") is False def test_logical_or_basic(self): """Test basic OR operator functionality.""" - assert cel.evaluate("true || true") is True - assert cel.evaluate("true || false") is True - assert cel.evaluate("false || true") is True - assert cel.evaluate("false || false") is False + assert evaluate("true || true") is True + assert evaluate("true || false") is True + assert evaluate("false || true") is True + assert evaluate("false || false") is False def test_logical_not_basic(self): """Test basic NOT operator functionality.""" - assert cel.evaluate("!true") is False - assert cel.evaluate("!false") is True + assert evaluate("!true") is False + assert evaluate("!false") is True # Note: !!true currently evaluates to False in this CEL implementation # This may be a parser issue or different CEL behavior - result = cel.evaluate("!!true") + result = evaluate("!!true") # Document current behavior rather than assert expected behavior print(f"!!true evaluates to: {result} (expected: True)") # assert cel.evaluate("!!false") is False # Also likely incorrect @@ -40,28 +41,28 @@ def test_logical_not_basic(self): def test_logical_operator_precedence(self): """Test operator precedence in logical expressions.""" # NOT has higher precedence than AND/OR - assert cel.evaluate("!false && true") is True - assert cel.evaluate("!false || false") is True + assert evaluate("!false && true") is True + assert evaluate("!false || false") is True # AND has higher precedence than OR - assert cel.evaluate("true || false && false") is True - assert cel.evaluate("false && false || true") is True + assert evaluate("true || false && false") is True + assert evaluate("false && false || true") is True def test_logical_with_comparisons(self): """Test logical operators combined with comparison operators.""" - assert cel.evaluate("1 < 2 && 3 > 2") is True - assert cel.evaluate("1 > 2 || 3 > 2") is True - assert cel.evaluate("!(1 > 2)") is True - assert cel.evaluate("1 == 1 && 2 == 2") is True + assert evaluate("1 < 2 && 3 > 2") is True + assert evaluate("1 > 2 || 3 > 2") is True + assert evaluate("!(1 > 2)") is True + assert evaluate("1 == 1 && 2 == 2") is True def test_logical_with_variables(self): """Test logical operators with context variables.""" context = {"a": True, "b": False, "x": 5, "y": 10} - assert cel.evaluate("a && !b", context) is True - assert cel.evaluate("b || a", context) is True - assert cel.evaluate("x < y && a", context) is True - assert cel.evaluate("x > y || b", context) is False + assert evaluate("a && !b", context) is True + assert evaluate("b || a", context) is True + assert evaluate("x < y && a", context) is True + assert evaluate("x > y || b", context) is False def test_logical_short_circuit_and(self): """Test short-circuit evaluation for AND operator.""" @@ -73,8 +74,8 @@ def test_logical_short_circuit_and(self): } # False && anything should short-circuit - assert cel.evaluate("false && should_not_call()", context) is False - assert cel.evaluate("get_false() && should_not_call()", context) is False + assert evaluate("false && should_not_call()", context) is False + assert evaluate("get_false() && should_not_call()", context) is False def test_logical_short_circuit_or(self): """Test short-circuit evaluation for OR operator.""" @@ -86,20 +87,20 @@ def test_logical_short_circuit_or(self): } # True || anything should short-circuit - assert cel.evaluate("true || should_not_call()", context) is True - assert cel.evaluate("get_true() || should_not_call()", context) is True + assert evaluate("true || should_not_call()", context) is True + assert evaluate("get_true() || should_not_call()", context) is True def test_complex_logical_expressions(self): """Test complex logical expressions with multiple operators.""" context = {"a": 1, "b": 2, "c": 3, "d": 4} # Complex AND/OR combinations - assert cel.evaluate("a < b && b < c && c < d", context) is True - assert cel.evaluate("a > b || b < c || c > d", context) is True + assert evaluate("a < b && b < c && c < d", context) is True + assert evaluate("a > b || b < c || c > d", context) is True # Mixed with parentheses - assert cel.evaluate("(a < b && b < c) || (c > d)", context) is True - assert cel.evaluate("!(a > b) && (b < c)", context) is True + assert evaluate("(a < b && b < c) || (c > d)", context) is True + assert evaluate("!(a > b) && (b < c)", context) is True def test_logical_with_null_values(self): """Test logical operators with null values.""" @@ -108,9 +109,9 @@ def test_logical_with_null_values(self): # In CEL, null is generally falsy, but exact behavior may vary # These tests verify current behavior try: - result = cel.evaluate("null_val && true_val", context) + result = evaluate("null_val && true_val", context) assert result is False or result is None - except ValueError: + except (TypeError, ValueError): # Some CEL implementations may throw errors for null in logical context pass @@ -121,25 +122,24 @@ def test_logical_type_coercion(self): Mixed-type operations should fail with "No such overload". """ # These should fail - non-boolean operands not allowed per CEL spec - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate("'string' && true") + with pytest.raises(TypeError, match="No such overload"): + evaluate("'string' && true") - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate("'' && true") + with pytest.raises(TypeError, match="No such overload"): + evaluate("'' && true") - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate("42 || false") + with pytest.raises(TypeError, match="No such overload"): + evaluate("42 || false") - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate("0 || true") + assert evaluate("0 || true") is True - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate("!'string'") + with pytest.raises(TypeError, match="No such overload"): + evaluate("!'string'") def test_logical_in_conditionals(self): """Test logical operators in conditional expressions.""" context = {"x": 5, "y": 10} - assert cel.evaluate("x < y && y > 0 ? 'positive' : 'negative'", context) == "positive" - assert cel.evaluate("x > y || y < 0 ? 'true' : 'false'", context) == "false" - assert cel.evaluate("!(x > y) ? 'correct' : 'wrong'", context) == "correct" + assert evaluate("x < y && y > 0 ? 'positive' : 'negative'", context) == "positive" + assert evaluate("x > y || y < 0 ? 'true' : 'false'", context) == "false" + assert evaluate("!(x > y) ? 'correct' : 'wrong'", context) == "correct" diff --git a/tests/test_map_function.py b/tests/test_map_function.py index 3c93f2e..462b2ca 100644 --- a/tests/test_map_function.py +++ b/tests/test_map_function.py @@ -1,7 +1,7 @@ """Test the map() function with its documented PARTIAL support and limitations.""" import pytest -from cel import evaluate +from conftest import evaluate class TestMapFunctionSupport: @@ -48,15 +48,21 @@ def test_documented_map_limitations(self): # This is the documented issue: mixed int/float arithmetic in map() # See docs/reference/cel-compliance.md for details - with pytest.raises(TypeError, match="Unsupported.*operation.*Int.*Float"): + with pytest.raises( + (TypeError, ValueError), match="No such overload|Unsupported.*operation" + ): evaluate("[1, 2, 3].map(x, x * 2.0)") # Complex mixed arithmetic should also fail - with pytest.raises(TypeError, match="Unsupported.*operation.*Int.*Float"): + with pytest.raises( + (TypeError, ValueError), match="No such overload|Unsupported.*operation" + ): evaluate("[1, 2, 3].map(x, x * 2 + 1.5)") # Integer + float literal fails due to type mismatch - with pytest.raises(TypeError, match="Unsupported.*operation.*Int.*Float"): + with pytest.raises( + (TypeError, ValueError), match="No such overload|Unsupported.*operation" + ): evaluate("[1, 2, 3].map(x, x + 1.0)") def test_map_function_workarounds(self): @@ -92,7 +98,7 @@ def test_map_function_documentation_examples(self): # Example from cel-language-basics.md that may have type restrictions # This should fail according to documentation - with pytest.raises(TypeError): + with pytest.raises((TypeError, ValueError)): evaluate("[1, 2, 3].map(x, x * 2.0)") # Mixed int/float # Examples that should work diff --git a/tests/test_optional_values.py b/tests/test_optional_values.py index 6d4b47f..b7f85c8 100644 --- a/tests/test_optional_values.py +++ b/tests/test_optional_values.py @@ -2,8 +2,12 @@ import pytest +def empty_context(): + return cel.Context() + + def test_optional_of_wrapper(): - opt = cel.evaluate("optional.of(42)") + opt = cel.evaluate("optional.of(42)", empty_context()) assert isinstance(opt, cel.OptionalValue) assert opt.has_value() is True assert opt.value() == 42 @@ -12,7 +16,7 @@ def test_optional_of_wrapper(): def test_optional_none_wrapper(): - opt = cel.evaluate("optional.none()") + opt = cel.evaluate("optional.none()", empty_context()) assert isinstance(opt, cel.OptionalValue) assert opt.has_value() is False assert opt.or_value("default") == "default" @@ -22,7 +26,7 @@ def test_optional_none_wrapper(): def test_optional_of_null_distinct(): - opt = cel.evaluate("optional.of(null)") + opt = cel.evaluate("optional.of(null)", empty_context()) assert isinstance(opt, cel.OptionalValue) assert opt.has_value() is True assert opt.value() is None @@ -30,10 +34,13 @@ def test_optional_of_null_distinct(): def test_optional_in_context(): + context = cel.Context() opt = cel.OptionalValue.of(123) - assert cel.evaluate("opt.orValue(0)", {"opt": opt}) == 123 - assert cel.evaluate("opt.hasValue()", {"opt": opt}) is True + context.add_variable("opt", cel.prepare(opt)) + assert cel.evaluate("opt.orValue(0)", context) == 123 + assert cel.evaluate("opt.hasValue()", context) is True none_opt = cel.OptionalValue.none() - assert cel.evaluate("opt.orValue(7)", {"opt": none_opt}) == 7 - assert cel.evaluate("opt.hasValue()", {"opt": none_opt}) is False + context.add_variable("opt", cel.prepare(none_opt)) + assert cel.evaluate("opt.orValue(7)", context) == 7 + assert cel.evaluate("opt.hasValue()", context) is False diff --git a/tests/test_parser_errors.py b/tests/test_parser_errors.py index 01f7400..4a35ac1 100644 --- a/tests/test_parser_errors.py +++ b/tests/test_parser_errors.py @@ -8,6 +8,7 @@ import cel import pytest +from conftest import evaluate class TestParserErrors: @@ -17,36 +18,36 @@ def test_unclosed_single_quote_raises_clean_error(self): """Test that unclosed single quotes raise proper ValueError exceptions.""" # Previously caused panics, now gracefully handled with catch_unwind with pytest.raises(ValueError, match="Failed to parse expression"): - cel.evaluate("'unclosed quote", {}) + evaluate("'unclosed quote", {}) def test_unclosed_double_quote_raises_clean_error(self): """Test that unclosed double quotes raise proper ValueError exceptions.""" # Previously the original issue: 'timestamp("2024-01-01T00:00:00Z") # Now safely handled with panic catching with pytest.raises(ValueError, match="Failed to parse expression"): - cel.evaluate('"unclosed quote', {}) + evaluate('"unclosed quote', {}) def test_complex_unclosed_quote_in_function_call(self): """Test the specific case from the original user report.""" # This was the exact expression that previously caused panics # Now safely returns a clean ValueError with pytest.raises(ValueError, match="Failed to parse expression"): - cel.evaluate('\'timestamp("2024-01-01T00:00:00Z")', {}) + evaluate('\'timestamp("2024-01-01T00:00:00Z")', {}) def test_unclosed_parentheses(self): """Test unclosed parentheses handling.""" with pytest.raises(ValueError): - cel.evaluate("(1 + 2", {}) + evaluate("(1 + 2", {}) def test_unclosed_brackets(self): """Test unclosed square brackets handling.""" with pytest.raises(ValueError): - cel.evaluate("[1, 2, 3", {}) + evaluate("[1, 2, 3", {}) def test_unclosed_braces(self): """Test unclosed curly braces handling.""" with pytest.raises(ValueError): - cel.evaluate("{'key': 'value'", {}) + evaluate("{'key': 'value'", {}) def test_mismatched_quotes_in_expressions(self): """Test various mismatched quote scenarios.""" @@ -59,7 +60,7 @@ def test_mismatched_quotes_in_expressions(self): for expr in invalid_expressions: with pytest.raises(ValueError, match="Failed to parse expression"): - cel.evaluate(expr, {}) + evaluate(expr, {}) class TestParserErrorDocumentation: @@ -68,20 +69,20 @@ class TestParserErrorDocumentation: def test_good_syntax_works(self): """Verify that correct syntax still works.""" # These should all work fine - assert cel.evaluate("'hello'", {}) == "hello" - assert cel.evaluate('"hello"', {}) == "hello" - assert cel.evaluate("timestamp('2024-01-01T00:00:00Z')", {}) - assert cel.evaluate('timestamp("2024-01-01T00:00:00Z")', {}) + assert evaluate("'hello'", {}) == "hello" + assert evaluate('"hello"', {}) == "hello" + assert evaluate("timestamp('2024-01-01T00:00:00Z')", {}) + assert evaluate('timestamp("2024-01-01T00:00:00Z")', {}) def test_different_error_types(self): """Document the different types of errors now properly handled.""" # Runtime error (undefined variable) - properly mapped to RuntimeError with pytest.raises(RuntimeError, match="Undefined variable or function"): - cel.evaluate("undefined_variable", {}) + evaluate("undefined_variable", {}) # Parse error (invalid syntax) - previously caused panics, now clean ValueError with pytest.raises(ValueError, match="Failed to parse expression"): - cel.evaluate("'unclosed", {}) + evaluate("'unclosed", {}) class TestCLIErrorHandling: diff --git a/tests/test_performance_verification.py b/tests/test_performance_verification.py index a50512a..6250f85 100644 --- a/tests/test_performance_verification.py +++ b/tests/test_performance_verification.py @@ -8,6 +8,7 @@ import time import cel +from conftest import evaluate def test_large_list_conversion_performance(): @@ -16,7 +17,7 @@ def test_large_list_conversion_performance(): large_list = list(range(1000)) start_time = time.time() - result = cel.evaluate("size(items)", {"items": large_list}) + result = evaluate("size(items)", {"items": large_list}) end_time = time.time() # Verify correctness @@ -32,7 +33,7 @@ def test_large_dict_conversion_performance(): large_dict = {f"key_{i}": i for i in range(100)} start_time = time.time() - result = cel.evaluate("size(data)", {"data": large_dict}) + result = evaluate("size(data)", {"data": large_dict}) end_time = time.time() # Verify correctness @@ -58,7 +59,7 @@ def test_nested_structure_conversion_performance(): } start_time = time.time() - result = cel.evaluate("size(data.level1.level2.level3.numbers)", {"data": nested_data}) + result = evaluate("size(data.level1.level2.level3.numbers)", {"data": nested_data}) end_time = time.time() # Verify correctness @@ -80,7 +81,7 @@ def test_function(a, b, c, d, e): # Test with multiple function calls start_time = time.time() for _i in range(50): # 50 function calls - result = cel.evaluate("test_func(1, 2, 3, 4, 5)", context) + result = evaluate("test_func(1, 2, 3, 4, 5)", context) assert result == 15 end_time = time.time() @@ -101,12 +102,12 @@ def test_mixed_type_conversion_performance(): start_time = time.time() # Test various operations on mixed data - result1 = cel.evaluate("size(data.integers)", {"data": mixed_data}) - result2 = cel.evaluate("size(data.floats)", {"data": mixed_data}) - result3 = cel.evaluate("size(data.strings)", {"data": mixed_data}) - result4 = cel.evaluate("size(data.booleans)", {"data": mixed_data}) - result5 = cel.evaluate("size(data.dates)", {"data": mixed_data}) - result6 = cel.evaluate("size(data.bytes)", {"data": mixed_data}) + result1 = evaluate("size(data.integers)", {"data": mixed_data}) + result2 = evaluate("size(data.floats)", {"data": mixed_data}) + result3 = evaluate("size(data.strings)", {"data": mixed_data}) + result4 = evaluate("size(data.booleans)", {"data": mixed_data}) + result5 = evaluate("size(data.dates)", {"data": mixed_data}) + result6 = evaluate("size(data.bytes)", {"data": mixed_data}) end_time = time.time() # Verify correctness @@ -127,7 +128,7 @@ def test_string_processing_performance(): long_string = "hello world " * 100 start_time = time.time() - result = cel.evaluate("text + ' suffix'", {"text": long_string}) + result = evaluate("text + ' suffix'", {"text": long_string}) end_time = time.time() # Verify correctness diff --git a/tests/test_prepare.py b/tests/test_prepare.py new file mode 100644 index 0000000..d119862 --- /dev/null +++ b/tests/test_prepare.py @@ -0,0 +1,129 @@ +import datetime +from collections import UserDict + +import cel +import pytest + + +def execute_value(value): + context = cel.Context() + context.add_variable("value", cel.prepare(value)) + return cel.compile("value").execute(context) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + (None, None), + (False, False), + (42, 42), + (2**63, 2**63), + (3.5, 3.5), + ("hello", "hello"), + (b"hello", b"hello"), + ([1, "two", None], [1, "two", None]), + ((1, 2, 3), [1, 2, 3]), + ({"nested": {"values": [1, 2]}}, {"nested": {"values": [1, 2]}}), + (UserDict({"answer": 42}), {"answer": 42}), + (datetime.timedelta(seconds=90), datetime.timedelta(seconds=90)), + ], +) +def test_prepare_supported_values(source, expected): + assert execute_value(source) == expected + + +def test_prepare_signed_and_unsigned_range_boundaries(): + assert execute_value(-(2**63)) == -(2**63) + assert execute_value(2**63 - 1) == 2**63 - 1 + assert execute_value(2**64 - 1) == 2**64 - 1 + + +def test_prepare_bytes_and_nested_tuple_snapshot(): + source = (b"payload", {"items": (1, 2)}) + prepared = cel.prepare(source) + context = cel.Context() + context.add_variable("data", prepared) + assert cel.compile("data[0]").execute(context) == b"payload" + assert cel.compile("data[1].items[1]").execute(context) == 2 + + +def test_prepare_dict_subclass_uses_mapping_protocol(): + class DictSubclass(dict): + def __getitem__(self, key): + if key == "dynamic": + return 42 + return super().__getitem__(key) + + def keys(self): + return ["dynamic"] + + assert execute_value(DictSubclass()) == {"dynamic": 42} + + +def test_prepare_datetime_values(): + aware = datetime.datetime(2024, 1, 2, 3, 4, tzinfo=datetime.timezone.utc) + assert execute_value(aware) == aware + + naive = datetime.datetime(2024, 1, 2, 3, 4) + result = execute_value(naive) + assert result.replace(tzinfo=None) == naive + + +def test_prepare_optional_values(): + value = cel.OptionalValue.of({"answer": 42}) + result = execute_value(value) + assert isinstance(result, cel.OptionalValue) + assert result.value() == {"answer": 42} + + result = execute_value(cel.OptionalValue.none()) + assert isinstance(result, cel.OptionalValue) + assert result.has_value() is False + + +def test_prepare_is_a_snapshot_and_survives_source_deletion(): + source = {"objects": [{"enabled": True}]} + prepared = cel.prepare(source) + source["objects"][0]["enabled"] = False + source["objects"].append({"enabled": False}) + del source + + context = cel.Context() + context.add_variable("data", prepared) + assert cel.compile("data.objects[0].enabled").execute(context) is True + assert cel.compile("size(data.objects)").execute(context) == 1 + + +def test_prepared_value_is_reusable_and_prepare_is_idempotent(): + prepared = cel.prepare({"answer": 42}) + prepared_again = cel.prepare(prepared) + + first = cel.Context() + second = cel.Context() + first.add_variable("data", prepared) + second.add_variable("data", prepared_again) + + program = cel.compile("data.answer") + assert program.execute(first) == 42 + assert program.execute(second) == 42 + + +def test_prepared_repr_is_opaque(): + secret = "do-not-render-this-payload" + prepared = cel.prepare({"secret": secret, "items": list(range(100))}) + representation = repr(prepared) + assert "PreparedValue" in representation + assert secret not in representation + assert "99" not in representation + + +def test_unsupported_values_fail_during_prepare(): + with pytest.raises(ValueError, match="Failed to prepare"): + cel.prepare(object()) + + with pytest.raises(ValueError, match="Failed to prepare"): + cel.prepare(lambda: None) + + +def test_prepared_value_has_no_public_constructor(): + with pytest.raises(TypeError): + cel.PreparedValue() diff --git a/tests/test_reduce.py b/tests/test_reduce.py index d461dfa..236e6f4 100644 --- a/tests/test_reduce.py +++ b/tests/test_reduce.py @@ -4,4 +4,4 @@ def test_reduce_macro_via_compile_execute(): program = cel.compile("[1, 2, 3, 4].reduce(acc, x, 0, acc + x)") - assert program.execute() == 10 + assert program.execute(cel.Context()) == 10 diff --git a/tests/test_stdlib.py b/tests/test_stdlib.py index 4534866..463df15 100644 --- a/tests/test_stdlib.py +++ b/tests/test_stdlib.py @@ -9,6 +9,7 @@ import cel import pytest from cel.stdlib import STDLIB_FUNCTIONS, add_stdlib_to_context, substring +from conftest import evaluate class TestSubstringFunction: @@ -45,18 +46,16 @@ def test_substring_in_cel_expression(self): context.add_function("substring", substring) # Basic usage - result = cel.evaluate('substring("hello world", 0, 5)', context) + result = evaluate('substring("hello world", 0, 5)', context) assert result == "hello" # With context variable - context.add_variable("text", "hello world") - result = cel.evaluate("substring(text, 6)", context) + context.add_variable("text", cel.prepare("hello world")) + result = evaluate("substring(text, 6)", context) assert result == "world" # Chained with other operations - result = cel.evaluate( - 'substring("HELLO", 0, 2) + substring("world", 0, 3)', context - ) + result = evaluate('substring("HELLO", 0, 2) + substring("world", 0, 3)', context) assert result == "HEwor" @@ -69,7 +68,7 @@ def test_add_stdlib_to_context(self): add_stdlib_to_context(context) # Verify substring is available - result = cel.evaluate('substring("test", 1, 3)', context) + result = evaluate('substring("test", 1, 3)', context) assert result == "es" def test_stdlib_functions_dict(self): @@ -87,13 +86,11 @@ def test_substring_with_string_methods(self): add_stdlib_to_context(context) # Extract and check - result = cel.evaluate('substring("hello world", 0, 5).size()', context) + result = evaluate('substring("hello world", 0, 5).size()', context) assert result == 5 # Extract and test membership - result = cel.evaluate( - 'substring("hello world", 6, 11).startsWith("wor")', context - ) + result = evaluate('substring("hello world", 6, 11).startsWith("wor")', context) assert result is True def test_substring_with_context_variables(self): @@ -101,21 +98,21 @@ def test_substring_with_context_variables(self): context = cel.Context() add_stdlib_to_context(context) - context.add_variable("data", {"message": "Hello, World!", "start": 0, "end": 5}) + context.add_variable( + "data", cel.prepare({"message": "Hello, World!", "start": 0, "end": 5}) + ) - result = cel.evaluate("substring(data.message, data.start, data.end)", context) + result = evaluate("substring(data.message, data.start, data.end)", context) assert result == "Hello" def test_substring_in_conditional(self): """Test substring in conditional expressions.""" context = cel.Context() add_stdlib_to_context(context) - context.add_variable("email", "user@example.com") + context.add_variable("email", cel.prepare("user@example.com")) # Extract domain - result = cel.evaluate( - 'substring(email, 5, 12) == "example" ? "valid" : "invalid"', context - ) + result = evaluate('substring(email, 5, 12) == "example" ? "valid" : "invalid"', context) assert result == "valid" @@ -131,11 +128,11 @@ def test_basic_substring_example(self): add_stdlib_to_context(context) # Extract "hello" from "hello world" - result = cel.evaluate('substring("hello world", 0, 5)', context) + result = evaluate('substring("hello world", 0, 5)', context) assert result == "hello" # Extract from index to end - result = cel.evaluate('substring("hello world", 6)', context) + result = evaluate('substring("hello world", 6)', context) assert result == "world" def test_substring_with_variables(self): @@ -145,10 +142,10 @@ def test_substring_with_variables(self): context = cel.Context() context.add_function("substring", substring) - context.add_variable("text", "The quick brown fox") + context.add_variable("text", cel.prepare("The quick brown fox")) # Extract words - result = cel.evaluate("substring(text, 4, 9)", context) + result = evaluate("substring(text, 4, 9)", context) assert result == "quick" def test_substring_string_manipulation(self): @@ -160,12 +157,12 @@ def test_substring_string_manipulation(self): add_stdlib_to_context(context) # Get first 3 characters - result = cel.evaluate('substring("JavaScript", 0, 3)', context) + result = evaluate('substring("JavaScript", 0, 3)', context) assert result == "Jav" # Get last characters (simulate with known length) - context.add_variable("lang", "Python") - result = cel.evaluate("substring(lang, 2)", context) + context.add_variable("lang", cel.prepare("Python")) + result = evaluate("substring(lang, 2)", context) assert result == "thon" @@ -185,7 +182,7 @@ def test_substring_available_upstream(self): Related upstream issue: https://github.com/cel-rust/cel-rust/issues/200 """ - assert cel.evaluate('"hello".substring(1, 3)', {}) == "el" + assert evaluate('"hello".substring(1, 3)', {}) == "el" # Note: test_upstream_improvements.py::TestStringUtilities::test_substring_not_implemented # also monitors this behavior. @@ -201,10 +198,10 @@ def test_our_wrapper_still_needed(self): """ # Without wrapper - should fail with pytest.raises(RuntimeError): - cel.evaluate('substring("test", 0, 2)', {}) + evaluate('substring("test", 0, 2)', {}) # With our wrapper - should succeed context = cel.Context() add_stdlib_to_context(context) - result = cel.evaluate('substring("test", 0, 2)', context) + result = evaluate('substring("test", 0, 2)', context) assert result == "te" diff --git a/tests/test_types.py b/tests/test_types.py index 873cf24..b17b443 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -14,6 +14,7 @@ import cel import pytest +from conftest import evaluate class TestBasicTypeConversion: @@ -22,65 +23,65 @@ class TestBasicTypeConversion: def test_none_values(self): """Test handling of None values in various contexts.""" # None in basic context - result = cel.evaluate("value", {"value": None}) + result = evaluate("value", {"value": None}) assert result is None # None in comparison - result = cel.evaluate("value == null", {"value": None}) + result = evaluate("value == null", {"value": None}) assert result is True # None in list - result = cel.evaluate("items[0]", {"items": [None, 1, 2]}) + result = evaluate("items[0]", {"items": [None, 1, 2]}) assert result is None def test_boolean_conversion(self): """Test boolean type conversion and edge cases.""" # Basic boolean values - assert cel.evaluate("value", {"value": True}) is True - assert cel.evaluate("value", {"value": False}) is False + assert evaluate("value", {"value": True}) is True + assert evaluate("value", {"value": False}) is False # Boolean in expressions - result = cel.evaluate("a && b", {"a": True, "b": False}) + result = evaluate("a && b", {"a": True, "b": False}) assert result is False # CEL returns boolean values for logical ops - result = cel.evaluate("a || b", {"a": False, "b": True}) + result = evaluate("a || b", {"a": False, "b": True}) assert result is True def test_string_conversion(self): """Test string conversion with various edge cases.""" # Empty string - result = cel.evaluate("value", {"value": ""}) + result = evaluate("value", {"value": ""}) assert result == "" # Very long string long_string = "a" * 10000 - result = cel.evaluate("value", {"value": long_string}) + result = evaluate("value", {"value": long_string}) assert result == long_string # Unicode strings unicode_string = "Hello äø–ē•Œ šŸŒ š“¤š“·š“²š“¬š“øš“­š“®" - result = cel.evaluate("value", {"value": unicode_string}) + result = evaluate("value", {"value": unicode_string}) assert result == unicode_string # String with null bytes (should be handled gracefully) string_with_null = "Hello\x00World" - result = cel.evaluate("value", {"value": string_with_null}) + result = evaluate("value", {"value": string_with_null}) assert result == string_with_null def test_bytes_conversion(self): """Test bytes conversion with various edge cases.""" # Empty bytes - result = cel.evaluate("value", {"value": b""}) + result = evaluate("value", {"value": b""}) assert result == b"" # Bytes with null bytes bytes_with_null = b"Hello\x00World\xff" - result = cel.evaluate("value", {"value": bytes_with_null}) + result = evaluate("value", {"value": bytes_with_null}) assert result == bytes_with_null # Large bytes object large_bytes = b"x" * 10000 - result = cel.evaluate("value", {"value": large_bytes}) + result = evaluate("value", {"value": large_bytes}) assert result == large_bytes @@ -91,38 +92,38 @@ def test_mixed_numeric_edge_cases(self): """Test edge cases with mixed numeric types.""" # Very large integers large_int = 2**62 - result = cel.evaluate("value", {"value": large_int}) + result = evaluate("value", {"value": large_int}) assert result == large_int # Very small integers small_int = -(2**62) - result = cel.evaluate("value", {"value": small_int}) + result = evaluate("value", {"value": small_int}) assert result == small_int # Very precise floats precise_float = 1.23456789012345678901234567890 - result = cel.evaluate("value", {"value": precise_float}) + result = evaluate("value", {"value": precise_float}) assert isinstance(result, float) # Note: precision may be lost due to float64 limitations def test_special_float_values(self): """Test special float values (inf, -inf, nan).""" # Positive infinity - result = cel.evaluate("value", {"value": float("inf")}) + result = evaluate("value", {"value": float("inf")}) assert math.isinf(result) and result > 0 # Negative infinity - result = cel.evaluate("value", {"value": float("-inf")}) + result = evaluate("value", {"value": float("-inf")}) assert math.isinf(result) and result < 0 # NaN - result = cel.evaluate("value", {"value": float("nan")}) + result = evaluate("value", {"value": float("nan")}) assert math.isnan(result) def test_large_numbers(self): """Test handling of large numbers.""" large_int = 2**50 - result = cel.evaluate("x + 1", {"x": large_int}) + result = evaluate("x + 1", {"x": large_int}) assert result == large_int + 1 def test_numeric_precision(self): @@ -131,7 +132,7 @@ def test_numeric_precision(self): a = 0.1 b = 0.2 c = 0.3 - result = cel.evaluate("a + b", {"a": a, "b": b}) + result = evaluate("a + b", {"a": a, "b": b}) # Due to floating point precision, this might not be exactly 0.3 assert abs(result - c) < 1e-10 @@ -142,7 +143,7 @@ class TestCollectionTypes: def test_list_conversion_edge_cases(self): """Test list conversion with various edge cases.""" # Empty list - result = cel.evaluate("value", {"value": []}) + result = evaluate("value", {"value": []}) assert result == [] # List with mixed types including problematic ones @@ -159,7 +160,7 @@ def test_list_conversion_edge_cases(self): [1, 2, 3], # nested list {"key": "value"}, # nested dict ] - result = cel.evaluate("value", {"value": mixed_list}) + result = evaluate("value", {"value": mixed_list}) assert len(result) == len(mixed_list) assert result[0] == 1 assert result[1] == 2.5 @@ -180,7 +181,7 @@ def test_dict_conversion_edge_cases(self): # Note: Python dicts with True/False keys behave specially # True == 1 and False == 0 for dict key purposes - result = cel.evaluate("value", {"value": mixed_dict}) + result = evaluate("value", {"value": mixed_dict}) # Verify the dict structure is preserved assert isinstance(result, dict) @@ -194,28 +195,28 @@ def test_mixed_key_types_in_dict(self): context = {"test_dict": test_dict} # Access by string key - result = cel.evaluate("test_dict['str_key']", context) + result = evaluate("test_dict['str_key']", context) assert result == "string value" # Access by integer key - result = cel.evaluate("test_dict[123]", context) + result = evaluate("test_dict[123]", context) assert result == "int value" def test_empty_containers(self): """Test empty lists, dicts, and strings.""" - assert cel.evaluate("size([])", {}) == 0 - assert cel.evaluate("size({})", {}) == 0 - assert cel.evaluate("size('')", {}) == 0 - assert cel.evaluate("x", {"x": []}) == [] - assert cel.evaluate("x", {"x": {}}) == {} + assert evaluate("size([])", {}) == 0 + assert evaluate("size({})", {}) == 0 + assert evaluate("size('')", {}) == 0 + assert evaluate("x", {"x": []}) == [] + assert evaluate("x", {"x": {}}) == {} def test_list_tuple_equivalence(self): """Test that tuples and lists are handled equivalently.""" list_data = [1, 2, 3] tuple_data = (1, 2, 3) - list_result = cel.evaluate("data[1]", {"data": list_data}) - tuple_result = cel.evaluate("data[1]", {"data": tuple_data}) + list_result = evaluate("data[1]", {"data": list_data}) + tuple_result = evaluate("data[1]", {"data": tuple_data}) assert list_result == tuple_result == 2 @@ -233,11 +234,12 @@ def __getitem__(self, key): return super().__getitem__(key) lazy = LazyDict() - assert cel.evaluate("data.key", {"data": lazy}) == "value" + assert evaluate("data.key", {"data": lazy}) == "value" lazy_ctx = LazyDict() - ctx = cel.Context(variables={"data": lazy_ctx}) - assert cel.evaluate("data.key", ctx) == "value" + ctx = cel.Context() + ctx.add_variable("data", cel.prepare(lazy_ctx)) + assert evaluate("data.key", ctx) == "value" def test_mapping_protocol_access(self): """Custom Mapping implementations should resolve member access.""" @@ -256,7 +258,7 @@ def __len__(self): return len(self._data) mapping = CustomMapping({"key": "value"}) - assert cel.evaluate("data.key", {"data": mapping}) == "value" + assert evaluate("data.key", {"data": mapping}) == "value" class TestComplexStructures: @@ -277,10 +279,10 @@ def test_complex_nested_structures(self): } } - result = cel.evaluate("level1.level2.level3.level4.value", context) + result = evaluate("level1.level2.level3.level4.value", context) assert result == "deep_value" - result = cel.evaluate("level1.level2.level3.level4.list[2].nested_key", context) + result = evaluate("level1.level2.level3.level4.list[2].nested_key", context) assert result == "nested_value" def test_complex_nested_with_datetime(self): @@ -301,15 +303,15 @@ def test_complex_nested_with_datetime(self): } # Access nested datetime - result = cel.evaluate("data.level1.level2.level3.datetime", {"data": complex_data}) + result = evaluate("data.level1.level2.level3.datetime", {"data": complex_data}) assert result == complex_data["level1"]["level2"]["level3"]["datetime"] # Access nested numbers - result = cel.evaluate("data.level1.level2.level3.numbers[1]", {"data": complex_data}) + result = evaluate("data.level1.level2.level3.numbers[1]", {"data": complex_data}) assert result == 2.5 # Access timedelta at root level - result = cel.evaluate("data.timedelta", {"data": complex_data}) + result = evaluate("data.timedelta", {"data": complex_data}) assert result == complex_data["timedelta"] @@ -317,8 +319,8 @@ class TestTypeErrors: """Test error conditions and edge cases.""" def test_invalid_context_key_type(self): - """Test that non-string keys in context raise appropriate errors.""" - with pytest.raises(ValueError, match="Variable name must be strings"): + """Context construction does not accept variable mappings.""" + with pytest.raises(TypeError): cel.Context({123: "value"}) def test_function_with_error(self): @@ -328,7 +330,7 @@ def error_function(): raise ValueError("Custom error") with pytest.raises(RuntimeError, match="Function 'error_function' error"): - cel.evaluate("error_function()", {"error_function": error_function}) + evaluate("error_function()", {"error_function": error_function}) def test_function_with_wrong_args(self): """Test that function argument mismatch is handled.""" @@ -337,7 +339,7 @@ def two_arg_function(a, b): return a + b with pytest.raises(RuntimeError, match="Function 'two_arg_function' error"): - cel.evaluate("two_arg_function(1)", {"two_arg_function": two_arg_function}) + evaluate("two_arg_function(1)", {"two_arg_function": two_arg_function}) def test_error_propagation_in_conversions(self): """Test that conversion errors are properly propagated.""" @@ -357,7 +359,7 @@ def test_error_propagation_in_conversions(self): } for key, value in valid_data.items(): - result = cel.evaluate("data." + key, {"data": valid_data}) + result = evaluate("data." + key, {"data": valid_data}) assert result == value @@ -375,40 +377,38 @@ def test_cel_keyword_conflicts(self): } # These should work using bracket notation - result = cel.evaluate("data['null']", {"data": problematic_data}) + result = evaluate("data['null']", {"data": problematic_data}) assert result == "not_null_value" - result = cel.evaluate("data['true']", {"data": problematic_data}) + result = evaluate("data['true']", {"data": problematic_data}) assert result == "not_boolean_true" - result = cel.evaluate("data['false']", {"data": problematic_data}) + result = evaluate("data['false']", {"data": problematic_data}) assert result == "not_boolean_false" - result = cel.evaluate("data['size']", {"data": problematic_data}) + result = evaluate("data['size']", {"data": problematic_data}) assert result == "not_function_size" class TestContextHandling: """Test context object and variable handling.""" - def test_context_update_overwrite(self): - """Test that context updates overwrite existing variables.""" - context = cel.Context({"x": 1}) - result = cel.evaluate("x", context) - assert result == 1 + def test_context_variable_replacement(self): + """Prepared variable insertion overwrites an existing binding.""" + context = cel.Context() + context.add_variable("x", cel.prepare(1)) + assert evaluate("x", context) == 1 - # Update context with new value - context.update({"x": 2}) - result = cel.evaluate("x", context) - assert result == 2 + context.add_variable("x", cel.prepare(2)) + assert evaluate("x", context) == 2 def test_unicode_strings(self): """Test Unicode string handling.""" unicode_text = "Hello, äø–ē•Œ! šŸŒ" - result = cel.evaluate("text", {"text": unicode_text}) + result = evaluate("text", {"text": unicode_text}) assert result == unicode_text - result = cel.evaluate("text + ' suffix'", {"text": unicode_text}) + result = evaluate("text + ' suffix'", {"text": unicode_text}) assert result == unicode_text + " suffix" @@ -423,9 +423,9 @@ def test_datetime_operations(self): context = {"dt": dt, "delta": delta} # Test datetime arithmetic - result = cel.evaluate("dt + delta", context) + result = evaluate("dt + delta", context) assert isinstance(result, datetime.datetime) # Test datetime comparison - result = cel.evaluate("dt < (dt + delta)", context) + result = evaluate("dt < (dt + delta)", context) assert result is True diff --git a/tests/test_upstream_improvements.py b/tests/test_upstream_improvements.py index 791e8d0..d817a3c 100644 --- a/tests/test_upstream_improvements.py +++ b/tests/test_upstream_improvements.py @@ -8,6 +8,7 @@ import cel import pytest +from conftest import evaluate class TestStringUtilities: @@ -20,10 +21,8 @@ def test_lower_ascii_not_implemented(self): When this test starts failing (raises different error), it means lowerAscii() has been implemented upstream. """ - with pytest.raises( - RuntimeError, match="Undefined variable or function.*lowerAscii" - ): - cel.evaluate('"HELLO".lowerAscii()') + with pytest.raises(RuntimeError, match="Undefined variable or function.*lowerAscii"): + evaluate('"HELLO".lowerAscii()') def test_upper_ascii_not_implemented(self): """ @@ -31,10 +30,8 @@ def test_upper_ascii_not_implemented(self): When this test starts failing, upperAscii() has been implemented. """ - with pytest.raises( - RuntimeError, match="Undefined variable or function.*upperAscii" - ): - cel.evaluate('"hello".upperAscii()') + with pytest.raises(RuntimeError, match="Undefined variable or function.*upperAscii"): + evaluate('"hello".upperAscii()') def test_index_of_not_implemented(self): """ @@ -42,10 +39,8 @@ def test_index_of_not_implemented(self): When this test starts failing, indexOf() has been implemented. """ - with pytest.raises( - RuntimeError, match="Undefined variable or function.*indexOf" - ): - cel.evaluate('"hello world".indexOf("world")') + with pytest.raises(RuntimeError, match="Undefined variable or function.*indexOf"): + evaluate('"hello world".indexOf("world")') def test_substring_not_implemented(self): """ @@ -53,7 +48,7 @@ def test_substring_not_implemented(self): This guards the cel-rust functionality we just pinned to. """ - assert cel.evaluate('"hello".substring(1, 3)') == "el" + assert evaluate('"hello".substring(1, 3)') == "el" def test_timestamp_date_implemented(self): """ @@ -61,9 +56,7 @@ def test_timestamp_date_implemented(self): This guards the cel-rust functionality we just pinned to. """ - assert ( - cel.evaluate("timestamp('2024-01-15T10:30:45.123Z').date()") == "2024-01-15" - ) + assert evaluate("timestamp('2024-01-15T10:30:45.123Z').date()") == "2024-01-15" class TestTypeIntrospection: @@ -76,7 +69,7 @@ def test_type_function_not_implemented(self): When this test starts failing, the type() function has been implemented. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*type"): - cel.evaluate("type(42)") + evaluate("type(42)") @pytest.mark.xfail( reason="type() function not implemented in cel v0.11.0 - should become available when type infrastructure is complete", @@ -89,11 +82,11 @@ def test_type_function_expected_behavior(self): This test is marked as expected failure and will start passing when type() is implemented upstream. """ - assert cel.evaluate("type(42)") == "int" - assert cel.evaluate('type("hello")') == "string" - assert cel.evaluate("type(true)") == "bool" - assert cel.evaluate("type([1, 2, 3])") == "list" - assert cel.evaluate('type({"key": "value"})') == "map" + assert evaluate("type(42)") == "int" + assert evaluate('type("hello")') == "string" + assert evaluate("type(true)") == "bool" + assert evaluate("type([1, 2, 3])") == "list" + assert evaluate('type({"key": "value"})') == "map" class TestMixedArithmetic: @@ -105,8 +98,11 @@ def test_mixed_int_uint_addition_fails(self): When this test starts failing, mixed arithmetic has been fixed. """ - with pytest.raises(TypeError, match="Cannot mix signed and unsigned integers"): - cel.evaluate("1 + 2u") + with pytest.raises( + (TypeError, ValueError), + match="No such overload|Cannot mix signed and unsigned integers", + ): + evaluate("1 + 2u") def test_mixed_int_uint_multiplication_fails(self): """ @@ -114,8 +110,10 @@ def test_mixed_int_uint_multiplication_fails(self): When this test starts failing, mixed arithmetic has been fixed. """ - with pytest.raises(TypeError, match="Unsupported.*operation"): - cel.evaluate("3 * 2u") + with pytest.raises( + (TypeError, ValueError), match="No such overload|Unsupported.*operation" + ): + evaluate("3 * 2u") @pytest.mark.xfail( reason="Mixed signed/unsigned arithmetic not supported in cel v0.11.0", @@ -127,9 +125,9 @@ def test_mixed_arithmetic_expected_behavior(self): This test will pass when upstream supports mixed int/uint operations. """ - assert cel.evaluate("1 + 2u") == 3 - assert cel.evaluate("3 * 2u") == 6 - assert cel.evaluate("10u - 3") == 7 + assert evaluate("1 + 2u") == 3 + assert evaluate("3 * 2u") == 6 + assert evaluate("10u - 3") == 7 class TestOptionalValues: @@ -137,9 +135,9 @@ class TestOptionalValues: def test_optional_of_implemented(self): """Test optional.of() and optional.none() behavior.""" - assert cel.evaluate("optional.of(42).orValue(0)") == 42 - assert cel.evaluate("optional.of(null).orValue('default')") is None - assert cel.evaluate("optional.none().orValue('default')") == "default" + assert evaluate("optional.of(42).orValue(0)") == 42 + assert evaluate("optional.of(null).orValue('default')") is None + assert evaluate("optional.none().orValue('default')") == "default" def test_optional_chaining_not_implemented(self): """ @@ -150,9 +148,7 @@ def test_optional_chaining_not_implemented(self): # This currently likely fails with parse error, but when optional chaining # is implemented, it should work with pytest.raises((ValueError, RuntimeError)): - cel.evaluate( - "user?.profile?.name", {"user": {"profile": {"name": "Alice"}}} - ) + evaluate("user?.profile?.name", {"user": {"profile": {"name": "Alice"}}}) def test_optional_expected_behavior(self): """ @@ -160,9 +156,9 @@ def test_optional_expected_behavior(self): This test verifies the CEL spec for optional values. """ - assert cel.evaluate("optional.of(42).orValue(0)") == 42 - assert cel.evaluate("optional.of(null).orValue('default')") is None - assert cel.evaluate("optional.none().orValue('default')") == "default" + assert evaluate("optional.of(42).orValue(0)") == 42 + assert evaluate("optional.of(null).orValue('default')") is None + assert evaluate("optional.none().orValue('default')") == "default" class TestMapFunctionImprovements: @@ -174,8 +170,10 @@ def test_map_mixed_arithmetic_currently_fails(self): When this test starts failing, map() type coercion has been improved. """ - with pytest.raises(TypeError, match="Unsupported.*operation.*Int.*Float"): - cel.evaluate("[1, 2, 3].map(x, x * 2.0)") + with pytest.raises( + (TypeError, ValueError), match="No such overload|Unsupported.*operation" + ): + evaluate("[1, 2, 3].map(x, x * 2.0)") @pytest.mark.xfail( reason="map() function mixed arithmetic not supported in cel v0.11.0", @@ -187,8 +185,8 @@ def test_map_mixed_arithmetic_expected_behavior(self): This test will pass when upstream improves type coercion in map(). """ - assert cel.evaluate("[1, 2, 3].map(x, x * 2.0)") == [2.0, 4.0, 6.0] - assert cel.evaluate("[1, 2, 3].map(x, x + 1.5)") == [2.5, 3.5, 4.5] + assert evaluate("[1, 2, 3].map(x, x * 2.0)") == [2.0, 4.0, 6.0] + assert evaluate("[1, 2, 3].map(x, x + 1.5)") == [2.5, 3.5, 4.5] class TestLogicalOperatorBehavior: @@ -204,57 +202,55 @@ def test_or_operator_cel_compliant_behavior(self): Reference: https://github.com/tektoncd/triggers/issues/644 """ # These correctly fail - first operand must be boolean per CEL spec - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate("42 || false") # Non-boolean first operand fails + with pytest.raises((TypeError, ValueError), match="No such overload"): + evaluate("42 || false") # Non-boolean first operand fails - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate('0 || "default"') # Non-boolean first operand fails + with pytest.raises((TypeError, ValueError), match="No such overload"): + evaluate('0 || "default"') # Non-boolean first operand fails # CEL's logical operators with boolean first operand work correctly - assert cel.evaluate("true || 99") # Short-circuits to True - assert cel.evaluate("false || 99") == 99 # Returns second operand per CEL spec - assert ( - cel.evaluate("false || 'default'") == "default" - ) # Any type for second operand + assert evaluate("true || 99") # Short-circuits to True + with pytest.raises((TypeError, ValueError), match="No such overload"): + evaluate("false || 99") + with pytest.raises((TypeError, ValueError), match="No such overload"): + evaluate("false || 'default'") # AND operator has stricter requirements for both operands - assert not cel.evaluate("false && 99") # Short-circuits to False - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate( - "true && 99" - ) # AND requires both operands to be boolean when evaluated + assert not evaluate("false && 99") # Short-circuits to False + with pytest.raises((TypeError, ValueError), match="No such overload"): + evaluate("true && 99") # AND requires both operands to be boolean when evaluated def test_or_operator_correct_boolean_behavior(self): """ Test OR operator with boolean operands follows CEL specification. """ # Boolean logical operations work as expected - assert cel.evaluate("true || false") - assert cel.evaluate("false || true") - assert not cel.evaluate("false || false") - assert cel.evaluate("true || true") + assert evaluate("true || false") + assert evaluate("false || true") + assert not evaluate("false || false") + assert evaluate("true || true") def test_and_operator_correct_boolean_behavior(self): """ Test AND operator with boolean operands follows CEL specification. """ # Boolean logical operations work as expected - assert not cel.evaluate("true && false") - assert not cel.evaluate("false && true") - assert not cel.evaluate("false && false") - assert cel.evaluate("true && true") + assert not evaluate("true && false") + assert not evaluate("false && true") + assert not evaluate("false && false") + assert evaluate("true && true") def test_ternary_operator_requires_boolean_condition(self): """ Test ternary operator requires boolean condition per CEL specification. """ # Boolean condition works correctly - assert cel.evaluate("true ? 42 : 0") == 42 - assert cel.evaluate("false ? 42 : 0") == 0 + assert evaluate("true ? 42 : 0") == 42 + assert evaluate("false ? 42 : 0") == 0 # Non-boolean condition fails as expected - with pytest.raises(ValueError, match="No such overload"): - cel.evaluate("42 ? true : false") + with pytest.raises((TypeError, ValueError), match="No such overload"): + evaluate("42 ? true : false") class TestMissingStringFunctions: @@ -266,10 +262,8 @@ def test_last_index_of_not_implemented(self): When this test starts failing, lastIndexOf() has been implemented. """ - with pytest.raises( - RuntimeError, match="Undefined variable or function.*lastIndexOf" - ): - cel.evaluate('"hello world hello".lastIndexOf("hello")') + with pytest.raises(RuntimeError, match="Undefined variable or function.*lastIndexOf"): + evaluate('"hello world hello".lastIndexOf("hello")') def test_replace_not_implemented(self): """ @@ -277,10 +271,8 @@ def test_replace_not_implemented(self): When this test starts failing, replace() has been implemented. """ - with pytest.raises( - RuntimeError, match="Undefined variable or function.*replace" - ): - cel.evaluate('"hello world".replace("world", "universe")') + with pytest.raises(RuntimeError, match="Undefined variable or function.*replace"): + evaluate('"hello world".replace("world", "universe")') def test_split_not_implemented(self): """ @@ -288,7 +280,7 @@ def test_split_not_implemented(self): This guards the cel-rust functionality we just pinned to. """ - assert cel.evaluate('"hello,world,test".split(",")') == [ + assert evaluate('"hello,world,test".split(",")') == [ "hello", "world", "test", @@ -301,7 +293,7 @@ def test_join_not_implemented(self): When this test starts failing, join() has been implemented. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*join"): - cel.evaluate('["hello", "world"].join(",")') + evaluate('["hello", "world"].join(",")') class TestMissingAggregationFunctions: @@ -314,7 +306,7 @@ def test_sum_function_not_available(self): When this test starts failing, sum() has been implemented upstream. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*sum"): - cel.evaluate("sum([1, 2, 3, 4, 5])") + evaluate("sum([1, 2, 3, 4, 5])") def test_fold_function_not_available(self): """ @@ -324,15 +316,13 @@ def test_fold_function_not_available(self): """ # Method syntax with pytest.raises((RuntimeError, ValueError)): - cel.evaluate("[1, 2, 3, 4, 5].fold(0, (acc, x) -> acc + x)") + evaluate("[1, 2, 3, 4, 5].fold(0, (acc, x) -> acc + x)") # Global function syntax - with pytest.raises(RuntimeError, match="Undefined variable or function.*fold"): - cel.evaluate("fold([1, 2, 3], 0, sum + x)") + with pytest.raises(RuntimeError, match="Undefined variable or function.*(fold|sum)"): + evaluate("fold([1, 2, 3], 0, sum + x)") - @pytest.mark.xfail( - reason="Aggregation functions not implemented in cel v0.11.1", strict=False - ) + @pytest.mark.xfail(reason="Aggregation functions not implemented in cel v0.11.1", strict=False) def test_aggregation_functions_expected_behavior(self): """ Test expected aggregation function behavior when implemented. @@ -340,12 +330,12 @@ def test_aggregation_functions_expected_behavior(self): This test will pass when upstream implements sum() and fold(). """ # Sum function - assert cel.evaluate("sum([1, 2, 3, 4, 5])") == 15 - assert cel.evaluate("sum([1.1, 2.2, 3.3])") == pytest.approx(6.6) + assert evaluate("sum([1, 2, 3, 4, 5])") == 15 + assert evaluate("sum([1.1, 2.2, 3.3])") == pytest.approx(6.6) # Fold function (syntax may differ when actually implemented) - assert cel.evaluate("[1, 2, 3, 4].fold(0, (acc, x) -> acc + x)") == 10 - assert cel.evaluate("[1, 2, 3].fold(1, (acc, x) -> acc * x)") == 6 + assert evaluate("[1, 2, 3, 4].fold(0, (acc, x) -> acc + x)") == 10 + assert evaluate("[1, 2, 3].fold(1, (acc, x) -> acc * x)") == 6 class TestMathFunctions: @@ -358,7 +348,7 @@ def test_ceil_not_implemented(self): When this test starts failing, ceil() has been implemented. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*ceil"): - cel.evaluate("ceil(3.14)") + evaluate("ceil(3.14)") def test_floor_not_implemented(self): """ @@ -367,7 +357,7 @@ def test_floor_not_implemented(self): When this test starts failing, floor() has been implemented. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*floor"): - cel.evaluate("floor(3.14)") + evaluate("floor(3.14)") def test_round_not_implemented(self): """ @@ -376,7 +366,7 @@ def test_round_not_implemented(self): When this test starts failing, round() has been implemented. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*round"): - cel.evaluate("round(3.14)") + evaluate("round(3.14)") class TestValidationFunctions: @@ -389,7 +379,7 @@ def test_is_url_not_implemented(self): When this test starts failing, isURL() has been implemented. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*isURL"): - cel.evaluate('isURL("https://example.com")') + evaluate('isURL("https://example.com")') def test_is_ip_not_implemented(self): """ @@ -398,7 +388,7 @@ def test_is_ip_not_implemented(self): When this test starts failing, isIP() has been implemented. """ with pytest.raises(RuntimeError, match="Undefined variable or function.*isIP"): - cel.evaluate('isIP("192.168.1.1")') + evaluate('isIP("192.168.1.1")') # Expected improvements detection helpers @@ -434,6 +424,4 @@ def test_upstream_improvements_summary(): # This test documents our monitoring approach assert len(improvements_to_watch) > 0 - print( - f"Monitoring {len(improvements_to_watch)} categories of upstream improvements" - ) + print(f"Monitoring {len(improvements_to_watch)} categories of upstream improvements")