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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
837
shared/sx/async_eval.py
Normal 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,
|
||||
}
|
||||
@@ -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
205
shared/sx/handlers.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
159
shared/sx/tests/test_handlers.py
Normal file
159
shared/sx/tests/test_handlers.py
Normal 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>"
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user