Phase 1: add lazy node tree (Node/Element/Safe/Fragment/Media)

Introduce a FastHTML-style component model alongside the existing
function-based one, purely additive:

- Node: base renderable; __html__/__str__ render lazily so str()/f-string
  composition keeps working during migration.
- Element: the single class for any HTML element (tag + attrs + children),
  rendering via the existing memoized _render_element.
- Safe: wraps pre-rendered HTML (migration bridge for f-string components).
- Fragment: ordered children with no wrapper tag (replaces str(a)+str(b)).
- BaseComponent: base for higher-level components; render() returns a
  subtree, media declared via a Media attribute.
- Media: declarative JS deps with order-preserving dedup merge.
- collect_media()/render() helpers walk the tree.

The legacy Component() function now builds an Element and is Node-aware in
its child handling, so a tree mixing string- and node-returning components
renders correctly with byte-identical output. No call sites changed yet.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-13 06:56:37 +00:00
parent e7db7eb0e8
commit f673f3ac80
3 changed files with 406 additions and 23 deletions
+16
View File
@@ -5,11 +5,19 @@ re-exports the public API so ``from common.components import X`` keeps working.
""" """
from common.components.core import ( from common.components.core import (
BaseComponent,
Component, Component,
Element,
Fragment,
HTMLAttribute, HTMLAttribute,
HTMLTag, HTMLTag,
Media,
Node,
Safe,
_render_element, _render_element,
collect_media,
randomid, randomid,
render,
) )
from common.components.date_range_picker import ( from common.components.date_range_picker import (
DateRangeCalendar, DateRangeCalendar,
@@ -82,7 +90,15 @@ from common.utils import truncate
__all__ = [ __all__ = [
"truncate", "truncate",
"BaseComponent",
"Component", "Component",
"Element",
"Fragment",
"Media",
"Node",
"Safe",
"collect_media",
"render",
"HTMLAttribute", "HTMLAttribute",
"HTMLTag", "HTMLTag",
"_render_element", "_render_element",
+253 -23
View File
@@ -1,4 +1,22 @@
"""Escaping core: the Component builder and its memoised renderer.""" """Node layer: the lazy component tree, its renderer, and media collection.
A FastHTML-style model. Everything renderable is a :class:`Node`. The single
:class:`Element` class represents *any* HTML element (tag + attrs + children);
named builders like ``Div`` / ``Span`` are generated from a whitelist rather
than hand-written per tag (see ``primitives.py``). Higher-level, behaviour- or
media-bearing components subclass :class:`BaseComponent` and implement
``render()`` returning a node subtree.
Nodes are *lazy*: they hold structure and render to HTML only when asked
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
``Page()`` walk a finished tree and collect every component's declared JS
(:class:`Media`) instead of each view threading ``scripts=`` by hand.
Backwards compatibility: the legacy ``Component(tag_name=...)`` function still
returns a ``SafeText`` string, so existing string-based call sites keep working
during the migration. Its child handling is Node-aware, so a tree mixing old
(string-returning) and new (node-returning) components renders correctly.
"""
import hashlib import hashlib
from functools import lru_cache from functools import lru_cache
@@ -13,21 +31,121 @@ HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str HTMLTag = str
# ── Media: declarative JS dependencies ──────────────────────────────────────
def _dedup(*sequences: tuple[str, ...]) -> tuple[str, ...]:
"""First-seen dedup that preserves declaration order across sequences."""
seen: dict[str, None] = {}
for sequence in sequences:
for item in sequence:
seen.setdefault(item, None)
return tuple(seen)
class Media:
"""A component's JS dependencies, modelled on ``django.forms.Media``.
``js`` are static ES-module filenames (rendered as ``ModuleScript``);
``js_external`` are vendored UMD / classic bundles (rendered as
``StaticScript``). Addition merges with first-seen, order-preserving dedup,
so a page that uses a component many times emits each script once.
"""
__slots__ = ("js", "js_external")
def __init__(
self,
js: tuple[str, ...] | list[str] = (),
js_external: tuple[str, ...] | list[str] = (),
) -> None:
self.js = tuple(js)
self.js_external = tuple(js_external)
def __add__(self, other: "Media | None") -> "Media":
if not other:
return self
return Media(
_dedup(self.js, other.js),
_dedup(self.js_external, other.js_external),
)
def __radd__(self, other: "Media | None") -> "Media":
# Supports ``sum(medias, Media())`` and ``0 + media``.
if not other or other == 0:
return self
return other.__add__(self)
def __bool__(self) -> bool:
return bool(self.js or self.js_external)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, Media)
and self.js == other.js
and self.js_external == other.js_external
)
def __hash__(self) -> int:
return hash((self.js, self.js_external))
def __repr__(self) -> str:
return f"Media(js={self.js!r}, js_external={self.js_external!r})"
# ── Node tree ────────────────────────────────────────────────────────────────
class Node:
"""Base class for everything renderable to HTML."""
# Declared dependencies. Class-level default is shared and empty; concrete
# components override with their own ``Media(...)``.
media: Media = Media()
def _render(self) -> str:
raise NotImplementedError
def collect_media(self) -> Media:
"""Total media of this node and its subtree."""
return self.media
# `__html__` marks the value HTML-safe for Django (conditional_escape).
# `__str__` lets f-strings, ``str()`` and ``"".join(str(...))`` render the
# node — the bridge that keeps most existing composition working.
def __html__(self) -> str:
return self._render()
def __str__(self) -> str:
return self._render()
def _child_key(child: object) -> tuple[str, bool]:
"""Normalise a child to a ``(text, is_safe)`` pair.
Nodes render to safe HTML; ``SafeText`` (and anything exposing ``__html__``)
is already safe; plain strings are escaped. ``is_safe`` is part of the
render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never collide.
"""
if isinstance(child, Node):
return (child._render(), True)
if isinstance(child, str):
return (child, isinstance(child, SafeText))
if hasattr(child, "__html__"):
return (child.__html__(), True)
return (str(child), False)
@lru_cache(maxsize=4096) @lru_cache(maxsize=4096)
def _render_element( def _render_element(
tag_name: str, tag_name: str,
attrs_key: tuple[tuple[str, str], ...], attrs_key: tuple[tuple[str, str], ...],
children_key: tuple[tuple[str, bool], ...], children_key: tuple[tuple[str, bool], ...],
) -> str: ) -> str:
"""Pure, memoized HTML builder behind `Component`. """Pure, memoized HTML builder. Identical (tag, attrs, children) render once.
Inputs are fully hashable and fully determine the output, so identical ``attrs_key`` is (name, stringified value) pairs (values always escaped);
elements are rendered once. `attrs_key` is (name, stringified value) pairs ``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
(attribute values are always escaped). `children_key` is (child, is_safe)
pairs: SafeText children pass through, plain strings are escaped. The
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
render with the wrong escaping.
""" """
children_blob = "\n".join( children_blob = "\n".join(
child if is_safe else escape(child) for child, is_safe in children_key child if is_safe else escape(child) for child, is_safe in children_key
@@ -41,24 +159,136 @@ def _render_element(
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>" return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
def Component( class Element(Node):
"""Any HTML element: a tag name, attributes and children.
Children may be other nodes, ``SafeText``, or plain strings (escaped).
Rendering goes through the memoized :func:`_render_element`.
"""
def __init__(
self,
tag_name: str,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: "list | Node | str | None" = None,
tag_name: str = "", ) -> None:
) -> SafeText:
"""Render an HTML element. Attribute values are always escaped; children are
escaped unless they are `SafeText` (so nested components pass through),
preventing accidental HTML injection. Rendering is memoized via
`_render_element`."""
attributes = attributes or []
children = children or []
if not tag_name: if not tag_name:
raise ValueError("tag_name is required.") raise ValueError("tag_name is required.")
if isinstance(children, str): self.tag_name = tag_name
self.attributes = attributes or []
if children is None:
children = []
elif isinstance(children, (str, Node)):
children = [children] children = [children]
attrs_key = tuple((name, str(value)) for name, value in attributes) self.children = children
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
return mark_safe(_render_element(tag_name, attrs_key, children_key)) def collect_media(self) -> Media:
media = self.media
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
attrs_key = tuple((name, str(value)) for name, value in self.attributes)
children_key = tuple(_child_key(child) for child in self.children)
return _render_element(self.tag_name, attrs_key, children_key)
class Safe(Node):
"""A node wrapping pre-rendered, trusted HTML (the ``mark_safe`` analogue).
Used as the migration bridge for components still built from f-strings:
they return ``Safe(html)`` and declare their ``media`` explicitly rather
than atomising their markup into a node tree up front.
"""
def __init__(self, html: object, media: Media | None = None) -> None:
self._html = str(html)
if media is not None:
self.media = media
def _render(self) -> str:
return self._html
class Fragment(Node):
"""An ordered group of children with no wrapping tag.
Replaces ``mark_safe(str(a) + str(b))`` / ``"\\n".join(...)`` composition,
so media still bubbles up from the grouped children.
"""
def __init__(self, *children: object, separator: str = "") -> None:
self.children = [c for c in children if c is not None and c != ""]
self.separator = separator
def collect_media(self) -> Media:
media = Media()
for child in self.children:
if isinstance(child, Node):
media = media + child.collect_media()
return media
def _render(self) -> str:
parts = []
for child in self.children:
text, is_safe = _child_key(child)
parts.append(text if is_safe else escape(text))
return self.separator.join(parts)
class BaseComponent(Node):
"""Base for higher-level components: implement ``render()`` returning a node
subtree and declare ``media`` (a :class:`Media`).
``render()`` is called once and memoized; ``collect_media()`` returns this
component's own media merged with the rendered subtree's.
"""
def render(self) -> Node:
raise NotImplementedError
def _tree(self) -> Node:
cached = getattr(self, "_tree_cache", None)
if cached is None:
cached = self.render()
self._tree_cache = cached
return cached
def _render(self) -> str:
return self._tree()._render()
def collect_media(self) -> Media:
return self.media + self._tree().collect_media()
def render(node: "Node | str") -> SafeText:
"""Render a node (or pass a string through) to safe HTML."""
if isinstance(node, Node):
return mark_safe(node._render())
return mark_safe(str(node))
def collect_media(node: "Node | str") -> Media:
"""Collect the media of a node tree (empty for a bare string)."""
if isinstance(node, Node):
return node.collect_media()
return Media()
def Component(
attributes: list[HTMLAttribute] | None = None,
children: "list[HTMLTag] | HTMLTag | None" = None,
tag_name: str = "",
) -> SafeText:
"""Legacy element builder: returns a ``SafeText`` string.
Kept for backwards compatibility while call sites migrate to :class:`Element`
and the generated tag builders. Child handling is Node-aware, so a tree that
mixes string-returning and node-returning components still renders correctly.
"""
return render(Element(tag_name, attributes, children))
def randomid(seed: str = "", content: str = "", length: int = 10) -> str: def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
+137
View File
@@ -0,0 +1,137 @@
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
These cover the new machinery directly and assert byte-for-byte parity between
``Element`` and the legacy ``Component()`` shim, so the migration of call sites
in later phases can rely on identical output.
"""
import unittest
from django.utils.safestring import SafeText, mark_safe
from common.components import (
BaseComponent,
Component,
Element,
Fragment,
Media,
Node,
Safe,
collect_media,
render,
)
class ElementRenderTest(unittest.TestCase):
def test_matches_legacy_component(self):
element = Element("div", [("class", "test")], "hello")
legacy = Component(
tag_name="div", attributes=[("class", "test")], children="hello"
)
self.assertEqual(render(element), legacy)
self.assertEqual(render(element), '<div class="test">hello</div>')
def test_plain_string_children_escaped(self):
self.assertEqual(
render(Element("span", children=["<b>"])), "<span>&lt;b&gt;</span>"
)
def test_safe_children_pass_through(self):
self.assertEqual(
render(Element("span", children=[mark_safe("<b>x</b>")])),
"<span><b>x</b></span>",
)
def test_node_children_render_safely(self):
inner = Element("b", children=["x"])
self.assertEqual(
render(Element("span", children=[inner])), "<span><b>x</b></span>"
)
def test_legacy_component_renders_node_children(self):
# The compatibility bridge: a string-returning legacy Component must
# render nested Node children as HTML, not escape them.
inner = Element("b", children=["x"])
result = Component(tag_name="span", children=[inner])
self.assertEqual(result, "<span><b>x</b></span>")
self.assertIsInstance(result, SafeText)
class SafeAndFragmentTest(unittest.TestCase):
def test_safe_passes_html_through(self):
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
def test_fragment_concatenates(self):
frag = Fragment(
Element("span", children=["a"]), Element("span", children=["b"])
)
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
def test_fragment_skips_empty_children(self):
frag = Fragment("", None, Element("span", children=["a"]))
self.assertEqual(render(frag), "<span>a</span>")
def test_fragment_escapes_plain_strings(self):
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "&lt;x&gt;<y>")
class MediaTest(unittest.TestCase):
def test_merge_dedups_preserving_order(self):
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
def test_external_kept_separate(self):
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
self.assertEqual(merged.js, ("a.js",))
self.assertEqual(merged.js_external, ("umd.js",))
def test_sum_with_radd(self):
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
self.assertEqual(merged.js, ("a.js", "b.js"))
def test_falsy_when_empty(self):
self.assertFalse(Media())
self.assertTrue(Media(js=["a.js"]))
class MediaCollectionTest(unittest.TestCase):
def test_bubbles_through_element_children(self):
class Widget(BaseComponent):
media = Media(js=["widget.js"])
def render(self) -> Node:
return Element("div", children=["x"])
tree = Element("section", children=[Element("div", children=[Widget()])])
self.assertEqual(collect_media(tree).js, ("widget.js",))
def test_bubbles_through_fragment(self):
class Widget(BaseComponent):
media = Media(js=["w.js"])
def render(self) -> Node:
return Element("div")
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
def test_component_merges_own_and_subtree_media(self):
class Inner(BaseComponent):
media = Media(js=["inner.js"])
def render(self) -> Node:
return Element("span")
class Outer(BaseComponent):
media = Media(js=["outer.js"])
def render(self) -> Node:
return Element("div", children=[Inner()])
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
def test_bare_string_has_no_media(self):
self.assertFalse(collect_media("just a string"))
if __name__ == "__main__":
unittest.main()