6de61b965e
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.
137 lines
4.4 KiB
Python
137 lines
4.4 KiB
Python
"""Tests for the hardened sandboxed-Jinja expression engine."""
|
|
|
|
import pytest
|
|
|
|
from ledgrab.utils.template_expr import (
|
|
GLOBALS,
|
|
RESERVED_NAMES,
|
|
TemplateValidationError,
|
|
clamp,
|
|
compile_template,
|
|
extract_variables,
|
|
finalize_result,
|
|
validate_input_name,
|
|
validate_template_expression,
|
|
)
|
|
|
|
|
|
class TestCompileAndEval:
|
|
def test_basic_eval(self):
|
|
assert compile_template("min(a * 2, 1)")(a=0.3, raw={}) == pytest.approx(0.6)
|
|
|
|
def test_clamp_global(self):
|
|
assert compile_template("clamp((t - 18) / 10)")(t=22.5, raw={}) == pytest.approx(0.45)
|
|
|
|
def test_raw_subscript(self):
|
|
assert compile_template("raw['t'] / 100")(raw={"t": 42.0}) == pytest.approx(0.42)
|
|
|
|
def test_ternary_and_comparison(self):
|
|
expr = compile_template("a if a > 0.5 else b")
|
|
assert expr(a=0.8, b=0.1, raw={}) == pytest.approx(0.8)
|
|
assert expr(a=0.2, b=0.1, raw={}) == pytest.approx(0.1)
|
|
|
|
def test_all_globals_callable(self):
|
|
for tpl in ("min(a, b)", "max(a, b)", "abs(a - b)", "round(a, 1)", "clamp(a)"):
|
|
compile_template(tpl)(a=0.4, b=0.6, raw={})
|
|
|
|
|
|
class TestRejections:
|
|
@pytest.mark.parametrize(
|
|
"tpl",
|
|
[
|
|
"",
|
|
" ",
|
|
"a +", # syntax error
|
|
"10 ** 3", # power bomb
|
|
"'a' * 1000", # string repetition
|
|
"a | pprint", # filter
|
|
"a is defined", # test
|
|
"a.__class__", # attribute access
|
|
"raw['s'].format(1)", # str gadget via attribute
|
|
"dict(x=1)", # non-global call
|
|
"namespace(x=1)",
|
|
"range(3)",
|
|
"cycler(1, 2)",
|
|
"[0] * 1000000", # list-literal repetition (memory bomb)
|
|
"(1,) * 1000000", # tuple-literal repetition (memory bomb)
|
|
"[1, 2, 3]", # bare list literal
|
|
"{1: 2}", # dict literal
|
|
],
|
|
)
|
|
def test_rejected(self, tpl):
|
|
with pytest.raises(TemplateValidationError):
|
|
validate_template_expression(tpl)
|
|
|
|
@pytest.mark.parametrize(
|
|
"tpl",
|
|
[
|
|
"min(a * 2, 1)",
|
|
"(a + b) / 2",
|
|
"clamp((t - 18) / 10, 0, 1)",
|
|
"raw['x'] / 100",
|
|
"a if a > b else b",
|
|
"abs(a - b)",
|
|
],
|
|
)
|
|
def test_accepted(self, tpl):
|
|
validate_template_expression(tpl) # must not raise
|
|
|
|
|
|
class TestFinalizeResult:
|
|
def test_nan_returns_default(self):
|
|
assert finalize_result(float("nan"), 0.25) == 0.25
|
|
|
|
def test_inf_returns_default(self):
|
|
assert finalize_result(float("inf"), 0.25) == 0.25
|
|
assert finalize_result(float("-inf"), 0.25) == 0.25
|
|
|
|
def test_non_numeric_returns_default(self):
|
|
assert finalize_result("nope", 0.25) == 0.25
|
|
assert finalize_result(None, 0.25) == 0.25
|
|
|
|
def test_overflow_returns_default(self):
|
|
# float() of a multi-hundred-digit int (chained big-int multiply) raises
|
|
# OverflowError, not ValueError — must still fall back, not propagate.
|
|
assert finalize_result(10**400, 0.25) == 0.25
|
|
|
|
def test_clamps_to_unit(self):
|
|
assert finalize_result(5.0, 0.0) == 1.0
|
|
assert finalize_result(-1.0, 0.0) == 0.0
|
|
assert finalize_result(0.5, 0.0) == pytest.approx(0.5)
|
|
|
|
def test_clamp_helper(self):
|
|
assert clamp(2.0) == 1.0
|
|
assert clamp(-2.0) == 0.0
|
|
assert clamp(5.0, 0.0, 10.0) == 5.0
|
|
|
|
|
|
class TestInputNames:
|
|
@pytest.mark.parametrize("name", ["audio", "cpu_load", "_x", "Temp2"])
|
|
def test_valid(self, name):
|
|
validate_input_name(name)
|
|
|
|
@pytest.mark.parametrize("name", ["", "1bad", "has space", "a-b", "a.b"])
|
|
def test_invalid_identifier(self, name):
|
|
with pytest.raises(TemplateValidationError):
|
|
validate_input_name(name)
|
|
|
|
@pytest.mark.parametrize("name", sorted(RESERVED_NAMES))
|
|
def test_reserved(self, name):
|
|
with pytest.raises(TemplateValidationError):
|
|
validate_input_name(name)
|
|
|
|
def test_globals_are_reserved(self):
|
|
assert set(GLOBALS).issubset(RESERVED_NAMES)
|
|
assert "raw" in RESERVED_NAMES
|
|
|
|
|
|
class TestExtractVariables:
|
|
def test_excludes_globals_and_raw(self):
|
|
assert extract_variables("min(a, raw['x']) + b") == ["a", "b"]
|
|
|
|
def test_empty_for_uncompilable(self):
|
|
assert extract_variables("a +") == []
|
|
|
|
def test_constant_expression(self):
|
|
assert extract_variables("clamp(0.5)") == []
|