Add macros, declarative handlers (defhandler), and convert all fragment routes to sx
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user