Skip to content

Commit eb0935b

Browse files
committed
Add Context.set_variable_resolver for lazy variable resolution
Exposes cel 0.13's VariableResolver trait to Python. The callback is invoked with a variable name and returns the value (or None to fall through to variables registered via add_variable). Useful for contexts backed by databases, lazily-loaded config files, or other sources where materializing every variable upfront is wasteful. Exceptions from the callback are logged and treated as "not handled" rather than propagated to the caller. Refs #22
1 parent 1ea6117 commit eb0935b

5 files changed

Lines changed: 184 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Added
1515

16+
- `Context.set_variable_resolver(callback)` exposes cel 0.13's `VariableResolver` trait to Python. The callback receives a variable name and returns the value (or `None` to fall through to variables registered with `add_variable`). Useful for backing a CEL context with on-demand sources (database lookups, lazily-loaded config files, etc.) without materializing the full set of variables upfront. Exceptions raised by the resolver are logged and treated as "not handled".
1617
- Idiomatic Python exception mapping for several CEL runtime errors that previously fell through to `ValueError`:
1718
- Arithmetic overflow → `OverflowError` (e.g. `9223372036854775807 + 1`, including the new overflow-safe int math in cel 0.13).
1819
- Division by zero / modulo by zero → `ZeroDivisionError`.

python/cel/cel.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ class Context:
2626
"""Add a function to the context."""
2727
...
2828

29+
def set_variable_resolver(self, resolver: Callable[[str], Any]) -> None:
30+
"""Register a callback for lazy variable resolution.
31+
32+
The callback receives a variable name and returns the value, or None
33+
to fall through to variables added via add_variable().
34+
"""
35+
...
36+
2937
def update(self, variables: Dict[str, Any]) -> None:
3038
"""Update context with variables from a dictionary."""
3139
...

src/context.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ use std::collections::HashMap;
4343
pub struct Context {
4444
pub variables: HashMap<String, Value>,
4545
pub functions: HashMap<String, Py<PyAny>>,
46+
/// Optional Python callable for lazy variable resolution. Invoked with a
47+
/// variable name; returns the value (or None to fall through to `variables`).
48+
pub resolver: Option<Py<PyAny>>,
4649
}
4750

