Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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: ["*"]
22 changes: 13 additions & 9 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- '**/pyproject.toml'
- '**/uv.lock'
- '**/Cargo.toml'
- '**/Cargo.lock'

Expand Down Expand Up @@ -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
Comment thread
hardbyte marked this conversation as resolved.
continue-on-error: true # Don't fail CI on security advisories, just report

codeql:
Expand All @@ -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
uses: github/codeql-action/analyze@v4
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 0 additions & 5 deletions docs/requirements.txt

This file was deleted.

2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
44 changes: 31 additions & 13 deletions python/cel/cel.pyi
Original file line number Diff line number Diff line change
@@ -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."""
...

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
11 changes: 0 additions & 11 deletions python/cel/evaluation_modes.py

This file was deleted.

42 changes: 38 additions & 4 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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<Bound<'py, PyDict>> {
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<Bound<'py, PyDict>> {
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
Expand Down
55 changes: 55 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading