Make SxExpr a str subclass, sx_call/render functions return SxExpr
SxExpr is now a str subclass so it works everywhere a plain string does (join, isinstance, f-strings) while serialize() still emits it unquoted. sx_call() and all internal render functions (_render_to_sx, async_eval_to_sx, etc.) return SxExpr, eliminating the "forgot to wrap" bug class that caused the sx_content leak and list serialization bugs. - Phase 0: SxExpr(str) with .source property, __add__/__radd__ - Phase 1: sx_call returns SxExpr (drop-in, all 200+ sites unchanged) - Phase 2: async_eval_to_sx, async_eval_slot_to_sx, _render_to_sx, mobile_menu_sx return SxExpr; remove isinstance(str) workaround - Phase 3: Remove ~150 redundant SxExpr() wrappings across 45 files - Phase 4: serialize() docstring, handler return docs, ;; returns: sx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1010,10 +1010,10 @@ async def async_eval_to_sx(
|
||||
ctx = RequestContext()
|
||||
result = await _aser(expr, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
return result
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
return serialize(result)
|
||||
return SxExpr("")
|
||||
return SxExpr(serialize(result))
|
||||
|
||||
|
||||
async def async_eval_slot_to_sx(
|
||||
@@ -1039,10 +1039,10 @@ async def async_eval_slot_to_sx(
|
||||
if isinstance(comp, Component):
|
||||
result = await _aser_component(comp, expr[1:], env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
return result
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
return serialize(result)
|
||||
return SxExpr("")
|
||||
return SxExpr(serialize(result))
|
||||
else:
|
||||
import logging
|
||||
logging.getLogger("sx.eval").error(
|
||||
@@ -1056,14 +1056,10 @@ async def async_eval_slot_to_sx(
|
||||
# Fall back to normal async_eval_to_sx
|
||||
result = await _aser(expr, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
# Page helpers return SX source strings from render_to_sx() —
|
||||
# pass through directly instead of quoting via serialize().
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return serialize(result)
|
||||
if result is None or result is NIL:
|
||||
return SxExpr("")
|
||||
return SxExpr(serialize(result))
|
||||
|
||||
|
||||
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
@@ -1071,10 +1067,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
for everything else."""
|
||||
if isinstance(expr, (int, float, bool)):
|
||||
return expr
|
||||
if isinstance(expr, str):
|
||||
return expr
|
||||
if isinstance(expr, SxExpr):
|
||||
return expr
|
||||
if isinstance(expr, str):
|
||||
return expr
|
||||
if expr is None or expr is NIL:
|
||||
return NIL
|
||||
|
||||
|
||||
@@ -111,16 +111,19 @@ async def execute_handler(
|
||||
service_name: str,
|
||||
args: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Execute a declarative handler and return rendered sx/HTML string.
|
||||
"""Execute a declarative handler and return SX wire format (``SxExpr``).
|
||||
|
||||
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.
|
||||
Uses the async evaluator so I/O primitives (``query``, ``service``,
|
||||
``request-arg``, etc.) are awaited inline within control flow.
|
||||
|
||||
Returns ``SxExpr`` — pre-built sx source. Callers like
|
||||
``fetch_fragment`` check ``content-type: text/sx`` and wrap the
|
||||
response in ``SxExpr`` when consuming cross-service fragments.
|
||||
|
||||
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
|
||||
3. Evaluate via ``async_eval_to_sx`` (I/O inline, components serialized)
|
||||
4. Return ``SxExpr`` wire format
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval_to_sx
|
||||
|
||||
@@ -74,10 +74,10 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
)
|
||||
|
||||
|
||||
def mobile_menu_sx(*sections: str) -> str:
|
||||
def mobile_menu_sx(*sections: str) -> SxExpr:
|
||||
"""Assemble mobile menu from pre-built sections (deepest first)."""
|
||||
parts = [s for s in sections if s]
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
|
||||
|
||||
async def mobile_root_nav_sx(ctx: dict) -> str:
|
||||
@@ -96,13 +96,13 @@ async def mobile_root_nav_sx(ctx: dict) -> str:
|
||||
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _post_nav_items_sx(ctx: dict) -> str:
|
||||
async def _post_nav_items_sx(ctx: dict) -> SxExpr:
|
||||
"""Build post-level nav items (container_nav + admin cog). Shared by
|
||||
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
|
||||
post = ctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
if not slug:
|
||||
return ""
|
||||
return SxExpr("")
|
||||
parts: list[str] = []
|
||||
page_cart_count = ctx.get("page_cart_count", 0)
|
||||
if page_cart_count and page_cart_count > 0:
|
||||
@@ -130,11 +130,11 @@ async def _post_nav_items_sx(ctx: dict) -> str:
|
||||
is_admin_page=is_admin_page or None)
|
||||
if admin_nav:
|
||||
parts.append(admin_nav)
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
|
||||
|
||||
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
|
||||
selected: str = "") -> str:
|
||||
selected: str = "") -> SxExpr:
|
||||
"""Build post-admin nav items (calendars, markets, etc.). Shared by
|
||||
``post_admin_header_sx`` (desktop) and mobile menu."""
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
@@ -158,7 +158,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
|
||||
parts.append(await _render_to_sx("nav-link", href=href, label=label,
|
||||
select_colours=select_colours,
|
||||
is_selected=is_sel or None))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -177,7 +177,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
|
||||
label=title,
|
||||
href=call_url(ctx, "blog_url", f"/{slug}/"),
|
||||
level=1,
|
||||
items=SxExpr(nav),
|
||||
items=nav,
|
||||
)
|
||||
|
||||
|
||||
@@ -220,8 +220,8 @@ async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> st
|
||||
return await _render_to_sx("menu-row-sx",
|
||||
id="post-row", level=1,
|
||||
link_href=link_href,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
link_label_content=label_sx,
|
||||
nav=nav_sx,
|
||||
child_id="post-header-child",
|
||||
child=SxExpr(child) if child else None,
|
||||
oob=oob, external=True,
|
||||
@@ -244,8 +244,8 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
|
||||
return await _render_to_sx("menu-row-sx",
|
||||
id="post-admin-row", level=2,
|
||||
link_href=admin_href,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
link_label_content=label_sx,
|
||||
nav=nav_sx,
|
||||
child_id="post-admin-header-child", oob=oob,
|
||||
)
|
||||
|
||||
@@ -352,7 +352,7 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
|
||||
env = dict(get_component_env())
|
||||
env.update(extra_env)
|
||||
ctx = _get_request_context()
|
||||
return await async_eval_slot_to_sx(ast, env, ctx)
|
||||
return SxExpr(await async_eval_slot_to_sx(ast, env, ctx))
|
||||
|
||||
|
||||
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
|
||||
@@ -371,7 +371,7 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
|
||||
ast = _build_component_ast(__name, **kwargs)
|
||||
env = dict(get_component_env())
|
||||
ctx = _get_request_context()
|
||||
return await async_eval_to_sx(ast, env, ctx)
|
||||
return SxExpr(await async_eval_to_sx(ast, env, ctx))
|
||||
|
||||
|
||||
# Backwards-compat alias — layout infrastructure still imports this.
|
||||
@@ -420,7 +420,7 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
|
||||
parts.append("(list " + " ".join(items) + ")")
|
||||
else:
|
||||
parts.append(serialize(val))
|
||||
return "(" + " ".join(parts) + ")"
|
||||
return SxExpr("(" + " ".join(parts) + ")")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,31 +25,37 @@ from .types import Keyword, Symbol, NIL
|
||||
# SxExpr — pre-built sx source marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SxExpr:
|
||||
class SxExpr(str):
|
||||
"""Pre-built sx source that serialize() outputs unquoted.
|
||||
|
||||
``SxExpr`` is a ``str`` subclass, so it works everywhere a plain
|
||||
string does (join, startswith, f-strings, isinstance checks). The
|
||||
only difference: ``serialize()`` emits it unquoted instead of
|
||||
wrapping it in double-quotes.
|
||||
|
||||
Use this to nest sx call strings inside other sx_call() invocations
|
||||
without them being quoted as strings::
|
||||
|
||||
sx_call("parent", child=SxExpr(sx_call("child", x=1)))
|
||||
sx_call("parent", child=sx_call("child", x=1))
|
||||
# => (~parent :child (~child :x 1))
|
||||
"""
|
||||
__slots__ = ("source",)
|
||||
|
||||
def __init__(self, source: str):
|
||||
self.source = source
|
||||
def __new__(cls, source: str = "") -> "SxExpr":
|
||||
return str.__new__(cls, source)
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
"""The raw SX source string (backward compat)."""
|
||||
return str.__str__(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SxExpr({self.source!r})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.source
|
||||
return f"SxExpr({str.__repr__(self)})"
|
||||
|
||||
def __add__(self, other: object) -> "SxExpr":
|
||||
return SxExpr(self.source + str(other))
|
||||
return SxExpr(str.__add__(self, str(other)))
|
||||
|
||||
def __radd__(self, other: object) -> "SxExpr":
|
||||
return SxExpr(str(other) + self.source)
|
||||
return SxExpr(str.__add__(str(other), self))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,7 +289,26 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
"""Serialize a value back to s-expression text."""
|
||||
"""Serialize a value back to s-expression text.
|
||||
|
||||
Type dispatch order (first match wins):
|
||||
|
||||
- ``SxExpr`` → emitted unquoted (pre-built sx source)
|
||||
- ``list`` → ``(head ...)`` (s-expression list)
|
||||
- ``Symbol`` → bare name
|
||||
- ``Keyword`` → ``:name``
|
||||
- ``str`` → ``"quoted"`` (with escapes)
|
||||
- ``bool`` → ``true`` / ``false``
|
||||
- ``int/float`` → numeric literal
|
||||
- ``None/NIL`` → ``nil``
|
||||
- ``dict`` → ``{:key val ...}``
|
||||
|
||||
List serialization conventions (for ``sx_call`` kwargs):
|
||||
|
||||
- ``(list ...)`` — data array: client gets iterable for map/filter
|
||||
- ``(<> ...)`` — rendered content: client treats as DocumentFragment
|
||||
- ``(head ...)`` — AST: head is called as function (never use for data)
|
||||
"""
|
||||
if isinstance(expr, SxExpr):
|
||||
return expr.source
|
||||
|
||||
|
||||
Reference in New Issue
Block a user