Type component attributes with a covariant Attributes alias

Twin of the children fix: builders annotated ``attributes`` as
``list[HTMLAttribute] | None``, and ``list`` is invariant, so passing the
``list[tuple[str, str]]`` a caller naturally writes was a type error.

Add ``Attributes = Sequence[HTMLAttribute]`` (covariant) and use it for the
``attributes`` parameter of every builder. Locals that get appended/concatenated
stay a concrete ``list[HTMLAttribute]`` via the new ``as_attributes()``
normaliser, mirroring ``as_children()`` — builders call it once up front so
``attributes + [...]`` keeps working on a real list.

Pyright on common/components drops 45 → 42; the remaining errors are all
pre-existing and unrelated (django-stubs model access, the ``mark_safe``
``_Wrapped`` return type, and the separate ``FilterSelect`` options-list
invariance). Full suite green (443).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 18:20:57 +02:00
parent 7104605c06
commit 544da26a9d
3 changed files with 38 additions and 19 deletions
+18 -1
View File
@@ -24,6 +24,13 @@ from django.utils.safestring import SafeText, mark_safe
HTMLAttribute = tuple[str, str | int | bool] HTMLAttribute = tuple[str, str | int | bool]
# Type for a builder's ``attributes`` parameter. Covariant ``Sequence`` so a
# caller's ``list[tuple[str, str]]`` is accepted (a plain ``list[HTMLAttribute]``
# would be invariant and reject it). Locals that get ``.append()``-ed should
# stay a concrete ``list[HTMLAttribute]``.
Attributes = Sequence[HTMLAttribute]
HTMLTag = str HTMLTag = str
@@ -152,6 +159,16 @@ def as_children(children: "Children | Node") -> list[Child]:
return list(children) return list(children)
def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]:
"""Normalise an ``attributes`` argument to a mutable ``list[HTMLAttribute]``.
Builders take a covariant ``Attributes`` (so callers can pass a
``list[tuple[str, str]]``) but often append to or concatenate the value;
this turns it into a concrete list they can mutate.
"""
return list(attributes) if attributes else []
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.
@@ -201,7 +218,7 @@ class Element(Node):
def __init__( def __init__(
self, self,
tag_name: str, tag_name: str,
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
children: "Children | Node" = None, children: "Children | Node" = None,
) -> None: ) -> None:
if not tag_name: if not tag_name:
+17 -15
View File
@@ -14,6 +14,7 @@ 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 (
Attributes,
Children, Children,
Element, Element,
Fragment, Fragment,
@@ -21,6 +22,7 @@ from common.components.core import (
Media, Media,
Node, Node,
Safe, Safe,
as_attributes,
as_children, as_children,
randomid, randomid,
) )
@@ -53,7 +55,7 @@ def _html_element(tag_name: str):
"""Build a generic element builder for ``tag_name`` (the whitelist factory).""" """Build a generic element builder for ``tag_name`` (the whitelist factory)."""
def element( def element(
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
children: Children = None, children: Children = None,
) -> Element: ) -> Element:
return Element(tag_name, attributes, children) return Element(tag_name, attributes, children)
@@ -132,7 +134,7 @@ def Popover(
wrapped_content: str = "", wrapped_content: str = "",
wrapped_classes: str = "", wrapped_classes: str = "",
children: Children = None, children: Children = None,
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
id: str = "", id: str = "",
) -> Node: ) -> Node:
children = children or [] children = children or []
@@ -184,7 +186,7 @@ def PopoverTruncated(
def A( def A(
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
children: Children = None, children: Children = None,
url_name: str | None = None, url_name: str | None = None,
href: str | None = None, href: str | None = None,
@@ -196,7 +198,7 @@ def A(
- url_name: URL pattern name, resolved via reverse() - url_name: URL pattern name, resolved via reverse()
- href: Literal path string passed through as-is - href: Literal path string passed through as-is
""" """
attributes = attributes or [] attributes = as_attributes(attributes)
children = children or [] children = children or []
if url_name is not None and href is not None: if url_name is not None and href is not None:
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
@@ -212,7 +214,7 @@ def A(
def Button( def Button(
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
children: Children = None, children: Children = None,
size: str = "base", size: str = "base",
icon: bool = False, icon: bool = False,
@@ -225,7 +227,7 @@ def Button(
onclick: str = "", onclick: str = "",
name: str = "", name: str = "",
) -> Element: ) -> Element:
attributes = attributes or [] attributes = as_attributes(attributes)
children = children or [] children = children or []
# Separate custom class from other generic attributes # Separate custom class from other generic attributes
@@ -373,10 +375,10 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
def Input( def Input(
type: str = "text", type: str = "text",
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
children: Children = None, children: Children = None,
) -> Element: ) -> Element:
attributes = attributes or [] attributes = as_attributes(attributes)
children = children or [] children = children or []
return Element("input", attributes=attributes + [("type", type)], children=children) return Element("input", attributes=attributes + [("type", type)], children=children)
@@ -386,10 +388,10 @@ def Checkbox(
label: str | None = None, label: str | None = None,
checked: bool = False, checked: bool = False,
value: str = "1", value: str = "1",
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
) -> Node: ) -> Node:
"""A filter-agnostic Checkbox component.""" """A filter-agnostic Checkbox component."""
attributes = attributes or [] attributes = as_attributes(attributes)
input_attrs = [ input_attrs = [
("name", name), ("name", name),
("value", value), ("value", value),
@@ -418,10 +420,10 @@ def Radio(
label: str | None = None, label: str | None = None,
checked: bool = False, checked: bool = False,
value: str = "", value: str = "",
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
) -> Node: ) -> Node:
"""A filter-agnostic Radio component.""" """A filter-agnostic Radio component."""
attributes = attributes or [] attributes = as_attributes(attributes)
input_attrs = [ input_attrs = [
("name", name), ("name", name),
("value", value), ("value", value),
@@ -463,7 +465,7 @@ def Pill(
removable: bool = False, removable: bool = False,
extra_class: str = "", extra_class: str = "",
label_slot: bool = False, label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
) -> Node: ) -> Node:
"""A small label pill, optionally removable (× button). """A small label pill, optionally removable (× button).
@@ -475,7 +477,7 @@ def Pill(
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
markup single-sourced — see ``search_select.py``). markup single-sourced — see ``search_select.py``).
""" """
attributes = attributes or [] attributes = as_attributes(attributes)
pill_class = f"{_PILL_CLASS} {extra_class}".strip() pill_class = f"{_PILL_CLASS} {extra_class}".strip()
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")] pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
if value != "": if value != "":
@@ -858,7 +860,7 @@ def TableRow(data: dict | list | None = None) -> Element:
def Icon( def Icon(
name: str, name: str,
attributes: list[HTMLAttribute] | None = None, attributes: Attributes | None = None,
) -> Node: ) -> Node:
return Safe(get_icon(name)) return Safe(get_icon(name))
+3 -3
View File
@@ -22,7 +22,7 @@ from collections.abc import Callable, Iterable
from typing import TypedDict from typing import TypedDict
from common.components.core import Element, HTMLAttribute, Media, Node from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template from common.components.primitives import Div, Input, Pill, Span, Template
# Both comboboxes are wired by search_select.js. # Both comboboxes are wired by search_select.js.
@@ -176,9 +176,9 @@ def _option_row(option: SearchSelectOption) -> Node:
def _combobox_shell( def _combobox_shell(
*, *,
container_attributes: list[HTMLAttribute], container_attributes: Attributes,
pills: Node, pills: Node,
search_attributes: list[HTMLAttribute], search_attributes: Attributes,
options_children: list[Node], options_children: list[Node],
always_visible: bool, always_visible: bool,
items_visible: int, items_visible: int,