Skip to content

Commit 0d6bb70

Browse files
committed
Add EvaluationMode for CEL expression evaluation and update evaluate function
1 parent 86c4b1f commit 0d6bb70

File tree

4 files changed

+131
-31
lines changed

4 files changed

+131
-31
lines changed

python/cel/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
# Import the Rust extension
22
# Import CLI functionality
3+
from enum import Enum
4+
35
from . import cli
46
from .cel import *
57

8+
9+
class EvaluationMode(str, Enum):
10+
"""
11+
Defines the evaluation dialect for a CEL expression.
12+
"""
13+
14+
PYTHON = "python"
15+
"""
16+
Enables Python-friendly type promotions (e.g., int -> float). (Default)
17+
"""
18+
STRICT = "strict"
19+
"""
20+
Enforces strict cel-rust type rules with no automatic coercion to match Wasm behavior.
21+
"""
22+
23+
624
__doc__ = cel.__doc__
725
if hasattr(cel, "__all__"):
8-
__all__ = cel.__all__
26+
# Ensure EvaluationMode is always exported even if Rust module defines __all__
27+
__all__ = list(cel.__all__) + ["EvaluationMode"]
928
else:
1029
__all__ = [
1130
"evaluate",
1231
"Context",
32+
"EvaluationMode",
1333
]

python/cel/cel.pyi

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
Type stubs for the CEL Rust extension module.
33
"""
44

5-
from typing import Any, Dict, Union, Optional, Callable, overload
5+
from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Optional, Union, overload
6+
7+
if TYPE_CHECKING:
8+
from . import EvaluationMode
69

710
class Context:
811
"""CEL evaluation context for variables and functions."""
@@ -30,13 +33,20 @@ class Context:
3033
"""Update context with variables from a dictionary."""
3134
...
3235

33-
def evaluate(expression: str, context: Optional[Union[Dict[str, Any], Context]] = None) -> Any:
36+
def evaluate(
37+
expression: str,
38+
context: Optional[Union[Dict[str, Any], Context]] = None,
39+
*,
40+
mode: Union[Literal["python", "strict"], "EvaluationMode", str] = "python",
41+
) -> Any:
3442
"""
3543
Evaluate a CEL expression.
3644
3745
Args:
3846
expression: The CEL expression to evaluate
3947
context: Optional context with variables and functions
48+
mode: Evaluation mode - either "python" (default) for mixed arithmetic
49+
or "strict" for strict type matching
4050
4151
Returns:
4252
The result of evaluating the expression

python/cel/cli.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@
3838
from typing_extensions import Annotated
3939

