Replace sx_call() with render_to_sx() across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s

Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

- Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py
- Add (abort status msg) IO primitive in shared/sx/primitives_io.py
- Convert all 9 services: ~650 sx_call() invocations replaced
- Convert shared helpers (root_header_sx, full_page_sx, etc.) to async
- Fix likes service import bug (likes.models → models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 00:08:33 +00:00
parent 0554f8a113
commit e085fe43b4
51 changed files with 1824 additions and 1742 deletions

View File

@@ -83,12 +83,12 @@ def _as_sx(val: Any) -> SxExpr | None:
return SxExpr(f'(~rich-text :html "{escaped}")')
def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as a sx call string."""
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as sx wire format."""
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return sx_call("header-row-sx",
return await render_to_sx("header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
@@ -108,13 +108,13 @@ def mobile_menu_sx(*sections: str) -> str:
return "(<> " + " ".join(parts) + ")" if parts else ""
def mobile_root_nav_sx(ctx: dict) -> str:
async def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
return ""
return sx_call("mobile-root-nav",
return await render_to_sx("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
@@ -124,7 +124,7 @@ def mobile_root_nav_sx(ctx: dict) -> str:
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
def _post_nav_items_sx(ctx: dict) -> str:
async def _post_nav_items_sx(ctx: dict) -> str:
"""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 {}
@@ -135,7 +135,7 @@ def _post_nav_items_sx(ctx: dict) -> str:
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(sx_call("page-cart-badge", href=cart_href,
parts.append(await render_to_sx("page-cart-badge", href=cart_href,
count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip()
@@ -171,7 +171,7 @@ def _post_nav_items_sx(ctx: dict) -> str:
return "(<> " + " ".join(parts) + ")" if parts else ""
def _post_admin_nav_items_sx(ctx: dict, slug: str,
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
@@ -193,7 +193,7 @@ def _post_admin_nav_items_sx(ctx: dict, slug: str,
continue
href = url_fn(path)
is_sel = label == selected
parts.append(sx_call("nav-link", href=href, label=label,
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 ""
@@ -203,15 +203,15 @@ def _post_admin_nav_items_sx(ctx: dict, slug: str,
# Mobile menu section builders — wrap shared nav items for hamburger panel
# ---------------------------------------------------------------------------
def post_mobile_nav_sx(ctx: dict) -> str:
async def post_mobile_nav_sx(ctx: dict) -> str:
"""Post-level mobile menu section."""
nav = _post_nav_items_sx(ctx)
nav = await _post_nav_items_sx(ctx)
if not nav:
return ""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return sx_call("mobile-menu-section",
return await render_to_sx("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
@@ -219,22 +219,22 @@ def post_mobile_nav_sx(ctx: dict) -> str:
)
def post_admin_mobile_nav_sx(ctx: dict, slug: str,
async def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = _post_admin_nav_items_sx(ctx, slug, selected)
nav = await _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return sx_call("mobile-menu-section",
return await render_to_sx("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string."""
return sx_call("search-mobile",
async def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx wire format."""
return await render_to_sx("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -243,9 +243,9 @@ def search_mobile_sx(ctx: dict) -> str:
)
def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx call string."""
return sx_call("search-desktop",
async def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx wire format."""
return await render_to_sx("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -254,8 +254,8 @@ def search_desktop_sx(ctx: dict) -> str:
)
def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx call string."""
async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx wire format."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
@@ -263,11 +263,11 @@ def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
nav_sx = _post_nav_items_sx(ctx) or None
label_sx = await render_to_sx("post-label", feature_image=feature_image, title=title)
nav_sx = await _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("menu-row-sx",
return await render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
@@ -278,22 +278,22 @@ def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
)
def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sx call string."""
"""Post admin header row as sx wire format."""
# Label
label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"']
if selected:
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or None
nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None
if not admin_href:
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return sx_call("menu-row-sx",
return await render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
@@ -302,29 +302,29 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
)
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap.
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
"""
return sx_call("oob-header-sx",
return await render_to_sx("oob-header-sx",
parent_id=parent_id,
row=SxExpr(row_sx),
)
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return sx_call("header-child-sx",
return await render_to_sx("header-child-sx",
id=id, inner=SxExpr(f"(<> {inner_sx})"),
)
def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx call string."""
return sx_call("oob-sx",
"""Build OOB response as sx wire format."""
return await render_to_sx("oob-sx",
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -333,7 +333,7 @@ def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
)
def full_page_sx(ctx: dict, *, header_rows: str,
async def full_page_sx(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
@@ -344,8 +344,8 @@ def full_page_sx(ctx: dict, *, header_rows: str,
"""
# Auto-generate mobile nav from context when no menu provided
if not menu:
menu = mobile_root_nav_sx(ctx)
body_sx = sx_call("app-body",
menu = await mobile_root_nav_sx(ctx)
body_sx = await render_to_sx("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -359,6 +359,64 @@ def full_page_sx(ctx: dict, *, header_rows: str,
return sx_page(ctx, body_sx, meta_html=meta_html)
def _build_component_ast(__name: str, **kwargs: Any) -> list:
"""Build an AST list for a component call from Python kwargs.
Returns e.g. [Symbol("~card"), Keyword("title"), "hello", Keyword("count"), 3]
No SX string generation — values stay as native Python objects.
"""
from .types import Symbol, Keyword, NIL
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
ast: list = [comp_sym]
for key, val in kwargs.items():
kebab = key.replace("_", "-")
ast.append(Keyword(kebab))
if val is None:
ast.append(NIL)
elif isinstance(val, SxExpr):
# SxExpr values need to be parsed into AST
from .parser import parse
ast.append(parse(val.source))
else:
ast.append(val)
return ast
async def render_to_sx(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get SX wire format back. No SX string literals.
Builds an AST from Python values and evaluates it through the SX
evaluator, which resolves IO primitives and serializes component/tag
calls as SX wire format.
await render_to_sx("card", title="hello", count=3)
# equivalent to old: sx_call("card", title="hello", count=3)
# but values flow as native objects, not serialized strings
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_eval_to_sx(ast, env, ctx)
async def render_to_html(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get HTML back. No SX string literals.
Same as render_to_sx() but produces HTML output instead of SX wire
format. Used by route renders that need HTML (full pages, fragments).
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_render
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_render(ast, env, ctx)
def sx_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
@@ -428,27 +486,19 @@ def components_for_request() -> str:
return "\n".join(parts)
def sx_response(source_or_component: str, status: int = 200,
headers: dict | None = None, **kwargs: Any):
def sx_response(source: str, status: int = 200,
headers: dict | None = None):
"""Return an s-expression wire-format response.
Can be called with a raw sx string::
Takes a raw sx string::
return sx_response('(~test-row :nodeid "foo")')
Or with a component name + kwargs (builds the sx call)::
return sx_response("test-row", nodeid="foo", outcome="passed")
For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content.
"""
from quart import request, Response
if kwargs:
source = sx_call(source_or_component, **kwargs)
else:
source = source_or_component
body = source
# Validate the sx source parses as a single expression