diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a4aa46d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +# Dependabot keeps the three dependency surfaces current: the Rust crates +# (Cargo.lock), the Python lockfile (uv.lock) and the Actions the workflows pin. +# Minor and patch updates are grouped into one PR per ecosystem so a weekly +# refresh is one review rather than a dozen; majors stay separate because they +# tend to need a changelog entry (cel-rust majors change CEL behaviour). +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + groups: + rust-minor-and-patch: + update-types: [minor, patch] + + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + groups: + python-minor-and-patch: + update-types: [minor, patch] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a8cab5f..dbbb94f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - '**/pyproject.toml' + - '**/uv.lock' - '**/Cargo.toml' - '**/Cargo.lock' @@ -38,13 +39,16 @@ jobs: - name: Set up Python run: uv python install 3.12 - - name: Install dependencies - run: uv sync --dev - - name: Run Python security scan - # `safety check` is retired in favour of `safety scan`; run it with uvx so - # the scanner is not added to this project's dev dependencies. - run: uvx safety scan --detailed-output + # pip-audit checks the locked dependency set against the PyPI advisory + # database and needs no account. (`safety scan` requires an interactive + # login since Safety 3, so it exited 1 on every run and the + # continue-on-error that hid that made the step a no-op.) + # `uv export` writes the fully pinned, hashed set from uv.lock, and + # `--disable-pip` audits exactly those pins without resolving anything. + run: | + uv export --all-groups --no-emit-project --format requirements-txt --output-file requirements-audit.txt + uvx pip-audit --disable-pip --requirement requirements-audit.txt continue-on-error: true # Don't fail CI on security advisories, just report codeql: @@ -59,12 +63,12 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: python - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 \ No newline at end of file + uses: github/codeql-action/analyze@v4 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7786f0b..b2f1696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `Context.variables` and `Context.functions` are now readable from Python, as the + class docstring has promised since the beginning. Each returns a fresh dict: + variables converted back to Python values (through the same conversion + evaluation results use) and functions as the registered callables. Mutating the + returned dict does not change the context. + +### Fixed + +- The type stubs (`cel.pyi`) now use the parameter names the runtime actually + accepts: `evaluate(src, evaluation_context)` rather than `(expression, context)`, + and `Context.add_function(name, function)` rather than `func`. A keyword call + that type-checked, such as `evaluate(expression=...)`, failed at runtime with + `TypeError`, and the working spelling failed to type-check. `Context(variables, + functions)` also accepts `functions` positionally at runtime, which the stub now + reflects. +- The Python dependency scan in the security workflow had been failing on every + run since the switch to `safety scan`, which requires an interactive login + since Safety 3; `continue-on-error` hid that, so no Python advisories have been + checked since 0.8.0. The step now exports the locked dependency set with + `uv export` and audits it with `pip-audit`, which needs no account. + +### Removed + +- `cel.evaluation_modes.EvaluationMode`, left over from the evaluation-mode + feature removed in 0.5.2 and referenced by nothing. +- `docs/requirements.txt`, superseded by the `docs` dependency group that Read the + Docs and the README both use. + +### Updated + +- Dependabot now watches the Cargo lockfile, `uv.lock` and the GitHub Actions + pins weekly, grouping minor and patch bumps into one pull request per + ecosystem. +- CodeQL analysis uses `github/codeql-action` v4; v3 is deprecated in + December 2026. +- Package metadata: Python 3.11 to 3.14 and `Typing :: Typed` trove classifiers; + the README's licence line names Apache-2.0 rather than deferring to the crate + this package once wrapped; `mkdocs.yml` points at the Read the Docs URL that + actually serves the documentation. + ## [0.9.0] - 2026-09-09 Upgrades to cel-rust 0.14.5, which brings native `type()`, range-checked diff --git a/README.md b/README.md index 0a113ed..3a8bdbc 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ Contributions are welcome! Please see our [documentation](https://python-common- ## License -This project is licensed under the same terms as the original cel-interpreter crate. +This project is licensed under the [Apache License 2.0](LICENSE). ## Resources diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 1a9631b..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -mkdocs>=1.5.0 -mkdocs-material>=9.0.0 -mkdocstrings>=0.24.0 -mkdocstrings-python>=1.8.0 -pygments>=2.0.0 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 254f3c1..9e96be3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: Python CEL site_description: Common Expression Language for Python - Fast, Safe, and Simple -site_url: https://python-cel.readthedocs.io +site_url: https://python-common-expression-language.readthedocs.io repo_url: https://github.com/hardbyte/python-common-expression-language repo_name: hardbyte/python-common-expression-language diff --git a/pyproject.toml b/pyproject.toml index 49a85b4..66200db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,22 @@ authors = [ ] requires-python = ">=3.11" classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", "Programming Language :: Rust", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Interpreters", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", ] dynamic = ["version"] dependencies = [ diff --git a/python/cel/cel.pyi b/python/cel/cel.pyi index ab33998..44d1170 100644 --- a/python/cel/cel.pyi +++ b/python/cel/cel.pyi @@ -1,28 +1,46 @@ """ Type stubs for the CEL Rust extension module. + +Parameter names match the runtime signatures exposed by PyO3 (see +``cel.evaluate.__text_signature__``), so keyword calls that type-check also +run, and vice versa. """ -from typing import Any, Callable, Dict, Literal, Optional, Union, overload +from typing import Any, Callable, Dict, Optional, Union 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: ... + @property + def variables(self) -> Dict[str, Any]: + """The registered variables, as a new dict of Python values. + + Values come back through the same CEL-to-Python conversion evaluation + results use, so a variable added as a tuple reads back as a list. + Mutating the returned dict does not change the context; use + ``add_variable()`` or ``update()``. + """ + ... + + @property + def functions(self) -> Dict[str, Callable[..., Any]]: + """The registered functions, as a new dict of name to callable. + + Mutating the returned dict does not change the context; use + ``add_function()`` or ``update()``. + """ + ... + 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: + def add_function(self, name: str, function: Callable[..., Any]) -> None: """Add a function to the context.""" ... @@ -35,7 +53,7 @@ class Context: ... def update(self, variables: Dict[str, Any]) -> None: - """Update context with variables from a dictionary.""" + """Update context with variables (and callables, as functions) from a dictionary.""" ... class Program: @@ -88,15 +106,15 @@ class OptionalValue: def or_optional(self, other: OptionalValue) -> OptionalValue: ... def evaluate( - expression: str, - context: Optional[Union[Dict[str, Any], Context]] = None, + src: str, + evaluation_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 + src: The CEL expression to evaluate + evaluation_context: Optional context with variables and functions Returns: The result of evaluating the expression diff --git a/python/cel/evaluation_modes.py b/python/cel/evaluation_modes.py deleted file mode 100644 index 3dd5c1b..0000000 --- a/python/cel/evaluation_modes.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Evaluation mode enum for CEL. - -Kept for typing compatibility with cel.pyi. -""" - -from enum import Enum - - -class EvaluationMode(str, Enum): - PYTHON = "python" - STRICT = "strict" diff --git a/src/context.rs b/src/context.rs index 76e63e5..0e3f26a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -3,6 +3,7 @@ use ::cel::Value; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyDict; +use pyo3::IntoPyObjectExt; use std::collections::HashMap; #[pyo3::pyclass] @@ -27,10 +28,13 @@ use std::collections::HashMap; /// - 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. +/// variables (dict): A read-only snapshot mapping variable names (str) to +/// their values, converted back from CEL types to Python (so a tuple +/// added as a variable reads back as a list). Modify the context with +/// ``add_variable()`` or ``update()``, not by mutating this dict. +/// functions (dict): A read-only snapshot mapping function names (str) to +/// the registered Python callables. Modify the context with +/// ``add_function()`` or ``update()``. /// /// Thread Safety: /// Context objects are not thread-safe. Create separate Context instances @@ -207,6 +211,36 @@ impl Context { self.functions.insert(name, function); } + /// The registered variables, converted back to Python values. + /// + /// Returns a new dict on every access; mutating it does not affect the + /// context. Values go through the same conversion as evaluation results, + /// so CEL-only distinctions are lost (a `uint` reads back as `int`). + #[getter] + fn variables<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + for (name, value) in &self.variables { + dict.set_item( + name, + crate::RustyCelType(value.clone()).into_bound_py_any(py)?, + )?; + } + Ok(dict) + } + + /// The registered functions, by name. + /// + /// Returns a new dict on every access; mutating it does not affect the + /// context. + #[getter] + fn functions<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + for (name, function) in &self.functions { + dict.set_item(name, function.bind(py))?; + } + Ok(dict) + } + /// Registers a Python callable for lazy variable resolution. /// /// When evaluating an expression, CEL will call `resolver(name)` for each diff --git a/tests/test_context.py b/tests/test_context.py index dde8b74..d269a06 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -109,6 +109,61 @@ def test_nested_context_none(): assert cel.evaluate("size(data.A)", cel_context) == 1 +class TestContextAttributes: + """``Context.variables`` and ``Context.functions`` expose read-only snapshots.""" + + def test_variables_reads_back_python_values(self): + ctx = cel.Context({"n": 1, "s": "text", "items": (1, 2), "nested": {"k": None}}) + ctx.add_variable("flag", True) + # A tuple goes through CEL's list type, so it reads back as a list. + assert ctx.variables == { + "n": 1, + "s": "text", + "items": [1, 2], + "nested": {"k": None}, + "flag": True, + } + + def test_functions_reads_back_the_registered_callables(self): + def f(): + return 1 + + ctx = cel.Context(functions={"f": f}) + ctx.add_function("g", len) + assert ctx.functions == {"f": f, "g": len} + assert ctx.functions["f"] is f + + def test_update_sorts_callables_into_functions(self): + ctx = cel.Context() + ctx.update({"a": 1, "f": len}) + assert ctx.variables == {"a": 1} + assert ctx.functions == {"f": len} + + def test_empty_context(self): + ctx = cel.Context() + assert ctx.variables == {} + assert ctx.functions == {} + + def test_returned_dicts_are_snapshots(self): + ctx = cel.Context({"a": 1}) + snapshot = ctx.variables + snapshot["b"] = 2 + assert ctx.variables == {"a": 1} + with pytest.raises(RuntimeError, match="Undefined variable"): + cel.evaluate("b", ctx) + + funcs = ctx.functions + funcs["f"] = len + assert ctx.functions == {} + + def test_attributes_are_read_only(self): + ctx = cel.Context() + with pytest.raises(AttributeError): + ctx.variables = {} + with pytest.raises(AttributeError): + ctx.functions = {} + + class TestVariableResolver: """Tests for lazy variable resolution via set_variable_resolver.""" diff --git a/tests/test_type_stubs.py b/tests/test_type_stubs.py new file mode 100644 index 0000000..5c514c5 --- /dev/null +++ b/tests/test_type_stubs.py @@ -0,0 +1,134 @@ +"""The hand-written type stubs must describe the extension as it really is. + +``python/cel/cel.pyi`` is maintained by hand, so it can drift from the PyO3 +signatures. The consequence is nasty: a keyword call that satisfies the type +checker fails at runtime, or the working spelling fails to type-check. This +test parses the stub and compares every function and method signature it +declares against the runtime object, and checks that every public runtime +attribute is declared in the stub. +""" + +import ast +import inspect +from pathlib import Path + +import cel +import pytest + +STUB_PATH = Path(cel.__file__).with_name("cel.pyi") + + +def _stub_module() -> ast.Module: + return ast.parse(STUB_PATH.read_text(), filename=str(STUB_PATH)) + + +def _parameter_names(node: ast.FunctionDef) -> list[str]: + args = node.args + names = [a.arg for a in args.posonlyargs + args.args + args.kwonlyargs] + return [n for n in names if n not in ("self", "cls")] + + +def _runtime_parameter_names(obj) -> list[str]: + names = [p.name for p in inspect.signature(obj).parameters.values()] + return [n for n in names if n not in ("self", "cls")] + + +def _stub_functions(module: ast.Module) -> dict[str, ast.FunctionDef]: + return {n.name: n for n in module.body if isinstance(n, ast.FunctionDef)} + + +def _stub_classes(module: ast.Module) -> dict[str, ast.ClassDef]: + return {n.name: n for n in module.body if isinstance(n, ast.ClassDef)} + + +def _is_property(node: ast.FunctionDef) -> bool: + return any(isinstance(d, ast.Name) and d.id == "property" for d in node.decorator_list) + + +def _is_classmethod(node: ast.FunctionDef) -> bool: + return any(isinstance(d, ast.Name) and d.id == "classmethod" for d in node.decorator_list) + + +@pytest.mark.parametrize("name", ["evaluate", "compile"]) +def test_module_function_parameters_match_runtime(name): + stub = _stub_functions(_stub_module())[name] + assert _parameter_names(stub) == _runtime_parameter_names(getattr(cel, name)) + + +@pytest.mark.parametrize( + ("class_name", "method"), + [ + ("Context", "__init__"), + ("Context", "add_variable"), + ("Context", "add_function"), + ("Context", "set_variable_resolver"), + ("Context", "update"), + ("Program", "execute"), + ("Program", "variables"), + ("Program", "functions"), + ("Program", "references"), + ("OptionalValue", "of"), + ("OptionalValue", "none"), + ("OptionalValue", "has_value"), + ("OptionalValue", "value"), + ("OptionalValue", "or_value"), + ("OptionalValue", "or_optional"), + ], +) +def test_method_parameters_match_runtime(class_name, method): + class_node = _stub_classes(_stub_module())[class_name] + stub_methods = {n.name: n for n in class_node.body if isinstance(n, ast.FunctionDef)} + stub = stub_methods[method] + runtime_cls = getattr(cel, class_name) + # PyO3 puts the constructor signature on the class itself; ``__init__`` is + # the generic object slot wrapper. + runtime = runtime_cls if method == "__init__" else getattr(runtime_cls, method) + assert _parameter_names(stub) == _runtime_parameter_names(runtime), ( + f"{class_name}.{method}: stub declares {_parameter_names(stub)}, " + f"runtime accepts {_runtime_parameter_names(runtime)}" + ) + + +@pytest.mark.parametrize("class_name", ["Context", "Program", "OptionalValue"]) +def test_public_runtime_attributes_are_declared(class_name): + """Every public method/property on the extension class appears in the stub.""" + class_node = _stub_classes(_stub_module())[class_name] + declared = {n.name for n in class_node.body if isinstance(n, ast.FunctionDef)} + runtime_cls = getattr(cel, class_name) + public = { + name + for name, member in vars(runtime_cls).items() + if not name.startswith("_") + and ( + inspect.isroutine(member) + or isinstance(member, (property, type(runtime_cls.__dict__.get("__init__")))) + or type(member).__name__ in ("getset_descriptor", "method_descriptor") + ) + } + assert public <= declared, ( + f"{class_name} runtime members missing from stub: {public - declared}" + ) + + +@pytest.mark.parametrize( + ("class_name", "attribute"), + [("Context", "variables"), ("Context", "functions"), ("Program", "source")], +) +def test_properties_are_declared_as_properties(class_name, attribute): + class_node = _stub_classes(_stub_module())[class_name] + stub = {n.name: n for n in class_node.body if isinstance(n, ast.FunctionDef)}[attribute] + assert _is_property(stub) + assert isinstance(inspect.getattr_static(getattr(cel, class_name), attribute), property) or ( + type(inspect.getattr_static(getattr(cel, class_name), attribute)).__name__ + == "getset_descriptor" + ) + + +def test_optional_value_constructors_are_classmethods(): + class_node = _stub_classes(_stub_module())["OptionalValue"] + stub = {n.name: n for n in class_node.body if isinstance(n, ast.FunctionDef)} + assert _is_classmethod(stub["of"]) and _is_classmethod(stub["none"]) + # A PyO3 classmethod is a builtin bound to the class rather than a Python + # ``method`` object, so check what it is bound to. + assert cel.OptionalValue.of.__self__ is cel.OptionalValue + assert cel.OptionalValue.none.__self__ is cel.OptionalValue