feat(value-sources): add sandboxed-Jinja template combinator
A new `template` value source evaluates a hardened, sandboxed Jinja expression over the live values of other value sources — the system's first float combinator. Backend: - Shared engine (utils/template_expr.py): ImmutableSandboxedEnvironment with filters/tests and auto-injected globals stripped; only min/max/abs/round/ clamp exposed; rejects **, string/collection-literal repetition, attribute access and non-global calls; NaN/inf-safe result coercion. - TemplateValueSource model + TemplateValueStream runtime: compile-once, primitives-only eval context, raw[name] exposure, eval_interval throttle, ref-counted input acquire/release, rename-safe hot-update. - Validation: unbound-variable + reserved-name rejection, reference cycle/depth guards (depth-only at create, full cycle at update), runtime acquire() depth backstop, and delete referential-integrity. - API: Create/Update/Response schemas + discriminated unions, _RESPONSE_MAP, and an advisory POST /value-sources/validate-template endpoint. - Demo seed: a static source plus a template combinator example. Frontend: - Editor modal section: repeatable inputs list (EntitySelect rows), a zero-dependency Jinja syntax highlighter, a hints/reference panel, and a debounced live validator that gates Save (stale-response-safe). - Graph editor: read-only template node with one edge per input. - i18n (en/ru/zh), icon, and card rendering. Tests: engine, stream, factory/cycle, validate endpoint, and demo seed.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Tests for TemplateValueStream (the Jinja combinator runtime)."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.core.processing.value_stream import (
|
||||
TemplateValueStream,
|
||||
ValueStreamManager,
|
||||
)
|
||||
from ledgrab.storage.value_source import TemplateValueSource
|
||||
|
||||
|
||||
# --- Fakes for precise control over input values / raw -----------------------
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
_NO_RAW = object()
|
||||
|
||||
def __init__(self, value, raw=_NO_RAW):
|
||||
self._value = value
|
||||
self._raw = raw
|
||||
|
||||
def get_value(self):
|
||||
return self._value
|
||||
|
||||
# get_raw_value only exists when a raw value was provided
|
||||
def __getattr__(self, name):
|
||||
if name == "get_raw_value" and self._raw is not _FakeStream._NO_RAW:
|
||||
return lambda: self._raw
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
class _FakeVSM:
|
||||
def __init__(self, streams):
|
||||
self._streams = streams # id -> _FakeStream
|
||||
self.refcounts = defaultdict(int)
|
||||
|
||||
def acquire(self, vs_id):
|
||||
self.refcounts[vs_id] += 1
|
||||
return self._streams[vs_id]
|
||||
|
||||
def release(self, vs_id):
|
||||
self.refcounts[vs_id] -= 1
|
||||
|
||||
|
||||
def _inputs(*pairs):
|
||||
return [{"name": n, "value_source_id": i} for n, i in pairs]
|
||||
|
||||
|
||||
def _make(template, inputs, streams, default_value=0.0, eval_interval=None):
|
||||
vsm = _FakeVSM(streams)
|
||||
stream = TemplateValueStream(
|
||||
template=template,
|
||||
inputs=inputs,
|
||||
default_value=default_value,
|
||||
eval_interval=eval_interval,
|
||||
value_stream_manager=vsm,
|
||||
)
|
||||
stream.start()
|
||||
return stream, vsm
|
||||
|
||||
|
||||
class TestEvaluation:
|
||||
def test_eval_with_inputs(self):
|
||||
stream, vsm = _make("min(a * 2, 1)", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.3)})
|
||||
assert vsm.refcounts["vs_a"] == 1
|
||||
assert stream.get_value() == pytest.approx(0.6)
|
||||
|
||||
def test_clamps_out_of_range(self):
|
||||
stream, _ = _make("a * 10", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)})
|
||||
assert stream.get_value() == 1.0 # 5.0 clamped
|
||||
|
||||
def test_two_inputs(self):
|
||||
stream, _ = _make(
|
||||
"(a + b) / 2",
|
||||
_inputs(("a", "vs_a"), ("b", "vs_b")),
|
||||
{"vs_a": _FakeStream(0.2), "vs_b": _FakeStream(0.8)},
|
||||
)
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
|
||||
def test_shared_id_single_ref(self):
|
||||
# Two variables bound to the same source share one acquisition.
|
||||
stream, vsm = _make(
|
||||
"min(a + b, 1)",
|
||||
_inputs(("a", "vs_x"), ("b", "vs_x")),
|
||||
{"vs_x": _FakeStream(0.3)},
|
||||
)
|
||||
assert vsm.refcounts["vs_x"] == 1
|
||||
assert stream.get_value() == pytest.approx(0.6)
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_div_by_zero_returns_default(self):
|
||||
stream, _ = _make(
|
||||
"a / 0", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.25
|
||||
)
|
||||
assert stream.get_value() == 0.25
|
||||
|
||||
def test_missing_variable_returns_default(self):
|
||||
# template references 'b' but only 'a' is bound
|
||||
stream, _ = _make(
|
||||
"a + b", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.1
|
||||
)
|
||||
assert stream.get_value() == 0.1
|
||||
|
||||
def test_nan_returns_default(self):
|
||||
stream, _ = _make(
|
||||
"a - a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(float("inf"))}, default_value=0.3
|
||||
)
|
||||
# inf - inf = nan -> default
|
||||
assert stream.get_value() == 0.3
|
||||
|
||||
def test_invalid_template_uses_default(self):
|
||||
stream, _ = _make(
|
||||
"a +", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.42
|
||||
)
|
||||
assert stream.get_value() == 0.42
|
||||
|
||||
|
||||
class TestRawExposure:
|
||||
def test_raw_present_when_stream_exposes_it(self):
|
||||
stream, _ = _make(
|
||||
"raw['t'] / 100",
|
||||
_inputs(("t", "vs_t")),
|
||||
{"vs_t": _FakeStream(0.5, raw=42.0)},
|
||||
)
|
||||
assert stream.get_value() == pytest.approx(0.42)
|
||||
|
||||
def test_raw_absent_without_getter(self):
|
||||
# input stream has no get_raw_value -> raw['t'] -> None -> error -> default
|
||||
stream, _ = _make(
|
||||
"raw['t'] / 100",
|
||||
_inputs(("t", "vs_t")),
|
||||
{"vs_t": _FakeStream(0.5)},
|
||||
default_value=0.2,
|
||||
)
|
||||
assert stream.get_value() == 0.2
|
||||
|
||||
def test_non_numeric_raw_is_dropped(self):
|
||||
# raw value is a string -> never crosses into sandbox -> raw['t'] absent
|
||||
stream, _ = _make(
|
||||
"raw['t'] / 100",
|
||||
_inputs(("t", "vs_t")),
|
||||
{"vs_t": _FakeStream(0.5, raw="playing")},
|
||||
default_value=0.15,
|
||||
)
|
||||
assert stream.get_value() == 0.15
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_stop_releases_all(self):
|
||||
stream, vsm = _make(
|
||||
"min(a + b, 1)",
|
||||
_inputs(("a", "vs_a"), ("b", "vs_b")),
|
||||
{"vs_a": _FakeStream(0.1), "vs_b": _FakeStream(0.2)},
|
||||
)
|
||||
stream.stop()
|
||||
assert vsm.refcounts["vs_a"] == 0
|
||||
assert vsm.refcounts["vs_b"] == 0
|
||||
|
||||
def test_eval_interval_caches(self):
|
||||
backing = _FakeStream(0.2)
|
||||
stream, _ = _make("a", _inputs(("a", "vs_a")), {"vs_a": backing}, eval_interval=3600.0)
|
||||
first = stream.get_value()
|
||||
backing._value = 0.9 # change the live input
|
||||
# Cached within the interval -> still the first value.
|
||||
assert stream.get_value() == pytest.approx(first)
|
||||
|
||||
|
||||
class TestHotUpdate:
|
||||
def test_swap_input_releases_old_acquires_new(self):
|
||||
stream, vsm = _make(
|
||||
"a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.1), "vs_b": _FakeStream(0.9)}
|
||||
)
|
||||
assert vsm.refcounts["vs_a"] == 1
|
||||
new_src = TemplateValueSource(
|
||||
id="t1",
|
||||
name="t",
|
||||
source_type="template",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
template="a",
|
||||
inputs=_inputs(("a", "vs_b")),
|
||||
default_value=0.0,
|
||||
)
|
||||
stream.update_source(new_src)
|
||||
assert vsm.refcounts["vs_a"] == 0 # old released
|
||||
assert vsm.refcounts["vs_b"] == 1 # new acquired
|
||||
assert stream.get_value() == pytest.approx(0.9)
|
||||
|
||||
def test_rename_keeps_same_source(self):
|
||||
stream, vsm = _make("a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.7)})
|
||||
renamed = TemplateValueSource(
|
||||
id="t1",
|
||||
name="t",
|
||||
source_type="template",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
template="b", # variable renamed a -> b, same source id
|
||||
inputs=_inputs(("b", "vs_a")),
|
||||
default_value=0.0,
|
||||
)
|
||||
stream.update_source(renamed)
|
||||
assert vsm.refcounts["vs_a"] == 1 # not re-acquired (unchanged id)
|
||||
assert stream.get_value() == pytest.approx(0.7)
|
||||
|
||||
|
||||
class TestAcquireDepthBackstop:
|
||||
def test_self_reference_does_not_overflow(self):
|
||||
"""A cycle that bypassed storage validation must not stack-overflow."""
|
||||
now = datetime.now(timezone.utc)
|
||||
src = TemplateValueSource(
|
||||
id="vs_cycle",
|
||||
name="cycle",
|
||||
source_type="template",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
template="x",
|
||||
inputs=_inputs(("x", "vs_cycle")),
|
||||
default_value=0.0,
|
||||
)
|
||||
|
||||
class _CycleStore:
|
||||
def get_source(self, vs_id):
|
||||
return src
|
||||
|
||||
manager = ValueStreamManager(value_source_store=_CycleStore())
|
||||
stream = manager.acquire("vs_cycle") # must terminate, not recurse forever
|
||||
assert isinstance(stream.get_value(), float)
|
||||
Reference in New Issue
Block a user