diff --git a/CHANGELOG.md b/CHANGELOG.md
index f19866d..7aec2cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,34 +9,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking changes
-* `Tagifiable.tagify()` now returns `Tagified`, a tighter type that
- excludes the un-resolved `Tagifiable` arm of `TagNode`. Custom
- `.tagify()` implementations annotated with bare `TagList` or `Tag`
- return types will fail static type checking; update them to
- `-> Tagified` (or omit the return annotation). Runtime behavior of
- correct `.tagify()` implementations is unchanged. (#105)
+* `Tagifiable.tagify()` now returns `Tagified` — a non-generic union
+ that mirrors `TagChild`'s shape (including the flattening
+ conveniences `float`, `None`, `Sequence[Tagified]`) but excludes the
+ un-resolved `Tagifiable` arm. Custom `.tagify()` implementations
+ annotated with bare `TagList` or `Tag` return types will fail
+ static type checking; update them to `-> Tagified` (or omit the
+ return annotation). Runtime behavior of correct implementations is
+ unchanged. (#105, #116, #117)
* `Tag.tagify()` no longer preserves the caller's `Tag` subclass in
its return type. Code relying on the previous subclass-preserving
signature should `cast` the result. (#105)
+* `TagifiedTagList` is now a real subclass of `TagList["TagifiedNode"]`
+ (previously a `TypeAliasType` alias). `TagifiedTag` is a new parallel
+ subclass of `Tag["TagifiedNode"]`. Both are returned by `.tagify()`
+ and are runtime-`isinstance`-checkable. Their `__init__` / `append`
+ / `extend` / `insert` are statically narrowed to `Tagified`, so
+ pyright flags `tagified.append(SomeTagifiable())` at the call site.
+ The classes stay internal to `htmltools._core` — they are not
+ re-exported from the top-level `htmltools` namespace — so code that
+ wants to `isinstance`-check imports them explicitly from there.
+ Code that depended on `TagifiedTagList` being an alias (e.g.,
+ `typing.get_type_hints` introspection) needs to treat it as a
+ class instead.
+
+ In practice, pyright remains permissive about flowing a
+ `TagifiedTagList` into a parameter typed as bare `TagList` (or
+ `TagList[TagNode]`), so most downstream consumers of `.tagify()`'s
+ return do NOT need migration. (#116)
+
### New features
* `Tag` and `TagList` are now generic in their child type, defaulting
- to `TagNode`. Bare `Tag` / `TagList` retain today's meaning. Mutation
- methods (`append` / `extend` / `insert`) still accept `Tagifiable` at
- static-type-check time even on tagified containers — the invariant
- is enforced at runtime instead (`TagList.tagify()` raises `TypeError`
- and `get_html_string` raises `RuntimeError` for an un-tagified
- subtree). See `tests/test_types.py` for the rationale. (#105)
-
-* Added the public type alias `Tagified` — the union of all
- fully-tagified shapes — for use as the return annotation of
- `Tagifiable.tagify()` implementations. (#105)
+ to `TagNode`. Bare `Tag` / `TagList` retain today's meaning. (#105)
### Bug fixes
-* `TagList.tagify()` now raises `TypeError` at the boundary when a child's `.tagify()` returns a `TagList` containing an un-tagified `Tagifiable` object. The error names the offending class and slot index so buggy `.tagify()` implementations surface at the source rather than later at render time. The render-time `RuntimeError` raised by `get_html_string()` for an un-tagified child has also been clarified to include the offending class name and a hint that the tree was likely mutated after `.tagify()` was called. (#7, #105, #112)
+* `TagList.tagify()` now defensively normalizes every shape a child's
+ `.tagify()` can return. A return whose contents still include an
+ un-tagified `Tagifiable`, or a return that is itself a bare `Tag` /
+ `TagList` rather than a `TagifiedTag` / `TagifiedTagList`, raises
+ `TypeError` at the boundary, naming the offending class and slot
+ index. (Pyright already rejects these at the call site via the
+ `Tagified` return-type annotation; the runtime guard catches
+ implementations that bypass static checking.) `None` is dropped,
+ `float`/`int` is str-ified, and `Sequence` is flattened —
+ previously these last three shapes either crashed the render path
+ (`None` → `TypeError` in `html_escape`) or silently corrupted the
+ tag tree. The render-time `RuntimeError` raised by
+ `get_html_string()` for an un-tagified child has also been
+ clarified to include the offending class name and a hint that the
+ tree was likely mutated after `.tagify()` was called. (#7, #105,
+ #112, #117)
### Dependencies
diff --git a/htmltools/_core.py b/htmltools/_core.py
index cefc0ee..53b73f6 100644
--- a/htmltools/_core.py
+++ b/htmltools/_core.py
@@ -44,7 +44,7 @@
from typing import Literal, Protocol, SupportsIndex, runtime_checkable
from packaging.version import Version
-from typing_extensions import TypeAliasType, TypeVar
+from typing_extensions import TypeVar
from ._util import (
ensure_http_server,
@@ -70,6 +70,8 @@
"TagFunction",
"Tagifiable",
"Tagified",
+ "TagifiedTag",
+ "TagifiedTagList",
"consolidate_attrs",
"head_content",
"is_tag_child",
@@ -113,18 +115,6 @@ class MetadataNode:
# -----------------------------------------------------------------------------
# Tagified shape aliases
# -----------------------------------------------------------------------------
-# `TagifiedTagList` uses `TypeAliasType` so that the alias *name*
-# survives in pyright diagnostics (users see `TagifiedTagList` rather
-# than the expanded structural union) and so its forward reference to
-# `TagifiedNode` (defined later in the file) is resolved lazily.
-# (Contrast `Tagified` below, which is a plain `Union` because
-# `TypeAliasType` over its recursive arm leaks `Unknown` through
-# downstream pyright analysis — see the comment above that definition.)
-TagifiedTagList = TypeAliasType("TagifiedTagList", "TagList[TagifiedNode]")
-"""
-A `TagList` whose items are all tagified. This is the return type of
-`TagList.tagify()`.
-"""
# Kept as a plain `Union` (not `TypeAliasType`) so the arms are visible
# in pyright diagnostics — a value typed as `TagNodeLeaf` shows up as
@@ -142,7 +132,7 @@ class MetadataNode:
# .tagify() still needs to be called. Recursive — a tagified Tag's children
# are themselves tagified. TagList is NOT a member because TagList children
# are flattened (a TagList never appears as a child slot of another TagList).
-TagifiedNode = Union["Tag[TagifiedNode]", TagNodeLeaf]
+TagifiedNode = Union["TagifiedTag", TagNodeLeaf]
"""
A fully-tagified child-slot type. Members never include an un-resolved
`Tagifiable`; calling `.tagify()` on a node tree returns a structure whose
@@ -153,11 +143,36 @@ class MetadataNode:
# recursive-alias resolution leaks `Unknown` when downstream packages
# inspect the type in strict mode. The alias name is then lost in
# diagnostics, but downstream pyright stays clean.
-Tagified = Union[TagifiedTagList, TagifiedNode]
+#
+# `TagifiedChild` is the input-side type for mutators on `TagifiedTagList`
+# / `TagifiedTag` — it parallels `TagChild` (the input type for
+# `TagList` / `Tag` mutators) but excludes the un-resolved `Tagifiable`
+# arm. Non-generic for the same reason `TagChild` is non-generic (see
+# the comment near `TagChild`'s definition below). Defined here as an
+# internal name so maintainers reading this file can see the structural
+# parallel to `TagChild`; the publicly-exported name is `Tagified`,
+# aliased below.
+TagifiedChild = Union[
+ TagifiedNode,
+ "TagifiedTagList",
+ float,
+ None,
+ Sequence["TagifiedChild"],
+]
+
+Tagified = TagifiedChild
"""
-Anything `.tagify()` is permitted to return: either a top-level
-`TagifiedTagList`, or one of the `TagifiedNode` shapes (a fully-tagified
-`Tag` or a leaf).
+The shape contract for fully-tagified content. Used as:
+
+- the return type of `Tagifiable.tagify()` (and the protocol it satisfies),
+- the input-side type for mutation methods on `TagifiedTagList` /
+ `TagifiedTag` (`append` / `extend` / `insert` / `__init__`).
+
+Mirrors `TagChild` (the un-tagified input type) but excludes the
+`Tagifiable` arm — values typed `Tagified` are post-`.tagify()` shapes.
+The flattening conveniences (`float`, `None`, `Sequence[Tagified]`)
+match `TagChild`'s shape so the input-normalization machinery can be
+reused on both sides.
"""
@@ -191,12 +206,14 @@ class MetadataNode:
# arm caused pyright to leak `Sequence[Unknown]` into every `Tag`
# function signature when inspected from a downstream module in
# strict mode (e.g. Shiny's CI reported 2500+
-# `reportUnknownMemberType` errors). The trade-off is that
-# `TagList[TagifiedNode].append(some_tagifiable)` no longer
-# static-errors — the runtime guard in `TagList.get_html_string`
-# still catches it at render time. See
-# `tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable`
-# for the full rationale.
+# `reportUnknownMemberType` errors). The non-generic alias is the
+# only form that doesn't leak.
+#
+# Static input enforcement on tagified containers
+# (`TagifiedTagList.append(some_tagifiable)` must error) is provided
+# instead by the narrow-signature mutator overrides on
+# `TagifiedTagList` / `TagifiedTag`, which accept the non-generic
+# parallel union `Tagified` (defined above). See #116.
TagChild = Union[
TagNode,
"TagList",
@@ -404,11 +421,14 @@ def tagify(self) -> "TagifiedTagList":
slot index so the broken ``.tagify()`` is easy to find.
"""
- # Internally work with `TagList[Any]` so the loop body's assignments
- # don't need per-line casts. The runtime invariants are checked by
- # the post-condition loop below; the final `cast` narrows back to
- # `TagifiedTagList` for the public return type.
- cp: "TagList[Any]" = cast("TagList[Any]", copy(self))
+ # Construct a TagifiedTagList directly (not a cast over copy(self)) so
+ # the runtime instance is the subclass. We populate .data manually with
+ # a shallow copy of self.data, then iterate as before.
+ # Cast: self.data is list[TagNodeT] (pre-tagification); cp.data is
+ # typed list[TagifiedNode] (post-tagification). The mutation loop below
+ # converts all items in place, so the cast is valid by the time we return.
+ cp = TagifiedTagList()
+ cp.data = cast("list[TagifiedNode]", list(self.data))
# Iterate backwards because if we hit a Tagifiable object, it may be replaced
# with 0, 1, or more items (if it returns TagList).
@@ -417,36 +437,52 @@ def tagify(self) -> "TagifiedTagList":
if isinstance(child, Tagifiable):
tagified_child = child.tagify()
- if isinstance(tagified_child, TagList):
- # Flatten the returned TagList into this one. Cast: pyright
- # cannot fully resolve `TagifiedTagList`'s recursive child
- # alias here, leaving a `TagList[Unknown]` arm.
- cp[i : i + 1] = _tagchilds_to_tagnodes(
- cast("TagList[TagNode]", tagified_child)
- )
- else:
- cp[i] = tagified_child
+ # Normalize the return through `_tagchilds_to_tagnodes`, which
+ # uniformly handles every shape `Tagified` permits:
+ # - `TagList` -> flattened into this slot
+ # - `Sequence[Tagified]` -> flattened
+ # - `None` -> dropped
+ # - `float` / `int` -> str-ified
+ # - tagified leaf or Tag -> passthrough
+ # The cast widens the input to the helper (which expects
+ # `Iterable[TagChild]`); the helper's runtime branches accept
+ # the wider `Tagified` shape because `Tagified` is a subset.
+ cp[i : i + 1] = cast(
+ "list[TagifiedNode]",
+ _tagchilds_to_tagnodes(
+ cast("Iterable[TagChild]", [tagified_child])
+ ),
+ )
elif isinstance(child, MetadataNode):
cp[i] = copy(child)
- # Boundary check: after the recursion above, every child should be
- # a fully-tagified shape (Tag, TagList, MetadataNode, ReprHtml, str,
- # or HTML). A bare Tagifiable still present here means some child's
- # `.tagify()` returned a TagList containing un-tagified objects —
- # which violates the Tagifiable protocol. Surface that here, where
- # the offending class and index are still in scope, instead of
- # waiting for the render-time guard in `get_html_string` to raise
- # a less-actionable error. Tag and TagList are themselves
- # Tagifiable but are valid tagified shapes, so they are excluded.
- for i, child in enumerate(cp):
- if isinstance(child, Tagifiable) and not isinstance(child, (Tag, TagList)):
+ # Boundary check: every item in `cp` should now be a fully-tagified
+ # shape — `TagifiedTag` (NOT bare `Tag`), `TagifiedTagList` (NOT
+ # bare `TagList`), or a `TagNodeLeaf` (`MetadataNode`, `ReprHtml`,
+ # `str`, `HTML`). Anything `Tagifiable` that isn't one of the
+ # tagified subclasses means a child's `.tagify()` returned content
+ # that violates the `Tagified` contract — either a bare `Tag` /
+ # `TagList` (rather than `.tagify()`'d), or a `TagList` whose
+ # contents include un-tagified objects (e.g. a stray custom
+ # `Tagifiable`). Surface that here, where the offending class and
+ # index are still in scope, instead of waiting for the render-time
+ # guard in `get_html_string` to raise a less-actionable error.
+ # Cast `cp` to the wider parent type so pyright keeps the
+ # isinstance guard reachable; statically, `cp`'s items are
+ # `TagifiedNode` and pyright would flag the check as
+ # `reportUnnecessaryIsInstance`. The guard exists to catch runtime
+ # protocol violations — a misbehaving `.tagify()` can still place
+ # an un-tagified value in the list.
+ for i, child in enumerate(cast("TagList[TagNode]", cp)):
+ if isinstance(child, Tagifiable) and not isinstance(
+ child, (TagifiedTag, TagifiedTagList)
+ ):
raise TypeError(
"Expected a fully tagified value, but a child .tagify() "
- "returned a TagList containing an un-tagified "
- f"{type(child).__name__} at index {i}. "
- "A .tagify() implementation must recursively tagify its "
- "return value (consider returning `something.tagify()` "
+ f"returned an un-tagified {type(child).__name__} at index "
+ f"{i}. A .tagify() implementation must return a fully-"
+ "tagified value (consider returning `something.tagify()` "
"instead of `something`)."
)
@@ -618,6 +654,71 @@ def _repr_html_(self) -> str:
return str(self)
+# =============================================================================
+# TagifiedTagList class
+# =============================================================================
+class TagifiedTagList(TagList["TagifiedNode"]):
+ """
+ A `TagList` whose items are all fully tagified.
+
+ Returned by `TagList.tagify()` and by `.tagify()` implementations that
+ produce a list of tagified nodes. Mutators are narrowed to `Tagified`
+ so pyright rejects un-tagified inputs at the call site.
+
+ To append a non-tagified child to a `TagifiedTagList`, call `.tagify()`
+ on it first: ``tl.append(div("x").tagify())``.
+ """
+
+ # Why these overrides exist, and why they're suppressed:
+ #
+ # The parent `TagList.append/extend/insert` accept any `TagChild` —
+ # including un-tagified `Tagifiable` objects. We narrow the subclass
+ # versions to `TagifiedChild`, which excludes the un-tagified arm.
+ # That's the whole point: pyright now flags
+ # `tagified.append(SomeTagifiable())` at the call site.
+ #
+ # Narrowing what a method accepts in a subclass is technically a
+ # Liskov violation — a caller holding a `TagList` reference could
+ # legally pass an argument the subclass refuses. Pyright reports
+ # this as `reportIncompatibleMethodOverride`. We suppress because
+ # the violating scenario doesn't happen here: `TagifiedTagList`
+ # only ever comes out of `.tagify()`, not from upcasting an
+ # existing `TagList`.
+ #
+ # The Liskov-clean alternative would be to parameterize the parent's
+ # signatures: `TagList.append(item: TagChild[TagNodeT])` so a
+ # `TagList[TagifiedNode]` instance automatically narrows by type
+ # substitution — no subclass overrides needed. That was tried in #105
+ # (see the `TagChild` rationale comment) and abandoned because pyright
+ # leaks `Sequence[Unknown]` through recursive generic aliases in
+ # downstream strict mode. If that pyright limitation is ever fixed,
+ # this whole override block can come out and the LSP violation
+ # disappears.
+
+ def __init__(self, *args: TagifiedChild) -> None:
+ # cast: pass through to parent; the parent constructor accepts the
+ # wider TagChild union, of which TagifiedChild is a subset.
+ super().__init__(*cast("tuple[TagChild, ...]", args))
+
+ def append( # pyright: ignore[reportIncompatibleMethodOverride]
+ self, item: TagifiedChild, *args: TagifiedChild
+ ) -> None:
+ super().append(
+ cast("TagChild", item),
+ *cast("tuple[TagChild, ...]", args),
+ )
+
+ def extend( # pyright: ignore[reportIncompatibleMethodOverride]
+ self, other: Iterable[TagifiedChild]
+ ) -> None:
+ super().extend(cast("Iterable[TagChild]", other))
+
+ def insert( # pyright: ignore[reportIncompatibleMethodOverride]
+ self, i: SupportsIndex, item: TagifiedChild
+ ) -> None:
+ super().insert(i, cast("TagChild", item))
+
+
# =============================================================================
# TagAttrDict class
# =============================================================================
@@ -951,14 +1052,19 @@ def add_style(self: TagT, style: str | HTML, *, prepend: bool = False) -> TagT:
self.attrs.update({"style": self.attrs.get("style")}, {"style": style})
return self
- def tagify(self) -> "Tag[TagifiedNode]":
+ def tagify(self) -> "TagifiedTag":
"""
Convert any tagifiable children to Tag/TagList objects.
"""
- cp = copy(self)
- cp.children = cast("TagList[TagNodeT]", cp.children.tagify())
- return cast("Tag[TagifiedNode]", cp)
+ # Construct a TagifiedTag directly (not a cast over copy(self)) so the
+ # runtime instance is the subclass. Mirror Tag.__copy__'s shallow-copy
+ # behavior, then overwrite .children with the tagified result.
+ cp = TagifiedTag.__new__(TagifiedTag)
+ new_dict = {key: copy(value) for key, value in self.__dict__.items()}
+ cp.__dict__.update(new_dict)
+ cp.children = self.children.tagify()
+ return cp
def get_html_string(self, indent: int = 0, eol: str = "\n") -> str:
"""
@@ -1079,6 +1185,74 @@ def _repr_html_(self) -> str:
return str(self)
+# =============================================================================
+# TagifiedTag class
+# =============================================================================
+class TagifiedTag(Tag["TagifiedNode"]):
+ """
+ A `Tag` whose children are all fully tagified.
+
+ Returned by `Tag.tagify()`. Mutators are narrowed to `Tagified` so
+ pyright rejects un-tagified inputs at the call site. To append a
+ non-tagified child, call `.tagify()` on it first.
+ """
+
+ # Why these overrides exist, and why they're suppressed:
+ #
+ # The parent `Tag.append/extend/insert` accept any `TagChild` —
+ # including un-tagified `Tagifiable` objects. We narrow the subclass
+ # versions to `TagifiedChild`, which excludes the un-tagified arm.
+ # That's the whole point: pyright now flags
+ # `tagified.append(SomeTagifiable())` at the call site.
+ #
+ # Narrowing what a method accepts in a subclass is technically a
+ # Liskov violation — a caller holding a `Tag` reference could
+ # legally pass an argument the subclass refuses. Pyright reports
+ # this as `reportIncompatibleMethodOverride`. We suppress because
+ # the violating scenario doesn't happen here: `TagifiedTag` only
+ # ever comes out of `.tagify()`, not from upcasting an existing
+ # `Tag`.
+ #
+ # The Liskov-clean alternative would be to parameterize the parent's
+ # signatures: `Tag.append(item: TagChild[TagNodeT])` so a
+ # `Tag[TagifiedNode]` instance automatically narrows by type
+ # substitution — no subclass overrides needed. That was tried in #105
+ # (see the `TagChild` rationale comment) and abandoned because pyright
+ # leaks `Sequence[Unknown]` through recursive generic aliases in
+ # downstream strict mode. If that pyright limitation is ever fixed,
+ # this whole override block can come out and the LSP violation
+ # disappears.
+
+ def __init__(
+ self,
+ _name: str,
+ *args: TagifiedChild | TagAttrs,
+ _add_ws: TagAttrValue = True,
+ **kwargs: TagAttrValue,
+ ) -> None:
+ super().__init__(
+ _name,
+ *cast("tuple[TagChild | TagAttrs, ...]", args),
+ _add_ws=_add_ws,
+ **kwargs,
+ )
+
+ def append( # pyright: ignore[reportIncompatibleMethodOverride]
+ self, *args: TagifiedChild
+ ) -> None:
+ super().append(*cast("tuple[TagChild, ...]", args))
+
+ def extend( # pyright: ignore[reportIncompatibleMethodOverride]
+ self, x: Iterable[TagifiedChild]
+ ) -> None:
+ super().extend(cast("Iterable[TagChild]", x))
+
+ def insert( # pyright: ignore[reportIncompatibleMethodOverride]
+ self, index: SupportsIndex, x: TagifiedChild
+ ) -> None:
+ super().insert(index, cast("TagChild", x))
+
+
# Tags that have the form