From fbe42fdad4e4fa2b1ff52705279f32c521a6f684 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 4 Jul 2026 14:59:46 +1200 Subject: [PATCH 1/2] Upgrade to cel-rust 0.14 and prepare 0.7.0 Upgrade the underlying cel crate from 0.13 to 0.14, add an opt-in extended standard library, expose expression static analysis, and bump to 0.7.0. A 1.0 release is deferred until the upstream cel crate supports the CEL protobuf AST (cel.expr.Expr / CheckedExpr) so cross-implementation portability can be offered. cel 0.14 upgrade (src/lib.rs): - Share the standard-library Env across evaluations (LazyLock> + Context::with_env) instead of rebuilding it on every call. - Map the 0.14 ExecutionError variants to idiomatic Python exceptions with canonical CEL type names, and stop leaking Debug wrappers into messages. - Use the infallible add_variable_from_value. Custom functions callable as methods (src/lib.rs): - Bridge via FunctionContext and prepend the method receiver, so a registered function works as both f(x, y) and x.f(y). Extended standard library (python/cel/stdlib.py): - Opt-in libraries mirroring cel-go: core (bool/dyn/type/min/max), strings, math, sets, encoders (base64) and lists. Enable with add_stdlib_to_context; the CLI enables them automatically. Expression static analysis (src/lib.rs): - Program.variables(), .functions(), .references() and the .source property. Behaviour changes (cel 0.14): contains is string-only (use `in` for list/map membership), min/max moved to the core stdlib extension, and built-in functions take precedence over same-named user functions. Cross-implementation portability is documented as out of scope for now: CEL has no bytecode, ecosystem interchange uses the protobuf AST, and upstream cel-rust cannot yet produce/consume it. Tests: 463 passing (was 380); adds test_stdlib_extensions.py, test_introspection.py and test_cel_014_behaviour.py. --- CHANGELOG.md | 75 +++ Cargo.lock | 12 +- Cargo.toml | 4 +- README.md | 41 +- docs/index.md | 4 +- docs/reference/introspection.md | 82 ++++ docs/reference/python-api.md | 10 +- docs/reference/standard-library.md | 212 +++++++++ mkdocs.yml | 2 + python/cel/cel.pyi | 26 + python/cel/cli.py | 18 +- python/cel/stdlib.py | 662 ++++++++++++++++++++++++-- src/lib.rs | 315 +++++++++--- tests/test_cel_014_behaviour.py | 96 ++++ tests/test_compile.py | 4 +- tests/test_enhanced_error_handling.py | 4 +- tests/test_functions.py | 44 +- tests/test_introspection.py | 66 +++ tests/test_stdlib_extensions.py | 301 ++++++++++++ tests/test_upstream_improvements.py | 11 +- 20 files changed, 1833 insertions(+), 156 deletions(-) create mode 100644 docs/reference/introspection.md create mode 100644 docs/reference/standard-library.md create mode 100644 tests/test_cel_014_behaviour.py create mode 100644 tests/test_introspection.py create mode 100644 tests/test_stdlib_extensions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c837981..ada1fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-07-04 + +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 + +- Updated cel-rust from 0.13.0 to 0.14.0. + +### Added + +- **Extended standard library** (`cel.stdlib`): opt-in function libraries that + mirror [cel-go](https://github.com/google/cel-go)'s extensions and fill gaps + in cel-rust's built-ins. Register them with + `cel.stdlib.add_stdlib_to_context(context)` (all libraries) or pass + `extensions=[...]` to select individual ones. The CLI enables them + automatically. Libraries: + - `core`: `bool`, `dyn`, `type`, `min`, `max` + - `strings`: `charAt`, `indexOf`, `lastIndexOf`, `substring`, `replace`, + `split`, `join`, `lowerAscii`, `upperAscii`, `trim`, `reverse`, + `strings.quote` + - `math`: `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 the `math.bit*` operations + - `sets`: `sets.contains`, `sets.equivalent`, `sets.intersects` + - `encoders`: `base64.encode`, `base64.decode` + - `lists`: `contains`, `distinct`, `flatten`, `slice`, `sort`, `reverse`, + `first`, `last`, `lists.range` +- **Expression static analysis** on `Program`: `variables()`, `functions()` + and `references()` report the identifiers an expression uses without + evaluating it. `Program.source` exposes the original CEL text. +- **Method-call syntax for custom functions**: a registered function can now be + called either as `f(x, y)` or as `x.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)`). + +### Changed + +- **Behaviour change** (cel 0.14): `contains` is now a **string-only** built-in. + `[1, 2, 3].contains(2)` and `{...}.contains(k)` no longer resolve to a + built-in β€” use the `in` operator (`2 in [1, 2, 3]`, `"k" in m`) for list/map + membership, or enable the `lists` extension in `cel.stdlib` which restores a + multi-type `contains`. +- **Behaviour change** (cel 0.14): `min` and `max` are no longer part of the + core standard library. They are provided by the `core` extension in + `cel.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 `double` will not override the built-in `double()` + 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`). + +### Performance + +- 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 every `evaluate()`/`Program.execute()` call. + +### Notes + +- **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 upstream `cel` Rust + 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. + ## [0.6.0] - 2026-05-12 ### Updated diff --git a/Cargo.lock b/Cargo.lock index 0512dc8..88ce7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,9 +96,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" @@ -112,9 +112,9 @@ dependencies = [ [[package]] name = "cel" -version = "0.6.0" +version = "0.7.0" dependencies = [ - "cel 0.13.0", + "cel 0.14.0", "chrono", "log", "pyo3", @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "cel" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a40f338a8c3505921000b609279775792c07cc21f97a3011578c0c5e1738ae" +checksum = "6ed39583e427bf41d93c28c7f27c943a93bcb8697220ff3575fd53a9e13f3814" dependencies = [ "antlr4rust", "base64", diff --git a/Cargo.toml b/Cargo.toml index c2edf58..3451e15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cel" -version = "0.6.0" +version = "0.7.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -10,7 +10,7 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.27", features = ["chrono", "py-clone"]} -cel = { version = "0.13.0", features = ["chrono", "json", "regex", "bytes"] } +cel = { version = "0.14.0", features = ["chrono", "json", "regex", "bytes"] } log = "0.4.27" pyo3-log = { git = "https://github.com/a1phyr/pyo3-log.git", branch = "pyo3_0.27" } chrono = { version = "0.4.42", features = ["serde"] } diff --git a/README.md b/README.md index 9b6f446..5536c7c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Fast, Safe, and Expressive evaluation of Google's Common Expression Language (CEL) in Python, powered by Rust.** -The Common Expression Language (CEL) is a non-Turing complete language designed for simplicity, speed, and safety. This Python package wraps the Rust implementation [cel-interpreter](https://crates.io/crates/cel-interpreter) v0.10.0, providing microsecond-level expression evaluation with seamless Python integration. +The Common Expression Language (CEL) is a non-Turing complete language designed for simplicity, speed, and safety. This Python package wraps the Rust implementation [cel](https://crates.io/crates/cel) v0.14.0, providing microsecond-level expression evaluation with seamless Python integration. ## πŸš€ Use Cases @@ -121,11 +121,44 @@ access_granted = evaluate(policy, context) # True ## Features - βœ… **Fast Evaluation**: Microsecond-level expression evaluation via Rust -- βœ… **Rich Type System**: Integers, floats, strings, lists, maps, timestamps, durations -- βœ… **Python Integration**: Seamless type conversion and custom function support +- βœ… **Rich Type System**: Integers, floats, strings, lists, maps, timestamps, durations, bytes, optionals +- βœ… **Python Integration**: Seamless type conversion and custom function support (callable as `f(x)` or `x.f()`) +- βœ… **Extended Standard Library**: Optional `strings`, `math`, `sets`, `encoders` and `lists` extensions that mirror [cel-go](https://github.com/google/cel-go) (see `cel.stdlib`) +- βœ… **Static Analysis**: Inspect the variables and functions an expression references before running it (`Program.references()`) - βœ… **CLI Tools**: Interactive REPL and batch processing capabilities - βœ… **Safety First**: Non-Turing complete, safe for untrusted expressions +### Expression introspection + +```python +import cel + +program = cel.compile("resource.owner == user.id && size(roles) > 0") +program.variables() # ['resource', 'roles', 'user'] +program.functions() # ['_&&_', '_==_', '_>_', 'size'] +``` + +### Extended standard library + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) # adds strings, math, sets, encoders, lists + +cel.evaluate('"Hello World".lowerAscii()', ctx) # 'hello world' +cel.evaluate("math.greatest([3, 1, 2])", ctx) # 3 +cel.evaluate("[1, 2, 3].contains(2)", ctx) # True +cel.evaluate('base64.encode(b"hi")', ctx) # 'aGk=' +``` + +> **Portability note:** CEL has no portable "bytecode". Cross-implementation +> interchange in the CEL ecosystem uses the protobuf AST (`cel.expr.Expr` / +> `CheckedExpr`), which the upstream `cel` Rust crate does not yet produce or +> consume. The portable artifact for this library is the CEL **source string**; +> use `Program.references()` for static analysis. See the docs for details. + ## Documentation πŸ“š **Complete documentation available at**: https://python-common-expression-language.readthedocs.io/ @@ -192,4 +225,4 @@ This project is licensed under the same terms as the original cel-interpreter cr - [πŸ“– **Documentation**](https://python-common-expression-language.readthedocs.io/) - [🌐 **CEL Homepage**](https://cel.dev/) - [πŸ“‹ **CEL Specification**](https://github.com/google/cel-spec) -- [βš™οΈ **cel-interpreter Rust crate**](https://crates.io/crates/cel-interpreter) +- [βš™οΈ **cel Rust crate**](https://crates.io/crates/cel) diff --git a/docs/index.md b/docs/index.md index 53c6417..9de6ab4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -114,10 +114,10 @@ Built on Rust with PyO3 - evaluate expressions in **microseconds**, not millisec Safe by Design: Built on a memory-safe Rust core. The non-Turing complete nature of CEL prevents infinite loops, and comprehensive error handling traps evaluation errors as Python exceptions. ### 🎯 **Production Ready** -380+ tests, comprehensive CLI, type safety, and high CEL spec compliance. +450+ tests, comprehensive CLI, type safety, and high CEL spec compliance. ### πŸš€ **Up to Date** -Built on cel-rust 0.13 β€” tracks upstream improvements in correctness and performance. +Built on cel-rust 0.14 β€” tracks upstream improvements in correctness and performance. ### πŸ”§ **Developer Friendly** Dual interfaces (Python API + CLI), rich error messages, extensive documentation, and full IDE support. diff --git a/docs/reference/introspection.md b/docs/reference/introspection.md new file mode 100644 index 0000000..7c61be9 --- /dev/null +++ b/docs/reference/introspection.md @@ -0,0 +1,82 @@ +# Expression Introspection + +`compile()` returns a `Program` that can report which variables and functions +an expression references, without evaluating it. This is useful for validating +that a context supplies everything an expression needs, building autocomplete, +or rejecting expressions that touch disallowed identifiers. + +## `Program.variables()` + +Returns the sorted list of variable names the expression reads: + +```python +import cel + +program = cel.compile("user.age >= min_age && size(roles) > 0") +assert program.variables() == ["min_age", "roles", "user"] +``` + +Only the *root* identifier of a field access is a variable β€” `user.age` +references `user`, not `age`. Names bound by comprehension macros are reported +as variables because they appear as identifiers: + +```python +import cel + +program = cel.compile("[1, 2, 3].map(x, x * 2)") +assert "x" in program.variables() +``` + +## `Program.functions()` + +Returns the sorted list of function and operator names. Operators are reported +using their canonical CEL overload identifiers (e.g. `_>_`, `_&&_`): + +```python +import cel + +program = cel.compile("size(items) > 0") +functions = program.functions() +assert "size" in functions +assert "_>_" in functions +``` + +## `Program.references()` + +Returns both lists together: + +```python +import cel + +program = cel.compile("price * quantity > threshold") +refs = program.references() +assert refs == { + "variables": ["price", "quantity", "threshold"], + "functions": ["_*_", "_>_"], +} +``` + +## Validating a context + +```python +import cel + +program = cel.compile("price * quantity") +context = {"price": 10, "quantity": 3} + +missing = [name for name in program.variables() if name not in context] +assert missing == [] +assert program.execute(context) == 30 +``` + +## Portability + +CEL has no portable "bytecode". Cross-implementation interchange in the CEL +ecosystem is done with the protobuf AST (`cel.expr.Expr` and the type-checked +`CheckedExpr`), which the upstream `cel` Rust crate does not currently produce +or consume β€” it has no protobuf support and no separate type-checking phase. + +As a result, a compiled `Program` cannot be serialized and shipped to another +CEL implementation. The portable artifact for this library is the CEL **source +string**, which is standardized and trivially cross-language. Use +`Program.references()` when you need static analysis of an expression. diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index 2177648..bb9892b 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -528,10 +528,10 @@ from cel import evaluate # String + int operations raise TypeError try: evaluate('"hello" + 42') # String + int - # β†’ TypeError: No such overload (or Unsupported addition operation, depending on operand order) + # β†’ TypeError: Unsupported operation: string + int (or "No such overload", depending on operand order) assert False, "Should have raised TypeError" except TypeError as e: - assert "overload" in str(e).lower() or "Unsupported addition operation" in str(e) + assert "overload" in str(e).lower() or "unsupported operation" in str(e).lower() # Mixed signed/unsigned int operations raise TypeError try: @@ -544,10 +544,10 @@ except TypeError as e: # Unsupported multiplication raises TypeError try: evaluate('"text" * "more"') # String multiplication - # β†’ TypeError: No such overload (or Unsupported multiplication operation) + # β†’ TypeError: Unsupported operation: string * string (or "No such overload") assert False, "Should have raised TypeError" except TypeError as e: - assert "overload" in str(e).lower() or "Unsupported multiplication operation" in str(e) + assert "overload" in str(e).lower() or "unsupported operation" in str(e).lower() ``` #### Mixed Type Arithmetic Errors @@ -561,7 +561,7 @@ from cel import evaluate try: evaluate("1 + 2.5") # int + double except TypeError as e: - assert "overload" in str(e).lower() or "Unsupported addition operation" in str(e) + assert "overload" in str(e).lower() or "unsupported operation" in str(e).lower() print(f"Mixed arithmetic error: {e}") # Mixed types from context diff --git a/docs/reference/standard-library.md b/docs/reference/standard-library.md new file mode 100644 index 0000000..ea49798 --- /dev/null +++ b/docs/reference/standard-library.md @@ -0,0 +1,212 @@ +# Extended Standard Library + +The Rust `cel` crate implements the CEL core specification: the +`has`/`all`/`exists`/`exists_one`/`map`/`filter` macros, the +`int`/`uint`/`double`/`string`/`bytes`/`timestamp`/`duration` conversions, +`size`, and the string predicates `contains`/`startsWith`/`endsWith`/`matches`. + +`cel.stdlib` adds the functions that the core crate does *not* ship, grouped +into libraries that mirror [cel-go](https://github.com/google/cel-go)'s +extension libraries. These functions are **opt-in**: `evaluate()` and +`compile()` expose only the Rust-native standard library by default. + +## Enabling the extensions + +Register the functions on a [`Context`][cel.Context] and pass that context to +`evaluate()` (or `Program.execute()`): + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate('"Hello World".lowerAscii()', ctx) == "hello world" +assert cel.evaluate("math.greatest([3, 1, 2])", ctx) == 3 +``` + +To register only some libraries, pass their names: + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx, extensions=["math", "lists"]) + +assert cel.evaluate("[1, 1, 2, 3].distinct()", ctx) == [1, 2, 3] +``` + +The `cel` command-line tool enables every extension automatically. + +## Calling convention + +Because CEL treats `x.f(a)` as sugar for `f(x, a)`, every function below can be +called either as a method or as a free function. Namespaced functions such as +`math.greatest` use their dotted name. + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +# Method and free-function forms are equivalent. +assert cel.evaluate('"hello".charAt(0)', ctx) == "h" +assert cel.evaluate('charAt("hello", 0)', ctx) == "h" +``` + +## Libraries + +### core + +`bool`, `dyn`, `type`, `min`, `max`. + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate('bool("true")', ctx) is True +assert cel.evaluate("dyn(5)", ctx) == 5 +assert cel.evaluate("type(1) == type(2)", ctx) is True +assert cel.evaluate("min(3, 1, 2)", ctx) == 1 +assert cel.evaluate("max([4, 9, 2])", ctx) == 9 +``` + +!!! note "`type()` limitations" + `type(x)` returns the CEL type *name* as a string (e.g. `"int"`), so + `type(x) == type(y)` works but comparing against a bare type identifier + (`type(x) == int`) does not. Because Python has a single integer type, a CEL + `uint` is reported as `"int"`. + +### strings + +`charAt`, `indexOf`, `lastIndexOf`, `substring`, `replace`, `split`, `join`, +`lowerAscii`, `upperAscii`, `trim`, `reverse`, `strings.quote`. + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate('"hello world".indexOf("o")', ctx) == 4 +assert cel.evaluate('"a,b,c".split(",")', ctx) == ["a", "b", "c"] +assert cel.evaluate('["a", "b", "c"].join("-")', ctx) == "a-b-c" +assert cel.evaluate('" padded ".trim()', ctx) == "padded" +assert cel.evaluate('"abc".reverse()', ctx) == "cba" +``` + +### math + +`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 the bit operations `math.bitOr`, +`math.bitAnd`, `math.bitXor`, `math.bitNot`, `math.bitShiftLeft`, +`math.bitShiftRight`. + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate("math.abs(-7)", ctx) == 7 +assert cel.evaluate("math.round(2.5)", ctx) == 3.0 # half away from zero +assert cel.evaluate("math.sqrt(16.0)", ctx) == 4.0 +assert cel.evaluate("math.bitOr(5, 2)", ctx) == 7 +``` + +### sets + +`sets.contains`, `sets.equivalent`, `sets.intersects`. + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate("sets.contains([1, 2, 3], [1, 2])", ctx) is True +assert cel.evaluate("sets.equivalent([1, 2], [2, 1])", ctx) is True +assert cel.evaluate("sets.intersects([1, 2], [2, 3])", ctx) is True +``` + +### encoders + +`base64.encode`, `base64.decode`. + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate('base64.encode(b"hello")', ctx) == "aGVsbG8=" +assert cel.evaluate('base64.decode("aGVsbG8=")', ctx) == b"hello" +``` + +### lists + +`contains`, `distinct`, `flatten`, `slice`, `sort`, `reverse`, `first`, `last`, +`lists.range`. + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate("[1, 2, 3].contains(2)", ctx) is True +assert cel.evaluate("[1, 1, 2, 3].distinct()", ctx) == [1, 2, 3] +assert cel.evaluate("[[1, 2], [3]].flatten()", ctx) == [1, 2, 3] +assert cel.evaluate("[3, 1, 2].sort()", ctx) == [1, 2, 3] +assert cel.evaluate("lists.range(3)", ctx) == [0, 1, 2] +``` + +`first` and `last` return CEL optional values, so combine them with the +optional member methods: + +```python +import cel +from cel.stdlib import add_stdlib_to_context + +ctx = cel.Context() +add_stdlib_to_context(ctx) + +assert cel.evaluate("[10, 20, 30].first().value()", ctx) == 10 +assert cel.evaluate("[].first().orValue(-1)", ctx) == -1 +``` + +!!! info "`contains` and the built-in string overload" + The Rust core provides a string-only `contains`. The `lists` extension adds + a multi-type `contains` for lists and maps, but the built-in string overload + still takes precedence for `string.contains(string)`. + +## Registering your own functions + +`cel.stdlib` is built on the ordinary custom-function API, so you can register +your own functions the same way. A function registered under one name is +callable both as `f(x)` and `x.f()`: + +```python +import cel + +ctx = cel.Context() +ctx.add_function("shout", lambda s: s.upper() + "!") + +assert cel.evaluate('"hi".shout()', ctx) == "HI!" +assert cel.evaluate('shout("hi")', ctx) == "HI!" +``` + +See [Extending CEL](../tutorials/extending-cel.md) for more. diff --git a/mkdocs.yml b/mkdocs.yml index db31046..254f3c1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,8 @@ nav: - CLI Usage Recipes: how-to-guides/cli-recipes.md - Reference: - Python API: reference/python-api.md + - Extended Standard Library: reference/standard-library.md + - Expression Introspection: reference/introspection.md - CLI Reference: reference/cli-reference.md - Development: - Contributing & Developer Guide: contributing.md diff --git a/python/cel/cel.pyi b/python/cel/cel.pyi index ab258fd..ab33998 100644 --- a/python/cel/cel.pyi +++ b/python/cel/cel.pyi @@ -41,10 +41,36 @@ class Context: class Program: """Compiled CEL program that can be executed multiple times.""" + @property + def source(self) -> str: + """The original CEL source this program was compiled from.""" + ... + def execute(self, context: Optional[Union[Dict[str, Any], Context]] = None) -> Any: """Execute the compiled program with an optional context.""" ... + def variables(self) -> list[str]: + """Return the sorted variable names this expression references. + + Performs static analysis without evaluating the expression. Names bound + by comprehension macros (e.g. the ``x`` in ``[1, 2].map(x, x * 2)``) are + included, since they appear as identifiers. + """ + ... + + def functions(self) -> list[str]: + """Return the sorted function/operator names this expression references. + + Includes named functions (``size``) and CEL operator overload + identifiers for operators used in the expression (e.g. ``_+_``). + """ + ... + + def references(self) -> Dict[str, list[str]]: + """Return ``{"variables": [...], "functions": [...]}`` for this expression.""" + ... + def compile(expression: str) -> Program: """Compile a CEL expression into a reusable Program object.""" ... diff --git a/python/cel/cli.py b/python/cel/cli.py index 383f5f3..22ea1e1 100644 --- a/python/cel/cli.py +++ b/python/cel/cli.py @@ -39,7 +39,7 @@ # Import directly from relative modules to avoid circular imports from .cel import Context, evaluate -from .stdlib import add_stdlib_to_context +from .stdlib import STDLIB_FUNCTIONS, add_stdlib_to_context # Initialize Rich console console = Console() @@ -74,8 +74,10 @@ class CELLexer(RegexLexer): (r"\b(in|if|else|and|or|not)\b", token.Keyword), # Built-in functions ( - r"\b(size|has|timestamp|duration|int|uint|double|string|bytes|" - r"startsWith|endsWith|contains|matches|substring)\b(?=\()", + r"\b(size|has|timestamp|duration|int|uint|double|string|bytes|bool|dyn|type|" + r"startsWith|endsWith|contains|matches|substring|charAt|indexOf|lastIndexOf|" + r"replace|split|join|lowerAscii|upperAscii|trim|reverse|min|max|" + r"distinct|flatten|slice|sort|first|last)\b(?=\()", token.Name.Function, ), # String literals @@ -245,7 +247,15 @@ def __init__(self, evaluator: CELEvaluator, history_limit: int = 10): "double", "string", "bytes", - "substring", # stdlib function + # Extended standard-library functions (registered by the CLI). + # Simple names only; namespaced helpers (math.*, sets.*, base64.*, + # lists.*) are surfaced via the namespace prefixes below. + *sorted(name for name in STDLIB_FUNCTIONS if "." not in name), + "math", + "sets", + "base64", + "lists", + "strings", ] # Command dispatch dictionary for cleaner organization diff --git a/python/cel/stdlib.py b/python/cel/stdlib.py index 85ec5fa..ce8d4bd 100644 --- a/python/cel/stdlib.py +++ b/python/cel/stdlib.py @@ -1,63 +1,651 @@ -""" -Standard library functions for CEL that aren't available in cel-rust. +"""Extended standard-library functions for CEL. + +The Rust ``cel`` crate that powers this package implements the CEL core +specification (the ``has``/``all``/``exists``/``exists_one``/``map``/``filter`` +macros, the ``int``/``uint``/``double``/``string``/``bytes``/``timestamp``/ +``duration`` conversions, ``size``, and the string predicates +``contains``/``startsWith``/``endsWith``/``matches``). It does **not** ship a +number of functions that +other CEL implementations β€” notably `cel-go `_ +and its extension libraries β€” make available. + +This module fills those gaps with pure-Python implementations, registered as +ordinary CEL functions. They are grouped into libraries that mirror cel-go: + +========== ================================================================ +Library Functions +========== ================================================================ +core ``bool``, ``dyn``, ``type``, ``min``, ``max`` +strings ``charAt``, ``indexOf``, ``lastIndexOf``, ``substring``, + ``replace``, ``split``, ``join``, ``lowerAscii``, ``upperAscii``, + ``trim``, ``reverse``, ``strings.quote`` +math ``math.greatest``, ``math.least``, ``math.abs``, ``math.sign``, + ``math.ceil``, ``math.floor``, ``math.round``, ``math.trunc``, + ``math.isNaN``, ``math.isInf``, ``math.isFinite``, ``math.sqrt``, + ``math.bitOr``, ``math.bitAnd``, ``math.bitXor``, ``math.bitNot``, + ``math.bitShiftLeft``, ``math.bitShiftRight`` +sets ``sets.contains``, ``sets.equivalent``, ``sets.intersects`` +encoders ``base64.encode``, ``base64.decode`` +lists ``contains``, ``distinct``, ``flatten``, ``slice``, ``sort``, + ``reverse``, ``first``, ``last``, ``lists.range`` +========== ================================================================ + +Because CEL treats ``x.f(a)`` as sugar for ``f(x, a)``, every function here can +be called either as a method (``"hello".charAt(1)``) or as a free function +(``charAt("hello", 1)``). Namespaced functions such as ``math.greatest`` are +called with their dotted name. -This module provides Python implementations of CEL standard library functions -that are missing from the upstream cel-rust implementation. +These functions are **opt-in**: :func:`cel.evaluate` and :func:`cel.compile` +expose only the Rust-native standard library by default. Use +:func:`add_stdlib_to_context` (or the ``cel`` command-line tool, which enables +them automatically) to make them available. + +Compatibility notes / known limitations versus cel-go: + +* ``type(x)`` returns the CEL type *name* as a string (e.g. ``"int"``) rather + than a first-class CEL type value, so ``type(x) == type(y)`` works but + comparing against a bare type identifier (``type(x) == int``) does not. + Because Python has a single ``int`` type, a CEL ``uint`` is reported as + ``"int"``. +* The cel-go ``strings.format`` and ``strings.quote`` verbs are only partially + covered: ``strings.quote`` performs CEL-style escaping but ``strings.format`` + is not implemented. +* ``distinct``/``sort`` and the ``sets`` functions compare elements with + Python equality, which treats ``True == 1`` and ``False == 0``. Lists that + mix booleans with the integers ``0``/``1`` may therefore behave differently + from a spec-strict CEL implementation, which keeps the types distinct. """ -from typing import Any +from __future__ import annotations +import base64 as _base64 +import math as _math +from datetime import datetime, timedelta +from typing import Any, Callable -def substring(s: str, start: int, end: int | None = None) -> str: +from .cel import OptionalValue + +# --------------------------------------------------------------------------- +# Core specification functions missing from cel-rust +# --------------------------------------------------------------------------- + +# String spellings cel-go accepts for bool() conversion. +_BOOL_TRUE = {"1", "t", "true", "TRUE", "True"} +_BOOL_FALSE = {"0", "f", "false", "FALSE", "False"} + + +def bool_(value: Any) -> bool: + """Convert a value to a boolean, following CEL's ``bool()`` conversion. + + Booleans pass through unchanged. Strings are converted using the set of + spellings CEL recognises (``"1"``, ``"t"``, ``"true"``, ``"TRUE"``, + ``"True"`` are true; ``"0"``, ``"f"``, ``"false"``, ``"FALSE"``, ``"False"`` + are false). Any other value raises ``ValueError``. """ - Extract a substring from a string. + if isinstance(value, bool): + return value + if isinstance(value, str): + if value in _BOOL_TRUE: + return True + if value in _BOOL_FALSE: + return False + raise ValueError(f"cannot convert string {value!r} to bool") + raise ValueError(f"cannot convert {type(value).__name__} to bool") - This implements the CEL substring() function which is not yet available - in cel-rust upstream. See https://github.com/cel-rust/cel-rust/issues/200 - Args: - s: The source string - start: Starting index (0-based, inclusive) - end: Optional ending index (0-based, exclusive). If not provided, - extracts to the end of the string. - - Returns: - The extracted substring - - Examples: - >>> substring("hello world", 0, 5) - 'hello' - >>> substring("hello world", 6) - 'world' - >>> substring("hello", 1, 4) - 'ell' +def dyn(value: Any) -> Any: + """Return the value unchanged. + + CEL's ``dyn()`` erases static type information; at runtime it is the + identity function. + """ + return value + + +def type_(value: Any) -> str: + """Return the CEL type name of a value as a string. + + See the module docstring for the limitations of this shim (notably that it + returns a string rather than a CEL type value, and cannot distinguish + ``uint`` from ``int``). + """ + if value is None: + return "null" + if isinstance(value, OptionalValue): + return "optional_type" + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "double" + if isinstance(value, str): + return "string" + if isinstance(value, (bytes, bytearray)): + return "bytes" + if isinstance(value, datetime): + return "timestamp" + if isinstance(value, timedelta): + return "duration" + if isinstance(value, (list, tuple)): + return "list" + if isinstance(value, dict): + return "map" + return type(value).__name__ + + +def _collect_numbers(args: tuple[Any, ...]) -> list[Any]: + """Normalise ``greatest``/``least``/``min``/``max`` arguments to a list. + + Accepts either a single list argument or several scalar arguments. + """ + if len(args) == 1 and isinstance(args[0], (list, tuple)): + items = list(args[0]) + else: + items = list(args) + if not items: + raise ValueError("at least one argument is required") + return items + + +def min_(*args: Any) -> Any: + """Return the smallest of the arguments (or of a single list argument).""" + return min(_collect_numbers(args)) + + +def max_(*args: Any) -> Any: + """Return the largest of the arguments (or of a single list argument).""" + return max(_collect_numbers(args)) + + +# --------------------------------------------------------------------------- +# strings extension (mirrors cel-go's ext.Strings) +# --------------------------------------------------------------------------- + + +def char_at(s: str, index: int) -> str: + """Return the character at ``index``. ``index == len(s)`` yields ``""``.""" + if index == len(s): + return "" + if index < 0 or index > len(s): + raise IndexError(f"charAt: index {index} out of range for string of length {len(s)}") + return s[index] + + +def index_of(s: str, substr: str, offset: int = 0) -> int: + """Return the index of the first occurrence of ``substr`` at or after ``offset`` (or -1). + + ``offset`` must be within ``[0, len(s)]`` (cel-go raises otherwise). + """ + if offset < 0 or offset > len(s): + raise IndexError(f"indexOf: offset {offset} out of range for string of length {len(s)}") + return s.find(substr, offset) + + +def last_index_of(s: str, substr: str, offset: int | None = None) -> int: + """Return the index of the last occurrence of ``substr`` (or -1). + + When ``offset`` is given (which must be within ``[0, len(s)]``), only + occurrences starting at or before ``offset`` are considered. """ + if offset is None: + return s.rfind(substr) + if offset < 0 or offset > len(s): + raise IndexError(f"lastIndexOf: offset {offset} out of range for string of length {len(s)}") + return s.rfind(substr, 0, offset + len(substr)) + + +def substring(s: str, start: int, end: int | None = None) -> str: + """Extract a substring from ``start`` (inclusive) to ``end`` (exclusive). + + Indices must satisfy ``0 <= start <= end <= len(s)`` (cel-go raises on + out-of-range or reversed indices rather than clamping). + """ + length = len(s) if end is None: - return s[start:] + end = length + if start < 0 or end > length or start > end: + raise IndexError(f"substring: [{start}:{end}] out of range for string of length {length}") return s[start:end] -# Dictionary mapping function names to their implementations -# This makes it easy to add all stdlib functions to a Context at once -STDLIB_FUNCTIONS = { - "substring": substring, +def replace(s: str, old: str, new: str, limit: int = -1) -> str: + """Replace occurrences of ``old`` with ``new``. + + ``limit`` bounds the number of replacements; a negative ``limit`` (the + default) replaces every occurrence. + """ + if limit < 0: + return s.replace(old, new) + return s.replace(old, new, limit) + + +def split(s: str, sep: str, limit: int | None = None) -> list[str]: + """Split ``s`` on ``sep``. + + ``limit`` bounds the number of returned pieces: ``limit`` of 0 returns an + empty list, a negative ``limit`` returns all pieces, and a positive + ``limit`` returns at most ``limit`` pieces. + """ + # An empty separator splits into individual characters (matching cel-go); + # Python's str.split("") raises instead. + if sep == "": + chars = list(s) + if limit is None or limit < 0 or limit >= len(chars): + return chars + if limit == 0: + return [] + return chars[: limit - 1] + ["".join(chars[limit - 1 :])] + if limit is None or limit < 0: + return s.split(sep) + if limit == 0: + return [] + return s.split(sep, limit - 1) + + +def join(items: list[Any], sep: str = "") -> str: + """Join a list of strings with ``sep``.""" + return sep.join(items) + + +def lower_ascii(s: str) -> str: + """Lowercase the ASCII letters in ``s``, leaving other characters unchanged.""" + return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in s) + + +def upper_ascii(s: str) -> str: + """Uppercase the ASCII letters in ``s``, leaving other characters unchanged.""" + return "".join(chr(ord(c) - 32) if "a" <= c <= "z" else c for c in s) + + +def trim(s: str) -> str: + """Remove leading and trailing whitespace from ``s``.""" + return s.strip() + + +_QUOTE_ESCAPES = { + "\\": "\\\\", + '"': '\\"', + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\x07": "\\a", + "\x08": "\\b", + "\x0c": "\\f", + "\x0b": "\\v", } -def add_stdlib_to_context(context: Any) -> None: +def quote(s: str) -> str: + """Return ``s`` as a double-quoted CEL string literal with escapes applied.""" + escaped = "".join(_QUOTE_ESCAPES.get(c, c) for c in s) + return f'"{escaped}"' + + +def reverse(value: Any) -> Any: + """Reverse a string or a list.""" + if isinstance(value, str): + return value[::-1] + if isinstance(value, (list, tuple)): + return list(value)[::-1] + raise TypeError(f"reverse: unsupported type {type(value).__name__}") + + +# --------------------------------------------------------------------------- +# math extension (mirrors cel-go's ext.Math, namespaced under ``math.``) +# --------------------------------------------------------------------------- + + +def math_greatest(*args: Any) -> Any: + """Return the greatest argument (or greatest element of a single list).""" + return max(_collect_numbers(args)) + + +def math_least(*args: Any) -> Any: + """Return the least argument (or least element of a single list).""" + return min(_collect_numbers(args)) + + +def math_abs(x: Any) -> Any: + """Return the absolute value of ``x``, preserving int/double.""" + return abs(x) + + +def math_sign(x: Any) -> Any: + """Return -1, 0 or 1 with the sign of ``x``, preserving int/double.""" + if isinstance(x, float): + if x > 0: + return 1.0 + if x < 0: + return -1.0 + return 0.0 + return (x > 0) - (x < 0) + + +def math_ceil(x: float) -> float: + """Return the ceiling of ``x`` as a double (NaN/Β±inf pass through).""" + if not _math.isfinite(x): + return x + return float(_math.ceil(x)) + + +def math_floor(x: float) -> float: + """Return the floor of ``x`` as a double (NaN/Β±inf pass through).""" + if not _math.isfinite(x): + return x + return float(_math.floor(x)) + + +def math_round(x: float) -> float: + """Return ``x`` rounded to the nearest integer, halves away from zero. + + NaN and Β±inf pass through unchanged. + """ + if not _math.isfinite(x): + return x + floor = _math.floor(x) + frac = x - floor + if frac < 0.5: + return float(floor) + if frac > 0.5: + return float(floor + 1) + # Exactly halfway: round away from zero. + return float(floor + 1) if x > 0 else float(floor) + + +def math_trunc(x: float) -> float: + """Return ``x`` truncated toward zero, as a double (NaN/Β±inf pass through).""" + if not _math.isfinite(x): + return x + return float(_math.trunc(x)) + + +def math_is_nan(x: float) -> bool: + """Return whether ``x`` is NaN.""" + return _math.isnan(x) + + +def math_is_inf(x: float) -> bool: + """Return whether ``x`` is positive or negative infinity.""" + return _math.isinf(x) + + +def math_is_finite(x: float) -> bool: + """Return whether ``x`` is neither NaN nor infinite.""" + return _math.isfinite(x) + + +def math_sqrt(x: Any) -> float: + """Return the square root of ``x`` as a double (NaN for negative inputs).""" + if x < 0: + return _math.nan + return _math.sqrt(x) + + +_U64_MASK = (1 << 64) - 1 + + +def _wrap_i64(n: int) -> int: + """Wrap an integer into the signed 64-bit range (two's complement). + + CEL integers are 64-bit; bit operations therefore wrap rather than growing + without bound (which would silently be coerced to a double when converted + back to a CEL value). + """ + n &= _U64_MASK + return n - (1 << 64) if n >= (1 << 63) else n + + +def math_bit_or(a: int, b: int) -> int: + """Bitwise OR of two integers.""" + return _wrap_i64(a | b) + + +def math_bit_and(a: int, b: int) -> int: + """Bitwise AND of two integers.""" + return _wrap_i64(a & b) + + +def math_bit_xor(a: int, b: int) -> int: + """Bitwise XOR of two integers.""" + return _wrap_i64(a ^ b) + + +def math_bit_not(a: int) -> int: + """Bitwise NOT of an integer.""" + return _wrap_i64(~a) + + +def math_bit_shift_left(a: int, n: int) -> int: + """Left-shift ``a`` by ``n`` bits (result wraps to 64 bits).""" + if n < 0: + raise ValueError("negative shift amount") + if n >= 64: + return 0 + return _wrap_i64(a << n) + + +def math_bit_shift_right(a: int, n: int) -> int: + """Logically right-shift ``a`` by ``n`` bits (zero-fill, 64-bit).""" + if n < 0: + raise ValueError("negative shift amount") + if n >= 64: + return 0 + return _wrap_i64((a & _U64_MASK) >> n) + + +# --------------------------------------------------------------------------- +# sets extension (mirrors cel-go's ext.Sets, namespaced under ``sets.``) +# --------------------------------------------------------------------------- + + +def sets_contains(container: list[Any], sublist: list[Any]) -> bool: + """Return whether every element of ``sublist`` appears in ``container``.""" + return all(item in container for item in sublist) + + +def sets_equivalent(a: list[Any], b: list[Any]) -> bool: + """Return whether ``a`` and ``b`` contain the same set of elements.""" + return all(item in b for item in a) and all(item in a for item in b) + + +def sets_intersects(a: list[Any], b: list[Any]) -> bool: + """Return whether ``a`` and ``b`` share at least one element.""" + return any(item in b for item in a) + + +# --------------------------------------------------------------------------- +# encoders extension (mirrors cel-go's ext.Encoders, namespaced under ``base64.``) +# --------------------------------------------------------------------------- + + +def base64_encode(data: bytes) -> str: + """Base64-encode bytes, returning a string.""" + if isinstance(data, str): + data = data.encode("utf-8") + return _base64.b64encode(data).decode("ascii") + + +def base64_decode(data: str) -> bytes: + """Base64-decode a string, returning bytes. + + Validation is strict: non-alphabet characters raise rather than being + silently discarded. """ - Add all stdlib functions to a CEL Context. + return _base64.b64decode(data, validate=True) + + +# --------------------------------------------------------------------------- +# lists extension (mirrors cel-go's ext.Lists) +# --------------------------------------------------------------------------- + + +def contains(container: Any, item: Any) -> bool: + """Return whether ``item`` is in ``container`` (list, map or string). + + This restores the multi-type ``contains`` that cel-rust 0.14 dropped: its + built-in ``contains`` is string-only, so this shim handles lists and maps + (for maps, membership tests the keys). The built-in string overload still + takes precedence for ``string.contains(string)``. + """ + return item in container + + +def distinct(items: list[Any]) -> list[Any]: + """Return the elements of ``items`` with duplicates removed, order preserved.""" + result: list[Any] = [] + for item in items: + if item not in result: + result.append(item) + return result + + +def flatten(items: list[Any], depth: int = 1) -> list[Any]: + """Flatten nested lists up to ``depth`` levels (default 1).""" + if depth < 0: + raise ValueError("flatten: depth must be non-negative") + result: list[Any] = [] + for item in items: + if isinstance(item, (list, tuple)) and depth > 0: + result.extend(flatten(list(item), depth - 1)) + else: + result.append(item) + return result + + +def slice_(items: list[Any], start: int, end: int) -> list[Any]: + """Return the sub-list ``items[start:end]``.""" + if start < 0 or end > len(items) or start > end: + raise IndexError(f"slice: [{start}:{end}] out of range for list of length {len(items)}") + return items[start:end] + + +def sort(items: list[Any]) -> list[Any]: + """Return a new list with ``items`` sorted in ascending order.""" + return sorted(items) + + +def first(items: list[Any]) -> OptionalValue: + """Return the first element as an optional, or ``optional.none()`` if empty.""" + if items: + return OptionalValue.of(items[0]) + return OptionalValue.none() + + +def last(items: list[Any]) -> OptionalValue: + """Return the last element as an optional, or ``optional.none()`` if empty.""" + if items: + return OptionalValue.of(items[-1]) + return OptionalValue.none() + + +def lists_range(n: int) -> list[int]: + """Return the list ``[0, 1, ..., n - 1]``.""" + return list(range(n)) + + +# --------------------------------------------------------------------------- +# Registries +# --------------------------------------------------------------------------- + +#: Extension libraries, each a mapping of CEL function name -> implementation. +EXTENSIONS: dict[str, dict[str, Callable[..., Any]]] = { + "core": { + "bool": bool_, + "dyn": dyn, + "type": type_, + "min": min_, + "max": max_, + }, + "strings": { + "charAt": char_at, + "indexOf": index_of, + "lastIndexOf": last_index_of, + "substring": substring, + "replace": replace, + "split": split, + "join": join, + "lowerAscii": lower_ascii, + "upperAscii": upper_ascii, + "trim": trim, + "reverse": reverse, + "strings.quote": quote, + }, + "math": { + "math.greatest": math_greatest, + "math.least": math_least, + "math.abs": math_abs, + "math.sign": math_sign, + "math.ceil": math_ceil, + "math.floor": math_floor, + "math.round": math_round, + "math.trunc": math_trunc, + "math.isNaN": math_is_nan, + "math.isInf": math_is_inf, + "math.isFinite": math_is_finite, + "math.sqrt": math_sqrt, + "math.bitOr": math_bit_or, + "math.bitAnd": math_bit_and, + "math.bitXor": math_bit_xor, + "math.bitNot": math_bit_not, + "math.bitShiftLeft": math_bit_shift_left, + "math.bitShiftRight": math_bit_shift_right, + }, + "sets": { + "sets.contains": sets_contains, + "sets.equivalent": sets_equivalent, + "sets.intersects": sets_intersects, + }, + "encoders": { + "base64.encode": base64_encode, + "base64.decode": base64_decode, + }, + "lists": { + "contains": contains, + "distinct": distinct, + "flatten": flatten, + "slice": slice_, + "sort": sort, + "reverse": reverse, + "first": first, + "last": last, + "lists.range": lists_range, + }, +} + +#: All extended-stdlib functions merged into a single ``name -> callable`` map. +STDLIB_FUNCTIONS: dict[str, Callable[..., Any]] = { + name: func for library in EXTENSIONS.values() for name, func in library.items() +} + + +def add_stdlib_to_context(context: Any, extensions: list[str] | None = None) -> None: + """Register the extended standard-library functions on a CEL context. Args: - context: A cel.Context object + context: A :class:`cel.Context` to register functions on. + extensions: Optional list of extension library names to add (any of + ``"core"``, ``"strings"``, ``"math"``, ``"sets"``, ``"encoders"``, + ``"lists"``). When ``None`` (the default) every library is added. + + Raises: + KeyError: If an unknown extension name is requested. Example: >>> import cel >>> from cel.stdlib import add_stdlib_to_context >>> context = cel.Context() >>> add_stdlib_to_context(context) - >>> cel.evaluate('substring("hello", 0, 2)', context) - 'he' + >>> cel.evaluate('"hello".charAt(0)', context) + 'h' + >>> cel.evaluate("math.greatest([3, 1, 2])", context) + 3 """ - for name, func in STDLIB_FUNCTIONS.items(): - context.add_function(name, func) + if extensions is None: + libraries = list(EXTENSIONS.values()) + else: + libraries = [EXTENSIONS[name] for name in extensions] + + for library in libraries: + for name, func in library.items(): + context.add_function(name, func) diff --git a/src/lib.rs b/src/lib.rs index f6122e5..fc37b89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ mod context; use ::cel::context::VariableResolver; use ::cel::objects::{Key, OptionalValue, TryIntoValue}; -use ::cel::{Context as CelContext, ExecutionError, Program, Value}; +use ::cel::{Context as CelContext, Env, ExecutionError, FunctionContext, Program, Value}; use log::warn; use pyo3::exceptions::{ PyIndexError, PyKeyError, PyOverflowError, PyRuntimeError, PyTypeError, PyValueError, @@ -19,7 +19,28 @@ use pyo3::PyTypeInfo; use std::collections::HashMap; use std::error::Error; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; + +/// The CEL standard-library environment (built-in functions, overloads and +/// macros), built once and shared across every evaluation. +/// +/// `Context::default()` rebuilds `Env::stdlib()` on each call, which re-registers +/// every built-in overload. Since the standard library never changes, we build it +/// a single time and hand out cheap `Arc` clones via [`stdlib_env`], letting +/// `evaluate()` and `Program.execute()` reuse the same environment. User-supplied +/// variables and functions still live on the per-call `Context`, so this sharing +/// is safe and has no observable effect beyond being faster. +static STDLIB_ENV: LazyLock> = LazyLock::new(|| Arc::new(Env::stdlib())); + +/// Returns a cheap `Arc` clone of the shared standard-library [`Env`]. +fn stdlib_env() -> Arc { + STDLIB_ENV.clone() +} + +/// Builds a fresh execution environment backed by the shared standard library. +fn new_environment() -> CelContext<'static> { + CelContext::with_env(stdlib_env()) +} /// A compiled CEL program that can be executed multiple times with different contexts. /// @@ -59,6 +80,72 @@ impl PyProgram { execute_compiled_program(&self.program, &self.source, context) } + /// The variable names referenced by this expression. + /// + /// This performs static analysis of the compiled expression without + /// evaluating it, returning the names of the variables the expression + /// reads. Useful for validating that a context supplies everything an + /// expression needs, or for restricting which variables an expression may + /// touch. + /// + /// Note: names bound by comprehension macros (the ``x`` in + /// ``[1, 2].map(x, x * 2)``) are reported as variables, since they appear + /// as identifiers in the expression. + /// + /// Returns: + /// A sorted list of referenced variable names. + fn variables(&self) -> Vec { + let mut names: Vec = self + .program + .references() + .variables() + .into_iter() + .map(|s| s.to_string()) + .collect(); + names.sort(); + names + } + + /// The function names referenced by this expression. + /// + /// This performs static analysis of the compiled expression without + /// evaluating it. Both named functions (``size``, ``startsWith``) and the + /// CEL operator overload identifiers for operators used in the expression + /// (e.g. ``_+_`` or ``_>_``) are included. + /// + /// Returns: + /// A sorted list of referenced function/operator names. + fn functions(&self) -> Vec { + let mut names: Vec = self + .program + .references() + .functions() + .into_iter() + .map(|s| s.to_string()) + .collect(); + names.sort(); + names + } + + /// Static analysis of the names this expression references. + /// + /// Returns: + /// A dict with two keys, ``"variables"`` and ``"functions"``, each + /// mapping to a sorted list of names. Equivalent to calling + /// :meth:`variables` and :meth:`functions`. + fn references<'py>(&self, py: Python<'py>) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("variables", self.variables())?; + dict.set_item("functions", self.functions())?; + Ok(dict) + } + + /// The original CEL source this program was compiled from. + #[getter] + fn source(&self) -> &str { + &self.source + } + fn __repr__(&self) -> String { format!("Program({:?})", self.source) } @@ -388,13 +475,11 @@ fn build_environment<'r>( )); }; - // Add any variables from the processed context + // Add any variables from the processed context. The values are already + // `cel::Value`s, so `add_variable_from_value` (infallible, `Into`) + // is the right entry point β€” no conversion or error handling needed here. for (name, value) in &ctx.variables { - environment - .add_variable(name.clone(), value.clone()) - .map_err(|e| { - PyValueError::new_err(format!("Failed to add variable '{name}': {e}")) - })?; + environment.add_variable_from_value(name.clone(), value.clone()); } // Register Python functions @@ -403,18 +488,40 @@ fn build_environment<'r>( let py_func_clone = Python::attach(|py| py_function.clone_ref(py)); let func_name_clone = function_name.clone(); - // Register a function that takes Arguments (variadic) and returns a Value + // Register a wrapper that bridges the CEL call to the Python callable. + // + // We take the raw `FunctionContext` (rather than the `Arguments` + // extractor) so that method-call syntax works: when an expression + // calls `target.func(a, b)`, CEL puts `target` in `ftx.this` and + // `[a, b]` in `ftx.args`. We prepend `this` to the argument list so + // the Python function receives `(target, a, b)`. This means a Python + // function `f(x, y)` can be invoked as either `f(x, y)` or + // `x.f(y)` β€” matching CEL's "receiver call is sugar for a function + // call with the receiver as the first argument" semantics and the + // way the standard library extensions (e.g. `list.contains(x)`, + // `"s".charAt(i)`) are written. environment.add_function( function_name, - move |args: ::cel::extractors::Arguments| -> Result { + move |ftx: &FunctionContext| -> Result { let py_func = py_func_clone.clone(); let func_name = func_name_clone.clone(); + // Collect the CEL argument values: the method target (if + // this was a receiver-style call) first, then the explicit + // arguments. + let mut cel_args: Vec = Vec::with_capacity(ftx.args.len() + 1); + if let Some(this) = &ftx.this { + cel_args.push(this.as_ref().try_into()?); + } + for arg in ftx.args.iter() { + cel_args.push(arg.as_ref().try_into()?); + } + Python::attach(|py| { // Convert CEL arguments to Python objects - let mut py_args = Vec::new(); - for cel_value in args.0.iter() { - let py_arg = RustyCelType(cel_value.clone()) + let mut py_args = Vec::with_capacity(cel_args.len()); + for cel_value in cel_args { + let py_arg = RustyCelType(cel_value) .into_pyobject(py) .map_err(|e| ExecutionError::FunctionError { function: func_name.clone(), @@ -470,7 +577,38 @@ fn build_environment<'r>( Ok(()) } -/// Enhanced error handling that maps CEL execution errors to appropriate Python exceptions +/// Human-readable CEL type name for a value (e.g. `int`, `uint`, `string`). +/// +/// Used to build type-focused error messages. `Value::type_of()` returns a +/// `ValueType` whose `Display` impl already yields the canonical CEL type name. +fn cel_type_name(value: &Value) -> String { + value.type_of().to_string() +} + +/// A concise, user-facing rendering of a value for error messages. +/// +/// Scalars render as their bare value (`5`, `hello`) rather than leaking the +/// internal `Debug` wrapper (`Int(5)`, `String("hello")`); anything else falls +/// back to `Debug`. +fn cel_value_display(value: &Value) -> String { + match value { + Value::Int(i) => i.to_string(), + Value::UInt(u) => u.to_string(), + Value::Float(f) => f.to_string(), + Value::Bool(b) => b.to_string(), + Value::String(s) => s.as_ref().clone(), + Value::Null => "null".to_string(), + other => format!("{other:?}"), + } +} + +/// Maps CEL execution errors to the most appropriate Python exception type with +/// an actionable message. +/// +/// The goal is that CEL runtime failures surface as the Python exception a +/// developer would expect (`ZeroDivisionError` for `1/0`, `KeyError` for a +/// missing map key, `OverflowError` for integer overflow, and so on) rather +/// than a single opaque error type. fn map_execution_error_to_python(error: &ExecutionError) -> PyErr { match error { ExecutionError::UndeclaredReference(name) => { @@ -478,44 +616,77 @@ fn map_execution_error_to_python(error: &ExecutionError) -> PyErr { "Undefined variable or function: '{name}'. Check that the variable is defined in the context or that the function name is spelled correctly." )) }, - ExecutionError::UnsupportedBinaryOperator(op, left_type, right_type) => { - let left_type_str = format!("{left_type:?}"); - let right_type_str = format!("{right_type:?}"); - match *op { - "add" => { - if (left_type_str.contains("Int") && right_type_str.contains("UInt")) || - (left_type_str.contains("UInt") && right_type_str.contains("Int")) { - PyTypeError::new_err(format!( - "Cannot mix signed and unsigned integers in arithmetic: {left_type:?} + {right_type:?}. Use explicit conversion: int(value) or uint(value)" - )) - } else { - PyTypeError::new_err(format!( - "Unsupported addition operation: {left_type:?} + {right_type:?}. Check that both operands are compatible types (int+int, double+double, string+string, etc.)" - )) - } - }, - "mul" => { - PyTypeError::new_err(format!( - "Unsupported multiplication operation: {left_type:?} * {right_type:?}. Ensure both operands are numeric and of compatible types. Use explicit conversion if needed: double(value)*double(value)" - )) - }, - "sub" => { - PyTypeError::new_err(format!( - "Unsupported subtraction operation: {left_type:?} - {right_type:?}. Ensure both operands are numeric and of compatible types." - )) - }, - "div" => { - PyTypeError::new_err(format!( - "Unsupported division operation: {left_type:?} / {right_type:?}. Ensure both operands are numeric and of compatible types." - )) - }, - _ => { - PyTypeError::new_err(format!( - "Unsupported operation '{op}' between {left_type:?} and {right_type:?}. Check the CEL specification for supported operations between these types." - )) - } + ExecutionError::UnsupportedBinaryOperator(op, left, right) => { + let left_type = cel_type_name(left); + let right_type = cel_type_name(right); + let symbol = match *op { + "add" => "+", + "sub" => "-", + "mul" => "*", + "div" => "/", + "rem" => "%", + other => other, + }; + if (left_type == "int" && right_type == "uint") + || (left_type == "uint" && right_type == "int") + { + PyTypeError::new_err(format!( + "Cannot mix signed and unsigned integers: {left_type} {symbol} {right_type}. \ + Convert explicitly with int(value) or uint(value)." + )) + } else { + PyTypeError::new_err(format!( + "Unsupported operation: {left_type} {symbol} {right_type}. CEL does not coerce \ + between types β€” both operands must be the same type. Convert explicitly with \ + int(x), uint(x), double(x) or string(x) as appropriate." + )) } }, + ExecutionError::UnsupportedIndex(container, index) => { + PyTypeError::new_err(format!( + "Cannot index a {} value with a {} key.", + cel_type_name(container), + cel_type_name(index) + )) + }, + ExecutionError::ValuesNotComparable(left, right) => { + PyTypeError::new_err(format!( + "Values of type {} and {} cannot be compared.", + cel_type_name(left), + cel_type_name(right) + )) + }, + ExecutionError::UnexpectedType { got, want } => { + PyTypeError::new_err(format!("Unexpected type: got '{got}', want '{want}'.")) + }, + ExecutionError::UnsupportedKeyType(value) => { + PyTypeError::new_err(format!( + "Value of type {} cannot be used as a map key. Keys must be int, uint, bool or string.", + cel_type_name(value) + )) + }, + ExecutionError::InvalidArgumentCount { expected, actual } => { + PyTypeError::new_err(format!( + "Invalid number of arguments: expected {expected}, got {actual}." + )) + }, + ExecutionError::NotSupportedAsMethod { method, target } => { + PyTypeError::new_err(format!( + "Method '{method}' is not supported on a {} value.", + cel_type_name(target) + )) + }, + ExecutionError::UnsupportedTargetType { target } => { + PyTypeError::new_err(format!( + "Unsupported target type: {} cannot be used here.", + cel_type_name(target) + )) + }, + ExecutionError::MissingArgumentOrTarget => { + PyTypeError::new_err( + "A function was called without a required argument or method target.", + ) + }, ExecutionError::FunctionError { function, message } => { PyRuntimeError::new_err(format!( "Function '{function}' error: {message}. Check function arguments and their types." @@ -523,15 +694,18 @@ fn map_execution_error_to_python(error: &ExecutionError) -> PyErr { }, ExecutionError::NoSuchOverload => { PyTypeError::new_err( - "No such overload. The operation isn't defined for the given operand types β€” \ - for example, mixing signed and unsigned integers (1 + 2u), indexing into a \ - string, or using an unsupported operator. Use explicit conversion \ - (int(x), uint(x), double(x)) or check the CEL specification." + "No such overload: the operation is not defined for the given operand types. \ + CEL does not coerce between types, so common causes are mixing int with uint or \ + double (1 + 2u, 1 + 2.5), indexing into a string, or calling a function with the \ + wrong argument types. Convert explicitly with int(x), uint(x), double(x) or \ + string(x), or check the CEL specification." ) }, ExecutionError::Overflow(op, left, right) => { PyOverflowError::new_err(format!( - "Arithmetic overflow in '{op}' on {left:?} and {right:?}." + "Arithmetic overflow in '{op}' on {} and {}.", + cel_value_display(left), + cel_value_display(right) )) }, ExecutionError::DivisionByZero(_) => { @@ -541,28 +715,19 @@ fn map_execution_error_to_python(error: &ExecutionError) -> PyErr { PyZeroDivisionError::new_err("modulo by zero in CEL expression") }, ExecutionError::IndexOutOfBounds(value) => { - PyIndexError::new_err(format!("index out of bounds: {value:?}")) + PyIndexError::new_err(format!("index out of bounds: {}", cel_value_display(value))) }, ExecutionError::NoSuchKey(name) => { PyKeyError::new_err(name.to_string()) }, - _ => { - // Fallback for any other execution errors - provide helpful message based on error content - let error_str = format!("{error:?}"); - if error_str.contains("UndeclaredReference") { - PyRuntimeError::new_err(format!( - "Undefined variable or function. Check that all variables are defined in the context and function names are spelled correctly. Error: {error}" - )) - } else if error_str.contains("UnsupportedBinaryOperator") { - PyTypeError::new_err(format!( - "Unsupported operation between incompatible types. Check the CEL specification for supported operations. Error: {error}" - )) - } else { - PyValueError::new_err(format!( - "CEL execution error: {error}. This may indicate an unsupported operation or invalid expression." - )) - } - } + ExecutionError::InternalError(message) => { + PyRuntimeError::new_err(format!("Internal CEL error: {message}")) + }, + // `ExecutionError` is `#[non_exhaustive]`; keep a helpful catch-all for any + // variant added upstream that we do not yet map explicitly. + _ => PyValueError::new_err(format!( + "CEL execution error: {error}. This may indicate an unsupported operation or invalid expression." + )), } } @@ -797,7 +962,7 @@ impl TryIntoValue for RustyPyType<'_> { /// - Python API Reference: For detailed API documentation #[pyfunction(signature = (src, evaluation_context=None))] fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyResult { - let mut environment = CelContext::default(); + let mut environment = new_environment(); let mut resolver_slot: Option = None; build_environment(evaluation_context, &mut environment, &mut resolver_slot)?; @@ -834,7 +999,7 @@ fn execute_compiled_program( src: &str, evaluation_context: Option<&Bound<'_, PyAny>>, ) -> PyResult> { - let mut environment = CelContext::default(); + let mut environment = new_environment(); let mut resolver_slot: Option = None; build_environment(evaluation_context, &mut environment, &mut resolver_slot)?; diff --git a/tests/test_cel_014_behaviour.py b/tests/test_cel_014_behaviour.py new file mode 100644 index 0000000..01ee193 --- /dev/null +++ b/tests/test_cel_014_behaviour.py @@ -0,0 +1,96 @@ +"""Documents the behaviour of cel-rust 0.14 that this release depends on. + +These tests pin down the semantics that changed (or are easy to get wrong) +between cel-rust 0.13 and 0.14 so regressions are caught early: + +* ``contains`` is a string-only built-in; list/map membership uses ``in``. +* ``min``/``max`` are not part of the core stdlib (moved to ``cel.stdlib``). +* Built-in functions take precedence over same-named user functions. +* Integer overflow raises rather than wrapping. +* Bytes concatenation works. +* Logical operators are error-resilient per the CEL spec. +""" + +import cel +import pytest + + +class TestContainsIsStringOnly: + def test_string_contains_builtin(self): + assert cel.evaluate('"hello".contains("ell")') is True + assert cel.evaluate('"hello".contains("xyz")') is False + + def test_list_contains_not_builtin(self): + # cel-rust 0.14 dropped the list/map contains overloads. + with pytest.raises(RuntimeError, match="Undefined variable or function"): + cel.evaluate("[1, 2, 3].contains(2)") + + def test_list_membership_uses_in(self): + assert cel.evaluate("2 in [1, 2, 3]") is True + assert cel.evaluate("9 in [1, 2, 3]") is False + + def test_map_membership_uses_in(self): + assert cel.evaluate('"a" in {"a": 1, "b": 2}') is True + assert cel.evaluate('"z" in {"a": 1}') is False + + +class TestMinMaxRemoved: + def test_min_not_in_core(self): + with pytest.raises(RuntimeError, match="Undefined variable or function"): + cel.evaluate("min([1, 2, 3])") + + def test_max_not_in_core(self): + with pytest.raises(RuntimeError, match="Undefined variable or function"): + cel.evaluate("max([1, 2, 3])") + + +class TestBuiltinsShadowUserFunctions: + def test_builtin_double_wins_over_user_function(self): + # `double` is a built-in conversion; a user function of the same name + # does not override it. This is intentional CEL behaviour. + context = cel.Context() + context.add_function("double", lambda x: x * 999) + assert cel.evaluate("double(21)", context) == 21.0 + + def test_user_function_with_unique_name_is_used(self): + context = cel.Context() + context.add_function("my_double", lambda x: x * 2) + assert cel.evaluate("my_double(21)", context) == 42 + + +class TestOverflow: + def test_int_addition_overflow_raises(self): + with pytest.raises(OverflowError): + cel.evaluate("9223372036854775807 + 1") + + def test_int_multiplication_overflow_raises(self): + with pytest.raises(OverflowError): + cel.evaluate("9223372036854775807 * 2") + + +class TestBytesConcatenation: + def test_bytes_concat_works(self): + assert cel.evaluate("b'hello' + b'world'") == b"helloworld" + + +class TestLogicalOperatorsAreErrorResilient: + def test_and_short_circuits_to_false(self): + # `X && false` is false even when X errors, per the CEL spec. + assert cel.evaluate("(1 / 0 == 0) && false") is False + + def test_or_short_circuits_to_true(self): + assert cel.evaluate("(1 / 0 == 0) || true") is True + + +class TestArithmeticIsStrict: + def test_no_int_double_coercion(self): + with pytest.raises(TypeError): + cel.evaluate("1 + 2.5") + + def test_no_signed_unsigned_mix(self): + with pytest.raises(TypeError): + cel.evaluate("1 + 2u") + + def test_explicit_conversion_works(self): + assert cel.evaluate("double(1) + 2.5") == 3.5 + assert cel.evaluate("1 + int(2u)") == 3 diff --git a/tests/test_compile.py b/tests/test_compile.py index a5be111..b1406a9 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -93,9 +93,9 @@ class TestCompileWithFunctions: def test_execute_with_custom_function(self): """Test executing with a custom Python function.""" - program = cel.compile("double(x)") + program = cel.compile("times_two(x)") ctx = Context() - ctx.add_function("double", lambda x: x * 2) + ctx.add_function("times_two", lambda x: x * 2) ctx.add_variable("x", 21) result = program.execute(ctx) assert result == 42 diff --git a/tests/test_enhanced_error_handling.py b/tests/test_enhanced_error_handling.py index 9ac4fdb..e263331 100644 --- a/tests/test_enhanced_error_handling.py +++ b/tests/test_enhanced_error_handling.py @@ -55,8 +55,8 @@ def test_unsupported_addition_type_error(self): cel.evaluate("'hello' + 42", {}) error_msg = str(exc_info.value) - assert "Unsupported addition operation" in error_msg - assert "Check that both operands are compatible types" in error_msg + assert "Unsupported operation" in error_msg + assert "string" in error_msg and "int" in error_msg def test_function_error_runtime_error(self): """Test that function errors raise RuntimeError with function context.""" diff --git a/tests/test_functions.py b/tests/test_functions.py index 39e1bff..e5193e5 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -10,16 +10,34 @@ class TestBuiltInCollectionFunctions: """Test built-in collection functions that work in CEL.""" def test_min_function_works(self): - """Test that min() function works correctly.""" - assert cel.evaluate("min([3, 1, 4, 1, 5])") == 1 - assert cel.evaluate("min([1.5, 2.3, 0.8])") == 0.8 - assert cel.evaluate("min(['banana', 'apple', 'cherry'])") == "apple" + """min() is provided by the extended stdlib (cel-rust 0.14 dropped it).""" + from cel.stdlib import add_stdlib_to_context + + ctx = cel.Context() + add_stdlib_to_context(ctx) + assert cel.evaluate("min([3, 1, 4, 1, 5])", ctx) == 1 + assert cel.evaluate("min([1.5, 2.3, 0.8])", ctx) == 0.8 + assert cel.evaluate("min(['banana', 'apple', 'cherry'])", ctx) == "apple" + # Variadic form is also supported. + assert cel.evaluate("min(3, 1, 2)", ctx) == 1 def test_max_function_works(self): - """Test that max() function works correctly.""" - assert cel.evaluate("max([3, 1, 4, 1, 5])") == 5 - assert cel.evaluate("max([1.5, 2.3, 0.8])") == 2.3 - assert cel.evaluate("max(['banana', 'apple', 'cherry'])") == "cherry" + """max() is provided by the extended stdlib (cel-rust 0.14 dropped it).""" + from cel.stdlib import add_stdlib_to_context + + ctx = cel.Context() + add_stdlib_to_context(ctx) + assert cel.evaluate("max([3, 1, 4, 1, 5])", ctx) == 5 + assert cel.evaluate("max([1.5, 2.3, 0.8])", ctx) == 2.3 + assert cel.evaluate("max(['banana', 'apple', 'cherry'])", ctx) == "cherry" + assert cel.evaluate("max(3, 1, 2)", ctx) == 3 + + def test_min_max_not_in_core(self): + """min()/max() are not part of the cel-rust core stdlib in 0.14.""" + with pytest.raises(RuntimeError, match="Undefined variable or function"): + cel.evaluate("min([1, 2, 3])") + with pytest.raises(RuntimeError, match="Undefined variable or function"): + cel.evaluate("max([1, 2, 3])") def test_custom_function(): @@ -574,14 +592,14 @@ def multiply_by_two(x): def is_even(x): return x % 2 == 0 - context = {"double": multiply_by_two, "even": is_even, "numbers": [1, 2, 3, 4, 5]} - - # Note: CEL's map() might not work directly with custom functions - # due to type system limitations, but we can test other combinations + # Note: "double" is a built-in CEL conversion function in cel-rust 0.14, + # and built-ins take precedence over user functions of the same name, so + # we register the custom helper under a non-colliding name. + context = {"times_two": multiply_by_two, "even": is_even, "numbers": [1, 2, 3, 4, 5]} # Test function with list filtering (conceptual - may need adaptation) # This tests the function itself, integration with CEL macros may vary - assert cel.evaluate("double(5)", context) == 10 + assert cel.evaluate("times_two(5)", context) == 10 assert cel.evaluate("even(4)", context) assert not cel.evaluate("even(3)", context) diff --git a/tests/test_introspection.py b/tests/test_introspection.py new file mode 100644 index 0000000..b07ea57 --- /dev/null +++ b/tests/test_introspection.py @@ -0,0 +1,66 @@ +"""Tests for compiled-program static analysis (Program.references).""" + +import cel + + +class TestProgramReferences: + """Program.variables(), .functions(), .references() and .source.""" + + def test_variables_simple(self): + program = cel.compile("a + b * c") + assert program.variables() == ["a", "b", "c"] + + def test_variables_deduplicated_and_sorted(self): + program = cel.compile("x + x + y") + assert program.variables() == ["x", "y"] + + def test_variables_from_member_access(self): + # Only the root identifier is a variable; field names are not. + program = cel.compile("user.profile.name") + assert program.variables() == ["user"] + + def test_no_variables_for_literal(self): + program = cel.compile("1 + 2") + assert program.variables() == [] + + def test_functions_includes_named_and_operators(self): + program = cel.compile("size(items) > 0") + functions = program.functions() + assert "size" in functions + # Operators are reported using their CEL overload identifiers. + assert "_>_" in functions + + def test_functions_for_macro(self): + program = cel.compile("[1, 2, 3].map(x, x * 2)") + # The comprehension variable is reported as a referenced variable. + assert "x" in program.variables() + + def test_references_dict(self): + program = cel.compile("user.age >= min_age && size(roles) > 0") + refs = program.references() + assert set(refs.keys()) == {"variables", "functions"} + assert refs["variables"] == ["min_age", "roles", "user"] + assert "size" in refs["functions"] + + def test_references_match_helper_methods(self): + program = cel.compile("a + f(b)") + refs = program.references() + assert refs["variables"] == program.variables() + assert refs["functions"] == program.functions() + + def test_source_getter(self): + source = "a + b" + program = cel.compile(source) + assert program.source == source + + def test_repr(self): + program = cel.compile("1 + 1") + assert repr(program) == 'Program("1 + 1")' + + def test_references_use_case_validate_context(self): + """Static analysis can validate that a context supplies all variables.""" + program = cel.compile("price * quantity") + provided = {"price": 10, "quantity": 3} + missing = [v for v in program.variables() if v not in provided] + assert missing == [] + assert program.execute(provided) == 30 diff --git a/tests/test_stdlib_extensions.py b/tests/test_stdlib_extensions.py new file mode 100644 index 0000000..397aac9 --- /dev/null +++ b/tests/test_stdlib_extensions.py @@ -0,0 +1,301 @@ +"""Tests for the extended standard library (cel.stdlib).""" + +import cel +import pytest +from cel.stdlib import EXTENSIONS, STDLIB_FUNCTIONS, add_stdlib_to_context + + +@pytest.fixture +def ctx(): + """A context with every extended-stdlib function registered.""" + context = cel.Context() + add_stdlib_to_context(context) + return context + + +def ev(expr, ctx): + return cel.evaluate(expr, ctx) + + +class TestRegistry: + def test_all_functions_merged(self): + total = sum(len(lib) for lib in EXTENSIONS.values()) + # "reverse" appears in both strings and lists; the merged map dedupes. + assert len(STDLIB_FUNCTIONS) == len({n for lib in EXTENSIONS.values() for n in lib}) + assert total >= len(STDLIB_FUNCTIONS) + + def test_selective_extension_loading(self): + context = cel.Context() + add_stdlib_to_context(context, extensions=["math"]) + assert cel.evaluate("math.abs(-3)", context) == 3 + with pytest.raises(RuntimeError): + cel.evaluate('"a".charAt(0)', context) + + def test_unknown_extension_raises(self): + with pytest.raises(KeyError): + add_stdlib_to_context(cel.Context(), extensions=["does-not-exist"]) + + +class TestCore: + def test_bool_from_strings(self, ctx): + assert ev('bool("true")', ctx) is True + assert ev('bool("false")', ctx) is False + assert ev('bool("1")', ctx) is True + assert ev('bool("0")', ctx) is False + assert ev("bool(true)", ctx) is True + + def test_bool_invalid_raises(self, ctx): + with pytest.raises((ValueError, RuntimeError)): + ev('bool("maybe")', ctx) + + def test_dyn_identity(self, ctx): + assert ev("dyn(5)", ctx) == 5 + assert ev('dyn("hi")', ctx) == "hi" + + def test_type_names(self, ctx): + assert ev("type(1)", ctx) == "int" + assert ev("type(1.5)", ctx) == "double" + assert ev('type("x")', ctx) == "string" + assert ev("type(true)", ctx) == "bool" + assert ev("type([1])", ctx) == "list" + assert ev("type({'a': 1})", ctx) == "map" + + def test_type_equality(self, ctx): + assert ev("type(1) == type(2)", ctx) is True + assert ev("type(1) == type('x')", ctx) is False + + def test_min_max(self, ctx): + assert ev("min([3, 1, 2])", ctx) == 1 + assert ev("max([3, 1, 2])", ctx) == 3 + assert ev("min(3, 1, 2)", ctx) == 1 + assert ev("max(3, 1, 2)", ctx) == 3 + + +class TestStrings: + def test_char_at(self, ctx): + assert ev('"hello".charAt(1)', ctx) == "e" + assert ev('"hello".charAt(5)', ctx) == "" # index == len -> "" + + def test_index_of(self, ctx): + assert ev('"hello".indexOf("l")', ctx) == 2 + assert ev('"hello".indexOf("l", 3)', ctx) == 3 + assert ev('"hello".indexOf("z")', ctx) == -1 + + def test_last_index_of(self, ctx): + assert ev('"hello".lastIndexOf("l")', ctx) == 3 + assert ev('"hello".lastIndexOf("z")', ctx) == -1 + + def test_substring(self, ctx): + assert ev('"hello".substring(1, 3)', ctx) == "el" + assert ev('"hello".substring(2)', ctx) == "llo" + + def test_replace(self, ctx): + assert ev('"a-a-a".replace("a", "b")', ctx) == "b-b-b" + assert ev('"a-a-a".replace("a", "b", 1)', ctx) == "b-a-a" + + def test_split(self, ctx): + assert ev('"a,b,c".split(",")', ctx) == ["a", "b", "c"] + assert ev('"a,b,c".split(",", 2)', ctx) == ["a", "b,c"] + + def test_join(self, ctx): + assert ev('["a", "b", "c"].join("-")', ctx) == "a-b-c" + assert ev('["a", "b"].join()', ctx) == "ab" + + def test_case(self, ctx): + assert ev('"Hello World".lowerAscii()', ctx) == "hello world" + assert ev('"Hello World".upperAscii()', ctx) == "HELLO WORLD" + + def test_trim(self, ctx): + assert ev('" padded ".trim()', ctx) == "padded" + + def test_reverse_string(self, ctx): + assert ev('"abc".reverse()', ctx) == "cba" + + def test_quote(self, ctx): + assert ev('strings.quote("a\\"b")', ctx) == '"a\\"b"' + + def test_global_call_form(self, ctx): + # Every string function also works called as a free function. + assert ev('charAt("hello", 0)', ctx) == "h" + assert ev('substring("hello", 1, 3)', ctx) == "el" + + def test_split_empty_separator_splits_chars(self, ctx): + # cel-go splits into characters; Python's str.split("") would raise. + assert ev('"abc".split("")', ctx) == ["a", "b", "c"] + + def test_substring_out_of_range_raises(self, ctx): + for expr in ( + '"hello".substring(-1)', + '"hello".substring(3, 1)', + '"hello".substring(0, 99)', + ): + with pytest.raises((IndexError, RuntimeError)): + ev(expr, ctx) + + def test_index_of_offset_out_of_range_raises(self, ctx): + with pytest.raises((IndexError, RuntimeError)): + ev('"hello".indexOf("l", -1)', ctx) + with pytest.raises((IndexError, RuntimeError)): + ev('"hello".lastIndexOf("l", 99)', ctx) + + +class TestMath: + def test_greatest_least(self, ctx): + assert ev("math.greatest(1, 5, 3)", ctx) == 5 + assert ev("math.least([4, 2, 8])", ctx) == 2 + + def test_abs_sign(self, ctx): + assert ev("math.abs(-7)", ctx) == 7 + assert ev("math.abs(-2.5)", ctx) == 2.5 + assert ev("math.sign(-3)", ctx) == -1 + assert ev("math.sign(3)", ctx) == 1 + assert ev("math.sign(0)", ctx) == 0 + + def test_rounding(self, ctx): + assert ev("math.ceil(1.2)", ctx) == 2.0 + assert ev("math.floor(1.8)", ctx) == 1.0 + assert ev("math.trunc(2.9)", ctx) == 2.0 + # round() is half-away-from-zero, not banker's rounding. + assert ev("math.round(2.5)", ctx) == 3.0 + assert ev("math.round(-2.5)", ctx) == -3.0 + assert ev("math.round(2.4)", ctx) == 2.0 + + def test_predicates(self, ctx): + assert ev("math.isNaN(0.0 / 0.0)", ctx) is True + assert ev("math.isInf(1.0 / 0.0)", ctx) is True + assert ev("math.isFinite(1.0)", ctx) is True + assert ev("math.isFinite(1.0 / 0.0)", ctx) is False + + def test_rounding_infinity_and_nan_pass_through(self, ctx): + # ceil/floor/round/trunc return inf/nan rather than raising. + assert ev("math.isInf(math.ceil(1.0 / 0.0))", ctx) is True + assert ev("math.isInf(math.floor(1.0 / 0.0))", ctx) is True + assert ev("math.isInf(math.round(1.0 / 0.0))", ctx) is True + assert ev("math.isNaN(math.trunc(0.0 / 0.0))", ctx) is True + + def test_round_boundary(self, ctx): + # The largest double below 0.5 must round to 0, not 1. + assert ev("math.round(0.49999999999999994)", ctx) == 0.0 + + def test_sqrt(self, ctx): + assert ev("math.sqrt(16.0)", ctx) == 4.0 + + def test_sqrt_negative_is_nan(self, ctx): + assert ev("math.isNaN(math.sqrt(-1.0))", ctx) is True + + def test_bit_ops(self, ctx): + assert ev("math.bitOr(5, 2)", ctx) == 7 + assert ev("math.bitAnd(6, 3)", ctx) == 2 + assert ev("math.bitXor(5, 3)", ctx) == 6 + assert ev("math.bitNot(0)", ctx) == -1 + assert ev("math.bitShiftLeft(1, 4)", ctx) == 16 + assert ev("math.bitShiftRight(16, 2)", ctx) == 4 + + def test_bit_shift_wraps_to_int64(self, ctx): + # Results stay 64-bit integers (two's complement) rather than being + # silently coerced to a float on overflow. + assert ev("math.bitShiftLeft(1, 63)", ctx) == -9223372036854775808 + assert ev("math.bitShiftLeft(1, 64)", ctx) == 0 + # Right shift is logical (zero-fill). + assert ev("math.bitShiftRight(-1, 1)", ctx) == 9223372036854775807 + + +class TestSets: + def test_contains(self, ctx): + assert ev("sets.contains([1, 2, 3], [1, 2])", ctx) is True + assert ev("sets.contains([1, 2, 3], [1, 4])", ctx) is False + assert ev("sets.contains([1, 2, 3], [])", ctx) is True + + def test_equivalent(self, ctx): + assert ev("sets.equivalent([1, 2], [2, 1])", ctx) is True + assert ev("sets.equivalent([1, 2, 2], [2, 1])", ctx) is True + assert ev("sets.equivalent([1, 2], [1, 3])", ctx) is False + + def test_intersects(self, ctx): + assert ev("sets.intersects([1, 2], [2, 3])", ctx) is True + assert ev("sets.intersects([1, 2], [3, 4])", ctx) is False + + +class TestEncoders: + def test_round_trip(self, ctx): + assert ev('base64.encode(b"hello")', ctx) == "aGVsbG8=" + assert ev('base64.decode("aGVsbG8=")', ctx) == b"hello" + + def test_decode_encode_identity(self, ctx): + assert ev('base64.decode(base64.encode(b"data"))', ctx) == b"data" + + def test_decode_rejects_invalid(self, ctx): + # Non-alphabet characters are rejected rather than silently dropped. + with pytest.raises(RuntimeError): + ev('base64.decode("aG!!VsbG8=")', ctx) + + +class TestLists: + def test_contains(self, ctx): + assert ev("[1, 2, 3].contains(2)", ctx) is True + assert ev("[1, 2, 3].contains(9)", ctx) is False + + def test_contains_does_not_shadow_string_builtin(self, ctx): + # The built-in string.contains overload still wins. + assert ev('"abc".contains("b")', ctx) is True + assert ev('"abc".contains("z")', ctx) is False + + def test_distinct(self, ctx): + assert ev("[1, 1, 2, 3, 3, 3].distinct()", ctx) == [1, 2, 3] + + def test_flatten(self, ctx): + assert ev("[[1, 2], [3], [4, 5]].flatten()", ctx) == [1, 2, 3, 4, 5] + assert ev("[[1, [2]], [3]].flatten()", ctx) == [1, [2], 3] + assert ev("[[1, [2]], [3]].flatten(2)", ctx) == [1, 2, 3] + + def test_slice(self, ctx): + assert ev("[1, 2, 3, 4, 5].slice(1, 3)", ctx) == [2, 3] + + def test_sort(self, ctx): + assert ev("[3, 1, 2].sort()", ctx) == [1, 2, 3] + + def test_reverse_list(self, ctx): + assert ev("[1, 2, 3].reverse()", ctx) == [3, 2, 1] + + def test_range(self, ctx): + assert ev("lists.range(4)", ctx) == [0, 1, 2, 3] + assert ev("lists.range(0)", ctx) == [] + + def test_first_last_return_optionals(self, ctx): + assert ev("[10, 20, 30].first().value()", ctx) == 10 + assert ev("[10, 20, 30].last().value()", ctx) == 30 + assert ev("[].first().hasValue()", ctx) is False + assert ev("[].first().orValue(-1)", ctx) == -1 + + +class TestMemberVsGlobalDispatch: + """A registered function can be called as a method or a free function.""" + + def test_member_call_on_literal(self): + context = cel.Context() + context.add_function("shout", lambda s: s.upper() + "!") + assert cel.evaluate('"hi".shout()', context) == "HI!" + assert cel.evaluate('shout("hi")', context) == "HI!" + + def test_member_call_with_args(self): + def clamp(x, lo, hi): + return max(lo, min(hi, x)) + + context = cel.Context() + context.add_function("clamp", clamp) + context.add_variable("value", 15) + # Method call: the target becomes the first argument. + assert cel.evaluate("value.clamp(0, 10)", context) == 10 + # Free-function call is equivalent. + assert cel.evaluate("clamp(15, 0, 10)", context) == 10 + + def test_member_call_on_variable(self): + context = cel.Context() + context.add_function("second", lambda lst: lst[1]) + context.add_variable("items", [10, 20, 30]) + assert cel.evaluate("items.second()", context) == 20 + + def test_member_call_on_macro_result(self): + context = cel.Context() + context.add_function("total", lambda lst: sum(lst)) + assert cel.evaluate("[1, 2, 3].map(x, x * 2).total()", context) == 12 diff --git a/tests/test_upstream_improvements.py b/tests/test_upstream_improvements.py index 560438c..a405381 100644 --- a/tests/test_upstream_improvements.py +++ b/tests/test_upstream_improvements.py @@ -292,8 +292,9 @@ def test_fold_function_not_available(self): with pytest.raises((RuntimeError, ValueError)): cel.evaluate("[1, 2, 3, 4, 5].fold(0, (acc, x) -> acc + x)") - # Global function syntax - with pytest.raises(RuntimeError, match="Undefined variable or function.*fold"): + # Global function syntax. As with reduce(), argument resolution can + # surface the first undeclared identifier ("sum") before "fold". + with pytest.raises(RuntimeError, match="Undefined variable or function"): cel.evaluate("fold([1, 2, 3], 0, sum + x)") def test_reduce_function_not_available(self): @@ -302,8 +303,10 @@ def test_reduce_function_not_available(self): When this test starts failing, reduce() has been implemented upstream. """ - # Global function syntax - with pytest.raises(RuntimeError, match="Undefined variable or function.*reduce"): + # Global function syntax. Arguments are resolved before the function is + # looked up, so the first undeclared identifier ("sum") may be reported + # rather than "reduce" β€” either way it is an undefined reference. + with pytest.raises(RuntimeError, match="Undefined variable or function"): cel.evaluate("reduce([1, 2, 3, 4, 5], 0, sum + x)") # Method syntax From 461b6510b56a53d851b17594bab70d0d68a4eac1 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Sat, 4 Jul 2026 15:26:13 +1200 Subject: [PATCH 2/2] Bump PyO3 to 0.29 and refresh dependencies Update PyO3 0.27 -> 0.29, which resolves two upstream advisories affecting earlier versions (out-of-bounds read in PyList/PyTuple nth/nth_back iterators; missing Sync bound on PyCFunction::new_closure). Switch pyo3-log from a git fork pinned to a 0.27 branch to the released 0.13.4 crate, removing the git dependency. Refresh transitive deps (chrono 0.4.45, log 0.4.33, regex 1.12.4, serde_json 1.0.150, arc-swap 1.9.2). --- CHANGELOG.md | 8 ++ Cargo.lock | 261 +++++++++++++++++++++++++-------------------------- Cargo.toml | 4 +- 3 files changed, 140 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada1fc2..bbff36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ be offered β€” see the Notes section below. ### Updated - 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_back` for + `PyList`/`PyTuple` iterators (high), and a missing `Sync` bound on + `PyCFunction::new_closure` closures (moderate). +- Switched `pyo3-log` from a git fork (pinned to a PyO3 0.27 branch) to the + released `pyo3-log` 0.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). ### Added diff --git a/Cargo.lock b/Cargo.lock index 88ce7de..83417fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -39,15 +39,18 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -57,9 +60,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "better_any" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1795ebc740ea791ffbe6685e0688ab1effec16c2864e0476db40bfdf0c02cb3d" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" [[package]] name = "bit-set" @@ -78,15 +81,15 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -102,9 +105,9 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.41" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -148,9 +151,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -168,9 +171,33 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] [[package]] name = "heck" @@ -180,9 +207,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -202,28 +229,20 @@ dependencies = [ "cc", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -235,9 +254,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "lock_api" @@ -250,24 +269,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "memoffset" -version = "0.9.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "minimal-lexical" @@ -305,9 +315,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "parking_lot" @@ -334,57 +344,60 @@ dependencies = [ [[package]] name = "pastey" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "proc-macro2" -version = "1.0.102" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e0f6df8eaa422d97d72edcd152e1451618fed47fabbdbd5a8864167b1d4aff7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" -version = "0.27.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" dependencies = [ "chrono", - "indoc", "libc", - "memoffset", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", - "unindent", ] [[package]] name = "pyo3-build-config" -version = "0.27.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.27.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" dependencies = [ "libc", "pyo3-build-config", @@ -392,8 +405,9 @@ dependencies = [ [[package]] name = "pyo3-log" -version = "0.13.1" -source = "git+https://github.com/a1phyr/pyo3-log.git?branch=pyo3_0.27#1b4d070c6b3d466a9f060c06683e3994e8dfb7e3" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64083bd3a16a353d9d62335808e8e13d0552d2a2b83fdb084496192dcfa9fcd" dependencies = [ "arc-swap", "log", @@ -402,9 +416,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.27.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -414,22 +428,21 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.27.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" dependencies = [ "heck", "proc-macro2", - "pyo3-build-config", "quote", "syn", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -445,9 +458,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -457,9 +470,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -468,9 +481,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustversion" @@ -478,12 +491,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "scopeguard" version = "1.2.0" @@ -521,34 +528,40 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "syn" -version = "2.0.108" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -557,9 +570,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "thiserror" @@ -589,21 +602,15 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "unicode-ident" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" - -[[package]] -name = "unindent" -version = "0.2.4" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "js-sys", "wasm-bindgen", @@ -611,9 +618,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -622,25 +629,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -648,22 +641,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -726,3 +719,9 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 3451e15..c518c2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,8 @@ name = "cel" crate-type = ["cdylib"] [dependencies] -pyo3 = { version = "0.27", features = ["chrono", "py-clone"]} +pyo3 = { version = "0.29", features = ["chrono", "py-clone"]} cel = { version = "0.14.0", features = ["chrono", "json", "regex", "bytes"] } log = "0.4.27" -pyo3-log = { git = "https://github.com/a1phyr/pyo3-log.git", branch = "pyo3_0.27" } +pyo3-log = "0.13.4" chrono = { version = "0.4.42", features = ["serde"] }