diff --git a/plan/frond-align-globals.md b/plan/frond-align-globals.md new file mode 100644 index 00000000..fed4355f --- /dev/null +++ b/plan/frond-align-globals.md @@ -0,0 +1,36 @@ +# Task: Align Frond function/closure globals to Node (ADR-0085) + +Outcome: a bare zero-argument callable global registered via add_global/addGlobal +is invoked and its return value is used for both `{{ g }}` output and `{% if g %}` +conditions, matching Node. `g()` still works and calls once. Scope is globals only. + +## Scope +- [x] Read the Node reference (engine.ts resolvePathPart auto-calls a function member) +- [x] Node: add guard test only (behaviour already correct); mutation-proved +- [x] Python: wrap zero-param callable globals; auto-call at end of _resolve +- [x] PHP: wrap zero-param Closure globals; auto-call at end of resolveVariable +- [x] Ruby: wrap zero-arity Proc/Method globals; auto-call at end of resolve +- [x] Shared fixture frond_globals_contract.json + runner test in all four +- [x] ADR-0085 + DECISIONS.md + CONTRACT-MAP.md + +## Parity +| Feature | Python | PHP | Ruby | Node | +|---------|--------|-----|------|------| +| bare zero-arg global auto-calls | ✅ | ✅ | ✅ | ✅ (reference) | + +## Tests (written first, real — no mocks, positive + negative) +- [x] zero arg global closure returning false is falsy in if +- [x] zero arg global closure returning true is truthy in if +- [x] zero arg global closure prints its return value +- [x] explicit call syntax still works +- [x] non callable global is unchanged +- [x] global returning a callable is not double called +- [x] unregistered function call is falsy + +## Bugs +- [x] tina4-documentation#90 — `{% if admin_only %}` always truthy in Python/PHP/Ruby + +## Commits +- (see git log; signed-off, Tina4 + Claude co-authors) + +## Status: In Progress diff --git a/tests/test_frond_globals_contract.py b/tests/test_frond_globals_contract.py new file mode 100644 index 00000000..a5608c30 --- /dev/null +++ b/tests/test_frond_globals_contract.py @@ -0,0 +1,84 @@ +"""Frond globals contract — ADR-0085. + +A bare zero-argument callable global is invoked and its RETURN VALUE is used for +both ``{{ g }}`` output and ``{% if g %}`` conditions. Explicit ``g()`` still +works and never double-calls. A non-callable global is unchanged, a global that +returns a callable is called once (not twice), and an unregistered ``nope()`` is +falsy rather than an error. + +Node is the reference implementation; these cases mirror +tina4-nodejs/test/frondGlobalsContract.test.ts against the shared fixture +tina4-documentation/plan/v3/fixtures/frond_globals_contract.json. + +No mocks: the closures are real and Frond renders real template source in memory. +""" +from tina4_python.frond import Frond + + +def _frond(): + Frond.clear_registry() + return Frond() + + +def test_zero_arg_global_closure_returning_false_is_falsy_in_if(): + """zero arg global closure returning false is falsy in if""" + f = _frond() + f.add_global("admin_only", lambda: False) + assert f.render_string("{% if admin_only %}Y{% else %}N{% endif %}") == "N" + + +def test_zero_arg_global_closure_returning_true_is_truthy_in_if(): + """zero arg global closure returning true is truthy in if""" + f = _frond() + f.add_global("admin_only", lambda: True) + assert f.render_string("{% if admin_only %}Y{% else %}N{% endif %}") == "Y" + + +def test_zero_arg_global_closure_prints_its_return_value(): + """zero arg global closure prints its return value""" + f = _frond() + f.add_global("greeting", lambda: "hello") + assert f.render_string("{{ greeting }}") == "hello" + + +def test_explicit_call_syntax_still_works(): + """explicit call syntax still works""" + f = _frond() + f.add_global("admin_only", lambda: False) + assert f.render_string("{% if admin_only() %}Y{% else %}N{% endif %}") == "N" + + +def test_non_callable_global_is_unchanged(): + """non callable global is unchanged""" + f = _frond() + f.add_global("site_name", "Tina4") + assert f.render_string("{{ site_name }}") == "Tina4" + + +def test_global_returning_a_callable_is_not_double_called(): + """global returning a callable is not double called""" + f = _frond() + calls = {"outer": 0, "inner": 0} + + def outer(): + calls["outer"] += 1 + + def inner(): + calls["inner"] += 1 + return "inner" + + return inner + + f.add_global("outer", outer) + # A bare reference calls the global ONCE and yields the inner function; the + # inner function must NOT be invoked (no double-call). + f.render_string("{% if outer %}Y{% endif %}") + assert calls["outer"] == 1 + assert calls["inner"] == 0 + + +def test_unregistered_function_call_is_falsy(): + """unregistered function call is falsy""" + f = _frond() + assert f.render_string("{% if nope() %}Y{% else %}N{% endif %}") == "N" + assert f.render_string("{{ nope() }}") == "" diff --git a/tina4_python/frond/engine.py b/tina4_python/frond/engine.py index fd52929b..1f0f16af 100644 --- a/tina4_python/frond/engine.py +++ b/tina4_python/frond/engine.py @@ -18,6 +18,7 @@ import contextvars import os import re +import inspect import html import hashlib import json @@ -477,6 +478,42 @@ def _resolve_member(value, part: str, context: dict): return None +class _CallableGlobal: + """A zero-argument callable registered through ``add_global`` (ADR-0085). + + Node's Frond auto-calls any function reached by a bare reference and uses + the return value. Python, PHP and Ruby stored the callable instead, so + ``{% if admin_only %}`` with ``add_global("admin_only", lambda: ...)`` was + always truthy. Wrapping a zero-parameter closure/lambda/function in this + marker at registration lets ``_resolve`` invoke it once for a bare + reference, while ``admin_only()`` still calls it through the ordinary + function-call path (the wrapper forwards). A global that needs arguments is + left unwrapped and behaves exactly as before. + """ + + __slots__ = ("fn",) + + def __init__(self, fn): + self.fn = fn + + def __call__(self, *args, **kwargs): + return self.fn(*args, **kwargs) + + +def _wrap_global(value): + """Wrap a bare zero-parameter closure/lambda/function so a bare reference + to it auto-calls (ADR-0085). Anything else — a value, or a callable that + declares parameters, or an arbitrary object with ``__call__`` — is returned + unchanged, matching Node, which only auto-calls plain functions.""" + if inspect.isfunction(value) or inspect.ismethod(value): + try: + if len(inspect.signature(value).parameters) == 0: + return _CallableGlobal(value) + except (TypeError, ValueError): + pass + return value + + def _resolve(expr: str, context: dict): """Resolve a dotted expression against the context. @@ -498,6 +535,11 @@ def _resolve(expr: str, context: dict): if value is None: return None + # ADR-0085: a bare reference to a zero-argument callable global uses its + # return value. ``g()`` never reaches here as a wrapper — the function-call + # path invokes the wrapper directly — so this calls each global at most once. + if isinstance(value, _CallableGlobal): + return value() return value @@ -2069,6 +2111,7 @@ def add_global(cls, instance, name: str, value): Callable as classmethod or instance method. See ``add_filter`` for the dual-call semantics. """ + value = _wrap_global(value) # ADR-0085 if instance is None: cls._class_globals[name] = value else: