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
36 changes: 36 additions & 0 deletions plan/frond-align-globals.md
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions tests/test_frond_globals_contract.py
Original file line number Diff line number Diff line change
@@ -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() }}") == ""
43 changes: 43 additions & 0 deletions tina4_python/frond/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import contextvars
import os
import re
import inspect
import html
import hashlib
import json
Expand Down Expand Up @@ -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.

Expand All @@ -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


Expand Down Expand Up @@ -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:
Expand Down
Loading