4851
#[pyo3::pymethods]
@@ -122,6 +125,7 @@ impl Context {
122125
let mut context = Context {
123126
variables: HashMap::new(),
124127
functions: HashMap::new(),
128+
resolver: None,
125129
};
126130

127131
if let Some(variables) = variables {
@@ -203,6 +207,42 @@ impl Context {
203207
self.functions.insert(name, function);
204208
}
205209

210+
/// Registers a Python callable for lazy variable resolution.
211+
///
212+
/// When evaluating an expression, CEL will call `resolver(name)` for each
213+
/// unbound variable name encountered. The callback should return the value
214+
/// (any Python type convertible to a CEL value) or `None` to fall through
215+
/// to variables registered with `add_variable`.
216+
///
217+
/// This is useful when materializing the full set of variables up front is
218+
/// expensive — for example, a dict-like backed by a database, filesystem,
219+
/// or remote API where you only want to fetch values the expression
220+
/// actually references.
221+
///
222+
/// Args:
223+
/// resolver (Callable[[str], Any]): Function that takes a variable name
224+
/// and returns the value or None.
225+
///
226+
/// Notes:
227+
/// - The resolver is consulted *before* explicitly-registered variables.
228+
/// Return None from the resolver to delegate to those.
229+
/// - Exceptions raised by the resolver are logged and treated as None.
230+
/// - The callback is invoked from Rust holding the GIL; keep it simple
231+
/// and avoid blocking on long-running I/O if possible.
232+
///
233+
/// Example:
234+
/// >>> from cel import Context, evaluate
235+
/// >>> store = {"user": {"name": "Alice", "age": 30}}
236+
/// >>> def lookup(name):
237+
/// ... return store.get(name)
238+
/// >>> ctx = Context()
239+
/// >>> ctx.set_variable_resolver(lookup)
240+
/// >>> evaluate("user.name", ctx)
241+
/// 'Alice'
242+
fn set_variable_resolver(&mut self, resolver: Py<PyAny>) {
243+
self.resolver = Some(resolver);
244+
}
245+
206246
/// Adds a variable to the context.
207247
///
208248
/// Variables added to the context become available for use in CEL expressions.

src/lib.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod context;
22

3+
use ::cel::context::VariableResolver;
34
use ::cel::objects::{Key, OptionalValue, TryIntoValue};
45
use ::cel::{Context as CelContext, ExecutionError, Program, Value};
56
use log::warn;
@@ -319,13 +320,50 @@ impl<'a> RustyPyType<'a> {
319320
}
320321
}
321322

323+
/// Bridges a Python callable to cel-rust's `VariableResolver` trait so users
324+
/// can resolve variables lazily on demand instead of materializing them up front.
325+
///
326+
/// The callback receives the variable name as a string and returns either a
327+
/// supported Python value or `None` (meaning "not handled — fall back to the
328+
/// statically-defined variables map"). Any exception raised by the callback
329+
/// is treated as "not handled" and a warning is logged.
330+
struct PyVariableResolver {
331+
callback: Py<PyAny>,
332+
}
333+
334+
impl VariableResolver for PyVariableResolver {
335+
fn resolve(&self, variable: &str) -> Option<Value> {
336+
Python::attach(|py| {
337+
let result = match self.callback.call1(py, (variable,)) {
338+
Ok(r) => r,
339+
Err(e) => {
340+
warn!("Variable resolver raised for '{variable}': {e}");
341+
return None;
342+
}
343+
};
344+
if result.is_none(py) {
345+
return None;
346+
}
347+
let bound = result.bind(py);
348+
match RustyPyType(bound).try_into_value() {
349+
Ok(v) => Some(v),
350+
Err(e) => {
351+
warn!("Variable resolver for '{variable}' returned an unsupported value: {e}");
352+
None
353+
}
354+
}
355+
})
356+
}
357+
}
358+
322359
/// Build a CEL execution environment from an optional evaluation context.
323360
///
324361
/// This consolidates the shared logic used by `evaluate()` and `Program.execute()`
325362
/// to keep behavior consistent between the two entrypoints.
326-
fn build_environment(
363+
fn build_environment<'r>(
327364
evaluation_context: Option<&Bound<'_, PyAny>>,
328-
environment: &mut CelContext<'_>,
365+
environment: &mut CelContext<'r>,
366+
resolver_out: &'r mut Option<PyVariableResolver>,
329367
) -> PyResult<()> {
330368
let mut ctx = context::Context::new(None, None)?;
331369

@@ -336,6 +374,11 @@ fn build_environment(
336374
// Clone variables and functions into our local Context
337375
ctx.variables = py_context_ref.variables.clone();
338376
ctx.functions = py_context_ref.functions.clone();
377+
if let Some(cb) = py_context_ref.resolver.as_ref() {
378+
*resolver_out = Some(PyVariableResolver {
379+
callback: Python::attach(|py| cb.clone_ref(py)),
380+
});
381+
}
339382
} else if let Ok(py_dict) = evaluation_context.cast::<PyDict>() {
340383
// User passed in a dict - let's process variables and functions from the dict
341384
ctx.update(py_dict)?;
@@ -417,6 +460,13 @@ fn build_environment(
417460
}
418461
}
419462

463+
// Attach the lazy resolver if one was provided. The resolver lives in
464+
// `*resolver_out` (caller-owned), and the cel::Context borrows it for
465+
// its lifetime `'r`.
466+
if let Some(resolver) = resolver_out.as_ref() {
467+
environment.set_variable_resolver(resolver);
468+
}
469+
420470
Ok(())
421471
}
422472

@@ -748,7 +798,8 @@ impl TryIntoValue for RustyPyType<'_> {
748798
#[pyfunction(signature = (src, evaluation_context=None))]
749799
fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyResult<RustyCelType> {
750800
let mut environment = CelContext::default();
751-
build_environment(evaluation_context, &mut environment)?;
801+
let mut resolver_slot: Option<PyVariableResolver> = None;
802+
build_environment(evaluation_context, &mut environment, &mut resolver_slot)?;
752803

753804
// Use panic::catch_unwind to handle parser panics gracefully
754805
let program = panic::catch_unwind(|| Program::compile(&src))
@@ -784,7 +835,8 @@ fn execute_compiled_program(
784835
evaluation_context: Option<&Bound<'_, PyAny>>,
785836
) -> PyResult<Py<PyAny>> {
786837
let mut environment = CelContext::default();
787-
build_environment(evaluation_context, &mut environment)?;
838+
let mut resolver_slot: Option<PyVariableResolver> = None;
839+
build_environment(evaluation_context, &mut environment, &mut resolver_slot)?;
788840

789841
// Use panic::catch_unwind to handle execution panics gracefully
790842
// AssertUnwindSafe is needed because the environment contains function closures

tests/test_context.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,82 @@ def test_nested_context_none():
107107
assert cel.evaluate("spec.host", cel_context) == "github.com"
108108
assert cel.evaluate("data['response-code']", cel_context) == "NOERROR"
109109
assert cel.evaluate("size(data.A)", cel_context) == 1
110+
111+
112+
class TestVariableResolver:
113+
"""Tests for lazy variable resolution via set_variable_resolver."""
114+
115+
def test_resolver_supplies_variable(self):
116+
"""Resolver callback can provide variables not registered statically."""
117+
ctx = cel.Context()
118+
ctx.set_variable_resolver(
119+
lambda name: {"name": "Alice", "age": 30} if name == "user" else None
120+
)
121+
assert cel.evaluate("user.name", ctx) == "Alice"
122+
assert cel.evaluate("user.age", ctx) == 30
123+
124+
def test_resolver_is_called_lazily(self):
125+
"""Resolver only fires for names the expression actually references."""
126+
accessed = []
127+
128+
def lookup(name):
129+
accessed.append(name)
130+
return {"limit": 50}.get(name)
131+
132+
ctx = cel.Context()
133+
ctx.set_variable_resolver(lookup)
134+
assert cel.evaluate("limit > 10", ctx) is True
135+
assert accessed == ["limit"]
136+
137+
def test_resolver_none_falls_through_to_static_variables(self):
138+
"""Returning None from the resolver delegates to add_variable()-registered values."""
139+
ctx = cel.Context(variables={"static_var": 42})
140+
ctx.set_variable_resolver(lambda name: None)
141+
assert cel.evaluate("static_var", ctx) == 42
142+
143+
def test_resolver_undefined_raises(self):
144+
"""When neither the resolver nor static variables supply a name, evaluate raises."""
145+
ctx = cel.Context()
146+
ctx.set_variable_resolver(lambda name: None)
147+
with pytest.raises(RuntimeError, match="Undefined variable or function"):
148+
cel.evaluate("missing", ctx)
149+
150+
def test_resolver_exception_is_swallowed(self):
151+
"""An exception from the resolver is treated as 'not handled' rather than propagated."""
152+
ctx = cel.Context(variables={"x": 7})
153+
154+
def explosive(name):
155+
raise ValueError(f"boom on {name}")
156+
157+
ctx.set_variable_resolver(explosive)
158+
# Falls through to the static variable
159+
assert cel.evaluate("x", ctx) == 7
160+
161+
def test_resolver_works_with_compiled_program(self):
162+
"""Resolver applies through compile()+execute(), not just evaluate()."""
163+
program = cel.compile("user.name")
164+
ctx = cel.Context()
165+
ctx.set_variable_resolver(lambda name: {"name": "Bob"} if name == "user" else None)
166+
assert program.execute(ctx) == "Bob"
167+
168+
def test_resolver_returns_various_types(self):
169+
"""Resolver values can be any supported Python type."""
170+
171+
def lookup(name):
172+
return {
173+
"i": 42,
174+
"f": 3.14,
175+
"s": "hello",
176+
"b": True,
177+
"l": [1, 2, 3],
178+
"m": {"k": "v"},
179+
}.get(name)
180+
181+
ctx = cel.Context()
182+
ctx.set_variable_resolver(lookup)
183+
assert cel.evaluate("i", ctx) == 42
184+
assert cel.evaluate("f", ctx) == 3.14
185+
assert cel.evaluate("s", ctx) == "hello"
186+
assert cel.evaluate("b", ctx) is True
187+
assert cel.evaluate("size(l)", ctx) == 3
188+
assert cel.evaluate("m.k", ctx) == "v"

0 commit comments

Comments
 (0)