4040
try:
41-
from . import cel
41+
from . import EvaluationMode, cel
4242
except ImportError:
4343
# Fallback for running as standalone script
4444
try:
4545
import cel
46+
from cel import EvaluationMode
4647
except ImportError:
4748
console = Console()
4849
console.print(
@@ -191,9 +192,10 @@ def _get_auto_renderable(result: Any) -> Any:
191192
class CELEvaluator:
192193
"""Enhanced CEL expression evaluator."""
193194

194-
def __init__(self, context: Optional[Dict[str, Any]] = None):
195-
"""Initialize evaluator with optional context."""
195+
def __init__(self, context: Optional[Dict[str, Any]] = None, mode: str = "python"):
196+
"""Initialize evaluator with optional context and mode."""
196197
self.context = context or {}
198+
self.mode = mode
197199
self._cel_context = None
198200
self._update_cel_context()
199201

@@ -208,7 +210,7 @@ def evaluate(self, expression: str) -> Any:
208210
"""Evaluate a CEL expression."""
209211
if not expression.strip():
210212
raise ValueError("Empty expression")
211-
return cel.evaluate(expression, self._cel_context)
213+
return cel.evaluate(expression, self._cel_context, mode=self.mode)
212214

213215
def update_context(self, new_context: Dict[str, Any]):
214216
"""Update the evaluation context."""
@@ -554,6 +556,13 @@ def main(
554556
typer.Option("--file", help="Read expressions from file (one per line)"),
555557
] = None,
556558
output: Annotated[str, typer.Option("-o", "--output", help="Output format")] = "auto",
559+
mode: Annotated[
560+
str,
561+
typer.Option(
562+
"--mode",
563+
help="Evaluation mode: python (mixed arithmetic) or strict (type matching)",
564+
),
565+
] = "python",
557566
interactive: Annotated[
558567
bool, typer.Option("-i", "--interactive", help="Start interactive REPL mode")
559568
] = False,
@@ -585,6 +594,10 @@ def main(
585594
586595
# Evaluate expressions from file
587596
cel --file expressions.cel --output json
597+
598+
# Use strict evaluation mode
599+
cel '1 + 2.0' --mode strict # This will fail due to mixed types
600+
cel '1.0 + 2.0' --mode strict # This will work
588601
"""
589602

590603
# Load context
@@ -600,8 +613,13 @@ def main(
600613
file_context = load_context_from_file(context_file)
601614
eval_context.update(file_context)
602615

616+
# Validate mode parameter
617+
if mode not in ("python", "strict"):
618+
console.print(f"[red]Error: Invalid mode '{mode}'. Use 'python' or 'strict'[/red]")
619+
raise typer.Exit(1)
620+
603621
# Initialize evaluator
604-
evaluator = CELEvaluator(eval_context)
622+
evaluator = CELEvaluator(eval_context, mode=mode)
605623

606624
# Interactive mode
607625
if interactive:

src/lib.rs

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@ use std::error::Error;
1616
use std::fmt;
1717
use std::sync::Arc;
1818

19+
#[derive(Clone, Debug, PartialEq)]
20+
pub enum EvaluationMode {
21+
PythonCompatible,
22+
Strict,
23+
}
24+
25+
impl<'py> FromPyObject<'py> for EvaluationMode {
26+
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
27+
let s: String = ob.extract()?;
28+
match s.as_str() {
29+
"python" => Ok(EvaluationMode::PythonCompatible),
30+
"strict" => Ok(EvaluationMode::Strict),
31+
_ => Err(PyTypeError::new_err(format!(
32+
"Invalid EvaluationMode: expected 'python' or 'strict', got '{s}'"
33+
))),
34+
}
35+
}
36+
}
37+
1938
#[derive(Debug)]
2039
struct RustyCelType(Value);
2140

@@ -424,6 +443,12 @@ impl TryIntoValue for RustyPyType<'_> {
424443
/// - A `cel.Context` object (recommended for reusable contexts)
425444
/// - A standard Python dictionary containing variables and functions
426445
/// - None (for expressions that don't require external variables)
446+
/// mode (Union[str, cel.EvaluationMode]): The evaluation mode to use.
447+
/// Defaults to "python". Can be:
448+
/// - "python" or EvaluationMode.PYTHON: Enables Python-friendly type
449+
/// promotions (e.g., int -> float) for better mixed arithmetic compatibility
450+
/// - "strict" or EvaluationMode.STRICT: Enforces strict CEL type rules
451+
/// with no automatic coercion to match WebAssembly behavior
427452
///
428453
/// Returns:
429454
/// Union[bool, int, float, str, list, dict, datetime.datetime, bytes, None]:
@@ -508,7 +533,7 @@ impl TryIntoValue for RustyPyType<'_> {
508533
///
509534
/// Using Context object for reusable evaluations:
510535
///
511-
/// >>> from cel import Context
536+
/// >>> from cel import Context, EvaluationMode
512537
/// >>> context = Context(
513538
/// ... variables={"base_url": "https://api.example.com"},
514539
/// ... functions={"len": len}
@@ -518,19 +543,32 @@ impl TryIntoValue for RustyPyType<'_> {
518543
/// >>> evaluate("len('hello world')", context)
519544
/// 11
520545
///
546+
/// Using different evaluation modes:
547+
///
548+
/// >>> # Python mode (default) - allows mixed arithmetic
549+
/// >>> evaluate("1 + 2.5")
550+
/// 3.5
551+
/// >>> evaluate("1 + 2.5", mode=EvaluationMode.PYTHON)
552+
/// 3.5
553+
/// >>> # Strict mode - enforces type matching
554+
/// >>> try:
555+
/// ... evaluate("1 + 2.5", mode=EvaluationMode.STRICT)
556+
/// ... except TypeError as e:
557+
/// ... print("Strict mode error:", e)
558+
/// Strict mode error: Unsupported addition operation: Int + Double...
559+
///
521560
/// See Also:
522561
/// - cel.Context: For managing reusable evaluation contexts
523562
/// - CEL Language Guide: For comprehensive language documentation
524563
/// - Python API Reference: For detailed API documentation
525-
#[pyfunction(signature = (src, evaluation_context=None))]
526-
fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyResult<RustyCelType> {
527-
// Preprocess expression for better mixed int/float arithmetic compatibility
528-
// First check if expression itself has mixed literals
529-
let mut processed_src = if expression_has_mixed_numeric_literals(&src) {
530-
preprocess_expression_for_mixed_arithmetic_always(&src)
531-
} else {
532-
src.clone()
533-
};
564+
#[pyfunction(signature = (src, evaluation_context=None, mode=None))]
565+
fn evaluate(
566+
src: String,
567+
evaluation_context: Option<&Bound<'_, PyAny>>,
568+
mode: Option<EvaluationMode>,
569+
) -> PyResult<RustyCelType> {
570+
// Use PythonCompatible as default if mode is not provided
571+
let mode = mode.unwrap_or(EvaluationMode::PythonCompatible);
534572

535573
let mut environment = CelContext::default();
536574
let mut ctx = context::Context::new(None, None)?;
@@ -539,7 +577,7 @@ fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyRes
539577
// Custom Rust functions can also be added to the environment...
540578
//environment.add_function("add", |a: i64, b: i64| a + b);
541579

542-
// Process the evaluation context if provided first to determine if we need preprocessing
580+
// Process the evaluation context if provided
543581
if let Some(evaluation_context) = evaluation_context {
544582
// Attempt to extract directly as a Context object
545583
if let Ok(py_context_ref) = evaluation_context.extract::<PyRef<context::Context>>() {
@@ -555,21 +593,35 @@ fn evaluate(src: String, evaluation_context: Option<&Bound<'_, PyAny>>) -> PyRes
555593
));
556594
};
557595

558-
// Smart numeric coercion for mixed int/float arithmetic compatibility
559596
variables_for_env = ctx.variables.clone();
597+
}
560598

561-
// Check if we should promote integers to floats for better compatibility
562-
let should_promote = should_promote_integers_to_floats(&variables_for_env)
563-
|| expression_has_mixed_numeric_literals(&src);
564-
565-
if should_promote {
566-
promote_integers_in_context(&mut variables_for_env);
567-
568-
// Always preprocess the expression when we're promoting types
569-
// This handles cases where context has floats but expression has integer literals
570-
processed_src = preprocess_expression_for_mixed_arithmetic_always(&src);
599+
// Apply type promotion logic based on evaluation mode (consolidated)
600+
let processed_src = match mode {
601+
EvaluationMode::PythonCompatible => {
602+
// Check if we should promote integers to floats for better compatibility
603+
let should_promote = should_promote_integers_to_floats(&variables_for_env)
604+
|| expression_has_mixed_numeric_literals(&src);
605+
606+
if should_promote {
607+
// Promote integers in context if we have one
608+
if !variables_for_env.is_empty() {
609+
promote_integers_in_context(&mut variables_for_env);
610+
}
611+
// Always preprocess the expression when promoting types
612+
preprocess_expression_for_mixed_arithmetic_always(&src)
613+
} else if expression_has_mixed_numeric_literals(&src) {
614+
// Preprocess expression even without context if it has mixed literals
615+
preprocess_expression_for_mixed_arithmetic_always(&src)
616+
} else {
617+
src.clone()
618+
}
571619
}
572-
}
620+
EvaluationMode::Strict => {
621+
// Do nothing - preserve strict type behavior with no promotions or rewriting
622+
src.clone()
623+
}
624+
};
573625

574626
// Use panic::catch_unwind to handle parser panics gracefully
575627
let program = panic::catch_unwind(|| Program::compile(&processed_src))

0 commit comments

Comments
 (0)