Refactor SX primitives: modular, isomorphic, general-purpose
Spec modularization: - Add (define-module :name) markers to primitives.sx creating 11 modules (7 core, 4 stdlib). Bootstrappers can now selectively include modules. - Add parse_primitives_by_module() to boundary_parser.py. - Remove split-ids primitive; inline at 4 call sites in blog/market queries. Python file split: - primitives.py: slimmed to registry + core primitives only (~350 lines) - primitives_stdlib.py: NEW — stdlib primitives (format, text, style, debug) - primitives_ctx.py: NEW — extracted 12 page context builders from IO - primitives_io.py: add register_io_handler decorator, auto-derive IO_PRIMITIVES from registry, move sync IO bridges here JS parity fixes: - = uses === (strict equality), != uses !== - round supports optional ndigits parameter - concat uses nil-check not falsy-check (preserves 0, "", false) - escape adds single quote entity (') matching Python/markupsafe - assert added (was missing from JS entirely) Bootstrapper modularization: - PRIMITIVES_JS_MODULES / PRIMITIVES_PY_MODULES dicts keyed by module - --modules CLI flag for selective inclusion (core.* always included) - Regenerated sx-ref.js and sx_ref.py with all fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Callable
|
||||
|
||||
from .types import Keyword, Lambda, NIL
|
||||
from .types import Keyword, NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -265,12 +265,6 @@ def prim_join(sep: str, coll: list) -> str:
|
||||
def prim_replace(s: str, old: str, new: str) -> str:
|
||||
return s.replace(old, new)
|
||||
|
||||
@register_primitive("strip-tags")
|
||||
def prim_strip_tags(s: str) -> str:
|
||||
"""Strip HTML tags from a string."""
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
|
||||
@register_primitive("slice")
|
||||
def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
|
||||
"""Slice a string or list: (slice coll start end?)."""
|
||||
@@ -432,181 +426,3 @@ def prim_into(target: Any, coll: Any) -> Any:
|
||||
return result
|
||||
raise ValueError(f"into: unsupported target type {type(target).__name__}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("format-date")
|
||||
def prim_format_date(date_str: Any, fmt: str) -> str:
|
||||
"""``(format-date date-str fmt)`` → formatted date string."""
|
||||
from datetime import datetime
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(date_str))
|
||||
return dt.strftime(fmt)
|
||||
except (ValueError, TypeError):
|
||||
return str(date_str) if date_str else ""
|
||||
|
||||
|
||||
@register_primitive("format-decimal")
|
||||
def prim_format_decimal(val: Any, places: Any = 2) -> str:
|
||||
"""``(format-decimal val places)`` → formatted decimal string."""
|
||||
try:
|
||||
return f"{float(val):.{int(places)}f}"
|
||||
except (ValueError, TypeError):
|
||||
return "0." + "0" * int(places)
|
||||
|
||||
|
||||
@register_primitive("parse-int")
|
||||
def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
|
||||
"""``(parse-int val default?)`` → int(val) with fallback."""
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
@register_primitive("parse-datetime")
|
||||
def prim_parse_datetime(val: Any) -> Any:
|
||||
"""``(parse-datetime "2024-01-15T10:00:00")`` → ISO string or nil."""
|
||||
from datetime import datetime
|
||||
if not val or val is NIL:
|
||||
return NIL
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(val))
|
||||
return dt.isoformat()
|
||||
except (ValueError, TypeError):
|
||||
return NIL
|
||||
|
||||
|
||||
@register_primitive("split-ids")
|
||||
def prim_split_ids(val: Any) -> list[int]:
|
||||
"""``(split-ids "1,2,3")`` → [1, 2, 3]. Parse comma-separated int IDs."""
|
||||
if not val or val is NIL:
|
||||
return []
|
||||
return [int(x.strip()) for x in str(val).split(",") if x.strip()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("assert")
|
||||
def prim_assert(condition: Any, message: str = "Assertion failed") -> bool:
|
||||
if not condition:
|
||||
raise RuntimeError(f"Assertion error: {message}")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("pluralize")
|
||||
def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str:
|
||||
"""``(pluralize count)`` → "s" if count != 1, else "".
|
||||
``(pluralize count "item" "items")`` → "item" or "items"."""
|
||||
try:
|
||||
n = int(count)
|
||||
except (ValueError, TypeError):
|
||||
n = 0
|
||||
if singular or plural != "s":
|
||||
return singular if n == 1 else plural
|
||||
return "" if n == 1 else "s"
|
||||
|
||||
|
||||
@register_primitive("escape")
|
||||
def prim_escape(s: Any) -> str:
|
||||
"""``(escape val)`` → HTML-escaped string."""
|
||||
from markupsafe import escape as _escape
|
||||
return str(_escape(str(s) if s is not None and s is not NIL else ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_primitive("css")
|
||||
def prim_css(*args: Any) -> Any:
|
||||
"""``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue.
|
||||
|
||||
Accepts keyword atoms (strings without colon prefix) and runtime
|
||||
strings. Returns a StyleValue with a content-addressed class name
|
||||
and all resolved CSS declarations.
|
||||
"""
|
||||
from .style_resolver import resolve_style
|
||||
atoms = tuple(
|
||||
(a.name if isinstance(a, Keyword) else str(a))
|
||||
for a in args if a is not None and a is not NIL and a is not False
|
||||
)
|
||||
if not atoms:
|
||||
return NIL
|
||||
return resolve_style(atoms)
|
||||
|
||||
|
||||
@register_primitive("merge-styles")
|
||||
def prim_merge_styles(*styles: Any) -> Any:
|
||||
"""``(merge-styles style1 style2)`` → merged StyleValue.
|
||||
|
||||
Merges multiple StyleValues; later declarations win.
|
||||
"""
|
||||
from .types import StyleValue
|
||||
from .style_resolver import merge_styles
|
||||
valid = [s for s in styles if isinstance(s, StyleValue)]
|
||||
if not valid:
|
||||
return NIL
|
||||
if len(valid) == 1:
|
||||
return valid[0]
|
||||
return merge_styles(valid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync IO bridge primitives
|
||||
#
|
||||
# These are declared in boundary.sx (I/O tier), NOT primitives.sx.
|
||||
# They bypass @register_primitive validation because they aren't pure.
|
||||
# But they must be evaluator-visible because they're called inline in .sx
|
||||
# code (inside let, filter, etc.) where the async IO interceptor can't
|
||||
# reach them — particularly in async_eval_ref.py which only intercepts
|
||||
# IO at the top level.
|
||||
#
|
||||
# The async evaluators also intercept these via IO_PRIMITIVES, so the
|
||||
# async path works too. This registration ensures the sync fallback works.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _bridge_app_url(service, *path_parts):
|
||||
from shared.infrastructure.urls import app_url
|
||||
path = str(path_parts[0]) if path_parts else "/"
|
||||
return app_url(str(service), path)
|
||||
|
||||
def _bridge_asset_url(*path_parts):
|
||||
from shared.infrastructure.urls import asset_url
|
||||
path = str(path_parts[0]) if path_parts else ""
|
||||
return asset_url(path)
|
||||
|
||||
def _bridge_config(key):
|
||||
from shared.config import config
|
||||
cfg = config()
|
||||
return cfg.get(str(key))
|
||||
|
||||
def _bridge_jinja_global(key, *default):
|
||||
from quart import current_app
|
||||
d = default[0] if default else None
|
||||
return current_app.jinja_env.globals.get(str(key), d)
|
||||
|
||||
def _bridge_relations_from(entity_type):
|
||||
from shared.sx.relations import relations_from
|
||||
return [
|
||||
{
|
||||
"name": d.name, "from_type": d.from_type, "to_type": d.to_type,
|
||||
"cardinality": d.cardinality, "nav": d.nav,
|
||||
"nav_icon": d.nav_icon, "nav_label": d.nav_label,
|
||||
}
|
||||
for d in relations_from(str(entity_type))
|
||||
]
|
||||
|
||||
_PRIMITIVES["app-url"] = _bridge_app_url
|
||||
_PRIMITIVES["asset-url"] = _bridge_asset_url
|
||||
_PRIMITIVES["config"] = _bridge_config
|
||||
_PRIMITIVES["jinja-global"] = _bridge_jinja_global
|
||||
_PRIMITIVES["relations-from"] = _bridge_relations_from
|
||||
|
||||
Reference in New Issue
Block a user