From f7232ebd66fc86c52c5e230c668ee4ae07675497 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 Aug 2025 22:23:20 +1200 Subject: [PATCH 1/7] Add type stubs for CEL Rust extension module --- python/cel/cel.pyi | 47 +++++++++++++++++++++++++++++++++++++++++++++ python/cel/py.typed | 0 2 files changed, 47 insertions(+) create mode 100644 python/cel/cel.pyi create mode 100644 python/cel/py.typed diff --git a/python/cel/cel.pyi b/python/cel/cel.pyi new file mode 100644 index 0000000..db988eb --- /dev/null +++ b/python/cel/cel.pyi @@ -0,0 +1,47 @@ +""" +Type stubs for the CEL Rust extension module. +""" + +from typing import Any, Dict, Union, Optional, Callable, overload + +class Context: + """CEL evaluation context for variables and 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 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 + """ + ... \ No newline at end of file diff --git a/python/cel/py.typed b/python/cel/py.typed new file mode 100644 index 0000000..e69de29 From 421d96e10be887b7429d5ad6e3910aa593eb0ee2 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 9 Aug 2025 22:27:45 +1200 Subject: [PATCH 2/7] Add type checking check in CI --- .github/workflows/ci.yml | 18 +++++++++++++++++- CHANGELOG.md | 5 +++++ docs/contributing.md | 12 ++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b09551a..948f0e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: run: uv run pytest --verbose --tb=short lint: - name: Code Quality + name: Code Quality & Type Checking runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -87,6 +87,22 @@ jobs: - name: Run Python linting run: uv run ruff check . + + - name: Test type stub files exist + run: | + test -f python/cel/cel.pyi || (echo "❌ Type stub file missing" && exit 1) + test -f python/cel/py.typed || (echo "❌ PEP 561 marker missing" && exit 1) + echo "✅ Type stub files present" + + - name: Run type checking tests + run: uv run pytest tests/test_type_stubs.py -v + + - name: Test ty type checker integration + run: | + echo "from cel import evaluate, Context; evaluate('1+1')" > test_types.py + uvx ty check test_types.py + rm test_types.py + echo "✅ Type checker integration working" linux: runs-on: ${{ matrix.platform.runner }} needs: [test, lint] diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d13f4..cd16b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### ✨ Added + +- **Type checking support**: Added complete type stub files (`.pyi`) for PyO3 extension + + ## [0.5.0] - 2025-08-08 diff --git a/docs/contributing.md b/docs/contributing.md index eb022fb..30e0b81 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -246,6 +246,18 @@ def add_function(self, name: str, func: Callable) -> None: """ ``` +### Type Checking Support + +This package includes manual type stub files (`.pyi`) for the PyO3 Rust extension to support static type checking. + +**Type checker verification:** +```bash +uvx ty check your_file.py # ✅ Should pass without "no member" errors +uvx mypy your_file.py # ✅ Works with proper MYPYPATH setup +``` + +See https://pyo3.rs/main/type-stub to opt-in to the automated types when implemented in Maturin. + ## Debugging & Troubleshooting ### Common Issues From 86c4b1f99060f30f464194f5980a0688b14df81b Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sun, 10 Aug 2025 06:27:03 +1200 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A7=B9=20fmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/cel/cel.pyi | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/python/cel/cel.pyi b/python/cel/cel.pyi index db988eb..6cfe160 100644 --- a/python/cel/cel.pyi +++ b/python/cel/cel.pyi @@ -6,29 +6,26 @@ from typing import Any, Dict, Union, Optional, Callable, overload class Context: """CEL evaluation context for variables and functions.""" - + @overload def __init__(self) -> None: ... - - @overload + @overload def __init__(self, variables: Dict[str, Any]) -> None: ... - @overload def __init__( - self, + self, variables: Optional[Dict[str, Any]] = None, *, - functions: Optional[Dict[str, Callable[..., 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.""" ... @@ -36,12 +33,12 @@ class Context: 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 """ - ... \ No newline at end of file + ... From 0d6bb701663b4f25cf10e5481e03473c950e4d7f Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sun, 10 Aug 2025 07:18:29 +1200 Subject: [PATCH 4/7] Add EvaluationMode for CEL expression evaluation and update evaluate function --- python/cel/__init__.py | 22 +++++++++- python/cel/cel.pyi | 14 +++++- python/cel/cli.py | 28 +++++++++--- src/lib.rs | 98 ++++++++++++++++++++++++++++++++---------- 4 files changed, 131 insertions(+), 31 deletions(-) diff --git a/python/cel/__init__.py b/python/cel/__init__.py index a0487b1..f4a7597 100644 --- a/python/cel/__init__.py +++ b/python/cel/__init__.py @@ -1,13 +1,33 @@ # Import the Rust extension # Import CLI functionality +from enum import Enum + from . import cli from .cel import * + +class EvaluationMode(str, Enum): + """ + Defines the evaluation dialect for a CEL expression. + """ + + PYTHON = "python" + """ + Enables Python-friendly type promotions (e.g., int -> float). (Default) + """ + STRICT = "strict" + """ + Enforces strict cel-rust type rules with no automatic coercion to match Wasm behavior. + """ + + __doc__ = cel.__doc__ if hasattr(cel, "__all__"): - __all__ = cel.__all__ + # Ensure EvaluationMode is always exported even if Rust module defines __all__ + __all__ = list(cel.__all__) + ["EvaluationMode"] else: __all__ = [ "evaluate", "Context", + "EvaluationMode", ] diff --git a/python/cel/cel.pyi b/python/cel/cel.pyi index 6cfe160..f31d0bf 100644 --- a/python/cel/cel.pyi +++ b/python/cel/cel.pyi @@ -2,7 +2,10 @@ Type stubs for the CEL Rust extension module. """ -from typing import Any, Dict, Union, Optional, Callable, overload +from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Optional, Union, overload + +if TYPE_CHECKING: + from . import EvaluationMode class Context: """CEL evaluation context for variables and functions.""" @@ -30,13 +33,20 @@ class Context: """Update context with variables from a dictionary.""" ... -def evaluate(expression: str, context: Optional[Union[Dict[str, Any], Context]] = None) -> Any: +def evaluate( + expression: str, + context: Optional[Union[Dict[str, Any], Context]] = None, + *, + mode: Union[Literal["python", "strict"], "EvaluationMode", str] = "python", +) -> Any: """ Evaluate a CEL expression. Args: expression: The CEL expression to evaluate context: Optional context with variables and functions + mode: Evaluation mode - either "python" (default) for mixed arithmetic + or "strict" for strict type matching Returns: The result of evaluating the expression diff --git a/python/cel/cli.py b/python/cel/cli.py index b6cc9d7..9d8e288 100644 --- a/python/cel/cli.py +++ b/python/cel/cli.py @@ -38,11 +38,12 @@ from typing_extensions import Annotated try: - from . import cel + from . import EvaluationMode, cel except ImportError: # Fallback for running as standalone script try: import cel + from cel import EvaluationMode except ImportError: console = Console() console.print( @@ -191,9 +192,10 @@ def _get_auto_renderable(result: Any) -> Any: class CELEvaluator: """Enhanced CEL expression evaluator.""" - def __init__(self, context: Optional[Dict[str, Any]] = None): - """Initialize evaluator with optional context.""" + def __init__(self, context: Optional[Dict[str, Any]] = None, mode: str = "python"): + """Initialize evaluator with optional context and mode.""" self.context = context or {} + self.mode = mode self._cel_context = None self._update_cel_context() @@ -208,7 +210,7 @@ def evaluate(self, expression: str) -> Any: """Evaluate a CEL expression.""" if not expression.strip(): raise ValueError("Empty expression") - return cel.evaluate(expression, self._cel_context) + return cel.evaluate(expression, self._cel_context, mode=self.mode) def update_context(self, new_context: Dict[str, Any]): """Update the evaluation context.""" @@ -554,6 +556,13 @@ def main( typer.Option("--file", help="Read expressions from file (one per line)"), ] = None, output: Annotated[str, typer.Option("-o", "--output", help="Output format")] = "auto", + mode: Annotated[ + str, + typer.Option( + "--mode", + help="Evaluation mode: python (mixed arithmetic) or strict (type matching)", + ), + ] = "python", interactive: Annotated[ bool, typer.Option("-i", "--interactive", help="Start interactive REPL mode") ] = False, @@ -585,6 +594,10 @@ def main( # Evaluate expressions from file cel --file expressions.cel --output json + + # Use strict evaluation mode + cel '1 + 2.0' --mode strict # This will fail due to mixed types + cel '1.0 + 2.0' --mode strict # This will work """ # Load context @@ -600,8 +613,13 @@ def main( file_context = load_context_from_file(context_file) eval_context.update(file_context) + # Validate mode parameter + if mode not in ("python", "strict"): + console.print(f"[red]Error: Invalid mode '{mode}'. Use 'python' or 'strict'[/red]") + raise typer.Exit(1) + # Initialize evaluator - evaluator = CELEvaluator(eval_context) + evaluator = CELEvaluator(eval_context, mode=mode) # Interactive mode if interactive: diff --git a/src/lib.rs b/src/lib.rs index 4581382..c833c28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,25 @@ use std::error::Error; use std::fmt; use std::sync::Arc; +#[derive(Clone, Debug, PartialEq)] +pub enum EvaluationMode { + PythonCompatible, + Strict, +} + +impl<'py> FromPyObject<'py> for EvaluationMode { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + let s: String = ob.extract()?; + match s.as_str() { + "python" => Ok(EvaluationMode::PythonCompatible), + "strict" => Ok(EvaluationMode::Strict), + _ => Err(PyTypeError::new_err(format!( + "Invalid EvaluationMode: expected 'python' or 'strict', got '{s}'" + ))), + } + } +} + #[derive(Debug)] struct RustyCelType(Value); @@ -424,6 +443,12 @@ impl TryIntoValue for RustyPyType<'_> { /// - 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) +/// mode (Union[str, cel.EvaluationMode]): The evaluation mode to use. +/// Defaults to "python". Can be: +/// - "python" or EvaluationMode.PYTHON: Enables Python-friendly type +/// promotions (e.g., int -> float) for better mixed arithmetic compatibility +/// - "strict" or EvaluationMode.STRICT: Enforces strict CEL type rules +/// with no automatic coercion to match WebAssembly behavior /// /// Returns: /// Union[bool, int, float, str, list, dict, datetime.datetime, bytes, None]: @@ -508,7 +533,7 @@ impl TryIntoValue for RustyPyType<'_> { /// /// Using Context object for reusable evaluations: /// -/// >>> from cel import Context +/// >>> from cel import Context, EvaluationMode /// >>> context = Context( /// ... variables={"base_url": "https://api.example.com"}, /// ... functions={"len": len} @@ -518,19 +543,32 @@ impl TryIntoValue for RustyPyType<'_> { /// >>> evaluate("len('hello world')", context) /// 11 /// +/// Using different evaluation modes: +/// +/// >>> # Python mode (default) - allows mixed arithmetic +/// >>> evaluate("1 + 2.5") +/// 3.5 +/// >>> evaluate("1 + 2.5", mode=EvaluationMode.PYTHON) +/// 3.5 +/// >>> # Strict mode - enforces type matching +/// >>> try: +/// ... evaluate("1 + 2.5", mode=EvaluationMode.STRICT) +/// ... except TypeError as e: +/// ... print("Strict mode error:", e) +/// Strict mode error: Unsupported addition operation: Int + Double... +/// /// 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 { - // Preprocess expression for better mixed int/float arithmetic compatibility - // First check if expression itself has mixed literals - let mut processed_src = if expression_has_mixed_numeric_literals(&src) { - preprocess_expression_for_mixed_arithmetic_always(&src) - } else { - src.clone() - }; +#[pyfunction(signature = (src, evaluation_context=None, mode=None))] +fn evaluate( + src: String, + evaluation_context: Option<&Bound<'_, PyAny>>, + mode: Option, +) -> PyResult { + // Use PythonCompatible as default if mode is not provided + let mode = mode.unwrap_or(EvaluationMode::PythonCompatible); let mut environment = CelContext::default(); let mut ctx = context::Context::new(None, None)?; @@ -539,7 +577,7 @@ fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyRes // Custom Rust functions can also be added to the environment... //environment.add_function("add", |a: i64, b: i64| a + b); - // Process the evaluation context if provided first to determine if we need preprocessing + // 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::>() { @@ -555,21 +593,35 @@ fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyRes )); }; - // Smart numeric coercion for mixed int/float arithmetic compatibility variables_for_env = ctx.variables.clone(); + } - // Check if we should promote integers to floats for better compatibility - let should_promote = should_promote_integers_to_floats(&variables_for_env) - || expression_has_mixed_numeric_literals(&src); - - if should_promote { - promote_integers_in_context(&mut variables_for_env); - - // Always preprocess the expression when we're promoting types - // This handles cases where context has floats but expression has integer literals - processed_src = preprocess_expression_for_mixed_arithmetic_always(&src); + // Apply type promotion logic based on evaluation mode (consolidated) + let processed_src = match mode { + EvaluationMode::PythonCompatible => { + // Check if we should promote integers to floats for better compatibility + let should_promote = should_promote_integers_to_floats(&variables_for_env) + || expression_has_mixed_numeric_literals(&src); + + if should_promote { + // Promote integers in context if we have one + if !variables_for_env.is_empty() { + promote_integers_in_context(&mut variables_for_env); + } + // Always preprocess the expression when promoting types + preprocess_expression_for_mixed_arithmetic_always(&src) + } else if expression_has_mixed_numeric_literals(&src) { + // Preprocess expression even without context if it has mixed literals + preprocess_expression_for_mixed_arithmetic_always(&src) + } else { + src.clone() + } } - } + EvaluationMode::Strict => { + // Do nothing - preserve strict type behavior with no promotions or rewriting + src.clone() + } + }; // Use panic::catch_unwind to handle parser panics gracefully let program = panic::catch_unwind(|| Program::compile(&processed_src)) From 79ad64fa9c60b7ea73fd57917addceb7e48f2932 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Mon, 11 Aug 2025 21:27:50 +1200 Subject: [PATCH 5/7] Add EvaluationMode for flexible type handling in CEL expressions --- docs/index.md | 6 ++ docs/reference/cli-reference.md | 21 ++++++ docs/reference/python-api.md | 125 +++++++++++++++++++++++++++++++- python/cel/__init__.py | 12 --- python/cel/cli.py | 4 +- 5 files changed, 153 insertions(+), 15 deletions(-) diff --git a/docs/index.md b/docs/index.md index 6b8e552..e10ff43 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,6 +56,11 @@ The Common Expression Language (CEL) is a non-Turing complete language designed # With context cel 'age >= 21' --context '{"age": 25}' # → true + # Mixed arithmetic with modes + cel '1 + 2.5' # → Error (default: strict mode) + cel '1 + 2.5' --mode python # → 3.5 (python-friendly mode) + cel '1 + 2.5' --mode strict # → Error (strict CEL rules) + # Interactive REPL cel --interactive ``` @@ -93,6 +98,7 @@ The Common Expression Language (CEL) is a non-Turing complete language designed ✅ **Safe by design** (Rust core) ✅ **Ready for production** ✅ **No GIL-blocking, safe concurrent evaluation** +✅ **Flexible type handling** (Python-friendly + strict modes) ## Why Python CEL? diff --git a/docs/reference/cli-reference.md b/docs/reference/cli-reference.md index d38d05a..62813ba 100644 --- a/docs/reference/cli-reference.md +++ b/docs/reference/cli-reference.md @@ -49,6 +49,23 @@ Enable debug mode with detailed error information. cel --debug 'user.role == "admin"' --context-file user.json ``` +#### `--mode`, `-m` +Set the evaluation mode for type handling. + +```bash +cel '1 + 2.5' --mode python # → 3.5 (python-friendly type promotions) +cel '1 + 2.5' --mode strict # → Error (default: strict CEL type rules) +cel '1 + 2.5' -m python # → 3.5 (short form) +``` + +**Values**: +- `python` - Python-friendly type promotions for mixed arithmetic +- `strict` (default) - Strict CEL type rules with no automatic coercion + +**Use Cases**: +- `python`: Best for JSON APIs, user-friendly applications, Python integration +- `strict`: Best for CEL spec compliance, type precision, cross-language consistency + ### Context Options #### `--context`, `-c` @@ -250,6 +267,10 @@ cel 'expression' cel 'expression' --context '{"key": "value"}' cel 'expression' --context-file context.json +# With evaluation mode +cel 'expression' --mode python # Python-friendly mixed arithmetic +cel 'expression' --mode strict # Default: strict CEL type rules + # Interactive mode cel --interactive ``` diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index 2d0945b..7d2c072 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -6,6 +6,96 @@ Complete autogenerated reference for the Python CEL library. ::: cel.evaluate +## Enums + +### EvaluationMode + +**Controls how CEL expressions handle type compatibility.** + +The EvaluationMode enum allows you to choose between Python-friendly type coercion (default) and strict CEL type enforcement: + +```python +from cel import evaluate, EvaluationMode + +# Python-friendly mode (default) - allows mixed arithmetic +result = evaluate("1 + 2.5") # → 3.5 +result = evaluate("1 + 2.5", mode=EvaluationMode.PYTHON) # → 3.5 +result = evaluate("1 + 2.5", mode="python") # → 3.5 + +# Strict mode - enforces strict CEL type rules +try: + evaluate("1 + 2.5", mode=EvaluationMode.STRICT) # TypeError +except TypeError as e: + print(f"Strict mode error: {e}") # → "Unsupported addition operation" + +try: + evaluate("1 + 2.5", mode="strict") # TypeError +except TypeError as e: + print(f"Strict mode error: {e}") # → "Unsupported addition operation" +``` + +#### Values + +**EvaluationMode.PYTHON** (or `"python"`) +*Default mode for Python API.* Enables Python-friendly type promotions for better mixed arithmetic compatibility: +- Integer literals are automatically promoted to floats when used with floats +- Context variables are promoted from int to float when mixed arithmetic is detected +- Expression preprocessing converts `1 + 2.5` to `1.0 + 2.5` for compatibility + +**EvaluationMode.STRICT** (or `"strict"`) +*Default mode for CLI.* Enforces strict CEL type rules with no automatic coercion: +- Mixed int/float arithmetic raises TypeError +- No type promotions or expression rewriting +- Matches WebAssembly CEL behavior exactly + +#### Default Modes + +**Note**: The Python API and CLI have different defaults: +- **Python API**: Defaults to `EvaluationMode.PYTHON` for seamless integration with Python code +- **CLI**: Defaults to `EvaluationMode.STRICT` for CEL specification compliance and testing + +#### When to Use Each Mode + +**Use PYTHON mode (Python API default) when:** +- Integrating with existing Python code that expects mixed numeric types +- Working with data from JSON APIs (which often mix ints and floats) +- Building user-friendly applications where type coercion feels natural +- Migrating from pure Python evaluation logic + +**Use STRICT mode (CLI default) when:** +- Building applications that need to match CEL implementations in other languages +- Working with systems where type precision is critical +- Following strict CEL specification compliance +- Debugging type-related issues in expressions + +#### Implementation Details + +In PYTHON mode, the library: +1. Analyzes context for mixed numeric types (int + float) +2. Promotes integers to floats in both context variables and expression literals +3. Preprocesses expressions like `1 + 2.5` to become `1.0 + 2.5` + +In STRICT mode, the library: +1. Performs no type promotions or expression rewriting +2. Passes expressions directly to the CEL evaluator +3. Raises TypeError for incompatible type operations + +**Example with Context:** +```python +from cel import evaluate, EvaluationMode + +context = {"x": 1, "y": 2.5} # Mixed int/float in context + +# Python mode handles mixed types gracefully +result = evaluate("x + y", context, mode=EvaluationMode.PYTHON) # → 3.5 + +# Strict mode rejects mixed types +try: + evaluate("x + y", context, mode=EvaluationMode.STRICT) # TypeError +except TypeError as e: + print("Mixed types not allowed in strict mode") +``` + ## Classes ### Context @@ -292,12 +382,45 @@ except TypeError as e: assert "Unsupported multiplication operation" in str(e) ``` +#### EvaluationMode-Specific Errors + +**Strict mode can produce additional TypeError exceptions:** + +```python +from cel import evaluate, EvaluationMode + +# Mixed numeric types in strict mode +try: + evaluate("1 + 2.5", mode=EvaluationMode.STRICT) +except TypeError as e: + assert "Unsupported addition operation" in str(e) + print(f"Strict mode type error: {e}") + +# Mixed types from context in strict mode +context = {"int_val": 10, "float_val": 2.5} +try: + evaluate("int_val * float_val", context, mode=EvaluationMode.STRICT) +except TypeError as e: + assert "Unsupported multiplication operation" in str(e) + print(f"Context type mixing error: {e}") +``` + +**Invalid mode strings raise TypeError:** + +```python +try: + evaluate("1 + 2", mode="invalid_mode") +except TypeError as e: + assert "Invalid EvaluationMode" in str(e) + print(f"Invalid mode error: {e}") +``` + ### Production Error Handling 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: - Safe handling of malformed expressions and untrusted input -- Safe evaluation wrappers +- Safe evaluation wrappers with EvaluationMode considerations - Context validation patterns - Defensive expression techniques - Logging and monitoring diff --git a/python/cel/__init__.py b/python/cel/__init__.py index f4a7597..ede5871 100644 --- a/python/cel/__init__.py +++ b/python/cel/__init__.py @@ -19,15 +19,3 @@ class EvaluationMode(str, Enum): """ Enforces strict cel-rust type rules with no automatic coercion to match Wasm behavior. """ - - -__doc__ = cel.__doc__ -if hasattr(cel, "__all__"): - # Ensure EvaluationMode is always exported even if Rust module defines __all__ - __all__ = list(cel.__all__) + ["EvaluationMode"] -else: - __all__ = [ - "evaluate", - "Context", - "EvaluationMode", - ] diff --git a/python/cel/cli.py b/python/cel/cli.py index 9d8e288..f009d22 100644 --- a/python/cel/cli.py +++ b/python/cel/cli.py @@ -192,7 +192,7 @@ def _get_auto_renderable(result: Any) -> Any: class CELEvaluator: """Enhanced CEL expression evaluator.""" - def __init__(self, context: Optional[Dict[str, Any]] = None, mode: str = "python"): + def __init__(self, context: Optional[Dict[str, Any]] = None, mode: str = "strict"): """Initialize evaluator with optional context and mode.""" self.context = context or {} self.mode = mode @@ -562,7 +562,7 @@ def main( "--mode", help="Evaluation mode: python (mixed arithmetic) or strict (type matching)", ), - ] = "python", + ] = "strict", interactive: Annotated[ bool, typer.Option("-i", "--interactive", help="Start interactive REPL mode") ] = False, From ce854de433a81dbbc6ec0f6def457a03748bbaf2 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Mon, 11 Aug 2025 21:50:44 +1200 Subject: [PATCH 6/7] Test evaluation mode and slight refactor --- .github/workflows/ci.yml | 5 +- docs/reference/python-api.md | 1 + python/cel/__init__.py | 18 +--- python/cel/cel.pyi | 5 +- python/cel/cli.py | 20 ++--- python/cel/evaluation_modes.py | 16 ++++ tests/test_evaluation_mode.py | 154 +++++++++++++++++++++++++++++++++ 7 files changed, 180 insertions(+), 39 deletions(-) create mode 100644 python/cel/evaluation_modes.py create mode 100644 tests/test_evaluation_mode.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 948f0e6..7261cba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,10 +93,7 @@ jobs: test -f python/cel/cel.pyi || (echo "❌ Type stub file missing" && exit 1) test -f python/cel/py.typed || (echo "❌ PEP 561 marker missing" && exit 1) echo "✅ Type stub files present" - - - name: Run type checking tests - run: uv run pytest tests/test_type_stubs.py -v - + - name: Test ty type checker integration run: | echo "from cel import evaluate, Context; evaluate('1+1')" > test_types.py diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index 7d2c072..55ef544 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -81,6 +81,7 @@ In STRICT mode, the library: 3. Raises TypeError for incompatible type operations **Example with Context:** + ```python from cel import evaluate, EvaluationMode diff --git a/python/cel/__init__.py b/python/cel/__init__.py index ede5871..4e8f430 100644 --- a/python/cel/__init__.py +++ b/python/cel/__init__.py @@ -1,21 +1,5 @@ # Import the Rust extension -# Import CLI functionality -from enum import Enum from . import cli from .cel import * - - -class EvaluationMode(str, Enum): - """ - Defines the evaluation dialect for a CEL expression. - """ - - PYTHON = "python" - """ - Enables Python-friendly type promotions (e.g., int -> float). (Default) - """ - STRICT = "strict" - """ - Enforces strict cel-rust type rules with no automatic coercion to match Wasm behavior. - """ +from .evaluation_modes import EvaluationMode diff --git a/python/cel/cel.pyi b/python/cel/cel.pyi index f31d0bf..c426a70 100644 --- a/python/cel/cel.pyi +++ b/python/cel/cel.pyi @@ -2,10 +2,9 @@ Type stubs for the CEL Rust extension module. """ -from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Optional, Union, overload +from typing import Any, Callable, Dict, Literal, Optional, Union, overload -if TYPE_CHECKING: - from . import EvaluationMode +from .evaluation_modes import EvaluationMode class Context: """CEL evaluation context for variables and functions.""" diff --git a/python/cel/cli.py b/python/cel/cli.py index f009d22..a6ae0c8 100644 --- a/python/cel/cli.py +++ b/python/cel/cli.py @@ -37,19 +37,9 @@ from rich.table import Table from typing_extensions import Annotated -try: - from . import EvaluationMode, cel -except ImportError: - # Fallback for running as standalone script - try: - import cel - from cel import EvaluationMode - except ImportError: - console = Console() - console.print( - "[red]Error: 'cel' package not found. Please install with: pip install common-expression-language[/red]" - ) - sys.exit(1) +# Import directly from relative modules to avoid circular imports +from .cel import Context, evaluate +from .evaluation_modes import EvaluationMode # Initialize Rich console console = Console() @@ -202,7 +192,7 @@ def __init__(self, context: Optional[Dict[str, Any]] = None, mode: str = "strict def _update_cel_context(self): """Update the internal CEL context object.""" if self.context: - self._cel_context = cel.Context(self.context) + self._cel_context = Context(self.context) else: self._cel_context = None @@ -210,7 +200,7 @@ def evaluate(self, expression: str) -> Any: """Evaluate a CEL expression.""" if not expression.strip(): raise ValueError("Empty expression") - return cel.evaluate(expression, self._cel_context, mode=self.mode) + return evaluate(expression, self._cel_context, mode=self.mode) def update_context(self, new_context: Dict[str, Any]): """Update the evaluation context.""" diff --git a/python/cel/evaluation_modes.py b/python/cel/evaluation_modes.py new file mode 100644 index 0000000..49b9db9 --- /dev/null +++ b/python/cel/evaluation_modes.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class EvaluationMode(str, Enum): + """ + Defines the evaluation dialect for a CEL expression. + """ + + PYTHON = "python" + """ + Enables Python-friendly type promotions (e.g., int -> float). (Default) + """ + STRICT = "strict" + """ + Enforces strict cel-rust type rules with no automatic coercion to match Wasm behavior. + """ diff --git a/tests/test_evaluation_mode.py b/tests/test_evaluation_mode.py new file mode 100644 index 0000000..16caee3 --- /dev/null +++ b/tests/test_evaluation_mode.py @@ -0,0 +1,154 @@ +"""Tests for EvaluationMode functionality.""" + +import pytest +from cel import Context, EvaluationMode, evaluate + + +def test_evaluation_mode_enum_values(): + """Test that EvaluationMode enum has expected values.""" + assert EvaluationMode.PYTHON == "python" + assert EvaluationMode.STRICT == "strict" + # String representation shows enum name, but equality works with values + assert str(EvaluationMode.PYTHON) == "EvaluationMode.PYTHON" + assert str(EvaluationMode.STRICT) == "EvaluationMode.STRICT" + + +def test_default_mode_is_python_compatible(): + """Test that the default evaluation mode allows mixed arithmetic.""" + # Without explicit mode, should use python by default + result = evaluate("1 + 2.5") + assert result == 3.5 + + +def test_python_mode_explicit(): + """Test explicit python mode allows mixed arithmetic.""" + # Using enum + result = evaluate("1 + 2.5", mode=EvaluationMode.PYTHON) + assert result == 3.5 + + # Using string + result = evaluate("1 + 2.5", mode="python") + assert result == 3.5 + + +def test_strict_mode_rejects_mixed_arithmetic(): + """Test that strict mode rejects mixed int/float arithmetic.""" + # Using enum + with pytest.raises(TypeError, match="Unsupported addition operation"): + evaluate("1 + 2.5", mode=EvaluationMode.STRICT) + + # Using string + with pytest.raises(TypeError, match="Unsupported addition operation"): + evaluate("1 + 2.5", mode="strict") + + +def test_same_type_arithmetic_works_in_both_modes(): + """Test that same-type arithmetic works in both modes.""" + # Integer arithmetic + assert evaluate("1 + 2", mode=EvaluationMode.PYTHON) == 3 + assert evaluate("1 + 2", mode=EvaluationMode.STRICT) == 3 + + # Float arithmetic + assert evaluate("1.5 + 2.5", mode=EvaluationMode.PYTHON) == 4.0 + assert evaluate("1.5 + 2.5", mode=EvaluationMode.STRICT) == 4.0 + + +def test_context_with_mixed_types(): + """Test evaluation modes with context containing mixed types.""" + context = {"x": 1, "y": 2.5} + + # Python mode should promote and work + result = evaluate("x + y", context, mode=EvaluationMode.PYTHON) + assert result == 3.5 + + # Strict should fail + with pytest.raises(TypeError, match="Unsupported addition operation"): + evaluate("x + y", context, mode=EvaluationMode.STRICT) + + +def test_context_object_with_mixed_types(): + """Test evaluation modes with Context object containing mixed types.""" + context = Context(variables={"a": 5, "b": 3.0}) + + # Python mode should work + result = evaluate("a + b", context, mode=EvaluationMode.PYTHON) + assert result == 8.0 + + # Strict should fail + with pytest.raises(TypeError, match="Unsupported addition operation"): + evaluate("a + b", context, mode=EvaluationMode.STRICT) + + +def test_invalid_mode_string(): + """Test that invalid mode strings raise appropriate errors.""" + with pytest.raises(TypeError, match="Invalid EvaluationMode"): + evaluate("1 + 2", mode="InvalidMode") + + +def test_context_type_promotion_in_python_mode(): + """Test that python mode promotes context integers when floats are present.""" + context = {"int_val": 10, "float_val": 2.5} + + # This should work in python mode due to type promotion + result = evaluate("int_val * float_val", context, mode=EvaluationMode.PYTHON) + assert result == 25.0 + + +def test_expression_preprocessing_in_python_mode(): + """Test that python mode preprocesses integer literals when mixed with floats.""" + # This expression has mixed literals, should work in python mode + result = evaluate("10 + 2.5", mode=EvaluationMode.PYTHON) + assert result == 12.5 + + # Should fail in strict mode + with pytest.raises(TypeError): + evaluate("10 + 2.5", mode=EvaluationMode.STRICT) + + +def test_non_arithmetic_expressions_work_in_both_modes(): + """Test that non-arithmetic expressions work the same in both modes.""" + # String operations + assert evaluate('"hello" + " world"', mode=EvaluationMode.PYTHON) == "hello world" + assert evaluate('"hello" + " world"', mode=EvaluationMode.STRICT) == "hello world" + + # Boolean operations + assert evaluate("true && false", mode=EvaluationMode.PYTHON) is False + assert evaluate("true && false", mode=EvaluationMode.STRICT) is False + + # List operations + assert evaluate("[1, 2, 3].size()", mode=EvaluationMode.PYTHON) == 3 + assert evaluate("[1, 2, 3].size()", mode=EvaluationMode.STRICT) == 3 + + +def test_mode_with_custom_functions(): + """Test that evaluation modes work with custom functions.""" + + def add_numbers(a, b): + return a + b + + context = Context(variables={"x": 1, "y": 2.5}, functions={"add": add_numbers}) + + # The Python function itself will handle mixed arithmetic + result = evaluate("add(x, y)", context, mode=EvaluationMode.STRICT) + assert result == 3.5 # Python function can handle mixed types + + # But CEL arithmetic still fails in strict mode + with pytest.raises(TypeError): + evaluate("x + y", context, mode=EvaluationMode.STRICT) + + +def test_mode_parameter_positions(): + """Test that mode parameter works in different positions.""" + context = {"a": 1, "b": 2} + + # Mode as third parameter + result1 = evaluate("a + b", context, EvaluationMode.PYTHON) + assert result1 == 3 + + # Mode as keyword argument + result2 = evaluate("a + b", context, mode=EvaluationMode.PYTHON) + assert result2 == 3 + + # Mode without context + result3 = evaluate("1 + 2", mode=EvaluationMode.PYTHON) + assert result3 == 3 From 0dea8144d3f7b231f48b7b2854be82d40c057464 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Mon, 11 Aug 2025 22:08:52 +1200 Subject: [PATCH 7/7] Add evaluation modes to changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd16b53..7ef5e94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### ✨ Added +- **EvaluationMode enum**: Control type handling behavior in CEL expressions + - `EvaluationMode.PYTHON` (default for Python API): Python-friendly type promotions + - `EvaluationMode.STRICT` (default for CLI): Strict CEL type rules with no coercion - **Type checking support**: Added complete type stub files (`.pyi`) for PyO3 extension