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:
@@ -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.")
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user