Add macros, declarative handlers (defhandler), and convert all fragment routes to sx

Phase 1 — Macros: defmacro + quasiquote syntax (`, ,, ,@) in parser,
evaluator, HTML renderer, and JS mirror. Macro type, expansion, and
round-trip serialization.

Phase 2 — Expanded primitives: app-url, url-for, asset-url, config,
format-date, parse-int (pure); service, request-arg, request-path,
nav-tree, get-children (I/O); jinja-global, relations-from (pure).
Updated _io_service to accept (service "registry-name" "method" :kwargs)
with auto kebab→snake conversion. DTO-to-dict now expands datetime fields
into year/month/day convenience keys. Tuple returns converted to lists.

Phase 3 — Declarative handlers: HandlerDef type, defhandler special form,
handler registry (service → name → HandlerDef), async evaluator+renderer
(async_eval.py) that awaits I/O primitives inline within control flow.
Handler loading from .sx files, execute_handler, blueprint factory.

Phase 4 — Convert all fragment routes: 13 Python fragment handlers across
8 services replaced with declarative .sx handler files. All routes.py
simplified to uniform sx dispatch pattern. Two Jinja HTML handlers
(events/container-cards, events/account-page) kept as Python.

New files: shared/sx/async_eval.py, shared/sx/handlers.py,
shared/sx/tests/test_handlers.py, plus 13 handler .sx files under
{service}/sx/handlers/. MarketService.product_by_slug() added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 00:22:18 +00:00
parent 13bcf755f6
commit ab75e505a8
48 changed files with 2538 additions and 638 deletions

View File

@@ -138,6 +138,8 @@ class MarketService(Protocol):
async def product_by_id(self, session: AsyncSession, product_id: int) -> ProductDTO | None: ...
async def product_by_slug(self, session: AsyncSession, slug: str) -> ProductDTO | None: ...
async def create_marketplace(
self, session: AsyncSession, container_type: str, container_id: int,
name: str, slug: str,

View File

@@ -75,6 +75,12 @@ class SqlMarketService:
).scalar_one_or_none()
return _product_to_dto(product) if product else None
async def product_by_slug(self, session: AsyncSession, slug: str) -> ProductDTO | None:
product = (
await session.execute(select(Product).where(Product.slug == slug))
).scalar_one_or_none()
return _product_to_dto(product) if product else None
async def create_marketplace(
self, session: AsyncSession, container_type: str, container_id: int,
name: str, slug: str,

View File

@@ -50,6 +50,15 @@
}
Component.prototype._component = true;
function Macro(params, restParam, body, closure, name) {
this.params = params;
this.restParam = restParam;
this.body = body;
this.closure = closure || {};
this.name = name || null;
}
Macro.prototype._macro = true;
/** Marker for pre-rendered HTML that bypasses escaping. */
function RawHTML(html) { this.html = html; }
RawHTML.prototype._raw = true;
@@ -58,6 +67,7 @@
function isKw(x) { return x && x._kw === true; }
function isLambda(x) { return x && x._lambda === true; }
function isComponent(x) { return x && x._component === true; }
function isMacro(x) { return x && x._macro === true; }
function isRaw(x) { return x && x._raw === true; }
// =========================================================================
@@ -181,6 +191,16 @@
if (raw === "(") { tok.next(); return parseList(tok, ")"); }
if (raw === "[") { tok.next(); return parseList(tok, "]"); }
if (raw === "{") { tok.next(); return parseMap(tok); }
// Quasiquote syntax
if (raw === "`") { tok._advance(1); return [new Symbol("quasiquote"), parseExpr(tok)]; }
if (raw === ",") {
tok._advance(1);
if (tok.pos < tok.text.length && tok.text[tok.pos] === "@") {
tok._advance(1);
return [new Symbol("splice-unquote"), parseExpr(tok)];
}
return [new Symbol("unquote"), parseExpr(tok)];
}
return tok.next();
}
@@ -372,6 +392,15 @@
if (sf) return sf(expr, env);
var ho = HO_FORMS[head.name];
if (ho) return ho(expr, env);
// Macro expansion
if (head.name in env) {
var macroVal = env[head.name];
if (isMacro(macroVal)) {
var expanded = expandMacro(macroVal, expr.slice(1), env);
return sxEval(expanded, env);
}
}
}
// Function call
@@ -576,6 +605,64 @@
return result;
};
SPECIAL_FORMS["defmacro"] = function (expr, env) {
var nameSym = expr[1];
var paramsExpr = expr[2];
var params = [], restParam = null;
for (var i = 0; i < paramsExpr.length; i++) {
var p = paramsExpr[i];
if (isSym(p) && p.name === "&rest") {
if (i + 1 < paramsExpr.length) {
var rp = paramsExpr[i + 1];
restParam = isSym(rp) ? rp.name : String(rp);
}
break;
}
if (isSym(p)) params.push(p.name);
else if (typeof p === "string") params.push(p);
}
var macro = new Macro(params, restParam, expr[3], merge({}, env), nameSym.name);
env[nameSym.name] = macro;
return macro;
};
SPECIAL_FORMS["quasiquote"] = function (expr, env) {
return qqExpand(expr[1], env);
};
function qqExpand(template, env) {
if (!Array.isArray(template)) return template;
if (!template.length) return [];
var head = template[0];
if (isSym(head)) {
if (head.name === "unquote") return sxEval(template[1], env);
if (head.name === "splice-unquote") throw new Error("splice-unquote not inside a list");
}
var result = [];
for (var i = 0; i < template.length; i++) {
var item = template[i];
if (Array.isArray(item) && item.length === 2 && isSym(item[0]) && item[0].name === "splice-unquote") {
var spliced = sxEval(item[1], env);
if (Array.isArray(spliced)) { for (var j = 0; j < spliced.length; j++) result.push(spliced[j]); }
else if (!isNil(spliced)) result.push(spliced);
} else {
result.push(qqExpand(item, env));
}
}
return result;
}
function expandMacro(macro, rawArgs, env) {
var local = merge({}, macro.closure, env);
for (var i = 0; i < macro.params.length; i++) {
local[macro.params[i]] = i < rawArgs.length ? rawArgs[i] : NIL;
}
if (macro.restParam !== null) {
local[macro.restParam] = rawArgs.slice(macro.params.length);
}
return sxEval(macro.body, local);
}
// --- Higher-order forms --------------------------------------------------
var HO_FORMS = {};
@@ -772,6 +859,8 @@
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defmacro"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defhandler"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["map"] = function (expr, env) {
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
@@ -913,6 +1002,12 @@
// Render-aware special forms
if (RENDER_FORMS[name]) return RENDER_FORMS[name](expr, env);
// Macro expansion
if (name in env && isMacro(env[name])) {
var mExpanded = expandMacro(env[name], expr.slice(1), env);
return renderDOM(mExpanded, env);
}
// HTML tag
if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env);
@@ -1051,7 +1146,13 @@
for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env));
return bs.join("");
}
if (name === "define" || name === "defcomp") { sxEval(expr, env); return ""; }
if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; }
// Macro expansion in string renderer
if (name in env && isMacro(env[name])) {
var smExp = expandMacro(env[name], expr.slice(1), env);
return renderStr(smExp, env);
}
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
if (name === "map") {

View File

@@ -19,8 +19,10 @@ Quick start::
from .types import (
NIL,
Component,
HandlerDef,
Keyword,
Lambda,
Macro,
Symbol,
)
from .parser import (
@@ -46,7 +48,9 @@ __all__ = [
"Symbol",
"Keyword",
"Lambda",
"Macro",
"Component",
"HandlerDef",
"NIL",
# Parser
"parse",

837
shared/sx/async_eval.py Normal file
View File

@@ -0,0 +1,837 @@
"""
Async s-expression evaluator and HTML renderer.
Mirrors the sync evaluator (evaluator.py) and HTML renderer (html.py) but
every step is ``async`` so I/O primitives can be ``await``ed inline.
This is the execution engine for ``defhandler`` — handlers contain I/O
calls (``query``, ``service``, ``request-arg``, etc.) interleaved with
control flow (``if``, ``let``, ``map``, ``when``). The sync
collect-then-substitute resolver can't handle data dependencies between
I/O results and control flow, so handlers need inline async evaluation.
Usage::
from shared.sx.async_eval import async_render
html = await async_render(handler_def.body, env, ctx)
"""
from __future__ import annotations
from typing import Any
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 .html import (
HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
escape_text, escape_attr, _RawHTML, css_class_collector,
)
# ---------------------------------------------------------------------------
# Async evaluate
# ---------------------------------------------------------------------------
async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Evaluate *expr* in *env*, awaiting I/O primitives inline."""
# --- literals ---
if isinstance(expr, (int, float, str, bool)):
return expr
if expr is None or expr is NIL:
return NIL
# --- symbol lookup ---
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}")
# --- keyword ---
if isinstance(expr, Keyword):
return expr.name
# --- dict literal ---
if isinstance(expr, dict):
return {k: await async_eval(v, env, ctx) for k, v in expr.items()}
# --- list ---
if not isinstance(expr, list):
return expr
if not expr:
return []
head = expr[0]
if not isinstance(head, (Symbol, Lambda, list)):
return [await async_eval(x, env, ctx) for x in expr]
if isinstance(head, Symbol):
name = head.name
# I/O primitives — await inline
if name in IO_PRIMITIVES:
args, kwargs = await _parse_io_args(expr[1:], env, ctx)
return await execute_io(name, args, kwargs, ctx)
# Special forms
sf = _ASYNC_SPECIAL_FORMS.get(name)
if sf is not None:
return await sf(expr, env, ctx)
ho = _ASYNC_HO_FORMS.get(name)
if ho is not None:
return await ho(expr, env, ctx)
# Macro expansion
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await async_eval(expanded, env, ctx)
# Render forms in eval position — delegate to renderer and return
# the HTML string. Allows (let ((x (<> ...))) ...) etc.
if name in ("<>", "raw!") or name in HTML_TAGS:
return await _arender(expr, env, ctx)
# --- function / lambda call ---
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):
return await _async_call_component(fn, expr[1:], env, ctx)
raise EvalError(f"Not callable: {fn!r}")
async def _parse_io_args(
exprs: list[Any], env: dict[str, Any], ctx: RequestContext,
) -> tuple[list[Any], dict[str, Any]]:
"""Parse and evaluate I/O node args."""
args: list[Any] = []
kwargs: dict[str, Any] = {}
i = 0
while i < len(exprs):
item = exprs[i]
if isinstance(item, Keyword) and i + 1 < len(exprs):
kwargs[item.name] = await async_eval(exprs[i + 1], env, ctx)
i += 2
else:
args.append(await async_eval(item, env, ctx))
i += 1
return args, kwargs
async def _async_call_lambda(
fn: Lambda, args: list[Any], caller_env: dict[str, Any], ctx: RequestContext,
) -> Any:
if len(args) != len(fn.params):
raise EvalError(f"{fn!r} expects {len(fn.params)} args, got {len(args)}")
local = dict(fn.closure)
local.update(caller_env)
for p, v in zip(fn.params, args):
local[p] = v
return await async_eval(fn.body, local, ctx)
async def _async_call_component(
comp: Component, raw_args: list[Any], env: dict[str, Any], ctx: RequestContext,
) -> Any:
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(raw_args):
arg = raw_args[i]
if isinstance(arg, Keyword) and i + 1 < len(raw_args):
kwargs[arg.name] = await async_eval(raw_args[i + 1], env, ctx)
i += 2
else:
children.append(await async_eval(arg, env, ctx))
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
local["children"] = children
return await async_eval(comp.body, local, ctx)
# ---------------------------------------------------------------------------
# Async special forms
# ---------------------------------------------------------------------------
async def _asf_if(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
return await async_eval(expr[2], env, ctx)
if len(expr) > 3:
return await async_eval(expr[3], env, ctx)
return NIL
async def _asf_when(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
result = NIL
for body_expr in expr[2:]:
result = await async_eval(body_expr, env, ctx)
return result
return NIL
async def _asf_and(expr, env, ctx):
result: Any = True
for arg in expr[1:]:
result = await async_eval(arg, env, ctx)
if not result:
return result
return result
async def _asf_or(expr, env, ctx):
result: Any = False
for arg in expr[1:]:
result = await async_eval(arg, env, ctx)
if result:
return result
return result
async def _asf_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 async_eval(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 async_eval(bindings[i + 1], local, ctx)
result: Any = NIL
for body_expr in expr[2:]:
result = await async_eval(body_expr, local, ctx)
return result
async def _asf_lambda(expr, env, ctx):
params_expr = expr[1]
param_names = []
for p in params_expr:
if isinstance(p, Symbol):
param_names.append(p.name)
elif isinstance(p, str):
param_names.append(p)
return Lambda(param_names, expr[2], dict(env))
async def _asf_define(expr, env, ctx):
name_sym = expr[1]
value = await async_eval(expr[2], env, ctx)
if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name
env[name_sym.name] = value
return value
async def _asf_defcomp(expr, env, ctx):
from .evaluator import _sf_defcomp
return _sf_defcomp(expr, env)
async def _asf_defmacro(expr, env, ctx):
from .evaluator import _sf_defmacro
return _sf_defmacro(expr, env)
async def _asf_defhandler(expr, env, ctx):
from .evaluator import _sf_defhandler
return _sf_defhandler(expr, env)
async def _asf_begin(expr, env, ctx):
result: Any = NIL
for sub in expr[1:]:
result = await async_eval(sub, env, ctx)
return result
async def _asf_quote(expr, env, ctx):
return expr[1] if len(expr) > 1 else NIL
async def _asf_quasiquote(expr, env, ctx):
return await _async_qq_expand(expr[1], env, ctx)
async def _async_qq_expand(template, env, ctx):
if not isinstance(template, list):
return template
if not template:
return []
head = template[0]
if isinstance(head, Symbol):
if head.name == "unquote":
return await async_eval(template[1], env, ctx)
if head.name == "splice-unquote":
raise EvalError("splice-unquote not inside a list")
result: list[Any] = []
for item in template:
if (isinstance(item, list) and len(item) == 2
and isinstance(item[0], Symbol) and item[0].name == "splice-unquote"):
spliced = await async_eval(item[1], env, ctx)
if isinstance(spliced, list):
result.extend(spliced)
elif spliced is not None and spliced is not NIL:
result.append(spliced)
else:
result.append(await _async_qq_expand(item, env, ctx))
return result
async def _asf_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 async_eval(clause[1], env, ctx)
if isinstance(test, Keyword) and test.name == "else":
return await async_eval(clause[1], env, ctx)
if await async_eval(test, env, ctx):
return await async_eval(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 async_eval(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await async_eval(result, env, ctx)
if await async_eval(test, env, ctx):
return await async_eval(result, env, ctx)
i += 2
return NIL
async def _asf_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 async_eval(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await async_eval(result, env, ctx)
if match_val == await async_eval(test, env, ctx):
return await async_eval(result, env, ctx)
i += 2
return NIL
async def _asf_thread_first(expr, env, ctx):
result = await async_eval(expr[1], env, ctx)
for form in expr[2:]:
if isinstance(form, list):
fn = await async_eval(form[0], env, ctx)
args = [result] + [await async_eval(a, env, ctx) for a in form[1:]]
else:
fn = await async_eval(form, env, ctx)
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
elif isinstance(fn, Lambda):
result = await _async_call_lambda(fn, args, env, ctx)
else:
raise EvalError(f"-> form not callable: {fn!r}")
return result
async def _asf_set_bang(expr, env, ctx):
value = await async_eval(expr[2], env, ctx)
env[expr[1].name] = value
return value
_ASYNC_SPECIAL_FORMS: dict[str, Any] = {
"if": _asf_if,
"when": _asf_when,
"cond": _asf_cond,
"case": _asf_case,
"and": _asf_and,
"or": _asf_or,
"let": _asf_let,
"let*": _asf_let,
"lambda": _asf_lambda,
"fn": _asf_lambda,
"define": _asf_define,
"defcomp": _asf_defcomp,
"defmacro": _asf_defmacro,
"defhandler": _asf_defhandler,
"begin": _asf_begin,
"do": _asf_begin,
"quote": _asf_quote,
"quasiquote": _asf_quasiquote,
"->": _asf_thread_first,
"set!": _asf_set_bang,
}
# ---------------------------------------------------------------------------
# Async higher-order forms
# ---------------------------------------------------------------------------
async def _aho_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):
results.append(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
results.append(fn(item))
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
async def _aho_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):
results.append(await _async_call_lambda(fn, [i, item], env, 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 _aho_filter(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):
val = await _async_call_lambda(fn, [item], env, ctx)
elif callable(fn):
val = fn(item)
else:
raise EvalError(f"filter requires callable, got {type(fn).__name__}")
if val:
results.append(item)
return results
async def _aho_reduce(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
acc = await async_eval(expr[2], env, ctx)
coll = await async_eval(expr[3], env, ctx)
for item in coll:
acc = await _async_call_lambda(fn, [acc, item], env, ctx) if isinstance(fn, Lambda) else fn(acc, item)
return acc
async def _aho_some(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
result = await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)
if result:
return result
return NIL
async def _aho_every(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if not (await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)):
return False
return True
async def _aho_for_each(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if isinstance(fn, Lambda):
await _async_call_lambda(fn, [item], env, ctx)
elif callable(fn):
fn(item)
return NIL
_ASYNC_HO_FORMS: dict[str, Any] = {
"map": _aho_map,
"map-indexed": _aho_map_indexed,
"filter": _aho_filter,
"reduce": _aho_reduce,
"some": _aho_some,
"every?": _aho_every,
"for-each": _aho_for_each,
}
# ---------------------------------------------------------------------------
# Async HTML renderer
# ---------------------------------------------------------------------------
async def async_render(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Render an s-expression to HTML, awaiting I/O primitives inline."""
if ctx is None:
ctx = RequestContext()
return await _arender(expr, env, ctx)
async def _arender(expr: Any, env: dict[str, Any], ctx: RequestContext) -> str:
if expr is None or expr is NIL or expr is False or expr is True:
return ""
if isinstance(expr, _RawHTML):
return expr.html
if isinstance(expr, str):
return escape_text(expr)
if isinstance(expr, (int, float)):
return escape_text(str(expr))
if isinstance(expr, Symbol):
val = await async_eval(expr, env, ctx)
return await _arender(val, env, ctx)
if isinstance(expr, Keyword):
return escape_text(expr.name)
if isinstance(expr, list):
if not expr:
return ""
return await _arender_list(expr, env, ctx)
if isinstance(expr, dict):
return ""
return escape_text(str(expr))
async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) -> str:
head = expr[0]
if isinstance(head, Symbol):
name = head.name
# I/O primitive — await, then render result
if name in IO_PRIMITIVES:
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
# raw!
if name == "raw!":
parts = []
for arg in expr[1:]:
val = await async_eval(arg, env, ctx)
if isinstance(val, _RawHTML):
parts.append(val.html)
elif isinstance(val, str):
parts.append(val)
elif val is not None and val is not NIL:
parts.append(str(val))
return "".join(parts)
# <>
if name == "<>":
parts = []
for child in expr[1:]:
parts.append(await _arender(child, env, ctx))
return "".join(parts)
# Render-aware special forms
arsf = _ASYNC_RENDER_FORMS.get(name)
if arsf is not None:
return await arsf(expr, env, ctx)
# Macro expansion
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await _arender(expanded, env, ctx)
# HTML tag
if name in HTML_TAGS:
return await _arender_element(name, expr[1:], env, ctx)
# Component
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Component):
return await _arender_component(val, expr[1:], env, ctx)
# Fallback — evaluate then render
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
if isinstance(head, (Lambda, list)):
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
# Data list
parts = []
for item in expr:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
async def _arender_element(
tag: str, args: list, env: dict[str, Any], ctx: RequestContext,
) -> str:
attrs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
attr_val = await async_eval(args[i + 1], env, ctx)
attrs[arg.name] = attr_val
i += 2
else:
children.append(arg)
i += 1
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
if collector is not None:
collector.update(str(class_val).split())
parts = [f"<{tag}"]
for attr_name, attr_val in attrs.items():
if attr_val is None or attr_val is NIL or attr_val is False:
continue
if attr_name in BOOLEAN_ATTRS:
if attr_val:
parts.append(f" {attr_name}")
elif attr_val is True:
parts.append(f" {attr_name}")
else:
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
parts.append(">")
opening = "".join(parts)
if tag in VOID_ELEMENTS:
return opening
child_parts = []
for child in children:
child_parts.append(await _arender(child, env, ctx))
return f"{opening}{''.join(child_parts)}</{tag}>"
async def _arender_component(
comp: Component, args: list, env: dict[str, Any], ctx: RequestContext,
) -> str:
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await async_eval(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_html = []
for c in children:
child_html.append(await _arender(c, env, ctx))
local["children"] = _RawHTML("".join(child_html))
return await _arender(comp.body, local, ctx)
async def _arender_lambda(
fn: Lambda, args: tuple, env: dict[str, Any], ctx: RequestContext,
) -> str:
local = dict(fn.closure)
local.update(env)
for p, v in zip(fn.params, args):
local[p] = v
return await _arender(fn.body, local, ctx)
# ---------------------------------------------------------------------------
# Async render-aware special forms
# ---------------------------------------------------------------------------
async def _arsf_if(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
return await _arender(expr[2], env, ctx)
if len(expr) > 3:
return await _arender(expr[3], env, ctx)
return ""
async def _arsf_when(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
if cond and cond is not NIL:
parts = []
for body_expr in expr[2:]:
parts.append(await _arender(body_expr, env, ctx))
return "".join(parts)
return ""
async def _arsf_cond(expr, env, ctx):
clauses = expr[1:]
if not clauses:
return ""
if isinstance(clauses[0], list) and len(clauses[0]) == 2:
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return await _arender(clause[1], env, ctx)
if isinstance(test, Keyword) and test.name == "else":
return await _arender(clause[1], env, ctx)
if await async_eval(test, env, ctx):
return await _arender(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 _arender(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await _arender(result, env, ctx)
if await async_eval(test, env, ctx):
return await _arender(result, env, ctx)
i += 2
return ""
async def _arsf_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 async_eval(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 async_eval(bindings[i + 1], local, ctx)
parts = []
for body_expr in expr[2:]:
parts.append(await _arender(body_expr, local, ctx))
return "".join(parts)
async def _arsf_begin(expr, env, ctx):
parts = []
for sub in expr[1:]:
parts.append(await _arender(sub, env, ctx))
return "".join(parts)
async def _arsf_define(expr, env, ctx):
await async_eval(expr, env, ctx)
return ""
async def _arsf_map(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
async def _arsf_map_indexed(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
parts = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (i, item), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(i, item), env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
async def _arsf_filter(expr, env, ctx):
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
async def _arsf_for_each(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
_ASYNC_RENDER_FORMS: dict[str, Any] = {
"if": _arsf_if,
"when": _arsf_when,
"cond": _arsf_cond,
"let": _arsf_let,
"let*": _arsf_let,
"begin": _arsf_begin,
"do": _arsf_begin,
"define": _arsf_define,
"defcomp": _arsf_define,
"defmacro": _arsf_define,
"defhandler": _arsf_define,
"map": _arsf_map,
"map-indexed": _arsf_map_indexed,
"filter": _arsf_filter,
"for-each": _arsf_for_each,
}

View File

@@ -33,7 +33,7 @@ from __future__ import annotations
from typing import Any
from .types import Component, Keyword, Lambda, NIL, RelationDef, Symbol
from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, RelationDef, Symbol
from .primitives import _PRIMITIVES
@@ -117,6 +117,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
if ho is not None:
return ho(expr, env)
# Macro expansion — if head resolves to a Macro, expand then eval
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return _eval(expanded, env)
# --- function / lambda call -------------------------------------------
fn = _eval(head, env)
args = [_eval(a, env) for a in expr[1:]]
@@ -417,6 +424,135 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
return result
def _sf_defmacro(expr: list, env: dict) -> Macro:
"""``(defmacro name (params... &rest rest) body)``"""
if len(expr) < 4:
raise EvalError("defmacro requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defmacro name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defmacro params must be a list")
params: list[str] = []
rest_param: str | None = None
i = 0
while i < len(params_expr):
p = params_expr[i]
if isinstance(p, Symbol) and p.name == "&rest":
if i + 1 < len(params_expr):
rp = params_expr[i + 1]
rest_param = rp.name if isinstance(rp, Symbol) else str(rp)
break
if isinstance(p, Symbol):
params.append(p.name)
elif isinstance(p, str):
params.append(p)
i += 1
macro = Macro(
params=params,
rest_param=rest_param,
body=expr[3],
closure=dict(env),
name=name_sym.name,
)
env[name_sym.name] = macro
return macro
def _sf_quasiquote(expr: list, env: dict) -> Any:
"""``(quasiquote template)`` — process quasiquote template."""
if len(expr) < 2:
raise EvalError("quasiquote requires a template")
return _qq_expand(expr[1], env)
def _qq_expand(template: Any, env: dict) -> Any:
"""Walk a quasiquote template, replacing unquote/splice-unquote."""
if not isinstance(template, list):
return template
if not template:
return []
# Check for (unquote x) or (splice-unquote x)
head = template[0]
if isinstance(head, Symbol):
if head.name == "unquote":
if len(template) < 2:
raise EvalError("unquote requires an expression")
return _eval(template[1], env)
if head.name == "splice-unquote":
raise EvalError("splice-unquote not inside a list")
# Walk children, handling splice-unquote
result: list[Any] = []
for item in template:
if isinstance(item, list) and len(item) == 2 and isinstance(item[0], Symbol) and item[0].name == "splice-unquote":
spliced = _eval(item[1], env)
if isinstance(spliced, list):
result.extend(spliced)
elif spliced is not None and spliced is not NIL:
result.append(spliced)
else:
result.append(_qq_expand(item, env))
return result
def _expand_macro(macro: Macro, raw_args: list[Any], env: dict) -> Any:
"""Expand a macro: bind unevaluated args, evaluate body to get new AST."""
local = dict(macro.closure)
local.update(env)
# Bind positional params
for i, param in enumerate(macro.params):
if i < len(raw_args):
local[param] = raw_args[i]
else:
local[param] = NIL
# Bind &rest param
if macro.rest_param is not None:
rest_start = len(macro.params)
local[macro.rest_param] = list(raw_args[rest_start:])
return _eval(macro.body, local)
def _sf_defhandler(expr: list, env: dict) -> HandlerDef:
"""``(defhandler name (&key param...) body)``"""
if len(expr) < 4:
raise EvalError("defhandler requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defhandler name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defhandler params must be a list")
params: list[str] = []
in_key = False
for p in params_expr:
if isinstance(p, Symbol):
if p.name == "&key":
in_key = True
continue
if in_key:
params.append(p.name)
elif isinstance(p, str):
params.append(p)
handler = HandlerDef(
name=name_sym.name,
params=params,
body=expr[3],
closure=dict(env),
)
env[f"handler:{name_sym.name}"] = handler
return handler
def _sf_set_bang(expr: list, env: dict) -> Any:
"""``(set! name value)`` — mutate existing binding."""
if len(expr) != 3:
@@ -518,6 +654,9 @@ _SPECIAL_FORMS: dict[str, Any] = {
"quote": _sf_quote,
"->": _sf_thread_first,
"set!": _sf_set_bang,
"defmacro": _sf_defmacro,
"quasiquote": _sf_quasiquote,
"defhandler": _sf_defhandler,
}

205
shared/sx/handlers.py Normal file
View File

@@ -0,0 +1,205 @@
"""
Declarative handler registry and blueprint factory.
Supports ``defhandler`` s-expressions that define fragment handlers
in .sx files instead of Python. Each handler is a self-contained
s-expression with a bounded primitive vocabulary, providing a clear
security boundary and AI legibility.
Usage::
from shared.sx.handlers import create_handler_blueprint, load_handler_file
# Load handler definitions from .sx files
load_handler_file("blog/sx/handlers/link-card.sx", "blog")
# Create a blueprint that dispatches to both sx and Python handlers
bp = create_handler_blueprint("blog")
bp.add_python_handler("nav-tree", nav_tree_handler_fn)
"""
from __future__ import annotations
import logging
import os
from typing import Any, Callable, Awaitable
from .types import HandlerDef
logger = logging.getLogger("sx.handlers")
# ---------------------------------------------------------------------------
# Registry — service → handler-name → HandlerDef
# ---------------------------------------------------------------------------
_HANDLER_REGISTRY: dict[str, dict[str, HandlerDef]] = {}
def register_handler(service: str, handler_def: HandlerDef) -> None:
"""Register a handler definition for a service."""
if service not in _HANDLER_REGISTRY:
_HANDLER_REGISTRY[service] = {}
_HANDLER_REGISTRY[service][handler_def.name] = handler_def
logger.debug("Registered handler %s:%s", service, handler_def.name)
def get_handler(service: str, name: str) -> HandlerDef | None:
"""Look up a registered handler by service and name."""
return _HANDLER_REGISTRY.get(service, {}).get(name)
def get_all_handlers(service: str) -> dict[str, HandlerDef]:
"""Return all handlers for a service."""
return dict(_HANDLER_REGISTRY.get(service, {}))
def clear_handlers(service: str | None = None) -> None:
"""Clear handler registry. If service given, clear only that service."""
if service is None:
_HANDLER_REGISTRY.clear()
else:
_HANDLER_REGISTRY.pop(service, None)
# ---------------------------------------------------------------------------
# Loading — parse .sx files and collect HandlerDef instances
# ---------------------------------------------------------------------------
def load_handler_file(filepath: str, service_name: str) -> list[HandlerDef]:
"""Parse an .sx file, evaluate it, and register any HandlerDef values."""
from .parser import parse_all
from .evaluator import _eval
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
source = f.read()
# Seed env with component definitions so handlers can reference components
env = dict(get_component_env())
exprs = parse_all(source)
handlers: list[HandlerDef] = []
for expr in exprs:
_eval(expr, env)
# Collect all HandlerDef values from the env
for key, val in env.items():
if isinstance(val, HandlerDef):
register_handler(service_name, val)
handlers.append(val)
return handlers
def load_handler_dir(directory: str, service_name: str) -> list[HandlerDef]:
"""Load all .sx files from a directory and register handlers."""
import glob as glob_mod
handlers: list[HandlerDef] = []
for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))):
handlers.extend(load_handler_file(filepath, service_name))
return handlers
# ---------------------------------------------------------------------------
# Handler execution
# ---------------------------------------------------------------------------
async def execute_handler(
handler_def: HandlerDef,
service_name: str,
args: dict[str, str] | None = None,
) -> str:
"""Execute a declarative handler and return rendered sx/HTML string.
Uses the async evaluator+renderer so I/O primitives (``query``,
``service``, ``request-arg``, etc.) are awaited inline within
control flow — no collect-then-substitute limitations.
1. Build env from component env + handler closure
2. Bind handler params from args (typically request.args)
3. Evaluate + render via async_render (handles I/O inline)
4. Return rendered string
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_render
from .types import NIL
if args is None:
args = {}
# Build environment
env = dict(get_component_env())
env.update(handler_def.closure)
# Bind handler params from request args
for param in handler_def.params:
env[param] = args.get(param, args.get(param.replace("-", "_"), NIL))
# 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)
# ---------------------------------------------------------------------------
# Blueprint factory
# ---------------------------------------------------------------------------
def create_handler_blueprint(service_name: str) -> Any:
"""Create a Quart Blueprint that dispatches fragment requests to
both sx-defined handlers and Python handler functions.
Usage::
bp = create_handler_blueprint("blog")
bp.add_python_handler("nav-tree", my_python_handler)
app.register_blueprint(bp)
"""
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
bp = Blueprint(
f"sx_handlers_{service_name}",
__name__,
url_prefix="/internal/fragments",
)
# Python-side handler overrides
_python_handlers: dict[str, Callable[[], Awaitable[str]]] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
# 1. Check Python handlers first (manual overrides)
py_handler = _python_handlers.get(fragment_type)
if py_handler is not None:
result = await py_handler()
return Response(result, status=200, content_type="text/sx")
# 2. Check sx handler registry
handler_def = get_handler(service_name, fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def,
service_name,
args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
# 3. No handler found — return empty
return Response("", status=200, content_type="text/sx")
def add_python_handler(name: str, fn: Callable[[], Awaitable[str]]) -> None:
"""Register a Python async function as a fragment handler."""
_python_handlers[name] = fn
bp.add_python_handler = add_python_handler # type: ignore[attr-defined]
bp._python_handlers = _python_handlers # type: ignore[attr-defined]
return bp

View File

@@ -314,15 +314,15 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
def components_for_request() -> str:
"""Return defcomp source for components the client doesn't have yet.
"""Return defcomp/defmacro source for definitions the client doesn't have yet.
Reads the ``SX-Components`` header (comma-separated component names
like ``~card,~nav-item``) and returns only the definitions the client
is missing. If the header is absent, returns all component defs.
is missing. If the header is absent, returns all defs.
"""
from quart import request
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
from .types import Component
from .types import Component, Macro
from .parser import serialize
loaded_raw = request.headers.get("SX-Components", "")
@@ -338,18 +338,26 @@ def components_for_request() -> str:
loaded = set(loaded_raw.split(","))
parts = []
for key, val in _COMPONENT_ENV.items():
if not isinstance(val, Component):
continue
# Skip components the client already has
if f"~{val.name}" in loaded or val.name in loaded:
continue
# Reconstruct defcomp source
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
if isinstance(val, Component):
# Skip components the client already has
if f"~{val.name}" in loaded or val.name in loaded:
continue
# Reconstruct defcomp source
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Macro):
if val.name in loaded:
continue
param_strs = list(val.params)
if val.rest_param:
param_strs.extend(["&rest", val.rest_param])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
return "\n".join(parts)

View File

@@ -27,8 +27,8 @@ from __future__ import annotations
import contextvars
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval, _call_component
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .evaluator import _eval, _call_component, _expand_macro
# ContextVar for collecting CSS class names during render.
# Set to a set[str] to collect; None to skip.
@@ -360,6 +360,8 @@ _RENDER_FORMS: dict[str, Any] = {
"map-indexed": _rsf_map_indexed,
"filter": _rsf_filter,
"for-each": _rsf_for_each,
"defmacro": _rsf_define, # side-effect only, returns ""
"defhandler": _rsf_define, # side-effect only, returns ""
}
@@ -420,6 +422,13 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
if name in _RENDER_FORMS:
return _RENDER_FORMS[name](expr, env)
# --- Macro expansion → expand then render --------------------------
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return _render(expanded, env)
# --- HTML tag → render as element ---------------------------------
if name in HTML_TAGS:
return _render_element(name, expr[1:], env)

View File

@@ -25,7 +25,7 @@ import hashlib
import os
from typing import Any
from .types import NIL, Component, Keyword, Symbol
from .types import NIL, Component, Keyword, Macro, Symbol
from .parser import parse
from .html import render as html_render, _render_component
@@ -54,7 +54,7 @@ def get_component_hash() -> str:
def _compute_component_hash() -> None:
"""Recompute _COMPONENT_HASH from all registered Component definitions."""
"""Recompute _COMPONENT_HASH from all registered Component and Macro definitions."""
global _COMPONENT_HASH
from .parser import serialize
parts = []
@@ -67,6 +67,13 @@ def _compute_component_hash() -> None:
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Macro):
param_strs = list(val.params)
if val.rest_param:
param_strs.extend(["&rest", val.rest_param])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
if parts:
digest = hashlib.sha256("\n".join(parts).encode()).hexdigest()[:12]
_COMPONENT_HASH = digest
@@ -118,13 +125,33 @@ def reload_if_changed() -> None:
load_sx_dir(directory)
def load_service_components(service_dir: str) -> None:
"""Load service-specific s-expression components from {service_dir}/sx/."""
def load_service_components(service_dir: str, service_name: str | None = None) -> None:
"""Load service-specific s-expression components and handlers.
Components from ``{service_dir}/sx/`` and handlers from
``{service_dir}/sx/handlers/`` or ``{service_dir}/sx/handlers.sx``.
"""
sx_dir = os.path.join(service_dir, "sx")
if os.path.isdir(sx_dir):
load_sx_dir(sx_dir)
watch_sx_dir(sx_dir)
# Load handler definitions if service_name is provided
if service_name:
load_handler_dir(os.path.join(sx_dir, "handlers"), service_name)
# Also check for a single handlers.sx file
handlers_file = os.path.join(sx_dir, "handlers.sx")
if os.path.isfile(handlers_file):
from .handlers import load_handler_file
load_handler_file(handlers_file, service_name)
def load_handler_dir(directory: str, service_name: str) -> None:
"""Load handler .sx files from a directory if it exists."""
if os.path.isdir(directory):
from .handlers import load_handler_dir as _load
_load(directory, service_name)
def register_components(sx_source: str) -> None:
"""Parse and evaluate s-expression component definitions into the
@@ -262,17 +289,25 @@ def client_components_tag(*names: str) -> str:
from .parser import serialize
parts = []
for key, val in _COMPONENT_ENV.items():
if not isinstance(val, Component):
continue
if names and val.name not in names and key.lstrip("~") not in names:
continue
# Reconstruct defcomp source from the Component object
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
if isinstance(val, Component):
if names and val.name not in names and key.lstrip("~") not in names:
continue
# Reconstruct defcomp source from the Component object
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Macro):
if names and val.name not in names:
continue
param_strs = list(val.params)
if val.rest_param:
param_strs.extend(["&rest", val.rest_param])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
if not parts:
return ""
source = "\n".join(parts)

View File

@@ -225,6 +225,20 @@ def _parse_expr(tok: Tokenizer) -> Any:
if raw == "{":
tok.next_token() # consume the '{'
return _parse_map(tok)
# Quasiquote syntax: ` , ,@
if raw == "`":
tok._advance(1) # consume the backtick
inner = _parse_expr(tok)
return [Symbol("quasiquote"), inner]
if raw == ",":
tok._advance(1) # consume the comma
# Check for splice-unquote (,@) — no whitespace between , and @
if tok.pos < len(tok.text) and tok.text[tok.pos] == "@":
tok._advance(1) # consume the @
inner = _parse_expr(tok)
return [Symbol("splice-unquote"), inner]
inner = _parse_expr(tok)
return [Symbol("unquote"), inner]
# Everything else: strings, keywords, symbols, numbers
token = tok.next_token()
return token
@@ -276,6 +290,15 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
if isinstance(expr, list):
if not expr:
return "()"
# Quasiquote sugar: [Symbol("quasiquote"), x] → `x
if (len(expr) == 2 and isinstance(expr[0], Symbol)):
name = expr[0].name
if name == "quasiquote":
return "`" + serialize(expr[1], indent, pretty)
if name == "unquote":
return "," + serialize(expr[1], indent, pretty)
if name == "splice-unquote":
return ",@" + serialize(expr[1], indent, pretty)
if pretty:
return _serialize_pretty(expr, indent)
items = [serialize(item, indent, False) for item in expr]

View File

@@ -408,6 +408,84 @@ def prim_into(target: Any, coll: Any) -> Any:
raise ValueError(f"into: unsupported target type {type(target).__name__}")
# ---------------------------------------------------------------------------
# URL helpers
# ---------------------------------------------------------------------------
@register_primitive("app-url")
def prim_app_url(service: str, path: str = "/") -> str:
"""``(app-url "blog" "/my-post/")`` → full URL for service."""
from shared.infrastructure.urls import app_url
return app_url(service, path)
@register_primitive("url-for")
def prim_url_for(endpoint: str, **kwargs: Any) -> str:
"""``(url-for "endpoint")`` → quart.url_for."""
from quart import url_for
return url_for(endpoint, **kwargs)
@register_primitive("asset-url")
def prim_asset_url(path: str = "") -> str:
"""``(asset-url "/img/logo.png")`` → versioned static URL."""
from shared.infrastructure.urls import asset_url
return asset_url(path)
@register_primitive("config")
def prim_config(key: str) -> Any:
"""``(config "key")`` → shared.config.config()[key]."""
from shared.config import config
cfg = config()
return cfg.get(key)
@register_primitive("jinja-global")
def prim_jinja_global(key: str, default: Any = None) -> Any:
"""``(jinja-global "key")`` → current_app.jinja_env.globals[key]."""
from quart import current_app
return current_app.jinja_env.globals.get(key, default)
@register_primitive("relations-from")
def prim_relations_from(entity_type: str) -> list[dict]:
"""``(relations-from "page")`` → list of RelationDef dicts."""
from shared.sx.relations import relations_from
return [
{
"name": d.name, "from_type": d.from_type, "to_type": d.to_type,
"cardinality": d.cardinality, "nav": d.nav,
"nav_icon": d.nav_icon, "nav_label": d.nav_label,
}
for d in relations_from(entity_type)
]
# ---------------------------------------------------------------------------
# Format helpers
# ---------------------------------------------------------------------------
@register_primitive("format-date")
def prim_format_date(date_str: Any, fmt: str) -> str:
"""``(format-date date-str fmt)`` → formatted date string."""
from datetime import datetime
try:
dt = datetime.fromisoformat(str(date_str))
return dt.strftime(fmt)
except (ValueError, TypeError):
return str(date_str) if date_str else ""
@register_primitive("parse-int")
def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
"""``(parse-int val default?)`` → int(val) with fallback."""
try:
return int(val)
except (ValueError, TypeError):
return default
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------

View File

@@ -19,6 +19,7 @@ Usage in s-expressions::
from __future__ import annotations
import contextvars
from typing import Any
# ---------------------------------------------------------------------------
@@ -34,6 +35,11 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"action",
"current-user",
"htmx-request?",
"service",
"request-arg",
"request-path",
"nav-tree",
"get-children",
})
@@ -41,6 +47,23 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
# Request context (set per-request by the resolver)
# ---------------------------------------------------------------------------
# ContextVar for the handler's domain service object.
# Set by the handler blueprint before executing a defhandler.
_handler_service: contextvars.ContextVar[Any] = contextvars.ContextVar(
"_handler_service", default=None
)
def set_handler_service(service_obj: Any) -> None:
"""Bind the local domain service for ``(service ...)`` primitive calls."""
_handler_service.set(service_obj)
def get_handler_service() -> Any:
"""Get the currently bound handler service, or None."""
return _handler_service.get(None)
class RequestContext:
"""Per-request context provided to I/O primitives.
@@ -140,6 +163,129 @@ async def _io_htmx_request(
return ctx.is_htmx
async def _io_service(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(service "svc-name" "method-name" :key val ...)`` → call domain service.
Looks up the service from the shared registry by name, then calls the
named method with ``g.s`` (async session) + keyword args. Falls back
to the bound handler service if only one positional arg is given.
"""
if not args:
raise ValueError("service requires at least a method name")
if len(args) >= 2:
# (service "calendar" "associated-entries" :key val ...)
from shared.services.registry import services as svc_registry
svc_name = str(args[0]).replace("-", "_")
svc = getattr(svc_registry, svc_name, None)
if svc is None:
raise RuntimeError(f"No service registered as: {svc_name}")
method_name = str(args[1]).replace("-", "_")
else:
# (service "method-name" :key val ...) — legacy / bound service
svc = get_handler_service()
if svc is None:
raise RuntimeError(
"No handler service bound — cannot call (service ...)")
method_name = str(args[0]).replace("-", "_")
method = getattr(svc, method_name, None)
if method is None:
raise RuntimeError(f"Service has no method: {method_name}")
# Convert kwarg keys from kebab-case to snake_case
clean_kwargs = {k.replace("-", "_"): v for k, v in kwargs.items()}
from quart import g
result = await method(g.s, **clean_kwargs)
return _convert_result(result)
def _dto_to_dict(obj: Any) -> dict[str, Any]:
"""Convert a DTO/dataclass/namedtuple to a plain dict.
Adds ``{field}_year``, ``{field}_month``, ``{field}_day`` convenience
keys for any datetime-valued field so sx handlers can build URL paths
without parsing date strings.
"""
if hasattr(obj, "_asdict"):
d = dict(obj._asdict())
elif hasattr(obj, "__dict__"):
d = {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
else:
return {"value": obj}
# Expand datetime fields into year/month/day convenience keys
for key, val in list(d.items()):
if hasattr(val, "year") and hasattr(val, "strftime"):
d[f"{key}_year"] = val.year
d[f"{key}_month"] = val.month
d[f"{key}_day"] = val.day
return d
def _convert_result(result: Any) -> Any:
"""Convert a service method result for sx consumption."""
if result is None:
from .types import NIL
return NIL
if isinstance(result, tuple):
# Tuple returns (e.g. (entries, has_more)) → list for sx access
return [_convert_result(item) for item in result]
if hasattr(result, "__dataclass_fields__") or hasattr(result, "_asdict"):
return _dto_to_dict(result)
if isinstance(result, list):
return [
_dto_to_dict(item)
if hasattr(item, "__dataclass_fields__") or hasattr(item, "_asdict")
else item
for item in result
]
return result
async def _io_request_arg(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(request-arg "name" default?)`` → request.args.get(name, default)."""
if not args:
raise ValueError("request-arg requires a name")
from quart import request
name = str(args[0])
default = args[1] if len(args) > 1 else None
return request.args.get(name, default)
async def _io_request_path(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(request-path)`` → request.path."""
from quart import request
return request.path
async def _io_nav_tree(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> list[dict[str, Any]]:
"""``(nav-tree)`` → list of navigation menu node dicts."""
from quart import g
from shared.services.navigation import get_navigation_tree
nodes = await get_navigation_tree(g.s)
return [_dto_to_dict(node) for node in nodes]
async def _io_get_children(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> list[dict[str, Any]]:
"""``(get-children :parent-type "page" :parent-id 1 ...)``"""
from quart import g
from shared.services.relationships import get_children
clean = {k.replace("-", "_"): v for k, v in kwargs.items()}
children = await get_children(g.s, **clean)
return [_dto_to_dict(child) for child in children]
# ---------------------------------------------------------------------------
# Handler registry
# ---------------------------------------------------------------------------
@@ -150,4 +296,9 @@ _IO_HANDLERS: dict[str, Any] = {
"action": _io_action,
"current-user": _io_current_user,
"htmx-request?": _io_htmx_request,
"service": _io_service,
"request-arg": _io_request_arg,
"request-path": _io_request_path,
"nav-tree": _io_nav_tree,
"get-children": _io_get_children,
}

View File

@@ -2,7 +2,7 @@
import pytest
from shared.sx import parse, evaluate, EvalError, Symbol, Keyword, NIL
from shared.sx.types import Lambda, Component
from shared.sx.types import Lambda, Component, Macro, HandlerDef
# ---------------------------------------------------------------------------
@@ -324,3 +324,82 @@ class TestSetBang:
env = {"x": 1}
ev("(set! x 42)", env)
assert env["x"] == 42
# ---------------------------------------------------------------------------
# Macros
# ---------------------------------------------------------------------------
class TestMacro:
def test_defmacro_creates_macro(self):
env = {}
ev("(defmacro double (x) `(+ ,x ,x))", env)
assert isinstance(env["double"], Macro)
assert env["double"].name == "double"
def test_simple_expansion(self):
env = {}
ev("(defmacro double (x) `(+ ,x ,x))", env)
assert ev("(double 5)", env) == 10
def test_quasiquote_with_splice(self):
env = {}
ev("(defmacro add-all (&rest nums) `(+ ,@nums))", env)
assert ev("(add-all 1 2 3)", env) == 6
def test_rest_param(self):
env = {}
ev("(defmacro my-list (&rest items) `(list ,@items))", env)
assert ev("(my-list 1 2 3)", env) == [1, 2, 3]
def test_macro_with_let(self):
env = {}
ev("(defmacro bind-and-add (name val) `(let ((,name ,val)) (+ ,name 1)))", env)
assert ev("(bind-and-add x 10)", env) == 11
def test_quasiquote_standalone(self):
"""Quasiquote without defmacro works for template expansion."""
env = {"x": 42}
result = ev("`(a ,x b)", env)
assert result == [Symbol("a"), 42, Symbol("b")]
def test_quasiquote_splice(self):
env = {"rest": [1, 2, 3]}
result = ev("`(a ,@rest b)", env)
assert result == [Symbol("a"), 1, 2, 3, Symbol("b")]
def test_macro_wrong_arity(self):
"""Macro with too few args gets NIL for missing params."""
env = {}
ev("(defmacro needs-two (a b) `(+ ,a ,b))", env)
# Calling with 1 arg — b becomes NIL
with pytest.raises(Exception):
ev("(needs-two 5)", env)
def test_macro_in_html_render(self):
"""Macros expand correctly in HTML render context."""
from shared.sx.html import render as html_render
env = {}
ev('(defmacro bold (text) `(strong ,text))', env)
expr = parse('(bold "hello")')
result = html_render(expr, env)
assert result == "<strong>hello</strong>"
# ---------------------------------------------------------------------------
# defhandler
# ---------------------------------------------------------------------------
class TestDefhandler:
def test_defhandler_creates_handler(self):
env = {}
ev("(defhandler link-card (&key slug keys) slug)", env)
assert isinstance(env["handler:link-card"], HandlerDef)
assert env["handler:link-card"].name == "link-card"
assert env["handler:link-card"].params == ["slug", "keys"]
def test_defhandler_body_preserved(self):
env = {}
ev("(defhandler test-handler (&key id) (str id))", env)
handler = env["handler:test-handler"]
assert handler.body is not None

View File

@@ -0,0 +1,159 @@
"""Tests for the declarative handler system."""
import asyncio
import pytest
from shared.sx import parse, evaluate
from shared.sx.types import HandlerDef
from shared.sx.handlers import (
register_handler,
get_handler,
get_all_handlers,
clear_handlers,
)
from shared.sx.async_eval import async_eval, async_render
from shared.sx.primitives_io import RequestContext
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
class TestHandlerRegistry:
def setup_method(self):
clear_handlers()
def test_register_and_get(self):
env = {}
evaluate(parse("(defhandler test-card (&key slug) slug)"), env)
handler = env["handler:test-card"]
register_handler("blog", handler)
assert get_handler("blog", "test-card") is handler
def test_get_nonexistent(self):
assert get_handler("blog", "nope") is None
def test_get_all_handlers(self):
env = {}
evaluate(parse("(defhandler h1 (&key a) a)"), env)
evaluate(parse("(defhandler h2 (&key b) b)"), env)
register_handler("svc", env["handler:h1"])
register_handler("svc", env["handler:h2"])
all_h = get_all_handlers("svc")
assert "h1" in all_h
assert "h2" in all_h
def test_clear_service(self):
env = {}
evaluate(parse("(defhandler h1 (&key a) a)"), env)
register_handler("svc", env["handler:h1"])
clear_handlers("svc")
assert get_handler("svc", "h1") is None
def test_clear_all(self):
env = {}
evaluate(parse("(defhandler h1 (&key a) a)"), env)
register_handler("svc1", env["handler:h1"])
register_handler("svc2", env["handler:h1"])
clear_handlers()
assert get_all_handlers("svc1") == {}
assert get_all_handlers("svc2") == {}
# ---------------------------------------------------------------------------
# HandlerDef creation via evaluator
# ---------------------------------------------------------------------------
class TestHandlerDefCreation:
def test_basic(self):
env = {}
evaluate(parse("(defhandler my-handler (&key id name) (str id name))"), env)
h = env["handler:my-handler"]
assert isinstance(h, HandlerDef)
assert h.name == "my-handler"
assert h.params == ["id", "name"]
def test_no_params(self):
env = {}
evaluate(parse("(defhandler simple (&key) 42)"), env)
h = env["handler:simple"]
assert h.params == []
def test_handler_closure_captures_env(self):
env = {"x": 99}
evaluate(parse("(defhandler uses-closure (&key) x)"), env)
h = env["handler:uses-closure"]
assert h.closure.get("x") == 99
# ---------------------------------------------------------------------------
# Async evaluator
# ---------------------------------------------------------------------------
class TestAsyncEval:
def test_literals(self):
ctx = RequestContext()
assert asyncio.get_event_loop().run_until_complete(
async_eval(parse("42"), {}, ctx)) == 42
def test_let_and_arithmetic(self):
ctx = RequestContext()
result = asyncio.get_event_loop().run_until_complete(
async_eval(parse("(let ((x 10) (y 20)) (+ x y))"), {}, ctx))
assert result == 30
def test_if_when(self):
ctx = RequestContext()
result = asyncio.get_event_loop().run_until_complete(
async_eval(parse("(if true 1 2)"), {}, ctx))
assert result == 1
def test_map_lambda(self):
ctx = RequestContext()
result = asyncio.get_event_loop().run_until_complete(
async_eval(parse("(map (fn (x) (* x x)) (list 1 2 3))"), {}, ctx))
assert result == [1, 4, 9]
def test_macro_expansion(self):
ctx = RequestContext()
env = {}
asyncio.get_event_loop().run_until_complete(
async_eval(parse("(defmacro double (x) `(+ ,x ,x))"), env, ctx))
result = asyncio.get_event_loop().run_until_complete(
async_eval(parse("(double 5)"), env, ctx))
assert result == 10
class TestAsyncRender:
def test_simple_html(self):
ctx = RequestContext()
result = asyncio.get_event_loop().run_until_complete(
async_render(parse('(div :class "test" "hello")'), {}, ctx))
assert result == '<div class="test">hello</div>'
def test_component(self):
ctx = RequestContext()
env = {}
evaluate(parse('(defcomp ~bold (&key text) (strong text))'), env)
result = asyncio.get_event_loop().run_until_complete(
async_render(parse('(~bold :text "hi")'), env, ctx))
assert result == "<strong>hi</strong>"
def test_let_with_render(self):
ctx = RequestContext()
result = asyncio.get_event_loop().run_until_complete(
async_render(parse('(let ((x "hello")) (span x))'), {}, ctx))
assert result == "<span>hello</span>"
def test_map_render(self):
ctx = RequestContext()
result = asyncio.get_event_loop().run_until_complete(
async_render(parse('(ul (map (fn (x) (li x)) (list "a" "b")))'), {}, ctx))
assert result == "<ul><li>a</li><li>b</li></ul>"
def test_macro_in_render(self):
ctx = RequestContext()
env = {}
evaluate(parse('(defmacro em-text (t) `(em ,t))'), env)
result = asyncio.get_event_loop().run_until_complete(
async_render(parse('(em-text "wow")'), env, ctx))
assert result == "<em>wow</em>"

View File

@@ -123,6 +123,52 @@ class TestParseAll:
assert parse_all(" ; only comments\n") == []
# ---------------------------------------------------------------------------
# Quasiquote
# ---------------------------------------------------------------------------
class TestQuasiquote:
def test_quasiquote_symbol(self):
result = parse("`x")
assert result == [Symbol("quasiquote"), Symbol("x")]
def test_quasiquote_list(self):
result = parse("`(a b c)")
assert result == [Symbol("quasiquote"), [Symbol("a"), Symbol("b"), Symbol("c")]]
def test_unquote(self):
result = parse(",x")
assert result == [Symbol("unquote"), Symbol("x")]
def test_splice_unquote(self):
result = parse(",@xs")
assert result == [Symbol("splice-unquote"), Symbol("xs")]
def test_quasiquote_with_unquote(self):
result = parse("`(a ,x b)")
assert result == [Symbol("quasiquote"), [
Symbol("a"),
[Symbol("unquote"), Symbol("x")],
Symbol("b"),
]]
def test_quasiquote_with_splice(self):
result = parse("`(a ,@rest)")
assert result == [Symbol("quasiquote"), [
Symbol("a"),
[Symbol("splice-unquote"), Symbol("rest")],
]]
def test_roundtrip_quasiquote(self):
assert serialize(parse("`(a ,x ,@rest)")) == "`(a ,x ,@rest)"
def test_roundtrip_unquote(self):
assert serialize(parse(",x")) == ",x"
def test_roundtrip_splice_unquote(self):
assert serialize(parse(",@xs")) == ",@xs"
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------

View File

@@ -127,6 +127,29 @@ class Lambda:
return evaluator(self.body, local)
# ---------------------------------------------------------------------------
# Macro
# ---------------------------------------------------------------------------
@dataclass
class Macro:
"""A macro — an AST-transforming function.
Created by ``(defmacro name (params... &rest rest) body)``.
Receives unevaluated arguments, evaluates its body to produce a new
s-expression, which is then evaluated in the caller's environment.
"""
params: list[str]
rest_param: str | None # &rest parameter name
body: Any # unevaluated — returns an s-expression to eval
closure: dict[str, Any] = field(default_factory=dict)
name: str | None = None
def __repr__(self):
tag = self.name or "macro"
return f"<{tag}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# Component
# ---------------------------------------------------------------------------
@@ -149,6 +172,27 @@ class Component:
return f"<Component ~{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# HandlerDef
# ---------------------------------------------------------------------------
@dataclass
class HandlerDef:
"""A declarative fragment handler defined in an .sx file.
Created by ``(defhandler name (&key param...) body)``.
The body is evaluated in a sandboxed environment with only
s-expression primitives available.
"""
name: str
params: list[str] # keyword parameter names
body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"<handler:{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# RelationDef
# ---------------------------------------------------------------------------
@@ -174,4 +218,4 @@ class RelationDef:
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Component | RelationDef | list | dict | _Nil | None
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | list | dict | _Nil | None