Compare commits
2 Commits
0fb87e3b1c
...
fbb7a1422c
| Author | SHA1 | Date | |
|---|---|---|---|
| fbb7a1422c | |||
| 09010db70e |
@@ -622,6 +622,71 @@ Each phase is independently deployable. The end state: a platform where the appl
|
||||
|
||||
**Source material ported from:** `artdag/core/artdag/sexp/parser.py` and `evaluator.py`. Stripped DAG-specific types (Binding), replaced Lambda dataclass with callable closure, added defcomp/Component, added web-oriented string primitives, added &key/&rest support in parser.
|
||||
|
||||
### Phase 2: HTML Renderer — COMPLETE
|
||||
|
||||
**Branch:** `sexpression`
|
||||
|
||||
**Delivered** (`shared/sexp/html.py`):
|
||||
- HSX-style renderer: s-expression AST → HTML string
|
||||
- ~100 HTML tags recognised (sections, headings, grouping, text, embedded, table, forms, interactive, template)
|
||||
- 14 void elements (br, img, input, meta, link, etc.) — no closing tag
|
||||
- 23 boolean attributes (disabled, checked, required, hidden, etc.)
|
||||
- Text and attribute escaping (XSS prevention: &, <, >, ")
|
||||
- `raw!` for trusted unescaped HTML
|
||||
- `<>` fragment rendering (no wrapper element)
|
||||
- Render-aware special forms: `if`, `when`, `cond`, `let`/`let*`, `begin`/`do`, `map`, `map-indexed`, `filter`, `for-each`, `define`, `defcomp` — these call `_render` on result branches so HTML tags inside control flow work correctly
|
||||
- `_render_component()` — render-aware component calling (vs evaluator's `_call_component` which only evaluates)
|
||||
- `_render_lambda_call()` — lambda bodies containing HTML tags are rendered directly
|
||||
- `_RawHTML` marker type — pre-rendered children pass through without double-escaping
|
||||
- Component children rendered to HTML string and wrapped as `_RawHTML` for safe embedding
|
||||
|
||||
**Key architectural decision:** The renderer maintains a parallel set of special form handlers (`_RENDER_FORMS`) that mirror the evaluator's special forms but call `_render` on results instead of `_eval`. This is necessary because the evaluator doesn't know about HTML tags — `_eval((p "Hello"))` fails with "Undefined symbol: p". The renderer intercepts these forms before they reach the evaluator.
|
||||
|
||||
**Dispatch order in `_render_list`:**
|
||||
1. `raw!` → unescaped HTML
|
||||
2. `<>` → fragment
|
||||
3. `_RENDER_FORMS` (checked before HTML_TAGS because `map` is both a render form and an HTML tag)
|
||||
4. `HTML_TAGS` → element rendering
|
||||
5. `~prefix` → component rendering
|
||||
6. Fallthrough → `_eval` then `_render`
|
||||
|
||||
**Tests** (`shared/sexp/tests/test_html.py`):
|
||||
- 63 tests: escaping (4), atoms (8), elements (6), attributes (8), boolean attrs (4), void elements (7), fragments (3), raw! (3), components (4), expressions with control flow (8), full pages (3), edge cases (5)
|
||||
- **172 total tests across all 3 files, all passing**
|
||||
|
||||
### Phase 3: Async Resolver — COMPLETE
|
||||
|
||||
**Branch:** `sexpression`
|
||||
|
||||
**Delivered** (`shared/sexp/`):
|
||||
- `resolver.py` — Async tree walker: collects I/O nodes from parsed tree, executes them in parallel via `asyncio.gather()`, substitutes results back, renders to HTML. Multi-pass resolution (up to 5 depth) for cases where resolved values contain further I/O. Graceful degradation: failed I/O nodes substitute empty string instead of crashing.
|
||||
- `primitives_io.py` — I/O primitive registry and handlers:
|
||||
- `(frag "service" "type" :key val ...)` → wraps `fetch_fragment`
|
||||
- `(query "service" "query-name" :key val ...)` → wraps `fetch_data`
|
||||
- `(action "service" "action-name" :key val ...)` → wraps `call_action`
|
||||
- `(current-user)` → user dict from `RequestContext`
|
||||
- `(htmx-request?)` → boolean from `RequestContext`
|
||||
- `RequestContext` — per-request state (user, is_htmx, extras) passed to I/O handlers
|
||||
|
||||
**Resolution strategy:**
|
||||
1. Parse s-expression tree
|
||||
2. Walk tree, collect all I/O nodes (frag, query, action, current-user, htmx-request?)
|
||||
3. Parse each node's positional args + keyword kwargs, evaluating expressions
|
||||
4. Dispatch all I/O in parallel via `asyncio.gather(return_exceptions=True)`
|
||||
5. Substitute results back into tree (fragments wrapped as `_RawHTML` to prevent escaping)
|
||||
6. Repeat up to 5 passes if resolved values introduce new I/O nodes
|
||||
7. Render fully-resolved tree to HTML via Phase 2 renderer
|
||||
|
||||
**Design decisions:**
|
||||
- I/O handlers use deferred imports (inside functions) so `shared.sexp` doesn't depend on infrastructure at import time — only when actually executing I/O
|
||||
- Tests mock at the `execute_io` boundary (patching `shared.sexp.resolver.execute_io`) rather than patching infrastructure imports, keeping tests self-contained with no external dependencies
|
||||
- Fragment results wrapped as `_RawHTML` since they're already-rendered HTML
|
||||
- Identity-based substitution (`id(expr)`) maps I/O nodes back to their tree position
|
||||
|
||||
**Tests** (`shared/sexp/tests/test_resolver.py`):
|
||||
- 27 tests: passthrough rendering (4), I/O collection (8), fragment resolution (3), query resolution (2), parallel I/O (1), request context (4), error handling (2), mixed content (3)
|
||||
- **199 total tests across all 4 files, all passing**
|
||||
|
||||
### Test Infrastructure — COMPLETE
|
||||
|
||||
**Delivered:**
|
||||
|
||||
473
shared/sexp/html.py
Normal file
473
shared/sexp/html.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
HSX-style HTML renderer.
|
||||
|
||||
Walks an s-expression tree and emits an HTML string. HTML elements are
|
||||
recognised by tag name; everything else is evaluated via the s-expression
|
||||
evaluator and then rendered recursively.
|
||||
|
||||
Usage::
|
||||
|
||||
from shared.sexp import parse, make_env
|
||||
from shared.sexp.html import render
|
||||
|
||||
expr = parse('(div :class "card" (h1 "Hello") (p "World"))')
|
||||
html = render(expr)
|
||||
# → '<div class="card"><h1>Hello</h1><p>World</p></div>'
|
||||
|
||||
Components defined with ``defcomp`` are evaluated and their result is
|
||||
rendered as HTML::
|
||||
|
||||
env = {}
|
||||
evaluate(parse('(defcomp ~card (&key title &rest children) ...)'), env)
|
||||
html = render(parse('(~card :title "Hi" (p "body"))'), env)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Keyword, Lambda, NIL, Symbol
|
||||
from .evaluator import _eval, _call_component
|
||||
|
||||
|
||||
class _RawHTML:
|
||||
"""Marker for pre-rendered HTML that should not be escaped."""
|
||||
__slots__ = ("html",)
|
||||
|
||||
def __init__(self, html: str):
|
||||
self.html = html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Tags that must not have a closing tag
|
||||
VOID_ELEMENTS = frozenset({
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"link", "meta", "param", "source", "track", "wbr",
|
||||
})
|
||||
|
||||
# Standard HTML tags (subset — any symbol that isn't recognised here will be
|
||||
# treated as a function call and evaluated instead of rendered as a tag).
|
||||
HTML_TAGS = frozenset({
|
||||
# Root / document
|
||||
"html", "head", "body",
|
||||
# Metadata
|
||||
"title", "meta", "link", "style", "script", "base", "noscript",
|
||||
# Sections
|
||||
"header", "footer", "main", "nav", "aside", "section", "article",
|
||||
"address", "hgroup",
|
||||
# Headings
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
# Grouping
|
||||
"div", "p", "blockquote", "pre", "figure", "figcaption",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "hr",
|
||||
# Text
|
||||
"a", "span", "em", "strong", "small", "s", "cite", "q",
|
||||
"abbr", "code", "var", "samp", "kbd", "sub", "sup",
|
||||
"i", "b", "u", "mark", "ruby", "rt", "rp",
|
||||
"bdi", "bdo", "br", "wbr", "time", "data",
|
||||
# Edits
|
||||
"ins", "del",
|
||||
# Embedded
|
||||
"img", "picture", "source", "iframe", "embed", "object", "param",
|
||||
"video", "audio", "track", "canvas", "map", "area",
|
||||
"svg", "math",
|
||||
# Table
|
||||
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
|
||||
"caption", "colgroup", "col",
|
||||
# Forms
|
||||
"form", "fieldset", "legend", "label", "input", "button",
|
||||
"select", "option", "optgroup", "textarea", "output",
|
||||
"datalist", "progress", "meter",
|
||||
# Interactive
|
||||
"details", "summary", "dialog",
|
||||
# Template
|
||||
"template", "slot",
|
||||
})
|
||||
|
||||
# Attributes that are boolean (presence = true, absence = false)
|
||||
BOOLEAN_ATTRS = frozenset({
|
||||
"async", "autofocus", "autoplay", "checked", "controls",
|
||||
"default", "defer", "disabled", "formnovalidate", "hidden",
|
||||
"inert", "ismap", "loop", "multiple", "muted", "nomodule",
|
||||
"novalidate", "open", "playsinline", "readonly", "required",
|
||||
"reversed", "selected",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def escape_text(s: str) -> str:
|
||||
"""Escape text content for safe HTML embedding."""
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
def escape_attr(s: str) -> str:
|
||||
"""Escape an attribute value for safe embedding in double quotes."""
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace('"', """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renderer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render(expr: Any, env: dict[str, Any] | None = None) -> str:
|
||||
"""Render an s-expression as an HTML string.
|
||||
|
||||
*expr* can be:
|
||||
- A parsed (unevaluated) s-expression from ``parse()``
|
||||
- An already-evaluated value (string, list, etc.)
|
||||
|
||||
*env* provides variable bindings for evaluation.
|
||||
"""
|
||||
if env is None:
|
||||
env = {}
|
||||
return _render(expr, env)
|
||||
|
||||
|
||||
def _render(expr: Any, env: dict[str, Any]) -> str:
|
||||
# --- nil / None / False → empty string --------------------------------
|
||||
if expr is None or expr is NIL or expr is False:
|
||||
return ""
|
||||
|
||||
# --- True → empty (typically from a boolean expression, not content) ---
|
||||
if expr is True:
|
||||
return ""
|
||||
|
||||
# --- pre-rendered HTML → pass through ----------------------------------
|
||||
if isinstance(expr, _RawHTML):
|
||||
return expr.html
|
||||
|
||||
# --- string → escaped text --------------------------------------------
|
||||
if isinstance(expr, str):
|
||||
return escape_text(expr)
|
||||
|
||||
# --- number → string --------------------------------------------------
|
||||
if isinstance(expr, (int, float)):
|
||||
return escape_text(str(expr))
|
||||
|
||||
# --- symbol → evaluate then render ------------------------------------
|
||||
if isinstance(expr, Symbol):
|
||||
val = _eval(expr, env)
|
||||
return _render(val, env)
|
||||
|
||||
# --- keyword → its name (unlikely in render context, but safe) --------
|
||||
if isinstance(expr, Keyword):
|
||||
return escape_text(expr.name)
|
||||
|
||||
# --- list → main dispatch ---------------------------------------------
|
||||
if isinstance(expr, list):
|
||||
if not expr:
|
||||
return ""
|
||||
return _render_list(expr, env)
|
||||
|
||||
# --- dict → skip (data, not renderable) -------------------------------
|
||||
if isinstance(expr, dict):
|
||||
return ""
|
||||
|
||||
# --- fallback ---------------------------------------------------------
|
||||
return escape_text(str(expr))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Render-aware special forms
|
||||
# ---------------------------------------------------------------------------
|
||||
# These mirror the evaluator's special forms but call _render on the result
|
||||
# branches, so that HTML tags inside (if ...), (when ...), (let ...) etc.
|
||||
# are rendered correctly instead of being evaluated as function calls.
|
||||
|
||||
def _rsf_if(expr: list, env: dict[str, Any]) -> str:
|
||||
cond = _eval(expr[1], env)
|
||||
if cond and cond is not NIL:
|
||||
return _render(expr[2], env)
|
||||
if len(expr) > 3:
|
||||
return _render(expr[3], env)
|
||||
return ""
|
||||
|
||||
|
||||
def _rsf_when(expr: list, env: dict[str, Any]) -> str:
|
||||
cond = _eval(expr[1], env)
|
||||
if cond and cond is not NIL:
|
||||
parts = []
|
||||
for body_expr in expr[2:]:
|
||||
parts.append(_render(body_expr, env))
|
||||
return "".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _rsf_cond(expr: list, env: dict[str, Any]) -> str:
|
||||
from .types import Keyword as Kw
|
||||
clauses = expr[1:]
|
||||
if not clauses:
|
||||
return ""
|
||||
# Scheme-style: ((test body) ...)
|
||||
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 _render(clause[1], env)
|
||||
if isinstance(test, Kw) and test.name == "else":
|
||||
return _render(clause[1], env)
|
||||
if _eval(test, env):
|
||||
return _render(clause[1], env)
|
||||
else:
|
||||
# Clojure-style: test body test body ...
|
||||
i = 0
|
||||
while i < len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Kw) and test.name == "else":
|
||||
return _render(result, env)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return _render(result, env)
|
||||
if _eval(test, env):
|
||||
return _render(result, env)
|
||||
i += 2
|
||||
return ""
|
||||
|
||||
|
||||
def _rsf_let(expr: list, env: dict[str, Any]) -> str:
|
||||
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] = _eval(binding[1], local)
|
||||
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] = _eval(bindings[i + 1], local)
|
||||
parts = []
|
||||
for body_expr in expr[2:]:
|
||||
parts.append(_render(body_expr, local))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _rsf_begin(expr: list, env: dict[str, Any]) -> str:
|
||||
parts = []
|
||||
for sub in expr[1:]:
|
||||
parts.append(_render(sub, env))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _rsf_define(expr: list, env: dict[str, Any]) -> str:
|
||||
_eval(expr, env) # side effect: define in env
|
||||
return ""
|
||||
|
||||
|
||||
def _rsf_defcomp(expr: list, env: dict[str, Any]) -> str:
|
||||
_eval(expr, env) # side effect: register component
|
||||
return ""
|
||||
|
||||
|
||||
def _render_lambda_call(fn: Lambda, args: tuple, env: dict[str, Any]) -> str:
|
||||
"""Call a lambda and render the result — the body may contain HTML tags."""
|
||||
local = dict(fn.closure)
|
||||
local.update(env)
|
||||
for p, v in zip(fn.params, args):
|
||||
local[p] = v
|
||||
return _render(fn.body, local)
|
||||
|
||||
|
||||
def _rsf_map(expr: list, env: dict[str, Any]) -> str:
|
||||
fn = _eval(expr[1], env)
|
||||
coll = _eval(expr[2], env)
|
||||
parts = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
parts.append(_render_lambda_call(fn, (item,), env))
|
||||
elif callable(fn):
|
||||
parts.append(_render(fn(item), env))
|
||||
else:
|
||||
parts.append(_render(item, env))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _rsf_map_indexed(expr: list, env: dict[str, Any]) -> str:
|
||||
fn = _eval(expr[1], env)
|
||||
coll = _eval(expr[2], env)
|
||||
parts = []
|
||||
for i, item in enumerate(coll):
|
||||
if isinstance(fn, Lambda):
|
||||
parts.append(_render_lambda_call(fn, (i, item), env))
|
||||
elif callable(fn):
|
||||
parts.append(_render(fn(i, item), env))
|
||||
else:
|
||||
parts.append(_render(item, env))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _rsf_filter(expr: list, env: dict[str, Any]) -> str:
|
||||
# filter returns a list — render each kept item
|
||||
result = _eval(expr, env)
|
||||
return _render(result, env)
|
||||
|
||||
|
||||
def _rsf_for_each(expr: list, env: dict[str, Any]) -> str:
|
||||
fn = _eval(expr[1], env)
|
||||
coll = _eval(expr[2], env)
|
||||
parts = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
parts.append(_render_lambda_call(fn, (item,), env))
|
||||
elif callable(fn):
|
||||
parts.append(_render(fn(item), env))
|
||||
else:
|
||||
parts.append(_render(item, env))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_RENDER_FORMS: dict[str, Any] = {
|
||||
"if": _rsf_if,
|
||||
"when": _rsf_when,
|
||||
"cond": _rsf_cond,
|
||||
"let": _rsf_let,
|
||||
"let*": _rsf_let,
|
||||
"begin": _rsf_begin,
|
||||
"do": _rsf_begin,
|
||||
"define": _rsf_define,
|
||||
"defcomp": _rsf_defcomp,
|
||||
"map": _rsf_map,
|
||||
"map-indexed": _rsf_map_indexed,
|
||||
"filter": _rsf_filter,
|
||||
"for-each": _rsf_for_each,
|
||||
}
|
||||
|
||||
|
||||
def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
|
||||
"""Render-aware component call: sets up scope then renders the body."""
|
||||
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] = _eval(args[i + 1], env)
|
||||
i += 2
|
||||
else:
|
||||
children.append(arg)
|
||||
i += 1
|
||||
|
||||
local = dict(comp.closure)
|
||||
local.update(env)
|
||||
for p in comp.params:
|
||||
if p in kwargs:
|
||||
local[p] = kwargs[p]
|
||||
else:
|
||||
local[p] = NIL
|
||||
if comp.has_children:
|
||||
# Render children to HTML and wrap as _RawHTML to prevent re-escaping
|
||||
local["children"] = _RawHTML("".join(_render(c, env) for c in children))
|
||||
return _render(comp.body, local)
|
||||
|
||||
|
||||
def _render_list(expr: list, env: dict[str, Any]) -> str:
|
||||
"""Render a list expression — could be an HTML element, special form,
|
||||
component call, or data list."""
|
||||
head = expr[0]
|
||||
|
||||
if isinstance(head, Symbol):
|
||||
name = head.name
|
||||
|
||||
# --- raw! → unescaped HTML ----------------------------------------
|
||||
if name == "raw!":
|
||||
parts = []
|
||||
for arg in expr[1:]:
|
||||
val = _eval(arg, env)
|
||||
if isinstance(val, str):
|
||||
parts.append(val)
|
||||
elif val is not None and val is not NIL:
|
||||
parts.append(str(val))
|
||||
return "".join(parts)
|
||||
|
||||
# --- <> → fragment (render children, no wrapper) ------------------
|
||||
if name == "<>":
|
||||
return "".join(_render(child, env) for child in expr[1:])
|
||||
|
||||
# --- Render-aware special forms --------------------------------------
|
||||
# Check BEFORE HTML_TAGS because some names overlap (e.g. `map`).
|
||||
if name in _RENDER_FORMS:
|
||||
return _RENDER_FORMS[name](expr, env)
|
||||
|
||||
# --- HTML tag → render as element ---------------------------------
|
||||
if name in HTML_TAGS:
|
||||
return _render_element(name, expr[1:], env)
|
||||
|
||||
# --- Component (~prefix) → render-aware component call ------------
|
||||
if name.startswith("~"):
|
||||
val = env.get(name)
|
||||
if isinstance(val, Component):
|
||||
return _render_component(val, expr[1:], env)
|
||||
# Fall through to evaluation
|
||||
|
||||
# --- Other special forms / function calls → evaluate then render ---
|
||||
result = _eval(expr, env)
|
||||
return _render(result, env)
|
||||
|
||||
# --- head is lambda or other callable → evaluate then render ----------
|
||||
if isinstance(head, (Lambda, list)):
|
||||
result = _eval(expr, env)
|
||||
return _render(result, env)
|
||||
|
||||
# --- data list → render each item -------------------------------------
|
||||
return "".join(_render(item, env) for item in expr)
|
||||
|
||||
|
||||
def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
|
||||
"""Render an HTML element: extract attrs (keywords), render children."""
|
||||
attrs: dict[str, Any] = {}
|
||||
children: list[Any] = []
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
# Keyword followed by value → attribute
|
||||
if isinstance(arg, Keyword) and i + 1 < len(args):
|
||||
attr_name = arg.name
|
||||
attr_val = _eval(args[i + 1], env)
|
||||
attrs[attr_name] = attr_val
|
||||
i += 2
|
||||
else:
|
||||
children.append(arg)
|
||||
i += 1
|
||||
|
||||
# Build opening tag
|
||||
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)
|
||||
|
||||
# Void elements: no closing tag, no children
|
||||
if tag in VOID_ELEMENTS:
|
||||
return opening
|
||||
|
||||
# Render children
|
||||
child_html = "".join(_render(child, env) for child in children)
|
||||
|
||||
return f"{opening}{child_html}</{tag}>"
|
||||
153
shared/sexp/primitives_io.py
Normal file
153
shared/sexp/primitives_io.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Async I/O primitives for the s-expression resolver.
|
||||
|
||||
These wrap rose-ash's inter-service communication layer so that s-expressions
|
||||
can fetch fragments, query data, call actions, and access request context.
|
||||
|
||||
Unlike pure primitives (primitives.py), these are **async** and are executed
|
||||
by the resolver rather than the evaluator. They are identified by name
|
||||
during the tree-walk phase and dispatched via ``asyncio.gather()``.
|
||||
|
||||
Usage in s-expressions::
|
||||
|
||||
(frag "blog" "link-card" :slug "apple")
|
||||
(query "market" "products-by-ids" :ids "1,2,3")
|
||||
(action "market" "create-marketplace" :name "Farm Shop" :slug "farm")
|
||||
(current-user)
|
||||
(htmx-request?)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry of async primitives (name → metadata)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Names that the resolver recognises as I/O nodes requiring async resolution.
|
||||
# The resolver collects these during tree-walk, groups them, and dispatches
|
||||
# them in parallel.
|
||||
IO_PRIMITIVES: frozenset[str] = frozenset({
|
||||
"frag",
|
||||
"query",
|
||||
"action",
|
||||
"current-user",
|
||||
"htmx-request?",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request context (set per-request by the resolver)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RequestContext:
|
||||
"""Per-request context provided to I/O primitives.
|
||||
|
||||
Populated by the resolver from the Quart request before resolution begins.
|
||||
"""
|
||||
__slots__ = ("user", "is_htmx", "extras")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user: dict[str, Any] | None = None,
|
||||
is_htmx: bool = False,
|
||||
extras: dict[str, Any] | None = None,
|
||||
):
|
||||
self.user = user
|
||||
self.is_htmx = is_htmx
|
||||
self.extras = extras or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I/O dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def execute_io(
|
||||
name: str,
|
||||
args: list[Any],
|
||||
kwargs: dict[str, Any],
|
||||
ctx: RequestContext,
|
||||
) -> Any:
|
||||
"""Execute an I/O primitive by name.
|
||||
|
||||
Called by the resolver after collecting and grouping I/O nodes.
|
||||
Returns the result to be substituted back into the tree.
|
||||
"""
|
||||
handler = _IO_HANDLERS.get(name)
|
||||
if handler is None:
|
||||
raise RuntimeError(f"Unknown I/O primitive: {name}")
|
||||
return await handler(args, kwargs, ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _io_frag(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(frag "service" "type" :key val ...)`` → fetch_fragment."""
|
||||
if len(args) < 2:
|
||||
raise ValueError("frag requires service and fragment type")
|
||||
service = str(args[0])
|
||||
frag_type = str(args[1])
|
||||
params = {k: v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
return await fetch_fragment(service, frag_type, params=params or None)
|
||||
|
||||
|
||||
async def _io_query(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(query "service" "query-name" :key val ...)`` → fetch_data."""
|
||||
if len(args) < 2:
|
||||
raise ValueError("query requires service and query name")
|
||||
service = str(args[0])
|
||||
query_name = str(args[1])
|
||||
params = {k: v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
return await fetch_data(service, query_name, params=params or None)
|
||||
|
||||
|
||||
async def _io_action(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(action "service" "action-name" :key val ...)`` → call_action."""
|
||||
if len(args) < 2:
|
||||
raise ValueError("action requires service and action name")
|
||||
service = str(args[0])
|
||||
action_name = str(args[1])
|
||||
payload = {k: v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
from shared.infrastructure.actions import call_action
|
||||
return await call_action(service, action_name, payload=payload or None)
|
||||
|
||||
|
||||
async def _io_current_user(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any] | None:
|
||||
"""``(current-user)`` → user dict from request context."""
|
||||
return ctx.user
|
||||
|
||||
|
||||
async def _io_htmx_request(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> bool:
|
||||
"""``(htmx-request?)`` → True if HX-Request header present."""
|
||||
return ctx.is_htmx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IO_HANDLERS: dict[str, Any] = {
|
||||
"frag": _io_frag,
|
||||
"query": _io_query,
|
||||
"action": _io_action,
|
||||
"current-user": _io_current_user,
|
||||
"htmx-request?": _io_htmx_request,
|
||||
}
|
||||
196
shared/sexp/resolver.py
Normal file
196
shared/sexp/resolver.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Async resolver — walks an s-expression tree, fetches I/O in parallel,
|
||||
substitutes results, and renders to HTML.
|
||||
|
||||
This is the DAG execution engine applied to page rendering. The strategy:
|
||||
|
||||
1. **Walk** the parsed tree and identify I/O nodes (``frag``, ``query``,
|
||||
``action``, ``current-user``, ``htmx-request?``).
|
||||
2. **Group** independent fetches.
|
||||
3. **Dispatch** via ``asyncio.gather()`` for maximum parallelism.
|
||||
4. **Substitute** resolved values back into the tree.
|
||||
5. **Render** the fully-resolved tree to HTML via the HTML renderer.
|
||||
|
||||
Usage::
|
||||
|
||||
from shared.sexp import parse
|
||||
from shared.sexp.resolver import resolve, RequestContext
|
||||
|
||||
expr = parse('''
|
||||
(div :class "page"
|
||||
(h1 "Blog")
|
||||
(raw! (frag "blog" "link-card" :slug "apple")))
|
||||
''')
|
||||
ctx = RequestContext(user=current_user, is_htmx=is_htmx_request())
|
||||
html = await resolve(expr, ctx=ctx, env={})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from .types import Component, Keyword, Lambda, NIL, Symbol
|
||||
from .evaluator import _eval
|
||||
from .html import render as html_render, _RawHTML
|
||||
from .primitives_io import (
|
||||
IO_PRIMITIVES,
|
||||
RequestContext,
|
||||
execute_io,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def resolve(
|
||||
expr: Any,
|
||||
*,
|
||||
ctx: RequestContext | None = None,
|
||||
env: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Resolve an s-expression tree and render to HTML.
|
||||
|
||||
1. Collect all I/O nodes from the tree.
|
||||
2. Execute them in parallel.
|
||||
3. Substitute results.
|
||||
4. Render to HTML.
|
||||
"""
|
||||
if ctx is None:
|
||||
ctx = RequestContext()
|
||||
if env is None:
|
||||
env = {}
|
||||
|
||||
# Resolve I/O nodes (may require multiple passes if I/O results
|
||||
# contain further I/O references, though typically one pass suffices).
|
||||
resolved = await _resolve_tree(expr, env, ctx)
|
||||
|
||||
# Render the fully-resolved tree to HTML
|
||||
return html_render(resolved, env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tree walker — collect, fetch, substitute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _resolve_tree(
|
||||
expr: Any,
|
||||
env: dict[str, Any],
|
||||
ctx: RequestContext,
|
||||
max_depth: int = 5,
|
||||
) -> Any:
|
||||
"""Resolve I/O nodes in the tree. Loops up to *max_depth* passes
|
||||
in case resolved values introduce new I/O nodes."""
|
||||
resolved = expr
|
||||
for _ in range(max_depth):
|
||||
# Collect I/O nodes
|
||||
io_nodes: list[_IONode] = []
|
||||
_collect_io(resolved, env, io_nodes)
|
||||
|
||||
if not io_nodes:
|
||||
break # nothing to fetch
|
||||
|
||||
# Execute all I/O in parallel
|
||||
results = await asyncio.gather(
|
||||
*[_execute_node(node, ctx) for node in io_nodes],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Build substitution map (node id → result)
|
||||
for node, result in zip(io_nodes, results):
|
||||
if isinstance(result, BaseException):
|
||||
# On error, substitute empty string (graceful degradation)
|
||||
node.result = ""
|
||||
else:
|
||||
node.result = result
|
||||
|
||||
# Substitute results back into tree
|
||||
resolved = _substitute(resolved, env, {id(n.expr): n for n in io_nodes})
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
class _IONode:
|
||||
"""A collected I/O node from the tree."""
|
||||
__slots__ = ("name", "args", "kwargs", "expr", "result")
|
||||
|
||||
def __init__(self, name: str, args: list[Any], kwargs: dict[str, Any], expr: list):
|
||||
self.name = name
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.expr = expr # original list reference for identity-based substitution
|
||||
self.result: Any = None
|
||||
|
||||
|
||||
def _collect_io(
|
||||
expr: Any,
|
||||
env: dict[str, Any],
|
||||
out: list[_IONode],
|
||||
) -> None:
|
||||
"""Walk the tree and collect I/O nodes into *out*."""
|
||||
if not isinstance(expr, list) or not expr:
|
||||
return
|
||||
|
||||
head = expr[0]
|
||||
|
||||
if isinstance(head, Symbol) and head.name in IO_PRIMITIVES:
|
||||
# Parse args and kwargs from the rest of the expression
|
||||
args, kwargs = _parse_io_args(expr[1:], env)
|
||||
out.append(_IONode(head.name, args, kwargs, expr))
|
||||
return # don't recurse into I/O node children
|
||||
|
||||
# Recurse into children
|
||||
for child in expr:
|
||||
if isinstance(child, list):
|
||||
_collect_io(child, env, out)
|
||||
|
||||
|
||||
def _parse_io_args(
|
||||
exprs: list[Any],
|
||||
env: dict[str, Any],
|
||||
) -> tuple[list[Any], dict[str, Any]]:
|
||||
"""Split I/O node arguments into positional args and keyword kwargs.
|
||||
|
||||
Evaluates each argument value so variables/expressions are resolved
|
||||
before the I/O call.
|
||||
"""
|
||||
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] = _eval(exprs[i + 1], env)
|
||||
i += 2
|
||||
else:
|
||||
args.append(_eval(item, env))
|
||||
i += 1
|
||||
return args, kwargs
|
||||
|
||||
|
||||
async def _execute_node(node: _IONode, ctx: RequestContext) -> Any:
|
||||
"""Execute a single I/O node."""
|
||||
return await execute_io(node.name, node.args, node.kwargs, ctx)
|
||||
|
||||
|
||||
def _substitute(
|
||||
expr: Any,
|
||||
env: dict[str, Any],
|
||||
node_map: dict[int, _IONode],
|
||||
) -> Any:
|
||||
"""Replace I/O nodes in the tree with their resolved results."""
|
||||
if not isinstance(expr, list) or not expr:
|
||||
return expr
|
||||
|
||||
# Check if this exact list is an I/O node
|
||||
node = node_map.get(id(expr))
|
||||
if node is not None:
|
||||
result = node.result
|
||||
# Fragment results are HTML strings — wrap as _RawHTML to prevent escaping
|
||||
if node.name == "frag" and isinstance(result, str):
|
||||
return _RawHTML(result)
|
||||
return result
|
||||
|
||||
# Recurse into children
|
||||
return [_substitute(child, env, node_map) for child in expr]
|
||||
365
shared/sexp/tests/test_html.py
Normal file
365
shared/sexp/tests/test_html.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Tests for the HSX-style HTML renderer."""
|
||||
|
||||
import pytest
|
||||
from shared.sexp import parse, evaluate
|
||||
from shared.sexp.html import render, escape_text, escape_attr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def r(text, env=None):
|
||||
"""Parse and render a single expression."""
|
||||
return render(parse(text), env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEscaping:
|
||||
def test_escape_text_ampersand(self):
|
||||
assert escape_text("A & B") == "A & B"
|
||||
|
||||
def test_escape_text_lt_gt(self):
|
||||
assert escape_text("<script>") == "<script>"
|
||||
|
||||
def test_escape_attr_quotes(self):
|
||||
assert escape_attr('he said "hi"') == "he said "hi""
|
||||
|
||||
def test_escape_attr_all(self):
|
||||
assert escape_attr('&<>"') == "&<>""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primitives / atoms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAtoms:
|
||||
def test_string(self):
|
||||
assert r('"Hello"') == "Hello"
|
||||
|
||||
def test_string_with_entities(self):
|
||||
assert r('"<b>bold</b>"') == "<b>bold</b>"
|
||||
|
||||
def test_integer(self):
|
||||
assert r("42") == "42"
|
||||
|
||||
def test_float(self):
|
||||
assert r("3.14") == "3.14"
|
||||
|
||||
def test_nil(self):
|
||||
assert r("nil") == ""
|
||||
|
||||
def test_true(self):
|
||||
assert r("true") == ""
|
||||
|
||||
def test_false(self):
|
||||
assert r("false") == ""
|
||||
|
||||
def test_keyword(self):
|
||||
assert r(":hello") == "hello"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple HTML elements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestElements:
|
||||
def test_div(self):
|
||||
assert r('(div "Hello")') == "<div>Hello</div>"
|
||||
|
||||
def test_empty_div(self):
|
||||
assert r("(div)") == "<div></div>"
|
||||
|
||||
def test_nested(self):
|
||||
assert r('(div (p "Hello"))') == "<div><p>Hello</p></div>"
|
||||
|
||||
def test_multiple_children(self):
|
||||
assert r('(ul (li "One") (li "Two"))') == \
|
||||
"<ul><li>One</li><li>Two</li></ul>"
|
||||
|
||||
def test_mixed_text_and_elements(self):
|
||||
assert r('(p "Hello " (strong "world"))') == \
|
||||
"<p>Hello <strong>world</strong></p>"
|
||||
|
||||
def test_deep_nesting(self):
|
||||
assert r('(div (div (div (span "deep"))))') == \
|
||||
"<div><div><div><span>deep</span></div></div></div>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAttributes:
|
||||
def test_single_attr(self):
|
||||
assert r('(div :class "card")') == '<div class="card"></div>'
|
||||
|
||||
def test_multiple_attrs(self):
|
||||
html = r('(div :class "card" :id "main")')
|
||||
assert 'class="card"' in html
|
||||
assert 'id="main"' in html
|
||||
|
||||
def test_attr_with_children(self):
|
||||
assert r('(div :class "card" (p "Body"))') == \
|
||||
'<div class="card"><p>Body</p></div>'
|
||||
|
||||
def test_attr_escaping(self):
|
||||
assert r('(div :title "A & B")') == '<div title="A & B"></div>'
|
||||
|
||||
def test_attr_with_quotes(self):
|
||||
assert r("""(div :title "say \\"hi\\"")""") == \
|
||||
'<div title="say "hi""></div>'
|
||||
|
||||
def test_false_attr_omitted(self):
|
||||
env = {"flag": False}
|
||||
html = render(parse("(div :hidden flag)"), env)
|
||||
assert html == "<div></div>"
|
||||
|
||||
def test_nil_attr_omitted(self):
|
||||
env = {"flag": None}
|
||||
html = render(parse("(div :data-x flag)"), env)
|
||||
assert html == "<div></div>"
|
||||
|
||||
def test_numeric_attr(self):
|
||||
assert r('(input :tabindex 1)') == '<input tabindex="1">'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boolean attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBooleanAttrs:
|
||||
def test_disabled_true(self):
|
||||
env = {"yes": True}
|
||||
html = render(parse("(button :disabled yes)"), env)
|
||||
assert html == "<button disabled></button>"
|
||||
|
||||
def test_disabled_false(self):
|
||||
env = {"no": False}
|
||||
html = render(parse("(button :disabled no)"), env)
|
||||
assert html == "<button></button>"
|
||||
|
||||
def test_checked(self):
|
||||
env = {"c": True}
|
||||
html = render(parse('(input :type "checkbox" :checked c)'), env)
|
||||
assert 'checked' in html
|
||||
assert 'type="checkbox"' in html
|
||||
|
||||
def test_required(self):
|
||||
env = {"r": True}
|
||||
html = render(parse("(input :required r)"), env)
|
||||
assert "required" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Void elements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVoidElements:
|
||||
def test_br(self):
|
||||
assert r("(br)") == "<br>"
|
||||
|
||||
def test_img(self):
|
||||
html = r('(img :src "/photo.jpg" :alt "Photo")')
|
||||
assert html == '<img src="/photo.jpg" alt="Photo">'
|
||||
|
||||
def test_input(self):
|
||||
html = r('(input :type "text" :name "q")')
|
||||
assert html == '<input type="text" name="q">'
|
||||
|
||||
def test_hr(self):
|
||||
assert r("(hr)") == "<hr>"
|
||||
|
||||
def test_meta(self):
|
||||
html = r('(meta :charset "utf-8")')
|
||||
assert html == '<meta charset="utf-8">'
|
||||
|
||||
def test_link(self):
|
||||
html = r('(link :rel "stylesheet" :href "/s.css")')
|
||||
assert html == '<link rel="stylesheet" href="/s.css">'
|
||||
|
||||
def test_void_no_closing_tag(self):
|
||||
# Void elements must not have a closing tag
|
||||
html = r("(br)")
|
||||
assert "</br>" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fragment (<>)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFragments:
|
||||
def test_basic_fragment(self):
|
||||
assert r('(<> (li "A") (li "B"))') == "<li>A</li><li>B</li>"
|
||||
|
||||
def test_empty_fragment(self):
|
||||
assert r("(<>)") == ""
|
||||
|
||||
def test_nested_fragment(self):
|
||||
html = r('(ul (<> (li "1") (li "2")))')
|
||||
assert html == "<ul><li>1</li><li>2</li></ul>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# raw!
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRawHtml:
|
||||
def test_raw_string(self):
|
||||
assert r('(raw! "<b>bold</b>")') == "<b>bold</b>"
|
||||
|
||||
def test_raw_no_escaping(self):
|
||||
html = r('(raw! "<script>alert(1)</script>")')
|
||||
assert "<script>" in html
|
||||
|
||||
def test_raw_with_variable(self):
|
||||
env = {"content": "<em>hi</em>"}
|
||||
html = render(parse("(raw! content)"), env)
|
||||
assert html == "<em>hi</em>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Components (defcomp / ~prefix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComponents:
|
||||
def test_basic_component(self):
|
||||
env = {}
|
||||
evaluate(parse('(defcomp ~badge (&key label) (span :class "badge" label))'), env)
|
||||
html = render(parse('(~badge :label "New")'), env)
|
||||
assert html == '<span class="badge">New</span>'
|
||||
|
||||
def test_component_with_children(self):
|
||||
env = {}
|
||||
evaluate(parse(
|
||||
'(defcomp ~card (&key title &rest children)'
|
||||
' (div :class "card" (h2 title) children))'
|
||||
), env)
|
||||
html = render(parse('(~card :title "Hi" (p "Body"))'), env)
|
||||
assert html == '<div class="card"><h2>Hi</h2><p>Body</p></div>'
|
||||
|
||||
def test_nested_components(self):
|
||||
env = {}
|
||||
evaluate(parse('(defcomp ~inner (&key text) (em text))'), env)
|
||||
evaluate(parse(
|
||||
'(defcomp ~outer (&key label)'
|
||||
' (div (~inner :text label)))'
|
||||
), env)
|
||||
html = render(parse('(~outer :label "Hello")'), env)
|
||||
assert html == "<div><em>Hello</em></div>"
|
||||
|
||||
def test_component_with_conditional(self):
|
||||
env = {}
|
||||
evaluate(parse(
|
||||
'(defcomp ~maybe (&key show text)'
|
||||
' (when show (span text)))'
|
||||
), env)
|
||||
assert render(parse('(~maybe :show true :text "Yes")'), env) == "<span>Yes</span>"
|
||||
assert render(parse('(~maybe :show false :text "No")'), env) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expressions in render context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpressions:
|
||||
def test_let_in_render(self):
|
||||
html = r('(let ((x "Hello")) (p x))')
|
||||
assert html == "<p>Hello</p>"
|
||||
|
||||
def test_if_true_branch(self):
|
||||
html = r('(if true (p "Yes") (p "No"))')
|
||||
assert html == "<p>Yes</p>"
|
||||
|
||||
def test_if_false_branch(self):
|
||||
html = r('(if false (p "Yes") (p "No"))')
|
||||
assert html == "<p>No</p>"
|
||||
|
||||
def test_when_true(self):
|
||||
html = r('(when true (p "Shown"))')
|
||||
assert html == "<p>Shown</p>"
|
||||
|
||||
def test_when_false(self):
|
||||
html = r('(when false (p "Hidden"))')
|
||||
assert html == ""
|
||||
|
||||
def test_map_rendering(self):
|
||||
html = r('(map (lambda (x) (li x)) (list "A" "B" "C"))')
|
||||
assert html == "<li>A</li><li>B</li><li>C</li>"
|
||||
|
||||
def test_variable_in_element(self):
|
||||
env = {"name": "World"}
|
||||
html = render(parse('(p (str "Hello " name))'), env)
|
||||
assert html == "<p>Hello World</p>"
|
||||
|
||||
def test_str_in_attr(self):
|
||||
env = {"slug": "apple"}
|
||||
html = render(parse('(a :href (str "/p/" slug) "Link")'), env)
|
||||
assert html == '<a href="/p/apple">Link</a>'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full page structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFullPage:
|
||||
def test_simple_page(self):
|
||||
html = r('''
|
||||
(html :lang "en"
|
||||
(head (title "Test"))
|
||||
(body (h1 "Hello")))
|
||||
''')
|
||||
assert '<html lang="en">' in html
|
||||
assert "<title>Test</title>" in html
|
||||
assert "<h1>Hello</h1>" in html
|
||||
assert "</body>" in html
|
||||
assert "</html>" in html
|
||||
|
||||
def test_form(self):
|
||||
html = r('''
|
||||
(form :method "post" :action "/login"
|
||||
(label :for "user" "Username")
|
||||
(input :type "text" :name "user" :required true)
|
||||
(button :type "submit" "Login"))
|
||||
''')
|
||||
assert '<form method="post" action="/login">' in html
|
||||
assert '<label for="user">Username</label>' in html
|
||||
assert 'required' in html
|
||||
assert "<button" in html
|
||||
|
||||
def test_table(self):
|
||||
html = r('''
|
||||
(table
|
||||
(thead (tr (th "Name") (th "Price")))
|
||||
(tbody
|
||||
(tr (td "Apple") (td "1.50"))
|
||||
(tr (td "Banana") (td "0.75"))))
|
||||
''')
|
||||
assert "<thead>" in html
|
||||
assert "<th>Name</th>" in html
|
||||
assert "<td>Apple</td>" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_empty_list(self):
|
||||
assert render([], {}) == ""
|
||||
|
||||
def test_none(self):
|
||||
assert render(None, {}) == ""
|
||||
|
||||
def test_dict_not_rendered(self):
|
||||
assert render({"a": 1}, {}) == ""
|
||||
|
||||
def test_number_list(self):
|
||||
# A list of plain numbers (not a call) renders each
|
||||
assert render([1, 2, 3], {}) == "123"
|
||||
|
||||
def test_string_list(self):
|
||||
assert render(["a", "b"], {}) == "ab"
|
||||
320
shared/sexp/tests/test_resolver.py
Normal file
320
shared/sexp/tests/test_resolver.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Tests for the async resolver.
|
||||
|
||||
Uses asyncio.run() directly — no pytest-asyncio dependency needed.
|
||||
Mocks execute_io at the resolver boundary to avoid infrastructure imports.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from shared.sexp import parse, evaluate
|
||||
from shared.sexp.resolver import resolve, _collect_io, _IONode
|
||||
from shared.sexp.primitives_io import RequestContext, execute_io
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(coro):
|
||||
"""Run an async coroutine synchronously."""
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
async def r(text, env=None, ctx=None):
|
||||
"""Parse and resolve a single expression."""
|
||||
return await resolve(parse(text), ctx=ctx, env=env)
|
||||
|
||||
|
||||
def mock_io(**responses):
|
||||
"""Patch execute_io to return canned responses by primitive name.
|
||||
|
||||
Usage::
|
||||
|
||||
with mock_io(frag='<b>Card</b>', query={"title": "Apple"}):
|
||||
html = run(r('...'))
|
||||
|
||||
For dynamic responses, pass a callable::
|
||||
|
||||
async def frag_handler(args, kwargs, ctx):
|
||||
return f"<b>{args[1]}</b>"
|
||||
with mock_io(frag=frag_handler):
|
||||
...
|
||||
"""
|
||||
async def side_effect(name, args, kwargs, ctx):
|
||||
val = responses.get(name)
|
||||
if val is None:
|
||||
# Delegate to real handler for context primitives
|
||||
if name == "current-user":
|
||||
return ctx.user
|
||||
if name == "htmx-request?":
|
||||
return ctx.is_htmx
|
||||
return None
|
||||
if callable(val) and asyncio.iscoroutinefunction(val):
|
||||
return await val(args, kwargs, ctx)
|
||||
if callable(val):
|
||||
return val(args, kwargs, ctx)
|
||||
return val
|
||||
|
||||
return patch("shared.sexp.resolver.execute_io", side_effect=side_effect)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic rendering (no I/O) — resolver should pass through to HTML renderer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPassthrough:
|
||||
def test_simple_html(self):
|
||||
assert run(r('(div "Hello")')) == "<div>Hello</div>"
|
||||
|
||||
def test_nested_html(self):
|
||||
assert run(r('(div (p "World"))')) == "<div><p>World</p></div>"
|
||||
|
||||
def test_with_env(self):
|
||||
assert run(r('(p name)', env={"name": "Alice"})) == "<p>Alice</p>"
|
||||
|
||||
def test_component(self):
|
||||
env = {}
|
||||
evaluate(parse('(defcomp ~tag (&key label) (span :class "tag" label))'), env)
|
||||
assert run(r('(~tag :label "New")', env=env)) == '<span class="tag">New</span>'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I/O node collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollectIO:
|
||||
def test_finds_frag(self):
|
||||
expr = parse('(div (frag "blog" "link-card" :slug "apple"))')
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, {}, nodes)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0].name == "frag"
|
||||
|
||||
def test_finds_query(self):
|
||||
expr = parse('(div (query "market" "products" :ids "1,2"))')
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, {}, nodes)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0].name == "query"
|
||||
|
||||
def test_finds_multiple(self):
|
||||
expr = parse('''
|
||||
(div
|
||||
(frag "blog" "card" :slug "a")
|
||||
(query "market" "products" :ids "1"))
|
||||
''')
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, {}, nodes)
|
||||
assert len(nodes) == 2
|
||||
|
||||
def test_finds_current_user(self):
|
||||
expr = parse('(div (current-user))')
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, {}, nodes)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0].name == "current-user"
|
||||
|
||||
def test_finds_htmx_request(self):
|
||||
expr = parse('(div (htmx-request?))')
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, {}, nodes)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0].name == "htmx-request?"
|
||||
|
||||
def test_no_io_nodes(self):
|
||||
expr = parse('(div (p "Hello"))')
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, {}, nodes)
|
||||
assert len(nodes) == 0
|
||||
|
||||
def test_evaluates_kwargs(self):
|
||||
expr = parse('(query "market" "products" :slug slug)')
|
||||
env = {"slug": "apple"}
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, env, nodes)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0].kwargs["slug"] == "apple"
|
||||
|
||||
def test_positional_args_evaluated(self):
|
||||
expr = parse('(frag app frag_type)')
|
||||
env = {"app": "blog", "frag_type": "card"}
|
||||
nodes: list[_IONode] = []
|
||||
_collect_io(expr, env, nodes)
|
||||
assert nodes[0].args == ["blog", "card"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fragment resolution (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFragResolution:
|
||||
def test_frag_substitution(self):
|
||||
"""Fragment result is substituted as raw HTML."""
|
||||
with mock_io(frag='<a href="/apple">Apple</a>'):
|
||||
html = run(r('(div (frag "blog" "link-card" :slug "apple"))'))
|
||||
assert '<a href="/apple">Apple</a>' in html
|
||||
assert "<" not in html # should NOT be escaped
|
||||
|
||||
def test_frag_with_surrounding(self):
|
||||
"""Fragment result sits alongside static HTML."""
|
||||
with mock_io(frag="<span>Card</span>"):
|
||||
html = run(r('(div (h1 "Title") (frag "blog" "card" :slug "x"))'))
|
||||
assert "<h1>Title</h1>" in html
|
||||
assert "<span>Card</span>" in html
|
||||
|
||||
def test_frag_params_forwarded(self):
|
||||
"""Keyword args are forwarded to the I/O handler."""
|
||||
received = {}
|
||||
|
||||
async def capture_frag(args, kwargs, ctx):
|
||||
received.update(kwargs)
|
||||
return "<b>ok</b>"
|
||||
|
||||
with mock_io(frag=capture_frag):
|
||||
run(r('(frag "blog" "card" :slug "apple" :size "large")'))
|
||||
assert received == {"slug": "apple", "size": "large"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query resolution (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryResolution:
|
||||
def test_query_result_dict(self):
|
||||
"""Query returning a dict renders as empty (dicts aren't renderable)."""
|
||||
with mock_io(query={"title": "Apple"}):
|
||||
html = run(r('(query "market" "product" :slug "apple")'))
|
||||
assert html == ""
|
||||
|
||||
def test_query_returns_list(self):
|
||||
"""Query returning a list of strings renders them."""
|
||||
with mock_io(query=["Apple", "Banana"]):
|
||||
html = run(r('(query "market" "product-names")'))
|
||||
assert "Apple" in html
|
||||
assert "Banana" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parallel I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParallelIO:
|
||||
def test_parallel_fetches(self):
|
||||
"""Multiple I/O nodes are fetched concurrently."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def counting_frag(args, kwargs, ctx):
|
||||
call_count["n"] += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return f"<div>{args[1]}</div>"
|
||||
|
||||
with mock_io(frag=counting_frag):
|
||||
html = run(r('''
|
||||
(div
|
||||
(frag "blog" "card-a")
|
||||
(frag "blog" "card-b")
|
||||
(frag "blog" "card-c"))
|
||||
'''))
|
||||
|
||||
assert "<div>card-a</div>" in html
|
||||
assert "<div>card-b</div>" in html
|
||||
assert "<div>card-c</div>" in html
|
||||
assert call_count["n"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request context primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRequestContext:
|
||||
def test_current_user(self):
|
||||
user = {"id": 1, "name": "Alice"}
|
||||
ctx = RequestContext(user=user)
|
||||
result = run(execute_io("current-user", [], {}, ctx))
|
||||
assert result == user
|
||||
|
||||
def test_htmx_true(self):
|
||||
ctx = RequestContext(is_htmx=True)
|
||||
assert run(execute_io("htmx-request?", [], {}, ctx)) is True
|
||||
|
||||
def test_htmx_false(self):
|
||||
ctx = RequestContext(is_htmx=False)
|
||||
assert run(execute_io("htmx-request?", [], {}, ctx)) is False
|
||||
|
||||
def test_no_user(self):
|
||||
ctx = RequestContext()
|
||||
assert run(execute_io("current-user", [], {}, ctx)) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_frag_error_degrades_gracefully(self):
|
||||
"""Failed I/O substitutes empty string, doesn't crash."""
|
||||
async def failing_frag(args, kwargs, ctx):
|
||||
raise ConnectionError("connection refused")
|
||||
|
||||
with mock_io(frag=failing_frag):
|
||||
html = run(r('(div (h1 "Title") (frag "blog" "broken"))'))
|
||||
assert "<h1>Title</h1>" in html
|
||||
assert "<div>" in html
|
||||
|
||||
def test_query_error_degrades_gracefully(self):
|
||||
"""Failed query substitutes empty string."""
|
||||
async def failing_query(args, kwargs, ctx):
|
||||
raise TimeoutError("timeout")
|
||||
|
||||
with mock_io(query=failing_query):
|
||||
html = run(r('(div (p "Static") (query "market" "broken"))'))
|
||||
assert "<p>Static</p>" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mixed static + I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMixedContent:
|
||||
def test_static_and_frag(self):
|
||||
with mock_io(frag="<span>Dynamic</span>"):
|
||||
html = run(r('''
|
||||
(div
|
||||
(h1 "Static Title")
|
||||
(p "Static body")
|
||||
(frag "blog" "widget"))
|
||||
'''))
|
||||
assert "<h1>Static Title</h1>" in html
|
||||
assert "<p>Static body</p>" in html
|
||||
assert "<span>Dynamic</span>" in html
|
||||
|
||||
def test_multiple_frag_types(self):
|
||||
"""Different fragment types in one tree."""
|
||||
async def dynamic_frag(args, kwargs, ctx):
|
||||
return f"<b>{args[1]}</b>"
|
||||
|
||||
with mock_io(frag=dynamic_frag):
|
||||
html = run(r('''
|
||||
(div
|
||||
(frag "blog" "header")
|
||||
(frag "market" "sidebar"))
|
||||
'''))
|
||||
assert "<b>header</b>" in html
|
||||
assert "<b>sidebar</b>" in html
|
||||
|
||||
def test_frag_and_query_together(self):
|
||||
"""Tree with both frag and query nodes."""
|
||||
async def mock_handler(args, kwargs, ctx):
|
||||
name = args[1] if len(args) > 1 else "?"
|
||||
return f"<i>{name}</i>"
|
||||
|
||||
with mock_io(frag=mock_handler, query="data"):
|
||||
html = run(r('''
|
||||
(div
|
||||
(frag "blog" "card")
|
||||
(query "market" "stats"))
|
||||
'''))
|
||||
assert "<i>card</i>" in html
|
||||
assert "data" in html
|
||||
Reference in New Issue
Block a user