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