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