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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/harel/cel.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,33 @@ def _to_cel(value: Any) -> Any:
return value


def _from_cel(value: Any) -> Any:
"""Normalize a celpy result to a canonical native/JSON Python value (SPEC §5.1).

No guard-language wrapper type (``celpy.celtypes.*``) may cross the engine boundary,
so every CEL result is coerced to its native equivalent here — the single choke point
for esv assignments, published payloads, and spawn args.
"""
if isinstance(value, celtypes.BoolType): # subclasses int — check before IntType
return bool(value)
if isinstance(value, (celtypes.IntType, celtypes.UintType)):
return int(value)
if isinstance(value, celtypes.DoubleType):
return float(value)
if isinstance(value, celtypes.StringType):
return str(value)
if isinstance(value, celtypes.BytesType):
return bytes(value)
if isinstance(value, dict): # MapType (and native dict): normalize keys + values
return {_from_cel(k): _from_cel(v) for k, v in value.items()}
if isinstance(value, list): # ListType (and native list)
return [_from_cel(v) for v in value]
return value


def evaluate(expr: str, bindings: dict[str, Any]) -> Any:
"""Evaluate a CEL expression against ``bindings`` (esvs + event/id/parent)."""
"""Evaluate a CEL expression, returning a canonical native/JSON value (§5.1)."""
try:
return _program(expr).evaluate(_to_cel(bindings))
return _from_cel(_program(expr).evaluate(_to_cel(bindings)))
except celpy.CELEvalError as exc: # type: ignore[attr-defined]
raise CelError(str(exc)) from exc
99 changes: 99 additions & 0 deletions tests/test_native_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Values crossing the boundary are canonical native/JSON types (SPEC §5.1).

CEL-produced values (assign RHS, published payloads, …) must not surface celpy wrapper
types (``IntType``/``DoubleType``/``BoolType``/``StringType``/``MapType``/``ListType``)
through the public API, snapshots, or `--json`.
"""

from __future__ import annotations

import json

from harel import Host, load_definitions

TYPES = """\
id: types
events:
go: {}
top:
esvs:
i: { type: int, init: 0 }
f: { type: float, init: 0.0 }
b: { type: bool, init: false }
s: { type: string, init: "" }
m: { type: map, init: {} }
l: { type: list, init: [] }
initial: { transition_to: a }
states:
a:
on_events:
go:
action:
- { assign: { i: "1 + 2" } }
- { assign: { f: "1.5 + 0.5" } }
- { assign: { b: "1 < 2" } }
- { assign: { s: "'a' + 'b'" } }
- { assign: { m: "{'k': 1 + 1}" } }
- { assign: { l: "[1, 2, 3]" } }
transition_to: done_
done_: {}
"""


def _run() -> object:
host = Host()
host.register_all(load_definitions(TYPES))
inst = host.create_root(host.machines["types"], "r")
host.run_to_quiescence()
host.deliver("r", "go")
host.run_to_quiescence()
return inst


def test_cel_assignments_are_native_python() -> None:
esvs = _run().resolved_esvs()
assert type(esvs["i"]) is int
assert type(esvs["f"]) is float
assert type(esvs["b"]) is bool
assert type(esvs["s"]) is str
assert type(esvs["m"]) is dict
assert type(esvs["l"]) is list
# nested values too (no wrappers inside containers)
assert type(esvs["m"]["k"]) is int and esvs["m"]["k"] == 2
assert [type(x) for x in esvs["l"]] == [int, int, int]
assert esvs["i"] == 3 and esvs["f"] == 2.0 and esvs["b"] is True and esvs["s"] == "ab"


def test_snapshot_contains_only_native_json_values() -> None:
snap = _run().to_snapshot()
json.dumps(snap) # must be plain-serializable

def _walk(v: object) -> None:
# celpy wrappers subclass their natives, so json.dumps alone wouldn't catch them.
assert "celpy" not in type(v).__module__, f"celpy type in snapshot: {type(v)}"
if isinstance(v, dict):
for k, val in v.items():
_walk(k)
_walk(val)
elif isinstance(v, list):
for x in v:
_walk(x)

_walk(snap)


def test_no_celpy_type_leaks_anywhere_in_esvs() -> None:
esvs = _run().resolved_esvs()

def _assert_native(v: object) -> None:
assert type(v).__module__ == "builtins", f"non-native value leaked: {type(v)}"
if isinstance(v, dict):
for k, val in v.items():
_assert_native(k)
_assert_native(val)
elif isinstance(v, list):
for x in v:
_assert_native(x)

for val in esvs.values():
_assert_native(val)
Loading