Fix defhandler to produce sx wire format instead of HTML
execute_handler was using async_render() which renders all the way to HTML. Fragment providers need to return sx source (s-expression strings) that consuming apps parse and render client-side. Added async_eval_to_sx() — a new execution mode that evaluates I/O primitives and control flow but serializes component/tag calls as sx source instead of rendering them to HTML. This mirrors how the old Python handlers used sx_call() to build sx strings. Also fixed: _ASER_FORMS checked after HTML_TAGS, causing "map" (which is both an HTML tag and an sx special form) to be serialized as a tag instead of evaluated. Moved _ASER_FORMS check before HTML_TAGS. Also fixed: empty? primitive now handles non-len()-able types gracefully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
|
||||
from .evaluator import _expand_macro, EvalError
|
||||
from .primitives import _PRIMITIVES
|
||||
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
|
||||
from .parser import SxExpr, serialize
|
||||
from .html import (
|
||||
HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
|
||||
escape_text, escape_attr, _RawHTML, css_class_collector,
|
||||
@@ -837,3 +838,386 @@ _ASYNC_RENDER_FORMS: dict[str, Any] = {
|
||||
"filter": _arsf_filter,
|
||||
"for-each": _arsf_for_each,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async serialize — evaluate I/O/control flow, produce sx source (not HTML)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Used by defhandler execution. Fragment providers need to return sx wire
|
||||
# format (s-expression source) that consuming apps parse and render client-
|
||||
# side. This mirrors the old Python handlers that used sx_call() to build
|
||||
# sx strings.
|
||||
#
|
||||
# _aser ("async serialize") works like _arender but instead of producing
|
||||
# HTML for components/tags/<>, it serializes them back to sx source with
|
||||
# their arguments evaluated.
|
||||
|
||||
|
||||
async def async_eval_to_sx(
|
||||
expr: Any,
|
||||
env: dict[str, Any],
|
||||
ctx: RequestContext | None = None,
|
||||
) -> str:
|
||||
"""Evaluate *expr* (resolving I/O inline) and produce sx source string.
|
||||
|
||||
Unlike ``async_render`` (which produces HTML), this produces sx wire
|
||||
format suitable for fragment responses that clients render themselves.
|
||||
"""
|
||||
if ctx is None:
|
||||
ctx = RequestContext()
|
||||
result = await _aser(expr, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
return serialize(result)
|
||||
|
||||
|
||||
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values
|
||||
for everything else."""
|
||||
if isinstance(expr, (int, float, bool)):
|
||||
return expr
|
||||
if isinstance(expr, str):
|
||||
return expr
|
||||
if isinstance(expr, SxExpr):
|
||||
return expr
|
||||
if expr is None or expr is NIL:
|
||||
return NIL
|
||||
|
||||
if isinstance(expr, Symbol):
|
||||
name = expr.name
|
||||
if name in env:
|
||||
return env[name]
|
||||
if name in _PRIMITIVES:
|
||||
return _PRIMITIVES[name]
|
||||
if name == "true":
|
||||
return True
|
||||
if name == "false":
|
||||
return False
|
||||
if name == "nil":
|
||||
return NIL
|
||||
raise EvalError(f"Undefined symbol: {name}")
|
||||
|
||||
if isinstance(expr, Keyword):
|
||||
return expr.name
|
||||
|
||||
if isinstance(expr, dict):
|
||||
return {k: await _aser(v, env, ctx) for k, v in expr.items()}
|
||||
|
||||
if not isinstance(expr, list):
|
||||
return expr
|
||||
if not expr:
|
||||
return []
|
||||
|
||||
head = expr[0]
|
||||
if not isinstance(head, (Symbol, Lambda, list)):
|
||||
return [await _aser(x, env, ctx) for x in expr]
|
||||
|
||||
if isinstance(head, Symbol):
|
||||
name = head.name
|
||||
|
||||
# I/O primitives — await, return actual data
|
||||
if name in IO_PRIMITIVES:
|
||||
args, kwargs = await _parse_io_args(expr[1:], env, ctx)
|
||||
return await execute_io(name, args, kwargs, ctx)
|
||||
|
||||
# <> — serialize children as sx fragment
|
||||
if name == "<>":
|
||||
return await _aser_fragment(expr[1:], env, ctx)
|
||||
|
||||
# raw! — serialize
|
||||
if name == "raw!":
|
||||
return await _aser_call("raw!", expr[1:], env, ctx)
|
||||
|
||||
# Component call — serialize (don't expand)
|
||||
if name.startswith("~"):
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
|
||||
# because some names like "map" are both HTML tags and sx forms)
|
||||
sf = _ASER_FORMS.get(name)
|
||||
if sf is not None:
|
||||
return await sf(expr, env, ctx)
|
||||
|
||||
# HTML tag — serialize (don't render to HTML)
|
||||
if name in HTML_TAGS:
|
||||
return await _aser_call(name, expr[1:], env, ctx)
|
||||
|
||||
# Macro expansion
|
||||
if name in env:
|
||||
val = env[name]
|
||||
if isinstance(val, Macro):
|
||||
expanded = _expand_macro(val, expr[1:], env)
|
||||
return await _aser(expanded, env, ctx)
|
||||
|
||||
# Function / lambda call — evaluate (produces data, not rendering)
|
||||
fn = await async_eval(head, env, ctx)
|
||||
args = [await async_eval(a, env, ctx) for a in expr[1:]]
|
||||
|
||||
if callable(fn) and not isinstance(fn, (Lambda, Component)):
|
||||
return fn(*args)
|
||||
if isinstance(fn, Lambda):
|
||||
return await _async_call_lambda(fn, args, env, ctx)
|
||||
if isinstance(fn, Component):
|
||||
# Component invoked as function — serialize the call
|
||||
return await _aser_call(f"~{fn.name}", expr[1:], env, ctx)
|
||||
raise EvalError(f"Not callable: {fn!r}")
|
||||
|
||||
|
||||
async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxExpr:
|
||||
"""Serialize ``(<> child1 child2 ...)`` to sx source."""
|
||||
parts: list[str] = []
|
||||
for child in children:
|
||||
result = await _aser(child, env, ctx)
|
||||
if isinstance(result, list):
|
||||
# Flatten lists (e.g. from map)
|
||||
for item in result:
|
||||
if item is not NIL and item is not None:
|
||||
parts.append(serialize(item))
|
||||
elif result is not NIL and result is not None:
|
||||
parts.append(serialize(result))
|
||||
if not parts:
|
||||
return SxExpr("")
|
||||
return SxExpr("(<> " + " ".join(parts) + ")")
|
||||
|
||||
|
||||
async def _aser_call(
|
||||
name: str, args: list, env: dict, ctx: RequestContext,
|
||||
) -> SxExpr:
|
||||
"""Serialize ``(name :key val child ...)`` — evaluate args but keep
|
||||
as sx source instead of rendering to HTML."""
|
||||
parts = [name]
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
val = await _aser(args[i + 1], env, ctx)
|
||||
if val is not NIL and val is not None:
|
||||
parts.append(f":{arg.name}")
|
||||
parts.append(serialize(val))
|
||||
i += 2
|
||||
else:
|
||||
result = await _aser(arg, env, ctx)
|
||||
if result is not NIL and result is not None:
|
||||
parts.append(serialize(result))
|
||||
i += 1
|
||||
return SxExpr("(" + " ".join(parts) + ")")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialize-mode special forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _assf_if(expr, env, ctx):
|
||||
cond = await async_eval(expr[1], env, ctx)
|
||||
if cond and cond is not NIL:
|
||||
return await _aser(expr[2], env, ctx)
|
||||
if len(expr) > 3:
|
||||
return await _aser(expr[3], env, ctx)
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_when(expr, env, ctx):
|
||||
cond = await async_eval(expr[1], env, ctx)
|
||||
if cond and cond is not NIL:
|
||||
result: Any = NIL
|
||||
for body_expr in expr[2:]:
|
||||
result = await _aser(body_expr, env, ctx)
|
||||
return result
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_let(expr, env, ctx):
|
||||
bindings = expr[1]
|
||||
local = dict(env)
|
||||
if isinstance(bindings, list):
|
||||
if bindings and isinstance(bindings[0], list):
|
||||
for binding in bindings:
|
||||
var = binding[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = await _aser(binding[1], local, ctx)
|
||||
elif len(bindings) % 2 == 0:
|
||||
for i in range(0, len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = await _aser(bindings[i + 1], local, ctx)
|
||||
result: Any = NIL
|
||||
for body_expr in expr[2:]:
|
||||
result = await _aser(body_expr, local, ctx)
|
||||
return result
|
||||
|
||||
|
||||
async def _assf_cond(expr, env, ctx):
|
||||
clauses = expr[1:]
|
||||
if not clauses:
|
||||
return NIL
|
||||
if (isinstance(clauses[0], list) and len(clauses[0]) == 2
|
||||
and not (isinstance(clauses[0][0], Symbol) and clauses[0][0].name in (
|
||||
"=", "<", ">", "<=", ">=", "!=", "and", "or"))):
|
||||
for clause in clauses:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name in ("else", ":else"):
|
||||
return await _aser(clause[1], env, ctx)
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _aser(clause[1], env, ctx)
|
||||
if await async_eval(test, env, ctx):
|
||||
return await _aser(clause[1], env, ctx)
|
||||
else:
|
||||
i = 0
|
||||
while i < len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _aser(result, env, ctx)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return await _aser(result, env, ctx)
|
||||
if await async_eval(test, env, ctx):
|
||||
return await _aser(result, env, ctx)
|
||||
i += 2
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_case(expr, env, ctx):
|
||||
match_val = await async_eval(expr[1], env, ctx)
|
||||
clauses = expr[2:]
|
||||
i = 0
|
||||
while i < len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return await _aser(result, env, ctx)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return await _aser(result, env, ctx)
|
||||
if match_val == await async_eval(test, env, ctx):
|
||||
return await _aser(result, env, ctx)
|
||||
i += 2
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_begin(expr, env, ctx):
|
||||
result: Any = NIL
|
||||
for sub in expr[1:]:
|
||||
result = await _aser(sub, env, ctx)
|
||||
return result
|
||||
|
||||
|
||||
async def _assf_define(expr, env, ctx):
|
||||
await async_eval(expr, env, ctx)
|
||||
return NIL
|
||||
|
||||
|
||||
async def _assf_lambda(expr, env, ctx):
|
||||
return await _asf_lambda(expr, env, ctx)
|
||||
|
||||
|
||||
async def _assf_and(expr, env, ctx):
|
||||
return await _asf_and(expr, env, ctx)
|
||||
|
||||
|
||||
async def _assf_or(expr, env, ctx):
|
||||
return await _asf_or(expr, env, ctx)
|
||||
|
||||
|
||||
async def _assf_quote(expr, env, ctx):
|
||||
return expr[1] if len(expr) > 1 else NIL
|
||||
|
||||
|
||||
async def _assf_quasiquote(expr, env, ctx):
|
||||
return await _async_qq_expand(expr[1], env, ctx)
|
||||
|
||||
|
||||
async def _assf_thread_first(expr, env, ctx):
|
||||
return await _asf_thread_first(expr, env, ctx)
|
||||
|
||||
|
||||
async def _assf_set_bang(expr, env, ctx):
|
||||
return await _asf_set_bang(expr, env, ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialize-mode higher-order forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _asho_ser_map(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
for p, v in zip(fn.params, [item]):
|
||||
local[p] = v
|
||||
results.append(await _aser(fn.body, local, ctx))
|
||||
elif callable(fn):
|
||||
results.append(fn(item))
|
||||
else:
|
||||
raise EvalError(f"map requires callable, got {type(fn).__name__}")
|
||||
return results
|
||||
|
||||
|
||||
async def _asho_ser_map_indexed(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
results = []
|
||||
for i, item in enumerate(coll):
|
||||
if isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = i
|
||||
local[fn.params[1]] = item
|
||||
results.append(await _aser(fn.body, local, ctx))
|
||||
elif callable(fn):
|
||||
results.append(fn(i, item))
|
||||
else:
|
||||
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
|
||||
return results
|
||||
|
||||
|
||||
async def _asho_ser_filter(expr, env, ctx):
|
||||
# filter is value-producing, delegate to eval
|
||||
return await async_eval(expr, env, ctx)
|
||||
|
||||
|
||||
async def _asho_ser_for_each(expr, env, ctx):
|
||||
fn = await async_eval(expr[1], env, ctx)
|
||||
coll = await async_eval(expr[2], env, ctx)
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(await _aser(fn.body, local, ctx))
|
||||
elif callable(fn):
|
||||
results.append(fn(item))
|
||||
return results
|
||||
|
||||
|
||||
_ASER_FORMS: dict[str, Any] = {
|
||||
"if": _assf_if,
|
||||
"when": _assf_when,
|
||||
"cond": _assf_cond,
|
||||
"case": _assf_case,
|
||||
"and": _assf_and,
|
||||
"or": _assf_or,
|
||||
"let": _assf_let,
|
||||
"let*": _assf_let,
|
||||
"lambda": _assf_lambda,
|
||||
"fn": _assf_lambda,
|
||||
"define": _assf_define,
|
||||
"defcomp": _assf_define,
|
||||
"defmacro": _assf_define,
|
||||
"defhandler": _assf_define,
|
||||
"begin": _assf_begin,
|
||||
"do": _assf_begin,
|
||||
"quote": _assf_quote,
|
||||
"quasiquote": _assf_quasiquote,
|
||||
"->": _assf_thread_first,
|
||||
"set!": _assf_set_bang,
|
||||
"map": _asho_ser_map,
|
||||
"map-indexed": _asho_ser_map_indexed,
|
||||
"filter": _asho_ser_filter,
|
||||
"for-each": _asho_ser_for_each,
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ async def execute_handler(
|
||||
4. Return rendered string
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_render
|
||||
from .async_eval import async_eval_to_sx
|
||||
from .types import NIL
|
||||
|
||||
if args is None:
|
||||
@@ -139,8 +139,9 @@ async def execute_handler(
|
||||
# Get request context for I/O primitives
|
||||
ctx = _get_request_context()
|
||||
|
||||
# Async eval+render — I/O primitives are awaited inline
|
||||
return await async_render(handler_def.body, env, ctx)
|
||||
# Async eval → sx source — I/O primitives are awaited inline,
|
||||
# but component/tag calls serialize to sx wire format (not HTML).
|
||||
return await async_eval_to_sx(handler_def.body, env, ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -189,7 +189,10 @@ def prim_is_dict(x: Any) -> bool:
|
||||
def prim_is_empty(coll: Any) -> bool:
|
||||
if coll is None or coll is NIL:
|
||||
return True
|
||||
return len(coll) == 0
|
||||
try:
|
||||
return len(coll) == 0
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
@register_primitive("contains?")
|
||||
def prim_contains(coll: Any, key: Any) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user