Type component children with a covariant Children alias

The builders annotated their ``children`` parameter as
``list[HTMLTag] | HTMLTag | None`` where ``HTMLTag = str``. ``list[str]`` is
invariant, so passing ``list[Element]`` / ``list[Node]`` — the normal case —
was a type error everywhere a component nested children.

Introduce a proper child type in core:

    Child    = Node | str
    Children = Sequence[Child] | str | None

``Sequence`` is covariant, so ``list[Element]`` / ``list[Node]`` are accepted;
``Child`` includes ``Node`` so node children are no longer rejected. ``Element``
itself also accepts a bare ``Node`` (it wraps one), typed ``Children | Node``.

Replace the ``list[HTMLTag] | HTMLTag | None`` annotations across primitives /
domain with ``Children``, and add ``as_children()`` to normalise a ``children``
argument to a ``list[Child]`` — retiring the repeated
``children if isinstance(children, list) else [children]`` dance that defeated
type narrowing. Inline ``mark_safe(...)`` SVG/markup children become ``Safe(...)``
nodes (a ``Node`` child instead of a stub-typed string).

Pyright on the component package drops from 43 to 22 errors; the remaining 22
are pre-existing and unrelated (django-stubs model access, the ``mark_safe``
``_Wrapped`` return type, and ``list[HTMLAttribute]`` attribute invariance).
Full suite green (443).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 18:14:09 +02:00
parent 9c42d85f52
commit 7104605c06
3 changed files with 55 additions and 30 deletions
+27 -1
View File
@@ -14,6 +14,7 @@ Nodes are *lazy*: they hold structure and render to HTML only when asked
""" """
import hashlib import hashlib
from collections.abc import Sequence
from functools import lru_cache from functools import lru_cache
from django.utils.html import escape from django.utils.html import escape
@@ -126,6 +127,31 @@ class Node:
return mark_safe(self._render()) return mark_safe(self._render())
# A renderable child is a node or a string (plain strings are escaped, SafeText
# and nodes pass through). ``Children`` is the type for a builder's ``children``
# parameter: a sequence of child nodes/strings, a bare string, or nothing. The
# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are
# accepted (a plain ``list[str]`` would be invariant and reject them). A single
# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the
# higher-level builders take ``Children``.
Child = Node | str
Children = Sequence[Child] | str | None
def as_children(children: "Children | Node") -> list[Child]:
"""Normalise a builder's ``children`` argument to a flat list.
Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a
sequence of them. Lets builders drop the ``children if isinstance(children,
list) else [children]`` dance and get a properly typed ``list[Child]``.
"""
if children is None:
return []
if isinstance(children, (str, Node)):
return [children]
return list(children)
def _child_key(child: object) -> tuple[str, bool]: def _child_key(child: object) -> tuple[str, bool]:
"""Normalise a child to a ``(text, is_safe)`` pair. """Normalise a child to a ``(text, is_safe)`` pair.
@@ -176,7 +202,7 @@ class Element(Node):
self, self,
tag_name: str, tag_name: str,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: "list | Node | str | None" = None, children: "Children | Node" = None,
) -> None: ) -> None:
if not tag_name: if not tag_name:
raise ValueError("tag_name is required.") raise ValueError("tag_name is required.")
+8 -9
View File
@@ -6,7 +6,7 @@ from django.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import HTMLTag, Node from common.components.core import Children, Node, as_children
from common.components.primitives import ( from common.components.primitives import (
A, A,
Div, Div,
@@ -21,13 +21,12 @@ from games.models import Game, Purchase, Session
def GameLink( def GameLink(
game_id: int, game_id: int,
name: str = "", name: str = "",
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> Node: ) -> Node:
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name.""" """Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
from django.urls import reverse from django.urls import reverse
children = children or [] display = as_children(children) or [name]
display = children if children else [name]
link = reverse("games:view_game", args=[game_id]) link = reverse("games:view_game", args=[game_id])
return Span( return Span(
@@ -38,7 +37,7 @@ def GameLink(
attributes=[ attributes=[
("class", "underline decoration-slate-500 sm:decoration-2"), ("class", "underline decoration-slate-500 sm:decoration-2"),
], ],
children=display if isinstance(display, list) else [display], children=display,
), ),
], ],
) )
@@ -54,7 +53,7 @@ _STATUS_COLORS = {
def GameStatus( def GameStatus(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
status: str = "u", status: str = "u",
display: str = "", display: str = "",
class_: str = "", class_: str = "",
@@ -76,12 +75,12 @@ def GameStatus(
return Span( return Span(
attributes=[("class", outer_class)], attributes=[("class", outer_class)],
children=[dot] + (children if isinstance(children, list) else [children]), children=[dot] + as_children(children),
) )
def PriceConverted( def PriceConverted(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> Node: ) -> Node:
"""Wrap content in a span that indicates the price was converted.""" """Wrap content in a span that indicates the price was converted."""
children = children or [] children = children or []
@@ -90,7 +89,7 @@ def PriceConverted(
("title", "Price is a result of conversion and rounding."), ("title", "Price is a result of conversion and rounding."),
("class", "decoration-dotted underline"), ("class", "decoration-dotted underline"),
], ],
children=children if isinstance(children, list) else [children], children=as_children(children),
) )
+20 -20
View File
@@ -14,13 +14,14 @@ from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import ( from common.components.core import (
Children,
Element, Element,
Fragment, Fragment,
HTMLAttribute, HTMLAttribute,
HTMLTag,
Media, Media,
Node, Node,
Safe, Safe,
as_children,
randomid, randomid,
) )
from common.icons import get_icon from common.icons import get_icon
@@ -53,7 +54,7 @@ def _html_element(tag_name: str):
def element( def element(
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: "list[HTMLTag] | HTMLTag | Node | None" = None, children: Children = None,
) -> Element: ) -> Element:
return Element(tag_name, attributes, children) return Element(tag_name, attributes, children)
@@ -113,7 +114,7 @@ def _popover_html(
children=[popover_content], children=[popover_content],
), ),
Div(attributes=[("data-popper-arrow", "")]), Div(attributes=[("data-popper-arrow", "")]),
mark_safe( # nosec — intentional HTML comment for Tailwind JIT Safe( # nosec — intentional HTML comment for Tailwind JIT
"<!-- for Tailwind CSS to generate decoration-dotted CSS " "<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->" "from Python component -->"
), ),
@@ -130,7 +131,7 @@ def Popover(
popover_content: str, popover_content: str,
wrapped_content: str = "", wrapped_content: str = "",
wrapped_classes: str = "", wrapped_classes: str = "",
children: list[HTMLTag] | None = None, children: Children = None,
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
id: str = "", id: str = "",
) -> Node: ) -> Node:
@@ -184,7 +185,7 @@ def PopoverTruncated(
def A( def A(
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
url_name: str | None = None, url_name: str | None = None,
href: str | None = None, href: str | None = None,
) -> Element: ) -> Element:
@@ -212,7 +213,7 @@ def A(
def Button( def Button(
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
size: str = "base", size: str = "base",
icon: bool = False, icon: bool = False,
color: str = "blue", color: str = "blue",
@@ -373,7 +374,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
def Input( def Input(
type: str = "text", type: str = "text",
attributes: list[HTMLAttribute] | None = None, attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> Element: ) -> Element:
attributes = attributes or [] attributes = attributes or []
children = children or [] children = children or []
@@ -481,12 +482,12 @@ def Pill(
pill_attrs.append(("data-value", str(value))) pill_attrs.append(("data-value", str(value)))
pill_attrs.extend(attributes) pill_attrs.extend(attributes)
label_child: HTMLTag = ( label_child: "Node | str" = (
Span(attributes=[("data-search-select-label", "")], children=[label]) Span(attributes=[("data-search-select-label", "")], children=[label])
if label_slot if label_slot
else label else label
) )
children: list[HTMLTag] = [label_child] children: list["Node | str"] = [label_child]
if removable: if removable:
children.append( children.append(
Element( Element(
@@ -634,7 +635,7 @@ def AddForm(
is applied to the main Submit button (the session form passes "" to match is applied to the main Submit button (the session form passes "" to match
its original markup). its original markup).
""" """
field_markup = fields if fields is not None else mark_safe(form.as_div()) field_markup = fields if fields is not None else Safe(form.as_div())
submit_attrs = [("class", submit_class)] if submit_class else [] submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Element( inner_form = Element(
@@ -682,7 +683,7 @@ def SearchField(
Div( Div(
attributes=[("class", "relative")], attributes=[("class", "relative")],
children=[ children=[
mark_safe( Safe(
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">' '<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" ' '<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
'fill="none" viewBox="0 0 24 24">' 'fill="none" viewBox="0 0 24 24">'
@@ -729,7 +730,7 @@ def SearchField(
def H1( def H1(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
badge: str = "", badge: str = "",
) -> Element: ) -> Element:
"""Heading with optional badge count.""" """Heading with optional badge count."""
@@ -753,14 +754,13 @@ def H1(
return Element( return Element(
"h1", "h1",
attributes=[("class", heading_class)], attributes=[("class", heading_class)],
children=(children if isinstance(children, list) else [children]) children=as_children(children) + ([badge_html] if badge_html else []),
+ ([badge_html] if badge_html else []),
) )
def Modal( def Modal(
modal_id: str, modal_id: str,
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> Node: ) -> Node:
"""Modal overlay with container. Content (form, buttons) goes in children.""" """Modal overlay with container. Content (form, buttons) goes in children."""
children = children or [] children = children or []
@@ -782,20 +782,20 @@ def Modal(
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900", "shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
), ),
], ],
children=(children if isinstance(children, list) else [children]), children=as_children(children),
), ),
], ],
) )
def TableTd( def TableTd(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> Element: ) -> Element:
"""Styled table cell.""" """Styled table cell."""
children = children or [] children = children or []
return Td( return Td(
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")], attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
children=children if isinstance(children, list) else [children], children=as_children(children),
) )
@@ -864,7 +864,7 @@ def Icon(
def TableHeader( def TableHeader(
children: list[HTMLTag] | HTMLTag | None = None, children: Children = None,
) -> Element: ) -> Element:
"""Table caption.""" """Table caption."""
children = children or [] children = children or []
@@ -877,7 +877,7 @@ def TableHeader(
"text-gray-900 bg-white dark:text-white dark:bg-gray-900", "text-gray-900 bg-white dark:text-white dark:bg-gray-900",
), ),
], ],
children=children if isinstance(children, list) else [children], children=as_children(children),
) )