From 4e42ac19cea8c2d63e0980c45dd12b9af787a62c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:32:11 -0400 Subject: [PATCH 01/26] refactor(types): introduce TagifiedTagList/TagifiedTag subclasses (#116) Replaces the TagifiedTagList TypeAliasType with a real subclass and adds a parallel TagifiedTag. Subclasses are runtime-isinstance-checkable. Also adds a non-generic TagifiedChild alias parallel to TagChild. Mutator overrides and the .tagify() return-type updates come in follow-up commits. --- htmltools/_core.py | 75 ++++++++++++++++++++++++------- tests/test_tagified_subclasses.py | 16 +++++++ 2 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 tests/test_tagified_subclasses.py diff --git a/htmltools/_core.py b/htmltools/_core.py index cefc0ee..ebee38c 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, @@ -113,18 +113,14 @@ 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()`. -""" +# `TagifiedTagList` and `TagifiedTag` are real subclasses (defined later in +# this file, after their parent classes). The Union aliases below are forward +# references to them. +# +# Why subclasses rather than `TypeAliasType` aliases: a subclass is runtime- +# `isinstance`-checkable, and we can override `append` / `extend` / `insert` / +# `__init__` with narrow `TagifiedChild`-only signatures so pyright rejects +# un-tagified inputs at the call site (closing the gap that motivated #115). # 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 +138,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,13 +149,28 @@ 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] +Tagified = Union["TagifiedTagList", TagifiedNode] """ Anything `.tagify()` is permitted to return: either a top-level `TagifiedTagList`, or one of the `TagifiedNode` shapes (a fully-tagified `Tag` or a leaf). """ +# Parallel to `TagChild` but with no `Tagifiable` arm. Non-generic for the +# same reason `TagChild` is non-generic (see comment near the `TagChild` +# definition below). +TagifiedChild = Union[ + "TagifiedNode", + "TagifiedTagList", + float, + None, + Sequence["TagifiedChild"], +] +""" +The static-input type for mutation methods on `TagifiedTagList` / +`TagifiedTag`. Mirrors `TagChild` but excludes the `Tagifiable` arm. +""" + # ----------------------------------------------------------------------------- # TagNode / TagChild (generic) and the TagNodeT TypeVar @@ -618,6 +629,25 @@ 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 `TagifiedChild` + 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())``. + """ + + # No body yet — overrides added in Task 7. Subclass exists for runtime + # isinstance() and as the static return type of `.tagify()`. + + # ============================================================================= # TagAttrDict class # ============================================================================= @@ -1079,6 +1109,21 @@ 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 `TagifiedChild` so + pyright rejects un-tagified inputs at the call site. To append a + non-tagified child, call `.tagify()` on it first. + """ + + # No body yet — overrides added in Task 8. + + # Tags that have the form _VOID_TAG_NAMES = { "area", diff --git a/tests/test_tagified_subclasses.py b/tests/test_tagified_subclasses.py new file mode 100644 index 0000000..46031d7 --- /dev/null +++ b/tests/test_tagified_subclasses.py @@ -0,0 +1,16 @@ +"""Runtime isinstance tests for the TagifiedTagList / TagifiedTag subclasses.""" + +from htmltools import Tag, TagList +from htmltools._core import TagifiedTag, TagifiedTagList + + +def test_TagifiedTagList_is_a_class() -> None: + # Was a TypeAliasType before #116; isinstance would raise TypeError. + # After: a real class. + assert isinstance(TagifiedTagList(), TagifiedTagList) + assert isinstance(TagifiedTagList(), TagList) + + +def test_TagifiedTag_is_a_class() -> None: + assert isinstance(TagifiedTag("div"), TagifiedTag) + assert isinstance(TagifiedTag("div"), Tag) From 84397cf06a3dcec6d3c2aca59cdf73e39dedea99 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:39:04 -0400 Subject: [PATCH 02/26] refactor(types): unquote TagifiedNode forward ref; add new names to _core __all__ (#116) --- htmltools/_core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index ebee38c..d667283 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -70,6 +70,9 @@ "TagFunction", "Tagifiable", "Tagified", + "TagifiedChild", + "TagifiedTag", + "TagifiedTagList", "consolidate_attrs", "head_content", "is_tag_child", @@ -160,7 +163,7 @@ class MetadataNode: # same reason `TagChild` is non-generic (see comment near the `TagChild` # definition below). TagifiedChild = Union[ - "TagifiedNode", + TagifiedNode, "TagifiedTagList", float, None, From fa7fd5b3dcddad64061c44e434ceb666195c9868 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:41:54 -0400 Subject: [PATCH 03/26] refactor(types): TagList.tagify() constructs TagifiedTagList directly (#116) The cast-over-copy() pattern returned a base TagList at runtime; now that TagifiedTagList is a real subclass, we construct one explicitly so isinstance(result, TagifiedTagList) is True. --- htmltools/_core.py | 33 ++++++++++++++++++++----------- tests/test_tagified_subclasses.py | 9 ++++++++- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index d667283..6aabbfe 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -418,11 +418,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). @@ -432,11 +435,16 @@ 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) + # Flatten the returned TagList into this one. Two casts: + # (1) pyright cannot fully resolve `TagifiedTagList`'s + # recursive child alias, leaving a `TagList[Unknown]` arm; + # (2) _tagchilds_to_tagnodes returns list[TagNode] but the + # slice target on TagifiedTagList expects Iterable[TagifiedNode]. + cp[i : i + 1] = cast( + "list[TagifiedNode]", + _tagchilds_to_tagnodes( + cast("TagList[TagNode]", tagified_child) + ), ) else: cp[i] = tagified_child @@ -453,7 +461,10 @@ def tagify(self) -> "TagifiedTagList": # 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): + # Cast cp to TagList[TagNode] so the isinstance guard below is not + # flagged as redundant — at runtime cp.data may still hold pre-tagified + # items if a child's .tagify() violated the protocol. + for i, child in enumerate(cast("TagList[TagNode]", cp)): if isinstance(child, Tagifiable) and not isinstance(child, (Tag, TagList)): raise TypeError( "Expected a fully tagified value, but a child .tagify() " diff --git a/tests/test_tagified_subclasses.py b/tests/test_tagified_subclasses.py index 46031d7..d9cacc0 100644 --- a/tests/test_tagified_subclasses.py +++ b/tests/test_tagified_subclasses.py @@ -1,6 +1,6 @@ """Runtime isinstance tests for the TagifiedTagList / TagifiedTag subclasses.""" -from htmltools import Tag, TagList +from htmltools import Tag, TagList, div from htmltools._core import TagifiedTag, TagifiedTagList @@ -14,3 +14,10 @@ def test_TagifiedTagList_is_a_class() -> None: def test_TagifiedTag_is_a_class() -> None: assert isinstance(TagifiedTag("div"), TagifiedTag) assert isinstance(TagifiedTag("div"), Tag) + + +def test_TagList_tagify_returns_TagifiedTagList_instance() -> None: + result = TagList("hi", div()).tagify() + assert isinstance(result, TagifiedTagList) + # Verify it's the actual class, not the base TagList + assert type(result) is TagifiedTagList From 7d16b347ab4159c6fa09a644f363526550d72add Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:46:38 -0400 Subject: [PATCH 04/26] docs(_core): clarify why TagList.tagify boundary check casts cp (#116) The previous comment cited .data's runtime contents; the cast is actually about pyright's static narrowing under the TagifiedTagList return type. The runtime guard exists to catch user .tagify() protocol violations. --- htmltools/_core.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index 6aabbfe..c3e008f 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -461,9 +461,13 @@ def tagify(self) -> "TagifiedTagList": # 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. - # Cast cp to TagList[TagNode] so the isinstance guard below is not - # flagged as redundant — at runtime cp.data may still hold pre-tagified - # items if a child's .tagify() violated the protocol. + # Cast `cp` to the wider parent type so pyright keeps the isinstance + # guard below reachable. Statically, `cp`'s items are `TagifiedNode` + # (which excludes the bare-`Tagifiable` arm), so pyright would flag + # the check as `reportUnnecessaryIsInstance`. The guard exists to + # catch runtime protocol violations — a misbehaving `.tagify()` may + # still place a bare `Tagifiable` in the list — so the cast preserves + # the defensive intent without weakening the runtime check. for i, child in enumerate(cast("TagList[TagNode]", cp)): if isinstance(child, Tagifiable) and not isinstance(child, (Tag, TagList)): raise TypeError( From 2a3e02e10fcfb00c503a279fc148427e03e8375e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:49:02 -0400 Subject: [PATCH 05/26] refactor(types): Tag.tagify() constructs TagifiedTag directly (#116) --- htmltools/_core.py | 13 +++++++++---- tests/test_tagified_subclasses.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index c3e008f..df55cc8 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -999,14 +999,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: """ diff --git a/tests/test_tagified_subclasses.py b/tests/test_tagified_subclasses.py index d9cacc0..b5e7f27 100644 --- a/tests/test_tagified_subclasses.py +++ b/tests/test_tagified_subclasses.py @@ -21,3 +21,18 @@ def test_TagList_tagify_returns_TagifiedTagList_instance() -> None: assert isinstance(result, TagifiedTagList) # Verify it's the actual class, not the base TagList assert type(result) is TagifiedTagList + + +def test_Tag_tagify_returns_TagifiedTag_instance() -> None: + result = div("hi").tagify() + assert isinstance(result, TagifiedTag) + assert type(result) is TagifiedTag + + +def test_TagifiedTagList_children_are_TagifiedTag() -> None: + # Recursive guarantee: nested children inside a tagified result are also + # TagifiedTag instances, not bare Tag. + result = TagList(div(div("inner"))).tagify() + inner = result[0] + assert isinstance(inner, TagifiedTag) + assert isinstance(inner.children[0], TagifiedTag) From 0cf5d443cfa3d3ec28a3877e7a6fe61e404c3c76 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:50:57 -0400 Subject: [PATCH 06/26] fix(_core): make _equals_impl symmetric across subclass relationships (#116) TagifiedTag (subclass of Tag) was failing equality against bare Tag because isinstance(y, type(x)) is False when x is the subclass and y is the parent. The subclass adds no instance attributes, so two objects with matching __dict__s should compare equal regardless of which is the subclass. Fixes test_taglist_tagifiable regression. --- htmltools/_core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index df55cc8..b5ddbda 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -2170,7 +2170,12 @@ def _normalize_text(txt: str | HTML) -> str: def _equals_impl(x: Any, y: Any) -> bool: - if not isinstance(y, type(x)): + # Symmetric class check: accept if either argument is an instance of + # the other's class. This handles the TagifiedTag / Tag (and + # TagifiedTagList / TagList) cases introduced by #116 — the subclass + # adds no instance attributes, so a tagified value with matching + # __dict__ is structurally equal to its un-tagified counterpart. + if not (isinstance(y, type(x)) or isinstance(x, type(y))): return False for key in x.__dict__.keys(): if getattr(x, key, None) != getattr(y, key, None): From 8337889394e61e937ad8f3193f8e6f89cae7a72a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:53:07 -0400 Subject: [PATCH 07/26] refactor(types): JSXTag.tagify() returns TagifiedTag (#116) --- htmltools/_jsx.py | 34 +++++++++++++++---------------- tests/test_tagified_subclasses.py | 9 ++++++++ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/htmltools/_jsx.py b/htmltools/_jsx.py index 9365070..f97ebec 100644 --- a/htmltools/_jsx.py +++ b/htmltools/_jsx.py @@ -19,7 +19,7 @@ Tag, TagAttrValue, Tagifiable, - TagifiedNode, + TagifiedTag, TagList, TagNode, ) @@ -111,7 +111,7 @@ def extend(self, x: Iterable[TagNode]) -> None: def append(self, *args: TagNode) -> None: self.children.append(*args) - def tagify(self) -> "Tag[TagifiedNode]": + def tagify(self) -> "TagifiedTag": metadata_nodes: list[MetadataNode] = [] # This function is recursively applied to the attributes and children. It does @@ -164,21 +164,21 @@ def tagify_tagifiable_and_get_metadata(x: Any) -> Any: ] ) - return cast( - "Tag[TagifiedNode]", - Tag( - "script", - { - "type": "text/javascript", - "data_needs_render": True, - }, - HTML("\n" + js + "\n"), - _lib_dependency("react", script={"src": "react.production.min.js"}), - _lib_dependency( - "react-dom", script={"src": "react-dom.production.min.js"} - ), - *metadata_nodes, - ), + # Was: cast("Tag[TagifiedNode]", Tag("script", ...)) + # Now: build a TagifiedTag directly. The children passed in (HTML, + # script-attr dict, lib-dependency metadata nodes) are all + # already TagifiedNode-shaped, so the constructor produces a + # legitimate tagified tag. + return TagifiedTag( + "script", + { + "type": "text/javascript", + "data_needs_render": True, + }, + HTML("\n" + js + "\n"), + _lib_dependency("react", script={"src": "react.production.min.js"}), + _lib_dependency("react-dom", script={"src": "react-dom.production.min.js"}), + *metadata_nodes, ) def __str__(self) -> str: diff --git a/tests/test_tagified_subclasses.py b/tests/test_tagified_subclasses.py index b5e7f27..466d3c6 100644 --- a/tests/test_tagified_subclasses.py +++ b/tests/test_tagified_subclasses.py @@ -36,3 +36,12 @@ def test_TagifiedTagList_children_are_TagifiedTag() -> None: inner = result[0] assert isinstance(inner, TagifiedTag) assert isinstance(inner.children[0], TagifiedTag) + + +def test_JSXTag_tagify_returns_TagifiedTag() -> None: + # Import locally because JSXTag is not in the top-level htmltools namespace. + from htmltools._jsx import JSXTag + + jsx = JSXTag("MyComponent") + result = jsx.tagify() + assert isinstance(result, TagifiedTag) From a9e169a219daadb88b492f7572c5bcd8a20d930e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 12:56:47 -0400 Subject: [PATCH 08/26] feat(types): narrow TagifiedTagList mutators to TagifiedChild (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overrides on __init__/append/extend/insert reject un-tagified inputs at the pyright call site. Pass-through bodies — no new runtime guards; existing tagify-boundary and render-time guards remain the runtime safety net. Subsumes the static-enforcement work of #115. --- htmltools/_core.py | 24 ++++++++++++++++++++++-- tests/test_types.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index b5ddbda..94ae25b 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -662,8 +662,28 @@ class TagifiedTagList(TagList["TagifiedNode"]): on it first: ``tl.append(div("x").tagify())``. """ - # No body yet — overrides added in Task 7. Subclass exists for runtime - # isinstance() and as the static return type of `.tagify()`. + 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)) # ============================================================================= diff --git a/tests/test_types.py b/tests/test_types.py index b13e82e..3785e47 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -31,6 +31,39 @@ def test_taglist_tagify_returns_TagifiedTagList() -> None: assert_type(TagList("hi").tagify(), TagifiedTagList) +def test_TagifiedTagList_append_rejects_Tagifiable_statically() -> None: + """Mutators on TagifiedTagList narrow input to TagifiedChild; appending + a Tagifiable must be a pyright error.""" + + class _SomeTagifiable: + def tagify(self) -> Tagified: + return "x" + + tl: TagifiedTagList = TagList("hi").tagify() + # Acceptable: a tagified node + tl.append("ok") + # Static error: bare Tagifiable is not in TagifiedChild. + tl.append(_SomeTagifiable()) # pyright: ignore[reportArgumentType] + + +def test_TagifiedTagList_extend_rejects_Tagifiable_statically() -> None: + class _SomeTagifiable: + def tagify(self) -> Tagified: + return "x" + + tl: TagifiedTagList = TagList("hi").tagify() + tl.extend([_SomeTagifiable()]) # pyright: ignore[reportArgumentType] + + +def test_TagifiedTagList_insert_rejects_Tagifiable_statically() -> None: + class _SomeTagifiable: + def tagify(self) -> Tagified: + return "x" + + tl: TagifiedTagList = TagList("hi").tagify() + tl.insert(0, _SomeTagifiable()) # pyright: ignore[reportArgumentType] + + def test_bare_TagList_is_not_assignable_to_TagifiedTagList() -> None: tl: TagList = TagList("hi") # A bare TagList means TagList[TagNode], which may still contain Tagifiables. From c210fd8e451661b03c0dd2af9c2551d339fe4432 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 13:02:41 -0400 Subject: [PATCH 09/26] refactor(_core): drop redundant quotes; document LSP narrowing on TagifiedTagList (#116) Annotations are already lazy via __future__ import; quoted strings on override signatures were inconsistent with the parent TagList. Also adds a docstring paragraph explaining why the reportIncompatibleMethodOverride suppressions are deliberate. --- htmltools/_core.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index 94ae25b..cd8a0f0 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -660,15 +660,25 @@ class TagifiedTagList(TagList["TagifiedNode"]): To append a non-tagified child to a `TagifiedTagList`, call `.tagify()` on it first: ``tl.append(div("x").tagify())``. + + Note on overrides: each mutator narrows its input from ``TagChild`` + to ``TagifiedChild``. This is contravariant narrowing (LSP-unsafe in + the abstract — a caller holding a ``TagList`` reference could pass + inputs the ``TagifiedTagList`` no longer accepts), so pyright flags + each override with ``reportIncompatibleMethodOverride``. The + suppression is deliberate: in this codebase, ``TagifiedTagList`` is + only ever obtained via ``.tagify()``, never by upcasting a + pre-existing ``TagList`` reference, so the LSP failure mode does + not occur in practice. """ - def __init__(self, *args: "TagifiedChild") -> None: + 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" + self, item: TagifiedChild, *args: TagifiedChild ) -> None: super().append( cast("TagChild", item), @@ -676,12 +686,12 @@ def append( # pyright: ignore[reportIncompatibleMethodOverride] ) def extend( # pyright: ignore[reportIncompatibleMethodOverride] - self, other: Iterable["TagifiedChild"] + self, other: Iterable[TagifiedChild] ) -> None: super().extend(cast("Iterable[TagChild]", other)) def insert( # pyright: ignore[reportIncompatibleMethodOverride] - self, i: SupportsIndex, item: "TagifiedChild" + self, i: SupportsIndex, item: TagifiedChild ) -> None: super().insert(i, cast("TagChild", item)) From 562e0f413c82fc4aa68544282a10f2a2fc1a35cc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 13:05:09 -0400 Subject: [PATCH 10/26] feat(types): narrow TagifiedTag mutators to TagifiedChild (#116) --- htmltools/_core.py | 39 ++++++++++++++++++++++++++++++++++++++- tests/test_types.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index cd8a0f0..ec29d77 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -1172,9 +1172,46 @@ class TagifiedTag(Tag["TagifiedNode"]): Returned by `Tag.tagify()`. Mutators are narrowed to `TagifiedChild` so pyright rejects un-tagified inputs at the call site. To append a non-tagified child, call `.tagify()` on it first. + + Note on overrides: each mutator narrows its input from ``TagChild`` + to ``TagifiedChild``. This is contravariant narrowing (LSP-unsafe in + the abstract — a caller holding a ``Tag`` reference could pass + inputs the ``TagifiedTag`` no longer accepts), so pyright flags + each override with ``reportIncompatibleMethodOverride``. The + suppression is deliberate: in this codebase, ``TagifiedTag`` is + only ever obtained via ``.tagify()``, never by upcasting a + pre-existing ``Tag`` reference, so the LSP failure mode does + not occur in practice. """ - # No body yet — overrides added in Task 8. + 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 diff --git a/tests/test_types.py b/tests/test_types.py index 3785e47..819a4be 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -128,6 +128,40 @@ def tagify(self) -> Tagified: tl.append(_SomeTagifiable()) +def test_TagifiedTag_append_rejects_Tagifiable_statically() -> None: + from htmltools._core import TagifiedTag + + class _SomeTagifiable: + def tagify(self) -> Tagified: + return "x" + + tag: TagifiedTag = div("hi").tagify() + tag.append("ok") + tag.append(_SomeTagifiable()) # pyright: ignore[reportArgumentType] + + +def test_TagifiedTag_extend_rejects_Tagifiable_statically() -> None: + from htmltools._core import TagifiedTag + + class _SomeTagifiable: + def tagify(self) -> Tagified: + return "x" + + tag: TagifiedTag = div("hi").tagify() + tag.extend([_SomeTagifiable()]) # pyright: ignore[reportArgumentType] + + +def test_TagifiedTag_insert_rejects_Tagifiable_statically() -> None: + from htmltools._core import TagifiedTag + + class _SomeTagifiable: + def tagify(self) -> Tagified: + return "x" + + tag: TagifiedTag = div("hi").tagify() + tag.insert(0, _SomeTagifiable()) # pyright: ignore[reportArgumentType] + + def test_bare_TagList_append_accepts_Tagifiable() -> None: class _OkTagifiable: def tagify(self) -> Tagified: From b725fe26e9780b5809dc800bfe77a23e0610a8fe Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 13:07:37 -0400 Subject: [PATCH 11/26] test(types): flip TagifiedTagList.append test to negative form (#116) The previous test documented a static-typing gap that #116 closes. Its body now asserts the static rejection that the subclass overrides provide. Also updates the line-27 assert_type to reflect Tag.tagify()'s new return type TagifiedTag. --- tests/test_types.py | 75 +++++++++++---------------------------------- 1 file changed, 18 insertions(+), 57 deletions(-) diff --git a/tests/test_types.py b/tests/test_types.py index 819a4be..88a8c46 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -14,17 +14,16 @@ from typing_extensions import assert_type from htmltools import ( - Tag, Tagifiable, Tagified, TagList, div, ) -from htmltools._core import TagifiedNode, TagifiedTagList +from htmltools._core import TagifiedTag, TagifiedTagList -def test_tag_tagify_returns_Tag_TagifiedNode() -> None: - assert_type(div("hi").tagify(), Tag[TagifiedNode]) +def test_tag_tagify_returns_TagifiedTag() -> None: + assert_type(div("hi").tagify(), TagifiedTag) def test_taglist_tagify_returns_TagifiedTagList() -> None: @@ -71,51 +70,21 @@ def test_bare_TagList_is_not_assignable_to_TagifiedTagList() -> None: _: TagifiedTagList = tl # pyright: ignore[reportAssignmentType] -def test_TagifiedTagList_append_accepts_Tagifiable() -> None: +def test_TagifiedTagList_append_rejects_Tagifiable() -> None: """ - Documents a deliberate static-typing gap: appending a ``Tagifiable`` - to a ``TagList[TagifiedNode]`` is **not** a static error today, even - though ``TagifiedNode`` does not include the ``Tagifiable`` arm of - ``TagNode``. The runtime catches it instead. - - Why we can't enforce it statically - ---------------------------------- - The natural enforcement would parameterize ``TagChild`` itself — - ``TagChild[TagNodeT] = TagNodeT | TagList[TagNodeT] | float | None | - Sequence[TagChild[TagNodeT]]`` — and use it in mutation signatures so - that ``TagList[TagifiedNode].append`` only accepts ``TagifiedNode`` - -shaped values. We tried that. Pyright (tested through 1.1.409) - does not fully re-bind ``TagNodeT`` through the recursive - ``Sequence["TagChild[TagNodeT]"]`` arm when a *downstream* module - imports the symbols in strict mode. Every ``Tag``-function signature - then leaks a ``Sequence[Unknown]`` arm, which surfaced as thousands - of ``reportUnknownMemberType`` errors in Shiny's CI — far more noise - than the win was worth. - - What we do instead - ------------------ - - ``TagChild`` is a plain non-generic ``Union`` (including the - recursive ``Sequence["TagChild"]`` arm for nested-list flattening). - - Mutation methods on ``TagList[TagNodeT]`` and ``Tag[TagNodeT]`` accept - bare ``TagChild`` (wide). This preserves the nested-list - ergonomics like ``tl.append([a, b, [c, d]])``. - - The "no un-tagified children in a tagified tree" invariant is - enforced at runtime: - * ``TagList.tagify()`` raises ``TypeError`` at the boundary - when a child's ``.tagify()`` returns a ``TagList`` containing - a ``Tagifiable``, naming the offending class and slot index. - * ``TagList.get_html_string`` raises ``RuntimeError`` at render - time if a ``Tagifiable`` is still in the tree (covers - mutation-after-tagify; see - ``test_tagify.py::test_render_guard_catches_mutation_after_tagify``). - - When to revisit - --------------- - If a future pyright/typing release handles recursive generic - ``TypeAliasType`` cleanly across module boundaries, flip this test - to a *negative* form (``# pyright: ignore[reportArgumentType]`` on - the ``tl.append(...)`` call) and reinstate ``TagChild[TagNodeT]`` on - ``TagList`` / ``Tag`` mutation-method signatures. + Appending a ``Tagifiable`` to a ``TagifiedTagList`` is a *static* error. + + Enforced by ``TagifiedTagList.append``'s narrow signature, which accepts + only ``TagifiedChild`` (a non-generic union that excludes the + ``Tagifiable`` arm of ``TagNode``). The previous design — a recursive + generic ``TagChild[TagNodeT]`` — was abandoned in #105 because it + leaked ``Sequence[Unknown]`` through downstream pyright in strict mode; + the subclass-with-narrow-overrides approach (#116) closes the gap + without parameterizing ``TagChild``. + + The runtime guards in ``TagList.tagify`` (boundary ``TypeError``) and + ``get_html_string`` (render-time ``RuntimeError``) remain the safety + net for code that uses ``# pyright: ignore`` to bypass the static check. """ class _SomeTagifiable: @@ -123,14 +92,10 @@ def tagify(self) -> Tagified: return "x" tl: TagifiedTagList = TagList("hi").tagify() - # This currently type-checks. In an ideal world it would static-error; - # see the docstring for why we accept the gap. - tl.append(_SomeTagifiable()) + tl.append(_SomeTagifiable()) # pyright: ignore[reportArgumentType] def test_TagifiedTag_append_rejects_Tagifiable_statically() -> None: - from htmltools._core import TagifiedTag - class _SomeTagifiable: def tagify(self) -> Tagified: return "x" @@ -141,8 +106,6 @@ def tagify(self) -> Tagified: def test_TagifiedTag_extend_rejects_Tagifiable_statically() -> None: - from htmltools._core import TagifiedTag - class _SomeTagifiable: def tagify(self) -> Tagified: return "x" @@ -152,8 +115,6 @@ def tagify(self) -> Tagified: def test_TagifiedTag_insert_rejects_Tagifiable_statically() -> None: - from htmltools._core import TagifiedTag - class _SomeTagifiable: def tagify(self) -> Tagified: return "x" From 399498d4532debe4f34daaee4822a69f5f0a23ef Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 13:14:39 -0400 Subject: [PATCH 12/26] test(types): document pyright's permissive variance on Tagified flows (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Originally planned as a fixture for the variance ERROR that the subclass approach was expected to surface. Empirical check: pyright accepts TagifiedTagList -> TagList (bare AND explicit-parameter) and the same for TagifiedTag -> Tag. Likely due to nominal-subclass handling + TypeVar default precedence. The fixture flips to lock in the observed *permissive* behavior — if pyright ever changes, this catches the regression. --- tests/test_types.py | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_types.py b/tests/test_types.py index 88a8c46..c5c2d47 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -14,9 +14,11 @@ from typing_extensions import assert_type from htmltools import ( + Tag, Tagifiable, Tagified, TagList, + TagNode, div, ) from htmltools._core import TagifiedTag, TagifiedTagList @@ -151,3 +153,55 @@ def tagify(self) -> TagifiedTagList: return TagList("x").tagify() _: Tagifiable = _Good() + + +def test_TagifiedTagList_to_bare_TagList_is_accepted() -> None: + """Documents pyright's observed permissive behavior on TagifiedTagList flows. + + ``TagList`` is invariant in ``TagNodeT`` per its ``TypeVar`` declaration, + so ``TagList[TagifiedNode]`` is *technically* not assignable to + ``TagList[TagNode]``. In practice, pyright (verified through 1.1.409) + treats a nominal subclass ``TagifiedTagList(TagList["TagifiedNode"])`` as + assignable to bare ``TagList`` and to explicit ``TagList[TagNode]``, + likely because the ``TagNodeT`` default and the class-hierarchy lookup + take precedence over the parameterized-instance invariance check. + + This is the answer to the "open question" in #116 — the previous + TypeAliasType alias appeared to "silently relax" variance, but the + subclass form has the same practical behavior. Downstream consumers + using ``def f(t: TagList): ...`` (or even ``TagList[TagNode]``) do NOT + need migration to accept tagified inputs. + + If pyright ever starts flagging these flows, this fixture will fail — + that's the signal to add a real migration note and update downstream + packages. + """ + + def f_bare_taglist(t: TagList) -> str: + return t.get_html_string() + + def f_explicit_taglist(t: TagList[TagNode]) -> str: + return t.get_html_string() + + tagified: TagifiedTagList = TagList("hi").tagify() + # All three of these are accepted by pyright. No `# pyright: ignore` + # is needed (and adding one would be flagged as unnecessary if + # `reportUnnecessaryTypeIgnoreComment` is enabled). + f_bare_taglist(tagified) + f_explicit_taglist(tagified) + _: TagList[TagNode] = tagified + + +def test_TagifiedTag_to_bare_Tag_is_accepted() -> None: + """Symmetric to ``test_TagifiedTagList_to_bare_TagList_is_accepted``.""" + + def f_bare_tag(t: Tag) -> str: + return t.get_html_string() + + def f_explicit_tag(t: Tag[TagNode]) -> str: + return t.get_html_string() + + tagified: TagifiedTag = div("hi").tagify() + f_bare_tag(tagified) + f_explicit_tag(tagified) + _: Tag[TagNode] = tagified From a6379ffba27481b87ba193ffb696adb57321e86b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 13:16:01 -0400 Subject: [PATCH 13/26] feat(types): export TagifiedTag, TagifiedTagList (now class), TagifiedChild (#116) --- htmltools/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/htmltools/__init__.py b/htmltools/__init__.py index 8fc57b9..d5665a3 100644 --- a/htmltools/__init__.py +++ b/htmltools/__init__.py @@ -18,6 +18,9 @@ TagFunction, Tagifiable, Tagified, + TagifiedChild, + TagifiedTag, + TagifiedTagList, TagList, TagNode, consolidate_attrs, @@ -63,6 +66,9 @@ "TagFunction", "Tagifiable", "Tagified", + "TagifiedChild", + "TagifiedTag", + "TagifiedTagList", "TagList", "TagNode", "ReprHtml", From cfb7e2e90b482773905c95a9c60a27dd0dda0d97 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 13:16:59 -0400 Subject: [PATCH 14/26] docs(_core): refresh TagChild non-generic rationale comment (#116) The trailing two sentences claiming that TagList[TagifiedNode].append "no longer static-errors" became false once #116 added narrow-signature mutator overrides on TagifiedTagList / TagifiedTag. The new comment states the static-input enforcement story correctly. --- htmltools/_core.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index ec29d77..a1e0f53 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -205,12 +205,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 `TagifiedChild` (defined above). See #116. TagChild = Union[ TagNode, "TagList", From 3246446bae8cdd769a3dfba42ff63e785d5dcf91 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 13:17:40 -0400 Subject: [PATCH 15/26] docs(changelog): document #116 subclass migration Adds entries for the TagifiedTagList -> class, TagifiedTag, and TagifiedChild additions. Calls out that pyright remains permissive on TagifiedTagList -> bare TagList flows in practice, so downstream consumers mostly don't need migration. Also retires the now-false "mutation methods still accept Tagifiable statically" note from #105 since #116 added the narrow-signature overrides. --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f19866d..394c8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,20 +20,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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"]` + rather than a `TypeAliasType` alias. `TagifiedTag` is a new parallel + subclass of `Tag["TagifiedNode"]`. Both are returned by `.tagify()` + and are runtime-`isinstance`-checkable. 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) + +* `TagifiedTagList.append` / `.extend` / `.insert` / `__init__`, and + the parallel methods on `TagifiedTag`, are now statically narrowed + to `TagifiedChild` (a new non-generic union parallel to `TagChild` + that excludes the `Tagifiable` arm). Pyright now flags + `tagified.append(SomeTagifiable())` as a type error at the call + site. The existing tagify-boundary `TypeError` and render-time + `RuntimeError` remain the runtime safety net for code that uses + `# pyright: ignore` to bypass the static check. (#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) + to `TagNode`. Bare `Tag` / `TagList` retain today's meaning. (#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) +* New public types `TagifiedTag` (subclass of `Tag["TagifiedNode"]`) + and `TagifiedTagList` (subclass of `TagList["TagifiedNode"]`), + returned by `Tag.tagify()` and `TagList.tagify()` respectively. + Runtime-`isinstance`-checkable; useful for narrowing tagified + values from a mixed pipeline. (#116) + +* New public type alias `TagifiedChild` — the non-generic input-side + parallel of `TagChild` used by `TagifiedTagList` / `TagifiedTag` + mutators. (#116) + ### 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) From 246ea0d2b2aa0173d4dd09e645f96983e968768c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:31:05 -0400 Subject: [PATCH 16/26] feat(types): collapse Tagified/TagifiedChild and normalize child.tagify() returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paired changes that only make sense together: 1. Collapse `TagifiedChild` into `Tagified`. `Tagified` is now the single non-generic union covering both `Tagifiable.tagify()`'s return shape AND the input-side type for mutators on `TagifiedTagList` / `TagifiedTag`. Includes the same flattening conveniences as `TagChild` (`float`, `None`, `Sequence[Tagified]`). Drops `TagifiedTag`, `TagifiedTagList`, and `TagifiedChild` from the top-level `htmltools` namespace — the classes stay accessible via `htmltools._core` for code that wants to isinstance-check, but the public surface added by this PR is just the widened `Tagified`. 2. Fix #117: `TagList.tagify()` now routes every return from a child's `.tagify()` through `_tagchilds_to_tagnodes`. That uniformly handles `None` (dropped), `float`/`int` (str-ified), `Sequence` (flattened), `TagList` (flattened), and plain nodes (passthrough). Previously only the `TagList` case was flattened; the other shapes slipped past the boundary check and either crashed the render path (`None` → `TypeError` in `html_escape`) or silently corrupted the tag tree. Without (2), the widening in (1) would create a static/runtime gap: pyright would accept `def tagify(self) -> Tagified: return None` while the framework still mishandled the return. Together, the static type and the runtime contract agree. Closes #117. --- CHANGELOG.md | 41 +++++++++++-------- htmltools/__init__.py | 6 --- htmltools/_core.py | 92 +++++++++++++++++++++++-------------------- tests/test_tagify.py | 47 +++++++++++++++++++++- tests/test_types.py | 6 +-- 5 files changed, 123 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 394c8a2..452fc81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 subclass of `Tag["TagifiedNode"]`. Both are returned by `.tagify()` and are runtime-`isinstance`-checkable. Code that depended on `TagifiedTagList` being an alias (e.g., `typing.get_type_hints` - introspection) needs to treat it as a class instead. + introspection) needs to treat it as a class instead. The classes + themselves are kept internal to `htmltools._core` — they're not + re-exported from the top-level `htmltools` namespace — so code that + wants to `isinstance`-check explicitly imports them from + `htmltools._core`. In practice, pyright remains permissive about flowing a `TagifiedTagList` into a parameter typed as bare `TagList` (or @@ -34,36 +38,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `TagifiedTagList.append` / `.extend` / `.insert` / `__init__`, and the parallel methods on `TagifiedTag`, are now statically narrowed - to `TagifiedChild` (a new non-generic union parallel to `TagChild` - that excludes the `Tagifiable` arm). Pyright now flags + to `Tagified` (now widened to a non-generic union parallel to + `TagChild` that excludes the `Tagifiable` arm). Pyright now flags `tagified.append(SomeTagifiable())` as a type error at the call site. The existing tagify-boundary `TypeError` and render-time `RuntimeError` remain the runtime safety net for code that uses `# pyright: ignore` to bypass the static check. (#116) +* `Tagified` (the return type of `Tagifiable.tagify()`) is now a + non-generic union that mirrors `TagChild`'s shape — including the + flattening conveniences `float`, `None`, and `Sequence[Tagified]`. + Custom `.tagify()` implementations may now return any of those + shapes statically. (Runtime normalization of `None` / `float` / + bare `Sequence` returns from `.tagify()` is tracked as #117.) (#116) + ### New features * `Tag` and `TagList` are now generic in their child type, defaulting to `TagNode`. Bare `Tag` / `TagList` retain today's meaning. (#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) - -* New public types `TagifiedTag` (subclass of `Tag["TagifiedNode"]`) - and `TagifiedTagList` (subclass of `TagList["TagifiedNode"]`), - returned by `Tag.tagify()` and `TagList.tagify()` respectively. - Runtime-`isinstance`-checkable; useful for narrowing tagified - values from a mixed pipeline. (#116) - -* New public type alias `TagifiedChild` — the non-generic input-side - parallel of `TagChild` used by `TagifiedTagList` / `TagifiedTag` - mutators. (#116) +* Updated the public type alias `Tagified` to cover both + `Tagifiable.tagify()`'s return shape and the input-side type for + mutators on `TagifiedTagList` / `TagifiedTag`. (#105, #116) ### 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 normalizes every return from a child's + `.tagify()` through the same path as un-tagified inputs + (`_tagchilds_to_tagnodes`). `None` is dropped, `float` and `int` are + str-ified, and `Sequence` returns are flattened — previously these + shapes slipped past the boundary check and either crashed the render + path (`None` → `TypeError` deep in `html_escape`) or silently + corrupted the tag tree. (#117) + ### Dependencies * Bumped `typing_extensions` floor to `>=4.12.0`, required for diff --git a/htmltools/__init__.py b/htmltools/__init__.py index d5665a3..8fc57b9 100644 --- a/htmltools/__init__.py +++ b/htmltools/__init__.py @@ -18,9 +18,6 @@ TagFunction, Tagifiable, Tagified, - TagifiedChild, - TagifiedTag, - TagifiedTagList, TagList, TagNode, consolidate_attrs, @@ -66,9 +63,6 @@ "TagFunction", "Tagifiable", "Tagified", - "TagifiedChild", - "TagifiedTag", - "TagifiedTagList", "TagList", "TagNode", "ReprHtml", diff --git a/htmltools/_core.py b/htmltools/_core.py index a1e0f53..53a4a5d 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -70,7 +70,6 @@ "TagFunction", "Tagifiable", "Tagified", - "TagifiedChild", "TagifiedTag", "TagifiedTagList", "consolidate_attrs", @@ -122,7 +121,7 @@ class MetadataNode: # # Why subclasses rather than `TypeAliasType` aliases: a subclass is runtime- # `isinstance`-checkable, and we can override `append` / `extend` / `insert` / -# `__init__` with narrow `TagifiedChild`-only signatures so pyright rejects +# `__init__` with narrow `Tagified`-only signatures so pyright rejects # un-tagified inputs at the call site (closing the gap that motivated #115). # Kept as a plain `Union` (not `TypeAliasType`) so the arms are visible @@ -152,26 +151,31 @@ 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] -""" -Anything `.tagify()` is permitted to return: either a top-level -`TagifiedTagList`, or one of the `TagifiedNode` shapes (a fully-tagified -`Tag` or a leaf). -""" - -# Parallel to `TagChild` but with no `Tagifiable` arm. Non-generic for the -# same reason `TagChild` is non-generic (see comment near the `TagChild` +# `Tagified` plays two roles: it's the return type of +# `Tagifiable.tagify()` AND the input-side type for mutators on +# `TagifiedTagList` / `TagifiedTag`. Mirrors `TagChild` 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). -TagifiedChild = Union[ +Tagified = Union[ TagifiedNode, "TagifiedTagList", float, None, - Sequence["TagifiedChild"], + Sequence["Tagified"], ] """ -The static-input type for mutation methods on `TagifiedTagList` / -`TagifiedTag`. Mirrors `TagChild` but excludes the `Tagifiable` arm. +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. """ @@ -212,7 +216,7 @@ class MetadataNode: # (`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 `TagifiedChild` (defined above). See #116. +# parallel union `Tagified` (defined above). See #116. TagChild = Union[ TagNode, "TagList", @@ -436,20 +440,22 @@ def tagify(self) -> "TagifiedTagList": if isinstance(child, Tagifiable): tagified_child = child.tagify() - if isinstance(tagified_child, TagList): - # Flatten the returned TagList into this one. Two casts: - # (1) pyright cannot fully resolve `TagifiedTagList`'s - # recursive child alias, leaving a `TagList[Unknown]` arm; - # (2) _tagchilds_to_tagnodes returns list[TagNode] but the - # slice target on TagifiedTagList expects Iterable[TagifiedNode]. - cp[i : i + 1] = cast( - "list[TagifiedNode]", - _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) @@ -657,14 +663,14 @@ 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 `TagifiedChild` + 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())``. Note on overrides: each mutator narrows its input from ``TagChild`` - to ``TagifiedChild``. This is contravariant narrowing (LSP-unsafe in + to ``Tagified``. This is contravariant narrowing (LSP-unsafe in the abstract — a caller holding a ``TagList`` reference could pass inputs the ``TagifiedTagList`` no longer accepts), so pyright flags each override with ``reportIncompatibleMethodOverride``. The @@ -674,13 +680,13 @@ class TagifiedTagList(TagList["TagifiedNode"]): not occur in practice. """ - def __init__(self, *args: TagifiedChild) -> None: + def __init__(self, *args: Tagified) -> None: # cast: pass through to parent; the parent constructor accepts the - # wider TagChild union, of which TagifiedChild is a subset. + # wider TagChild union, of which Tagified is a subset. super().__init__(*cast("tuple[TagChild, ...]", args)) def append( # pyright: ignore[reportIncompatibleMethodOverride] - self, item: TagifiedChild, *args: TagifiedChild + self, item: Tagified, *args: Tagified ) -> None: super().append( cast("TagChild", item), @@ -688,12 +694,12 @@ def append( # pyright: ignore[reportIncompatibleMethodOverride] ) def extend( # pyright: ignore[reportIncompatibleMethodOverride] - self, other: Iterable[TagifiedChild] + self, other: Iterable[Tagified] ) -> None: super().extend(cast("Iterable[TagChild]", other)) def insert( # pyright: ignore[reportIncompatibleMethodOverride] - self, i: SupportsIndex, item: TagifiedChild + self, i: SupportsIndex, item: Tagified ) -> None: super().insert(i, cast("TagChild", item)) @@ -1171,12 +1177,12 @@ class TagifiedTag(Tag["TagifiedNode"]): """ A `Tag` whose children are all fully tagified. - Returned by `Tag.tagify()`. Mutators are narrowed to `TagifiedChild` so + 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. Note on overrides: each mutator narrows its input from ``TagChild`` - to ``TagifiedChild``. This is contravariant narrowing (LSP-unsafe in + to ``Tagified``. This is contravariant narrowing (LSP-unsafe in the abstract — a caller holding a ``Tag`` reference could pass inputs the ``TagifiedTag`` no longer accepts), so pyright flags each override with ``reportIncompatibleMethodOverride``. The @@ -1189,7 +1195,7 @@ class TagifiedTag(Tag["TagifiedNode"]): def __init__( self, _name: str, - *args: TagifiedChild | TagAttrs, + *args: Tagified | TagAttrs, _add_ws: TagAttrValue = True, **kwargs: TagAttrValue, ) -> None: @@ -1201,17 +1207,17 @@ def __init__( ) def append( # pyright: ignore[reportIncompatibleMethodOverride] - self, *args: TagifiedChild + self, *args: Tagified ) -> None: super().append(*cast("tuple[TagChild, ...]", args)) def extend( # pyright: ignore[reportIncompatibleMethodOverride] - self, x: Iterable[TagifiedChild] + self, x: Iterable[Tagified] ) -> None: super().extend(cast("Iterable[TagChild]", x)) def insert( # pyright: ignore[reportIncompatibleMethodOverride] - self, index: SupportsIndex, x: TagifiedChild + self, index: SupportsIndex, x: Tagified ) -> None: super().insert(index, cast("TagChild", x)) diff --git a/tests/test_tagify.py b/tests/test_tagify.py index eb9e637..94e96db 100644 --- a/tests/test_tagify.py +++ b/tests/test_tagify.py @@ -1,6 +1,6 @@ import pytest -from htmltools import TagList, div, span +from htmltools import Tagified, TagList, div, span class _ReturnsTagifiable: @@ -46,3 +46,48 @@ def test_render_guard_catches_mutation_after_tagify() -> None: tagified.children.append(_NestedTagifiable()) with pytest.raises(RuntimeError, match="_NestedTagifiable"): tagified.get_html_string() + + +# ----------------------------------------------------------------------------- +# Boundary normalization of child.tagify() returns (closes #117) +# ----------------------------------------------------------------------------- +# The `Tagified` union now permits `None`, `float`, and `Sequence[Tagified]` +# returns from `.tagify()` (parallel to `TagChild` on the input side). The +# `TagList.tagify()` boundary routes every return through +# `_tagchilds_to_tagnodes`, which normalizes those shapes uniformly: drop +# `None`, str-ify `float`/`int`, flatten `Sequence`. + + +class _ReturnsNone: + def tagify(self) -> Tagified: + return None + + +class _ReturnsFloat: + def tagify(self) -> Tagified: + return 3.14 + + +class _ReturnsList: + def tagify(self) -> Tagified: + return ["a", "b"] + + +def test_tagify_returning_None_drops_the_slot() -> None: + tl = TagList(_ReturnsNone(), "after").tagify() + assert list(tl) == ["after"] + # Render must not crash. + assert tl.get_html_string() == "after" + + +def test_tagify_returning_float_is_strified() -> None: + tl = TagList(_ReturnsFloat(), "after").tagify() + assert list(tl) == ["3.14", "after"] + assert "3.14" in tl.get_html_string() + + +def test_tagify_returning_Sequence_flattens() -> None: + tl = TagList(_ReturnsList(), "after").tagify() + assert list(tl) == ["a", "b", "after"] + # Render: sibling text nodes concatenate without separators. + assert tl.get_html_string() == "abafter" diff --git a/tests/test_types.py b/tests/test_types.py index c5c2d47..ac43b9b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -33,7 +33,7 @@ def test_taglist_tagify_returns_TagifiedTagList() -> None: def test_TagifiedTagList_append_rejects_Tagifiable_statically() -> None: - """Mutators on TagifiedTagList narrow input to TagifiedChild; appending + """Mutators on TagifiedTagList narrow input to Tagified; appending a Tagifiable must be a pyright error.""" class _SomeTagifiable: @@ -43,7 +43,7 @@ def tagify(self) -> Tagified: tl: TagifiedTagList = TagList("hi").tagify() # Acceptable: a tagified node tl.append("ok") - # Static error: bare Tagifiable is not in TagifiedChild. + # Static error: bare Tagifiable is not in Tagified. tl.append(_SomeTagifiable()) # pyright: ignore[reportArgumentType] @@ -77,7 +77,7 @@ def test_TagifiedTagList_append_rejects_Tagifiable() -> None: Appending a ``Tagifiable`` to a ``TagifiedTagList`` is a *static* error. Enforced by ``TagifiedTagList.append``'s narrow signature, which accepts - only ``TagifiedChild`` (a non-generic union that excludes the + only ``Tagified`` (a non-generic union that excludes the ``Tagifiable`` arm of ``TagNode``). The previous design — a recursive generic ``TagChild[TagNodeT]`` — was abandoned in #105 because it leaked ``Sequence[Unknown]`` through downstream pyright in strict mode; From 5754005c6b975bd729ac06d245494f5295d5c948 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:33:40 -0400 Subject: [PATCH 17/26] docs(changelog): consolidate #116 / #117 entries Drops the stale "(tracked as #117)" caveat since the runtime normalization landed in this release. Removes the duplicate Tagified note from New features (already covered under Breaking changes). --- CHANGELOG.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 452fc81..5f6e6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,19 +48,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `Tagified` (the return type of `Tagifiable.tagify()`) is now a non-generic union that mirrors `TagChild`'s shape — including the flattening conveniences `float`, `None`, and `Sequence[Tagified]`. - Custom `.tagify()` implementations may now return any of those - shapes statically. (Runtime normalization of `None` / `float` / - bare `Sequence` returns from `.tagify()` is tracked as #117.) (#116) + Custom `.tagify()` implementations may return any of those shapes; + the framework normalizes them at the boundary (drops `None`, + str-ifies `float`/`int`, flattens `Sequence`). (#116, #117) ### New features * `Tag` and `TagList` are now generic in their child type, defaulting to `TagNode`. Bare `Tag` / `TagList` retain today's meaning. (#105) -* Updated the public type alias `Tagified` to cover both - `Tagifiable.tagify()`'s return shape and the input-side type for - mutators on `TagifiedTagList` / `TagifiedTag`. (#105, #116) - ### 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) From 4d31a1e99d1693e3b262cf5c7886aa087477aedd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:34:26 -0400 Subject: [PATCH 18/26] docs(changelog): clean up TagifiedTagList mutator entry wording (#116) --- CHANGELOG.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6e6df..6460b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,12 +38,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `TagifiedTagList.append` / `.extend` / `.insert` / `__init__`, and the parallel methods on `TagifiedTag`, are now statically narrowed - to `Tagified` (now widened to a non-generic union parallel to - `TagChild` that excludes the `Tagifiable` arm). Pyright now flags - `tagified.append(SomeTagifiable())` as a type error at the call - site. The existing tagify-boundary `TypeError` and render-time - `RuntimeError` remain the runtime safety net for code that uses - `# pyright: ignore` to bypass the static check. (#116) + to `Tagified` (which excludes the `Tagifiable` arm of `TagChild`). + Pyright now flags `tagified.append(SomeTagifiable())` as a type + error at the call site. The existing tagify-boundary `TypeError` + and render-time `RuntimeError` remain the runtime safety net for + code that uses `# pyright: ignore` to bypass the static check. (#116) * `Tagified` (the return type of `Tagifiable.tagify()`) is now a non-generic union that mirrors `TagChild`'s shape — including the From 992cc65da1974f1df894848961d289bf98b52c20 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:36:15 -0400 Subject: [PATCH 19/26] docs(changelog): consolidate 0.7.0 entries Eight entries -> five. The two `Tagified`-shape entries are now one; the two `TagifiedTagList` subclass entries (subclass-ness + mutator narrowing) are merged with sub-paragraphs; the two boundary-check bug fixes are combined into one entry covering the full normalization. --- CHANGELOG.md | 67 +++++++++++++++++++++++----------------------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6460b69..abc4364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,48 +9,37 @@ 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"]` - rather than a `TypeAliasType` alias. `TagifiedTag` is a new parallel + (previously a `TypeAliasType` alias). `TagifiedTag` is a new parallel subclass of `Tag["TagifiedNode"]`. Both are returned by `.tagify()` - and are runtime-`isinstance`-checkable. Code that depended on - `TagifiedTagList` being an alias (e.g., `typing.get_type_hints` - introspection) needs to treat it as a class instead. The classes - themselves are kept internal to `htmltools._core` — they're not + 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 explicitly imports them from - `htmltools._core`. + 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) -* `TagifiedTagList.append` / `.extend` / `.insert` / `__init__`, and - the parallel methods on `TagifiedTag`, are now statically narrowed - to `Tagified` (which excludes the `Tagifiable` arm of `TagChild`). - Pyright now flags `tagified.append(SomeTagifiable())` as a type - error at the call site. The existing tagify-boundary `TypeError` - and render-time `RuntimeError` remain the runtime safety net for - code that uses `# pyright: ignore` to bypass the static check. (#116) - -* `Tagified` (the return type of `Tagifiable.tagify()`) is now a - non-generic union that mirrors `TagChild`'s shape — including the - flattening conveniences `float`, `None`, and `Sequence[Tagified]`. - Custom `.tagify()` implementations may return any of those shapes; - the framework normalizes them at the boundary (drops `None`, - str-ifies `float`/`int`, flattens `Sequence`). (#116, #117) - ### New features * `Tag` and `TagList` are now generic in their child type, defaulting @@ -58,15 +47,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 normalizes every return from a child's - `.tagify()` through the same path as un-tagified inputs - (`_tagchilds_to_tagnodes`). `None` is dropped, `float` and `int` are - str-ified, and `Sequence` returns are flattened — previously these - shapes slipped past the boundary check and either crashed the render - path (`None` → `TypeError` deep in `html_escape`) or silently - corrupted the tag tree. (#117) +* `TagList.tagify()` now defensively normalizes every shape a child's + `.tagify()` can return. A return whose contents still include an + un-tagified `Tagifiable` raises `TypeError` at the boundary, naming + the offending class and slot index so buggy `.tagify()` + implementations surface at their source rather than later at render + time. `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 From 44f6979459a3f28a15235c324e95887ffcc53ded Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:43:51 -0400 Subject: [PATCH 20/26] fix(_core): tighten post-tagify boundary to reject bare Tag/TagList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous post-condition accepted any `Tag`/`TagList` in the tagified result — but `Tagified` excludes bare `Tag`/`TagList` (only `TagifiedTag` and `TagifiedTagList` are in the contract). A misbehaving `.tagify()` returning `div(some_tagifiable)` (a bare Tag wrapping an un-tagified child) slipped past the boundary and only failed at render time with the misleading "tree was mutated to add a Tagifiable" message. Tightens the check to require `TagifiedTag`/`TagifiedTagList` specifically. Updates the in-tree DelayedDep test that was relying on the lenient behavior — fix is a one-line `.tagify()` on the return. Adds three new tests covering bare-Tag returns with both Tagifiable and leaf children. Addresses Copilot review comment on PR #118. --- CHANGELOG.md | 17 ++++++----- htmltools/_core.py | 44 ++++++++++++++------------- tests/test_html_document.py | 5 +++- tests/test_tagify.py | 60 +++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abc4364..7aec2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,13 +49,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `TagList.tagify()` now defensively normalizes every shape a child's `.tagify()` can return. A return whose contents still include an - un-tagified `Tagifiable` raises `TypeError` at the boundary, naming - the offending class and slot index so buggy `.tagify()` - implementations surface at their source rather than later at render - time. `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 + 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, diff --git a/htmltools/_core.py b/htmltools/_core.py index 53a4a5d..15c9db6 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -460,30 +460,32 @@ def tagify(self) -> "TagifiedTagList": 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. - # Cast `cp` to the wider parent type so pyright keeps the isinstance - # guard below reachable. Statically, `cp`'s items are `TagifiedNode` - # (which excludes the bare-`Tagifiable` arm), so pyright would flag - # the check as `reportUnnecessaryIsInstance`. The guard exists to - # catch runtime protocol violations — a misbehaving `.tagify()` may - # still place a bare `Tagifiable` in the list — so the cast preserves - # the defensive intent without weakening the runtime check. + # 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, (Tag, TagList)): + 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`)." ) diff --git a/tests/test_html_document.py b/tests/test_html_document.py index 4c3da57..a45896f 100644 --- a/tests/test_html_document.py +++ b/tests/test_html_document.py @@ -192,7 +192,10 @@ class DelayedDep: ) def tagify(self): - return div("delayed dependency", self.dep) + # Must return a fully-tagified value (TagifiedTag), not a bare + # Tag. Calling `.tagify()` on `div(...)` produces the required + # subclass — see #116's tightened post-tagify boundary check. + return div("delayed dependency", self.dep).tagify() x = TagList(div("Hello", DelayedDep()), "world") diff --git a/tests/test_tagify.py b/tests/test_tagify.py index 94e96db..14b4595 100644 --- a/tests/test_tagify.py +++ b/tests/test_tagify.py @@ -91,3 +91,63 @@ def test_tagify_returning_Sequence_flattens() -> None: assert list(tl) == ["a", "b", "after"] # Render: sibling text nodes concatenate without separators. assert tl.get_html_string() == "abafter" + + +# ----------------------------------------------------------------------------- +# Strict post-tagify boundary: bare `Tag` / `TagList` returns are rejected. +# ----------------------------------------------------------------------------- +# `Tagified` permits `TagifiedTag` (not bare `Tag`) and `TagifiedTagList` +# (not bare `TagList`). A `.tagify()` that returns `div(some_tagifiable)` +# without calling `.tagify()` on the result will leave un-tagified content +# in the tree — the bare-Tag shape passes runtime `isinstance(_, Tag)` +# checks but fails the `Tagified` contract. The boundary catches this +# before render time, where the error message would otherwise be the +# misleading "tree was mutated to add a Tagifiable object after +# .tagify() was called." + + +class _ReturnsBareTagWithTagifiableChild: + """Wraps a Tagifiable in a bare div() — does NOT call .tagify() on result.""" + + def tagify( + self, + ): # intentionally unannotated; pyright would catch the contract violation + return div(_NestedTagifiable()) + + +class _ReturnsBareTagWithLeafChild: + """Even a bare Tag whose contents are leaves violates the contract.""" + + def tagify(self): + return div("leaf") + + +def test_taglist_tagify_raises_on_bare_Tag_return_with_tagifiable_child() -> None: + # Direct reproducer of the bug Copilot flagged on PR #118: a bare Tag + # carrying an un-tagified child slipped past the boundary and only + # failed at render time with a misleading "tree was mutated" error. + tl = TagList(_ReturnsBareTagWithTagifiableChild()) + with pytest.raises(TypeError, match=r"Tag at index"): + tl.tagify() + + +def test_taglist_tagify_raises_on_bare_Tag_return_with_leaf_child() -> None: + # Even when the bare Tag's children are leaves, the strict boundary + # raises — matching the static `Tagified` contract (which excludes bare + # `Tag`). Easy fix at the call site: append `.tagify()` to the return. + tl = TagList(_ReturnsBareTagWithLeafChild()) + with pytest.raises(TypeError, match=r"Tag at index"): + tl.tagify() + + +def test_taglist_tagify_accepts_self_tagified_Tag_return() -> None: + # The recommended fix: call `.tagify()` on the wrapping Tag before returning. + class _ReturnsTagifiedTag: + def tagify(self): + return div(_NestedTagifiable()).tagify() + + tl = TagList(_ReturnsTagifiedTag()).tagify() + html = tl.get_html_string() + assert "
" in html + assert "bar" in html + assert "
" in html From b7c0416a5ecf2db7b1a89d5c74f44892f242c87c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:49:27 -0400 Subject: [PATCH 21/26] refactor(_core): define TagifiedChild internally; Tagified = TagifiedChild The wider union now has two names in `_core.py`: - `TagifiedChild` (internal): the input-side union, named to parallel `TagChild` so maintainers can see the structural symmetry between the un-tagified and tagified sides. - `Tagified` (public): the user-facing name for the same shape, used in `Tagifiable.tagify()`'s protocol return type. Aliased via `Tagified = TagifiedChild`. Override signatures inside `TagifiedTagList` / `TagifiedTag` switch to `TagifiedChild` to reinforce the input-side parallel. Public-facing docstrings and the CHANGELOG continue to refer to `Tagified` (still the only exported name). At runtime the two names refer to the same Union object. --- htmltools/_core.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index 15c9db6..9880f40 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -151,19 +151,24 @@ 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` plays two roles: it's the return type of -# `Tagifiable.tagify()` AND the input-side type for mutators on -# `TagifiedTagList` / `TagifiedTag`. Mirrors `TagChild` 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). -Tagified = Union[ +# +# `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["Tagified"], + Sequence["TagifiedChild"], ] + +Tagified = TagifiedChild """ The shape contract for fully-tagified content. Used as: @@ -682,13 +687,13 @@ class TagifiedTagList(TagList["TagifiedNode"]): not occur in practice. """ - def __init__(self, *args: Tagified) -> None: + def __init__(self, *args: TagifiedChild) -> None: # cast: pass through to parent; the parent constructor accepts the - # wider TagChild union, of which Tagified is a subset. + # wider TagChild union, of which TagifiedChild is a subset. super().__init__(*cast("tuple[TagChild, ...]", args)) def append( # pyright: ignore[reportIncompatibleMethodOverride] - self, item: Tagified, *args: Tagified + self, item: TagifiedChild, *args: TagifiedChild ) -> None: super().append( cast("TagChild", item), @@ -696,12 +701,12 @@ def append( # pyright: ignore[reportIncompatibleMethodOverride] ) def extend( # pyright: ignore[reportIncompatibleMethodOverride] - self, other: Iterable[Tagified] + self, other: Iterable[TagifiedChild] ) -> None: super().extend(cast("Iterable[TagChild]", other)) def insert( # pyright: ignore[reportIncompatibleMethodOverride] - self, i: SupportsIndex, item: Tagified + self, i: SupportsIndex, item: TagifiedChild ) -> None: super().insert(i, cast("TagChild", item)) @@ -1197,7 +1202,7 @@ class TagifiedTag(Tag["TagifiedNode"]): def __init__( self, _name: str, - *args: Tagified | TagAttrs, + *args: TagifiedChild | TagAttrs, _add_ws: TagAttrValue = True, **kwargs: TagAttrValue, ) -> None: @@ -1209,17 +1214,17 @@ def __init__( ) def append( # pyright: ignore[reportIncompatibleMethodOverride] - self, *args: Tagified + self, *args: TagifiedChild ) -> None: super().append(*cast("tuple[TagChild, ...]", args)) def extend( # pyright: ignore[reportIncompatibleMethodOverride] - self, x: Iterable[Tagified] + self, x: Iterable[TagifiedChild] ) -> None: super().extend(cast("Iterable[TagChild]", x)) def insert( # pyright: ignore[reportIncompatibleMethodOverride] - self, index: SupportsIndex, x: Tagified + self, index: SupportsIndex, x: TagifiedChild ) -> None: super().insert(index, cast("TagChild", x)) From eef33532994e1ae1ca56ab29fcec6768c6b6ef43 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:55:43 -0400 Subject: [PATCH 22/26] docs(_core): trim Tagified-aliases header; demote LSP note to comment - Removes the "subclasses defined later in this file" preamble above the alias block; the surrounding code is now self-evident. - Moves the "Note on overrides" LSP-narrowing paragraph from each subclass docstring into a leading `#` comment block. It's maintainer context, not user-facing API documentation. --- htmltools/_core.py | 46 ++++++++++++++++++---------------------------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index 9880f40..40d7bef 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -115,14 +115,6 @@ class MetadataNode: # ----------------------------------------------------------------------------- # Tagified shape aliases # ----------------------------------------------------------------------------- -# `TagifiedTagList` and `TagifiedTag` are real subclasses (defined later in -# this file, after their parent classes). The Union aliases below are forward -# references to them. -# -# Why subclasses rather than `TypeAliasType` aliases: a subclass is runtime- -# `isinstance`-checkable, and we can override `append` / `extend` / `insert` / -# `__init__` with narrow `Tagified`-only signatures so pyright rejects -# un-tagified inputs at the call site (closing the gap that motivated #115). # Kept as a plain `Union` (not `TypeAliasType`) so the arms are visible # in pyright diagnostics — a value typed as `TagNodeLeaf` shows up as @@ -675,18 +667,17 @@ class TagifiedTagList(TagList["TagifiedNode"]): To append a non-tagified child to a `TagifiedTagList`, call `.tagify()` on it first: ``tl.append(div("x").tagify())``. - - Note on overrides: each mutator narrows its input from ``TagChild`` - to ``Tagified``. This is contravariant narrowing (LSP-unsafe in - the abstract — a caller holding a ``TagList`` reference could pass - inputs the ``TagifiedTagList`` no longer accepts), so pyright flags - each override with ``reportIncompatibleMethodOverride``. The - suppression is deliberate: in this codebase, ``TagifiedTagList`` is - only ever obtained via ``.tagify()``, never by upcasting a - pre-existing ``TagList`` reference, so the LSP failure mode does - not occur in practice. """ + # Note on overrides: each mutator narrows its input from `TagChild` to + # `TagifiedChild`. This is contravariant narrowing (LSP-unsafe in the + # abstract — a caller holding a `TagList` reference could pass inputs + # the `TagifiedTagList` no longer accepts), so pyright flags each + # override with `reportIncompatibleMethodOverride`. The suppression is + # deliberate: in this codebase, `TagifiedTagList` is only ever obtained + # via `.tagify()`, never by upcasting a pre-existing `TagList` + # reference, so the LSP failure mode does not occur in practice. + 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. @@ -1187,18 +1178,17 @@ class TagifiedTag(Tag["TagifiedNode"]): 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. - - Note on overrides: each mutator narrows its input from ``TagChild`` - to ``Tagified``. This is contravariant narrowing (LSP-unsafe in - the abstract — a caller holding a ``Tag`` reference could pass - inputs the ``TagifiedTag`` no longer accepts), so pyright flags - each override with ``reportIncompatibleMethodOverride``. The - suppression is deliberate: in this codebase, ``TagifiedTag`` is - only ever obtained via ``.tagify()``, never by upcasting a - pre-existing ``Tag`` reference, so the LSP failure mode does - not occur in practice. """ + # Note on overrides: each mutator narrows its input from `TagChild` to + # `TagifiedChild`. This is contravariant narrowing (LSP-unsafe in the + # abstract — a caller holding a `Tag` reference could pass inputs the + # `TagifiedTag` no longer accepts), so pyright flags each override with + # `reportIncompatibleMethodOverride`. The suppression is deliberate: in + # this codebase, `TagifiedTag` is only ever obtained via `.tagify()`, + # never by upcasting a pre-existing `Tag` reference, so the LSP failure + # mode does not occur in practice. + def __init__( self, _name: str, From b39be60a71cf1db8b848a3ec853b374250ce99bc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:57:09 -0400 Subject: [PATCH 23/26] docs(_jsx): drop was/now diff annotation from JSXTag.tagify comment --- htmltools/_jsx.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/htmltools/_jsx.py b/htmltools/_jsx.py index f97ebec..5b09404 100644 --- a/htmltools/_jsx.py +++ b/htmltools/_jsx.py @@ -164,11 +164,9 @@ def tagify_tagifiable_and_get_metadata(x: Any) -> Any: ] ) - # Was: cast("Tag[TagifiedNode]", Tag("script", ...)) - # Now: build a TagifiedTag directly. The children passed in (HTML, - # script-attr dict, lib-dependency metadata nodes) are all - # already TagifiedNode-shaped, so the constructor produces a - # legitimate tagified tag. + # The children passed in (HTML, script-attr dict, lib-dependency + # metadata nodes) are all already TagifiedNode-shaped, so the + # constructor produces a legitimate tagified tag. return TagifiedTag( "script", { From 502b3fcc80b382ffb4e6d39d99f4cef07a2e5a91 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 14:58:36 -0400 Subject: [PATCH 24/26] docs(_core): rewrite LSP override notes for readability --- htmltools/_core.py | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index 40d7bef..d8c175b 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -669,14 +669,21 @@ class TagifiedTagList(TagList["TagifiedNode"]): on it first: ``tl.append(div("x").tagify())``. """ - # Note on overrides: each mutator narrows its input from `TagChild` to - # `TagifiedChild`. This is contravariant narrowing (LSP-unsafe in the - # abstract — a caller holding a `TagList` reference could pass inputs - # the `TagifiedTagList` no longer accepts), so pyright flags each - # override with `reportIncompatibleMethodOverride`. The suppression is - # deliberate: in this codebase, `TagifiedTagList` is only ever obtained - # via `.tagify()`, never by upcasting a pre-existing `TagList` - # reference, so the LSP failure mode does not occur in practice. + # 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`. def __init__(self, *args: TagifiedChild) -> None: # cast: pass through to parent; the parent constructor accepts the @@ -1180,14 +1187,21 @@ class TagifiedTag(Tag["TagifiedNode"]): non-tagified child, call `.tagify()` on it first. """ - # Note on overrides: each mutator narrows its input from `TagChild` to - # `TagifiedChild`. This is contravariant narrowing (LSP-unsafe in the - # abstract — a caller holding a `Tag` reference could pass inputs the - # `TagifiedTag` no longer accepts), so pyright flags each override with - # `reportIncompatibleMethodOverride`. The suppression is deliberate: in - # this codebase, `TagifiedTag` is only ever obtained via `.tagify()`, - # never by upcasting a pre-existing `Tag` reference, so the LSP failure - # mode does not occur in practice. + # 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`. def __init__( self, From 032e3d97a596210f214193bab46237792047b0c5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 15:04:46 -0400 Subject: [PATCH 25/26] docs(_core): note Liskov-clean alternative if pyright recursive-alias improves --- htmltools/_core.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/htmltools/_core.py b/htmltools/_core.py index d8c175b..53b73f6 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -684,6 +684,16 @@ class TagifiedTagList(TagList["TagifiedNode"]): # 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 @@ -1202,6 +1212,16 @@ class TagifiedTag(Tag["TagifiedNode"]): # 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, From c8cdd1ad530321de960b4bedf93942acedd85850 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 19 May 2026 15:12:54 -0400 Subject: [PATCH 26/26] test(types): add canary for LSP-narrowing override suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `_LSPNarrowingCanary` to tests/test_types.py that replicates the contravariant-narrowing override pattern from `TagifiedTagList` / `TagifiedTag` in `_core.py`, with the same `# pyright: ignore[reportIncompatibleMethodOverride]` suppressions. File-level pyright config enables `reportUnnecessaryTypeIgnoreComment= error` and `reportIncompatibleMethodOverride=error`. The first makes all `# pyright: ignore` comments in the file self-validating; the second is needed because the rule defaults to "none" in basic mode when checking cross-module overrides. Together they form a tripwire: if pyright ever stops considering input-narrowing an LSP violation, the canary's `# pyright: ignore` becomes unused and CI fails — that's the signal to revisit the suppression in `_core.py` (and possibly switch to the `TagChild[TagNodeT]` parameterized-parent approach from #105). Also retires a related stale `# pyright: ignore[reportAssignmentType]` in `test_user_tagify_returning_bare_TagList_violates_Tagifiable`: the widening of `Tagified` to include `Sequence[Tagified]` made `TagList` structurally compatible with the `Tagifiable` protocol (`TagList` is a `Sequence`), so the assignment no longer errors. Renamed the test to `..._is_structurally_Tagifiable` and updated its docstring to document the observed leniency. --- tests/test_types.py | 85 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/tests/test_types.py b/tests/test_types.py index ac43b9b..82d120e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,4 @@ +# pyright: reportUnnecessaryTypeIgnoreComment=error, reportIncompatibleMethodOverride=error """ Static-type assertions for the tagified type contract. @@ -6,11 +7,17 @@ but produce type errors if the inferred types are wrong. Lines marked `# pyright: ignore[]` are *intentional* — they assert that -pyright would refuse the indicated assignment. +pyright would refuse the indicated assignment. The file-level +`reportUnnecessaryTypeIgnoreComment=error` setting above makes the assertions +self-checking: if pyright ever stops flagging a rule we expect, the now- +unused `# pyright: ignore` fails CI, signaling the typing landscape has +shifted and the suppression in `htmltools._core` may be removable. """ from __future__ import annotations +from collections.abc import Iterable + from typing_extensions import assert_type from htmltools import ( @@ -21,7 +28,13 @@ TagNode, div, ) -from htmltools._core import TagifiedTag, TagifiedTagList +from htmltools._core import ( + TagChild, + TagifiedChild, + TagifiedNode, # noqa: F401 — used in `class _LSPNarrowingCanary(TagList["TagifiedNode"])`'s string-quoted base + TagifiedTag, + TagifiedTagList, +) def test_tag_tagify_returns_TagifiedTag() -> None: @@ -136,15 +149,22 @@ def tagify(self) -> Tagified: tl.append(_OkTagifiable()) -def test_user_tagify_returning_bare_TagList_violates_Tagifiable() -> None: +def test_user_tagify_returning_bare_TagList_is_structurally_Tagifiable() -> None: + # Returning a bare `TagList` (instead of `TagifiedTagList`) from + # `.tagify()` is a contract violation — `Tagified` excludes bare + # `TagList` and `TagList` may carry un-tagified content. The + # runtime boundary in `TagList.tagify()` raises a clear `TypeError` + # when this happens. **Pyright does not currently catch the + # violation at the protocol assignment site** — `TagList` is a + # `Sequence`-like, and `Tagified`'s widened shape (which now + # includes `Sequence[Tagified]`) makes pyright accept the + # structural match. Documenting the observed leniency here; the + # runtime guard is the load-bearing check. class _Bad: - # Bare TagList annotation means TagList[TagNode], which is wider than - # TagifiedTagList. So this class is NOT structurally a Tagifiable - # under the new (tightened) protocol. def tagify(self) -> TagList: return TagList("x") - _: Tagifiable = _Bad() # pyright: ignore[reportAssignmentType] + _: Tagifiable = _Bad() def test_user_tagify_returning_TagifiedTagList_is_Tagifiable() -> None: @@ -205,3 +225,54 @@ def f_explicit_tag(t: Tag[TagNode]) -> str: f_bare_tag(tagified) f_explicit_tag(tagified) _: Tag[TagNode] = tagified + + +## ---------------------------------------------------------------------------- +## Canary for the Liskov-violation suppression in `htmltools._core`. +## ---------------------------------------------------------------------------- +## `TagifiedTagList` / `TagifiedTag` narrow their mutators' input from `TagChild` +## to `TagifiedChild` — pyright flags each override with +## `reportIncompatibleMethodOverride` because narrowing input is contravariantly +## LSP-unsafe. We suppress those in `_core.py` because the violating scenario +## doesn't occur in this codebase. +## +## The class below replicates the override pattern at module scope (pyright +## checks `reportIncompatibleMethodOverride` differently for nested classes — +## verified empirically) and carries the same suppression. The file-level +## `reportUnnecessaryTypeIgnoreComment=error` setting means: if pyright ever +## stops flagging input-narrowing this way (because the typing community +## settles on a different rule, or pyright's handling of recursive generics +## improves enough that the `TagChild[TagNodeT]` parameterized-parent approach +## from #105 becomes viable), the `# pyright: ignore` lines below become +## unnecessary and CI fails. That's the signal to revisit the suppression in +## `_core.py` and the rationale comments next to it. +class _LSPNarrowingCanary(TagList["TagifiedNode"]): + def append( # pyright: ignore[reportIncompatibleMethodOverride] + self, item: TagifiedChild, *args: TagifiedChild + ) -> None: + from typing import cast + + super().append( + cast("TagChild", item), + *cast("tuple[TagChild, ...]", args), + ) + + def extend( # pyright: ignore[reportIncompatibleMethodOverride] + self, other: Iterable[TagifiedChild] + ) -> None: + from typing import cast + + super().extend(cast("Iterable[TagChild]", other)) + + +def test_LSP_narrowing_override_canary_exists() -> None: + """Runtime smoke that the canary class is importable / instantiable. + + The static-type assertion is the file-level + `reportUnnecessaryTypeIgnoreComment=error` plus the `# pyright: ignore` + lines on `_LSPNarrowingCanary` above — this runtime body just exists so + pytest exercises the class. + """ + canary = _LSPNarrowingCanary() + canary.append("ok") + assert list(canary) == ["ok"]