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]
# 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
@@ -152,6 +159,16 @@ def as_children(children: "Children | Node") -> list[Child]:
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]:
"""Normalise a child to a ``(text, is_safe)`` pair.
@@ -201,7 +218,7 @@ class Element(Node):
def __init__(
self,
tag_name: str,
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
children: "Children | Node" = None,
) -> None:
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 common.components.core import (
Attributes,
Children,
Element,
Fragment,
@@ -21,6 +22,7 @@ from common.components.core import (
Media,
Node,
Safe,
as_attributes,
as_children,
randomid,
)
@@ -53,7 +55,7 @@ def _html_element(tag_name: str):
"""Build a generic element builder for ``tag_name`` (the whitelist factory)."""
def element(
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
children: Children = None,
) -> Element:
return Element(tag_name, attributes, children)
@@ -132,7 +134,7 @@ def Popover(
wrapped_content: str = "",
wrapped_classes: str = "",
children: Children = None,
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
id: str = "",
) -> Node:
children = children or []
@@ -184,7 +186,7 @@ def PopoverTruncated(
def A(
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
children: Children = None,
url_name: str | None = None,
href: str | None = None,
@@ -196,7 +198,7 @@ def A(
- url_name: URL pattern name, resolved via reverse()
- href: Literal path string passed through as-is
"""
attributes = attributes or []
attributes = as_attributes(attributes)
children = children or []
if url_name is not None and href is not None:
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
@@ -212,7 +214,7 @@ def A(
def Button(
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
children: Children = None,
size: str = "base",
icon: bool = False,
@@ -225,7 +227,7 @@ def Button(
onclick: str = "",
name: str = "",
) -> Element:
attributes = attributes or []
attributes = as_attributes(attributes)
children = children or []
# Separate custom class from other generic attributes
@@ -373,10 +375,10 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
def Input(
type: str = "text",
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
children: Children = None,
) -> Element:
attributes = attributes or []
attributes = as_attributes(attributes)
children = children or []
return Element("input", attributes=attributes + [("type", type)], children=children)
@@ -386,10 +388,10 @@ def Checkbox(
label: str | None = None,
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
) -> Node:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
attributes = as_attributes(attributes)
input_attrs = [
("name", name),
("value", value),
@@ -418,10 +420,10 @@ def Radio(
label: str | None = None,
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
) -> Node:
"""A filter-agnostic Radio component."""
attributes = attributes or []
attributes = as_attributes(attributes)
input_attrs = [
("name", name),
("value", value),
@@ -463,7 +465,7 @@ def Pill(
removable: bool = False,
extra_class: str = "",
label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
) -> Node:
"""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
markup single-sourced — see ``search_select.py``).
"""
attributes = attributes or []
attributes = as_attributes(attributes)
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
if value != "":
@@ -858,7 +860,7 @@ def TableRow(data: dict | list | None = None) -> Element:
def Icon(
name: str,
attributes: list[HTMLAttribute] | None = None,
attributes: Attributes | None = None,
) -> Node:
return Safe(get_icon(name))
+3 -3
View File
@@ -22,7 +22,7 @@ from collections.abc import Callable, Iterable
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
# Both comboboxes are wired by search_select.js.
@@ -176,9 +176,9 @@ def _option_row(option: SearchSelectOption) -> Node:
def _combobox_shell(
*,
container_attributes: list[HTMLAttribute],
container_attributes: Attributes,
pills: Node,
search_attributes: list[HTMLAttribute],
search_attributes: Attributes,
options_children: list[Node],
always_visible: bool,
items_visible: int,