All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Free-threaded Python. The test suite runs on the free-threaded build of
Python 3.14 (
python3.14t), and free-threaded wheels ship for Linux, macOS and Windows x64. Importingcelleaves the GIL disabled (#45).
- Parsing no longer holds the GIL.
compile(), and the parse insideevaluate(), release the GIL for expressions of 32 bytes or more, so threads that parse concurrently run in parallel: about 3× the throughput from 4 threads on a 4-core machine, with single-threaded cost unchanged. Executing a compiled program still holds the GIL (#45).
ProgramandOptionalValueare immutable; attributes can no longer be assigned on them.- One
Contextmay be evaluated against from many threads at once, and evaluations see a consistent snapshot. An evaluation that starts duringadd_variable,add_functionorupdatewaits for it. Mutating aContextfrom two threads at once raisesRuntimeErroron a free-threaded interpreter; build a context before sharing it, or guard mutation with a lock.
- On a free-threaded interpreter, evaluating against a
Contextwhile another thread was mutating it raisedValueError: evaluation_context must be a Context object or a dict. It now waits for the mutation to finish. - Loading the extension module a second time in one process (from a sub-interpreter, for instance) no longer panics.
- Building from the sdist requires maturin 1.14 or later.
Fixes import cel in a clean install, which has been broken since 0.6.0 for
environments without typing_extensions; makes evaluation against a cel.Context
cost the same regardless of how many variables and functions it carries; and
repairs the Python security scan that had been silently failing since 0.8.0.
Context.variablesandContext.functionsare 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.
- A
cel.Contextnow builds its CEL-side environment once and reuses it. Previously everyevaluate()andProgram.execute()call re-converted each variable and re-wrapped each registered Python function into a fresh cel-rust context, so the per-call cost grew with the size of the context: executing a pre-compiled1 + 2against aContextcarrying the 47 extended-stdlib functions (what thecelCLI sets up) took about 9 µs, against about 0.2 µs with no context. The environment is now cached on theContextand shared by every evaluation untiladd_variable,add_functionorupdatechanges it, bringing that case down to about 0.2 µs as well.set_variable_resolverdoes not invalidate the cache: the resolver is bound per call in a child scope (cel-rust'sContext::new_inner_scope), with the same lookup order as before (resolver, then registered variables). Dict contexts are still materialised on every call, since a dict can change between calls without notice; for hot loops, prefer aContext. - Behaviour that is unchanged and now pinned by tests: every mutator is visible on
the next evaluation; a Python function may modify, or evaluate against, the
Contextit was registered on while an evaluation is in progress (the change applies from the next evaluation); and oneContextcan be shared between threads.
- The
ValueErrorraised when the interpreter panics during evaluation now reads "Internal evaluation error" rather than "Internal parser error"; parse failures were never what it reported.
import celfailed in a clean install.cel.cliimportedAnnotatedfromtyping_extensions, which is not one of this package's declared dependencies. It used to arrive transitively through Typer, but Typer 0.21.2 (February 2026) dropped that dependency, sopip install common-expression-languageinto an environment with nothing else in it leftimport celraisingModuleNotFoundError. The development lockfile masked this because mypy still pullstyping_extensionsin.Annotatednow comes from the standard library (typing), which has provided it since Python 3.9. CI now installs the built wheel into an empty virtual environment and imports the package and runs thecelcommand, so an undeclared runtime dependency fails the build.- The type stubs (
cel.pyi) now use the parameter names the runtime actually accepts:evaluate(src, evaluation_context)rather than(expression, context), andContext.add_function(name, function)rather thanfunc. A keyword call that type-checked, such asevaluate(expression=...), failed at runtime withTypeError, and the working spelling failed to type-check.Context(variables, functions)also acceptsfunctionspositionally 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-errorhid that, so no Python advisories have been checked since 0.8.0. The step now exports the locked dependency set withuv exportand audits it withpip-audit, which needs no account.
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 thedocsdependency group that Read the Docs and the README both use.
- Dependabot now watches the Cargo lockfile,
uv.lockand the GitHub Actions pins weekly, grouping minor and patch bumps into one pull request per ecosystem. - CodeQL analysis uses
github/codeql-actionv4; v3 is deprecated in December 2026. - Package metadata: Python 3.11 to 3.14 and
Typing :: Typedtrove classifiers; the README's licence line names Apache-2.0 rather than deferring to the crate this package once wrapped;mkdocs.ymlpoints at the Read the Docs URL that actually serves the documentation.
- Naive
datetimevalues are interpreted in the host's local time zone when converted to a CELtimestamp, so the same value can mean a different instant on different machines (#50). The Python API reference now carries a warning, and the quick start, tutorials andContext.add_variabledocstring use timezone-awaredatetime.now(timezone.utc)instead of naivedatetime.now().
Upgrades to cel-rust 0.14.5, which brings native type(), range-checked
int()/uint() conversions and, notably, rejection of CEL reserved words as
identifiers. Also the first release published through PyPI trusted publishing,
with the GitHub release created automatically from this section.
- CEL reserved words are now rejected as identifiers. cel-rust 0.14.4 enforces the
specification's reserved word list (
as,break,const,continue,else,for,function,if,import,let,loop,package,namespace,return,var,void,while), so an expression such asvar == "x"now raisesValueErrorat parse time even if the context definesvar. Names that merely contain a reserved word (var_2) and map keys ({"var": 1}["var"]) are unaffected. Rename such variables before upgrading. type()is now provided natively by cel-rust 0.14.5 and returns a first-class CEL type value, sotype(x) == int,type(1u) == uintandtype(null) == null_typework inside expressions. Returned to Python, a type value arrives as its name. The string-returningtype()shim and the identitydyn()shim have been removed fromcel.stdlib'scorelibrary because the native functions take precedence anyway; the only visible differences are thatuintis now reported as"uint"rather than"int"andnullas"null_type"rather than"null".int()anduint()reject conversions that do not fit the target type (for exampleint(9223372036854775808u),uint(-1),int(1e300)andint(double("NaN"))), raisingOverflowErrorinstead of silently wrapping or saturating.durationandtimestampvalues report their spec type names fromtype():google.protobuf.Durationandgoogle.protobuf.Timestamp.
- Negative hexadecimal literals (
-0x10) parse; stacked unary operators (--1,!!true) cancel;\uescapes inside bytes literals are rejected per the spec; andoptional.of(1) == optional.of(1)compares by content (all from cel-rust 0.14.4/0.14.5).
- cel-rust 0.14.3 to 0.14.5 (the minimum is now 0.14.5), plus in-range transitive bumps (log 0.4.34, uuid 1.26.0, smallvec 1.16.0, cc 1.4.5, syn 3.0.5).
- Python dev dependencies moved from the deprecated
tool.uv.dev-dependenciestodependency-groups.dev;uv.lockrefreshed (ruff 0.16.6, typer 0.27.2, maturin 1.15.0). - GitHub Actions:
actions/checkoutv7,actions/setup-pythonv7,actions/upload-artifactv7,actions/download-artifactv8 (digest mismatches now fail the job),astral-sh/setup-uvv10 (cache disabled on pull requests from forks and on Dependabot rollups, to guard against cache poisoning).
- Releases publish to PyPI with
trusted publishing instead of a
stored
PYPI_API_TOKEN: the release job exchanges a short-lived GitHub OIDC token for the upload credential, so no long-lived PyPI token is kept in repository secrets. The job runs in apypienvironment, which the trusted publisher on PyPI is configured against. - The release job now also creates the GitHub release for the pushed tag, using the
matching
CHANGELOG.mdsection as the release notes and attaching the wheels and sdist. Pushing a tag previously published to PyPI but left no GitHub release behind.
Adds the sum aggregation to the extended standard library, refreshes the locked
Rust dependencies, and makes CI reproducible across Python 3.11 to 3.14.
cel.stdlib'scorelibrary gainssum, completing the aggregation trio (min,max,sum) requested in #14. It follows Kubernetes' CEL list library:int,uint,doubleanddurationare supported;sum([])is0; booleans are rejected rather than counted as1/0; and numbers cannot be mixed with durations. Like the rest of the extended stdlib it works in both call forms —sum([1, 2, 3])anditems.map(i, i.weight).sum().fold/reduceremain unavailable, and the reason is now documented incel.stdliband the standard-library reference: a CEL function receives evaluated arguments, whereas a fold needs its accumulator expression left unevaluated, and cel-rust expands its comprehension macros from a fixed table in the parser. They have to arrive in cel-rust rather than in this wrapper.
- The two lossy conversions at the Python callback boundary, which apply to every
cel.stdlibfunction rather than just the aggregations: auintresult returns as anint(Python has a single integer type), sosum([1u, 2u]) + 1uhas no overload where the native[1u, 2u][0] + 1uworks; anddurationis carried bydatetime.timedelta, whose resolution is microseconds, so sub-microsecond durations are rounded before a function sees them. Both behaviours are pinned by tests.
- Refreshed the locked Rust dependencies within their existing ranges: cel 0.14.0 to 0.14.3, PyO3 0.29.0 to 0.29.2, plus transitive bumps (regex 1.13.1, serde 1.0.229, serde_json 1.0.151, thiserror 2.0.20, uuid 1.24.1).
uv.lockis now committed so CI resolves the same Python dependencies as local development; refresh it withuv lock --upgrade.- CI tests Python 3.13 and 3.14 in addition to 3.11 and 3.12.
- Pinned ruff to the 0.16 line and excluded Markdown from
ruff format, so formatter releases no longer change what CI expects of the docs. - The security workflow runs
safety scanviauvx(the retiredsafety checkcommand no longer works, and the scanner is no longer added to this project's dev dependencies), and the Claude workflow usesanthropics/claude-code-action@v1instead of the deprecated@betatag.
Upgrades to cel-rust 0.14, ships an extended standard library that mirrors
cel-go, and exposes expression static analysis. A 1.0 release is intentionally
deferred until the upstream cel crate supports the CEL protobuf AST
(cel.expr.Expr / CheckedExpr) so that cross-implementation portability can
be offered — see the Notes section below.
- Updated cel-rust from 0.13.0 to 0.14.0.
- Updated PyO3 from 0.27 to 0.29. This resolves two upstream advisories that
affected earlier PyO3 versions: an out-of-bounds read in
nth/nth_backforPyList/PyTupleiterators (high), and a missingSyncbound onPyCFunction::new_closureclosures (moderate). - Switched
pyo3-logfrom a git fork (pinned to a PyO3 0.27 branch) to the releasedpyo3-log0.13.4 crate, removing the git dependency. - Refreshed transitive dependencies (chrono 0.4.45, log 0.4.33, regex 1.12.4, serde_json 1.0.150, arc-swap 1.9.2).
- Extended standard library (
cel.stdlib): opt-in function libraries that mirror cel-go's extensions and fill gaps in cel-rust's built-ins. Register them withcel.stdlib.add_stdlib_to_context(context)(all libraries) or passextensions=[...]to select individual ones. The CLI enables them automatically. Libraries:core:bool,dyn,type,min,maxstrings:charAt,indexOf,lastIndexOf,substring,replace,split,join,lowerAscii,upperAscii,trim,reverse,strings.quotemath:math.greatest,math.least,math.abs,math.sign,math.ceil,math.floor,math.round,math.trunc,math.isNaN,math.isInf,math.isFinite,math.sqrt, and themath.bit*operationssets:sets.contains,sets.equivalent,sets.intersectsencoders:base64.encode,base64.decodelists:contains,distinct,flatten,slice,sort,reverse,first,last,lists.range
- Expression static analysis on
Program:variables(),functions()andreferences()report the identifiers an expression uses without evaluating it.Program.sourceexposes the original CEL text. - Method-call syntax for custom functions: a registered function can now be
called either as
f(x, y)or asx.f(y). When called as a method the receiver is passed as the first argument, matching CEL's semantics. This is what lets the standard-library extensions be written as members ("s".charAt(i),[1,2].contains(x)).
- Behaviour change (cel 0.14):
containsis now a string-only built-in.[1, 2, 3].contains(2)and{...}.contains(k)no longer resolve to a built-in — use theinoperator (2 in [1, 2, 3],"k" in m) for list/map membership, or enable thelistsextension incel.stdlibwhich restores a multi-typecontains. - Behaviour change (cel 0.14):
minandmaxare no longer part of the core standard library. They are provided by thecoreextension incel.stdlib. - Behaviour change (cel 0.14): built-in functions and overloads now take
precedence over user-registered functions of the same name (e.g. registering
a function called
doublewill not override the built-indouble()conversion). Give custom functions names that do not collide with built-ins. - Error messages: type-mismatch errors now report canonical CEL type names
(e.g.
Unsupported operation: string + int) and several additional execution errors map to idiomatic Python exceptions (UnsupportedIndex,ValuesNotComparable,UnexpectedType,InvalidArgumentCount→TypeError).
- The CEL standard-library environment is now built once and shared across all
evaluations (via cel 0.14's
Env/Context::with_env) instead of being rebuilt on everyevaluate()/Program.execute()call.
- Cross-implementation portability / "bytecode" is explicitly out of scope.
CEL has no bytecode format; portable interchange in the ecosystem uses the
protobuf AST (
cel.expr.Expr/CheckedExpr), which the upstreamcelRust crate cannot yet produce or consume (it has no protobuf support and no type checker). The portable artifact for this library is the CEL source string;Program.references()provides static analysis.
- Updated cel-rust from 0.12.0 to 0.13.0.
Context.set_variable_resolver(callback)exposes cel 0.13'sVariableResolvertrait to Python. The callback receives a variable name and returns the value (orNoneto fall through to variables registered withadd_variable). Useful for backing a CEL context with on-demand sources (database lookups, lazily-loaded config files, etc.) without materializing the full set of variables upfront. Exceptions raised by the resolver are logged and treated as "not handled".- Idiomatic Python exception mapping for several CEL runtime errors that previously fell through to
ValueError:- Arithmetic overflow →
OverflowError(e.g.9223372036854775807 + 1, including the new overflow-safe int math in cel 0.13). - Division by zero / modulo by zero →
ZeroDivisionError. - List index out of bounds →
IndexError. - Missing map key (e.g.
{"a": 1}.b) →KeyError.
- Arithmetic overflow →
- Behaviour change (cel 0.13): bytes concatenation with
+now works per the CEL spec (b'hello' + b'world'returnsb'helloworld'). Previously raisedTypeError. - Behaviour change (cel 0.13): logical
&&and||are now "err-resilient" per CEL spec —X && falseshort-circuits tofalseandX || trueshort-circuits totrueeven whenXis not a boolean. Conversely,false || Xandtrue && Xnow raiseTypeErrorwhenXis not boolean (previouslyfalse || XreturnedX). - Error mapping: more operations now route through CEL's
NoSuchOverload(e.g.1 + 2u,1 * 2u, indexing into a string). These map toTypeErrorwith a generic message listing common causes and conversion functions (int(x),uint(x),double(x)). The previous type-specific messages (e.g. "Cannot mix signed and unsigned integers") are still produced for the operand orderings cel-rust dispatches viaUnsupportedBinaryOperator. Tests asserting on specific message text may need updating. - Behaviour change (cel 0.13): no implicit type coercion on map index access; indexing into a string now raises
TypeError(NoSuchOverload) per CEL spec. - Behaviour change (cel 0.13): integer arithmetic overflow now raises
OverflowErrorinstead of silently wrapping. Affects+,-,*on bothintanduintat the type's bounds.
-
Microbenchmarks comparing cel 0.13 vs 0.12 (release build, taking the min of 3 runs per scenario):
Scenario compile compiled execute evaluate x + y * 2+2.8% +8.4% +3.3% greet + ' ' + name+23.0% +4.3% +0.5% size(items)(1k)~0% +63.4% +53.2% map field access −7.1% +8.3% +6.5% custom Python fn +1.4% +6.7% −1.9% Most scenarios are within ~10% of 0.12; the
size(items)regression on a 1000-element list is cel 0.13's dyn-Val refactor adding per-element boxing overhead at the Python→CEL boundary. Smaller lists are not noticeably affected.
- Mapping conversion now respects dict subclasses and custom
Mappingimplementations, preserving__getitem__behavior for member access (issue #22)
compile()API andProgram.execute()for reuse and performanceOptionalValuewrapper to expose CEL optional values in Python- Compile vs execute benchmark script (
examples/performance/compile_execute_benchmark.py)
- Updated cel-rust from 0.11.6 to 0.12.0
- Documentation: pre-compilation guidance and OptionalValue examples
- Updated cel-rust from 0.11.4 to 0.11.6
- Updated PyO3 from 0.25.1 to 0.27.1
- Reduced logging verbosity
- Added new
cel.stdlibmodule with Python implementations of CEL functions missing from upstream cel-rust. - CLI automatically includes all stdlib functions
- Minor removed warning level logging in cel crate
- Python evaluation mode removed: The library now operates exclusively in strict CEL mode
- Removed:
EvaluationMode.PYTHONand all automatic integer-to-float promotion - Removed:
modeparameter fromevaluate()function - Removed:
--modeCLI option - Behavior change: Mixed arithmetic like
1 + 2.5now raisesTypeErrorinstead of automatically promoting to3.5 - Migration: Use explicit type conversion (e.g.,
double(1) + 2.5) for mixed arithmetic - Rationale: Eliminates complex AST preprocessing that was breaking
has()short-circuiting and other CEL functions
- Removed:
- CEL function short-circuiting: Fixed issue where
has()and other CEL functions failed due to AST preprocessing interference - String literal corruption: Eliminated string literal modification that occurred during integer promotion preprocessing
- Updated cel crate from v0.11.0 to v0.11.1
- Updated documentation to reflect strict CEL mode operation
- Updated tests to work with strict CEL mode only
- Removed complex preprocessing logic
- EvaluationMode enum: Control type handling behavior in CEL expressions (deprecated and removed in later version)
EvaluationMode.PYTHON(default for Python API): Python-friendly type promotions (removed)EvaluationMode.STRICT(default for CLI): Strict CEL type rules with no coercion (now the only mode)
- Type checking support: Added complete type stub files (
.pyi) for PyO3 extension
- Upgraded
celcrate (formerlycel-interpreter) 0.10.0 → 0.11.0:- Function registration now uses
IntoFunctiontrait. - Python integration updated to use
Argumentsextractor for variadic args. - Imports renamed from
cel_interpreter::tocel::. - No changes to Python API – all existing code continues to work.
- Function registration now uses
- Internal: Refactored Python integration to match new CEL API.
- Updated dependencies:
- pyo3: 0.25.0 → 0.25.1
- pyo3-log: 0.12.1 → 0.12.4
- Prepared for upcoming CEL Rust features:
- Enhanced type system & introspection
type()function support- Optional value handling
- Automatic type coercion for mixed int/float arithmetic (deprecated and removed in later version):
- Float literals automatically promote integer literals to floats.
- Context variables containing floats trigger int → float promotion.
- Preserves array indexing with integers (e.g.,
list[2]stays integer).
- Enhanced error handling:
- Parser panics now caught with
std::panic::catch_unwind. - Invalid expressions return a
ValueErrorinstead of crashing Python.
- Parser panics now caught with
- Mixed-type arithmetic now works in expressions like:
3.14 * 22 + 3.14value * 2(wherevalueis float)
- Parser panics from
cel-interpreterhandled gracefully with proper error messages. - Updated to latest PyO3 APIs to remove deprecation warnings.
- Bytes concatenation (
b'hello' + b'world') unsupported in cel-interpreter 0.10.0.- Spec requires: should work.
- Current: returns
"Unsupported binary operator 'add'". - Workaround:
bytes(string(part1) + string(part2)). - Status: Missing feature in cel-interpreter, not in our wrapper.
- cel-interpreter: 0.9.0 → 0.10.0 (breaking changes internally)
- log: 0.4.22 → 0.4.27
- chrono: 0.4.38 → 0.4.41
- pyo3: 0.22.6 → 0.25.0 (major API upgrade to
IntoPyObject) - pyo3-log: 0.11.0 → 0.12.1 (compatible with PyO3 0.25.0)
- PyO3 migration: moved from deprecated
IntoPyto newIntoPyObjectAPI. - New conversion system improves error handling and type safety.
- All 120 tests pass on current dependency set.