From 3f3cde1ae50ff0a7e39e584b0958b27a874497e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 02:07:11 +0000 Subject: [PATCH 1/2] Fix the silently failing Python security scan, add Dependabot, align stubs with the runtime The Python dependency scan has exited 1 on every run since 0.8.0 switched it to `safety scan`: Safety 3 demands an interactive login ("Please login or register Safety CLI ... EOF when reading a line"), and the step's continue-on-error hid the failure, so no Python advisories have actually been checked. Replace it with `uv export` of the locked, hashed dependency set plus `uvx pip-audit --disable-pip`, which needs no account and audits exactly the pins in uv.lock. CodeQL moves to codeql-action v4 (v3 is deprecated December 2026), and a Dependabot config watches Cargo.lock, uv.lock and the Actions pins weekly with minor/patch updates grouped per ecosystem. The type stubs named parameters the runtime does not accept: `evaluate( expression, context)` where PyO3 exposes `(src, evaluation_context)`, and `add_function(name, func)` where the runtime has `function`. A keyword call that type-checked failed at runtime with TypeError and the working spelling failed to type-check. The stub now mirrors the runtime, and a new test parses cel.pyi and compares every declared signature with inspect.signature of the extension, so the two cannot drift again. Context.variables and Context.functions, which the class docstring has documented as attributes all along, now exist as read-only getters returning fresh dicts. Also removes cel.evaluation_modes (dead since the evaluation-mode feature was removed in 0.5.2) and docs/requirements.txt (Read the Docs and the README use the docs dependency group), fixes the README licence line and the mkdocs site_url, and adds Python version and Typing :: Typed classifiers. Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW --- .github/dependabot.yml | 30 ++++++++ .github/workflows/security.yml | 21 +++--- CHANGELOG.md | 42 +++++++++++ README.md | 2 +- docs/requirements.txt | 5 -- mkdocs.yml | 2 +- pyproject.toml | 12 +++ python/cel/cel.pyi | 44 +++++++---- python/cel/evaluation_modes.py | 11 --- src/context.rs | 42 ++++++++++- tests/test_context.py | 55 ++++++++++++++ tests/test_type_stubs.py | 134 +++++++++++++++++++++++++++++++++ 12 files changed, 356 insertions(+), 44 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 docs/requirements.txt delete mode 100644 python/cel/evaluation_modes.py create mode 100644 tests/test_type_stubs.py 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..7bf5134 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -38,13 +38,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 +62,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 From a77cfd938578f2aaf867128f2fcff0eb1b330119 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 02:14:11 +0000 Subject: [PATCH 2/2] Run the security scan when uv.lock changes Dependabot's uv updater usually touches only uv.lock when it bumps a version already allowed by pyproject.toml, so the push filter that lists pyproject.toml and the Cargo files would have skipped the audit of exactly the change it is meant to check, leaving it to the weekly schedule. Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW --- .github/workflows/security.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 7bf5134..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'