feat(types): tagified content as immutable sibling classes (#116)#120
Merged
Conversation
…ing-classes design Delete the two 2026-05-18 decision docs (tag-mutation-wide-tagchild, tagify-returns-tagified) and replace with a single 2026-05-20-tagified-as-classes.md that records the final decision (TagifiedTag/TagifiedTagList are immutable runtime classes, siblings of Tag/TagList) and the five rejected alternatives along the way. The 2026-05-18 docs were framed around TagNodeT generics on Tag/TagList. The upcoming sibling-classes refactor removes those generics entirely, so the old decisions' framing no longer applies. Rather than amend two stale docs, consolidate the type-system history in one place.
…Tag class
Prep for the sibling-classes refactor:
- TagifiedTagList TypeAliasType deleted — becomes a real class in a
follow-up commit. TagifiedNode now references the new TagifiedTag
class by forward-reference.
- Tagified rewritten to a non-generic recursive Union:
Tagified = Union[TagifiedNode, float, None, Sequence[Tagified]]
TagifiedTagList (the class, defined in a follow-up) is structurally
Sequence[TagifiedNode] and matches the recursive arm.
- TagChild's redundant "TagList" arm dropped (TagList is structurally
Tagifiable, already covered by the TagNode arm). is_tag_child's
isinstance tuple shed its parallel TagList branch.
Pyright is RED after this commit — TagifiedTag and TagifiedTagList
don't exist yet. The classes land in follow-up commits, after which
make check returns to green.
TagNodeT TypeVar kept for now; deleted when Tag/TagList lose their
generic parameter in a later commit.
_TagBase is a shared-state ABC for Tag (buildable) and TagifiedTag (rendered). It declares the common attributes (name, attrs, add_ws); the children attribute is left to subclasses to declare with their concrete TagList / TagifiedTagList type. TagifiedTag is an immutable sibling of Tag: - No append/extend/insert mutators. - No add_class/remove_class/has_class (build-time only). - No __enter__/__exit__ (build-time only). - tagify() returns self (already in final form). - Render/equality/repr methods duplicate Tag's bodies for now; Task 10 will dedupe by extracting free helpers. The forward-reference noqa on TagifiedNode (line 129) can be removed now that TagifiedTag exists; the noqa on TagList.tagify's return type stays until Task 4 introduces TagifiedTagList. Pyright is still RED — TagifiedTagList is the remaining unresolved forward reference. Task 4 lands the class.
TagifiedTagList is an immutable sibling of TagList. Storage is an internal tuple; the public surface is the Sequence ABC plus __add__/__radd__ (which produce new instances — construction, not mutation), an idempotent tagify() that returns self, and render methods. Constructor accepts *args: Tagified and runs them through _tagchilds_to_tagnodes to normalize floats, drop Nones, and flatten nested Sequences — same pipeline TagList uses on the buildable side. This means TagifiedTagList(None, 42, ["x", "y"]) yields the same contents as TagList(None, 42, ["x", "y"]).tagify(). Render/equality/repr methods duplicate TagList's bodies for now; Task 10 dedupes by extracting free helpers. TagifiedTag.__init__'s temporary cast (added in db29da8 because TagifiedTagList didn't yet exist) is replaced with real construction. Pyright errors that remain are all about Tag/TagList still being generic on TagNodeT — Tasks 5 and 6 drop those generics.
…nstruct TagifiedTag Tag is no longer generic in TagNodeT. The class inherits state and shared render plumbing from _TagBase; build-time-only methods (add_class, __enter__/__exit__) stay on Tag. Mutators continue to accept TagChild. Tag.tagify() now constructs a fresh TagifiedTag explicitly (via __new__ + field population) rather than the copy-and-cast trick. Children are tagified by delegating to TagList.tagify(). Shared render/equality/repr methods (get_html_string, get_dependencies, save_html, render, show, __eq__, __str__, __repr__, _repr_html_) move up to _TagBase. TagifiedTag's duplicate copies (introduced in db29da8 as a Task 3 placeholder) are removed; both subclasses inherit from _TagBase. _render_tag_or_taglist and _tag_show signatures widened to cover all four concrete classes. Pyright errors that remain are about TagList still being generic on TagNodeT - Task 6 drops that.
…fy to construct TagifiedTagList TagList is now UserList[TagNode] — the element type is hardcoded and the TagNodeT generic parameter is removed. Mutators continue to accept the wide TagChild for input flexibility. TagList.tagify() constructs a fresh TagifiedTagList explicitly (via __new__ + direct _data assignment) rather than the alias-era copy-and-cast trick. When a child's .tagify() returns a TagifiedTagList, its contents are unwrapped explicitly because _util.flatten / _tagchilds_to_tagnodes don't recognize the new sibling type. The post-tagify boundary TypeError check from #117 is preserved but now validates against the sibling (TagifiedTag, TagifiedTagList) pair rather than (Tag, TagList) — returning an un-tagified Tag from .tagify() is now correctly diagnosed. TagList.{get_html_string,get_dependencies} and TagifiedTagList.{get_html_string,get_dependencies} now branch on _TagBase instead of Tag, so post-tagify TagifiedTag instances render through the same path. Without this, the loops would fall through to the bare-string arm and lose all whitespace/dependency handling. The TagNodeT TypeVar is removed entirely from _core.py — no other in-tree code referenced it. _TagBase.children annotation revisited: the reportIncompatibleVariableOverride suppressions on the two subclass __init__ assignments are still required because the subclasses narrow the union (TagList | TagifiedTagList) to a single concrete mutable attribute, which pyright treats as invariant. Suppressions retained. HTMLDocument._gen_html_tag_tree retains Tag-typed locals and casts .tagify() results back to Tag with a TODO(task-12) marker, since the helper _hoist_head_content is written against Tag's mutation API and will be rewired with the rest of HTMLDocument in Task 12. Pyright is now clean within _core.py. Remaining errors are in _jsx.py (Task 7) and tests (Tasks 8/14).
…TagifiedNode] Tag is no longer generic after the sibling-classes refactor; the Tag[TagifiedNode] alias for "fully-tagified Tag" is replaced by the concrete TagifiedTag class. JSXTag.tagify() now constructs a TagifiedTag directly (a <script> tag with JSON-encoded children that are already TagifiedNode-shaped), rather than building a Tag and casting. isinstance(x, Tag) sites in _jsx.py audited and widened to include TagifiedTag where consumption is render-time. Sites that specifically mutate (JSX construction internals) keep the narrow check.
Sweep remaining isinstance(x, Tag) / isinstance(x, TagList) sites and widen those whose intent is "any tag-shaped value" to also accept TagifiedTag / TagifiedTagList. Sites with mutator or build- time intent (context-manager paths, add_class, JSX construction internals) keep their narrow checks. Free-function parameter annotations that previously took Tag | TagList widened where the body operates render-side. HTMLDocument internals retain TODO(task-12) markers where deeper restructuring is needed. Phase B checkpoint: pyright is clean in htmltools/_core.py and htmltools/_jsx.py. Remaining errors (and 3 failing tests) are all in tests/ — Task 14 territory.
…helpers, _TagListBase mixin Three review-driven changes: 1. Tagified union lists TagifiedTagList explicitly. Even though Sequence[Tagified] covers it structurally, naming the class arm improves readability and keeps the alias parallel to its docstring. 2. isinstance(x, _TagBase) sites replaced with a new public helper is_tag_like(x: object) -> TypeIs[Tag | TagifiedTag]. Same shape for is_taglist_like covering TagList | TagifiedTagList. Both added to __all__ for export. Helpers narrow types via TypeIs and make the "either form" intent explicit at every consumer site. 3. _TagListBase mixin collapses the near-identical render plumbing on TagList and TagifiedTagList. Both classes now inherit (_TagListBase, UserList[TagNode]) and (_TagListBase, Sequence[TagifiedNode]) respectively. The mixin holds get_html_string, get_dependencies, save_html, render, show, __eq__, __str__, __repr__, _repr_html_; subclasses keep tagify, mutators (TagList only), and Sequence ABC requirements (TagifiedTagList). This subsumes the original Task 10 (free-fn extraction) — the reviewer's preference is the shared base class.
1. TagifiedNode mirrors TagNode's structure. TagifiedTagList moves
from a top-level arm of Tagified into TagifiedNode, alongside
TagifiedTag. The structural parallel is:
TagNode = Tagifiable | TagNodeLeaf (Tag/TagList via Tagifiable)
TagifiedNode = TagifiedTag | TagifiedTagList | TagNodeLeaf
Tagified = TagifiedNode | float | None | Sequence[Tagified]
TagChild = TagNode | float | None | Sequence[TagChild]
Tagified and TagChild now have identical structural shape; the
element-type unions (TagNode / TagifiedNode) carry the buildable-vs-
tagified distinction.
2. Tag.__init__ and TagifiedTag.__init__ shared logic extracted to
_parse_tag_args. Both __init__ now: set self.name, call the
helper for self.add_ws / self.attrs / kids, then construct
self.children with their concrete TagList / TagifiedTagList type.
Tag.__init__ continues to initialize prev_displayhook
(context-manager state, build-time only).
Symmetric with is_tag_like and is_taglist_like. Lets callers ask "is this a fully-tagified container?" without going through isinstance with the concrete classes. Exported via __all__.
…keep TagifiedTag/TagifiedTagList internal Public surface decision (recorded in decisions/2026-05-20-tagified- as-classes.md "Public surface" section): TagifiedTag and TagifiedTagList stay internal to htmltools._core. Rationale: push users toward .tagify() as the canonical construction path; direct constructor calls are mainly an internal/test convenience and exposing them invites confusion about whether to build directly or tagify. The three TypeIs helpers (is_tag_like, is_taglist_like, is_tagified) give users a public runtime-distinguishability path without exposing the tagified classes themselves. Tagified (the broad union, already exported) remains the annotation type for downstream .tagify() implementations.
Per user direction, only is_tagified is publicly exported. The two *_like helpers exist in htmltools._core for internal render plumbing but are deliberately not in the package's public API surface — "is this either-form-of-Tag" is usually a smell at user-code sites; users almost always want is_tagified or to operate on a known concrete type. Decision doc updated accordingly.
With TagifiedTag/TagifiedTagList immutable, the `elif isinstance(child, Tagifiable)` branch inside TagList.get_html_string is unreachable — no path from .tagify() back to mutation exists. Delete the branch and its dedicated test (test_render_guard_catches_mutation_after_tagify). The construction-time boundary TypeError in TagList.tagify (the #117 fix) remains in place; that guard catches a child's .tagify() returning un-tagified content, which is a different protocol violation that the immutability of the result does not prevent. The stale comment cross-reference in test_types.py docstring will be cleaned up when Task 14 rewrites the surrounding test.
…} for sibling classes _gen_html_tag_tree now tagifies BEFORE handing the tree to _hoist_head_content. Tagify first matters: dependencies attached inside Tagifiables (e.g. JSXTag) only materialize during .tagify(), and the subsequent get_dependencies() walk needs to see them. _hoist_head_content's parameter type tightens to TagifiedTag. The body thaws the top-level <html> wrapper (and the <head> child) into buildable Tags via a new _thaw_top helper, so the existing mutation-based splice logic (insert <meta>, append dependency scripts) works against a freshly mutable Tag while the deeper children stay frozen TagifiedTag instances. The caller's .render() re-tagifies the result. isinstance(child, Tag) sites updated to is_tag_like, since the head child may be a TagifiedTag from .tagify().
…ility tests Failing-test fixes: - test_html_document::test_tagify_first: DelayedDep.tagify now returns div(...).tagify() to satisfy the stricter post-117 boundary contract. - test_tags::test_tagify_deep_copy: assertions adapted to TagifiedTag (not Tag) as the tagify return type. - test_tags::test_taglist_tagifiable: cross-class equality removed; compare via get_html_string() / pre-tagify both sides. - test_tags slice-assignment site: construct a new TagifiedTagList rather than mutating one. - test_tags TagList[X] subscript: strip; TagList no longer generic. - test_types::test_tag_tagify_returns_Tag_TagifiedNode: renamed and rewritten to assert_type against the concrete TagifiedTag class. - test_types::test_TagifiedTagList_append_accepts_Tagifiable: premise inverted; rewritten as a static-error assertion (or replaced by the new disjoint tests). New tests: - tests/test_sibling_disjoint.py: 10 tests locking in disjointness of Tag/TagifiedTag and TagList/TagifiedTagList, absence of mutators on the tagified side, slice-preserves-type, .tagify() on already-tagified returns self, and the is_tagified helper. - tests/test_types.py additions: static-error assertions that pyright rejects mutator calls on tagified classes and rejects TagifiedTag in a Tag-typed parameter (honest variance).
Rewrites the 0.7.0 entry to reflect the sibling-classes design that landed in this branch. Drops outdated bullets that described the generic-on-TagNodeT parameterization (since reversed) and the render-time RuntimeError (since deleted). Adds entries for: - Tag/TagList no longer generic. - TagifiedTag/TagifiedTagList as immutable sibling classes. - def f(t: Tag) variance break with migration recipes. - is_tagified() helper export. Tagified, dependencies, and other-changes entries preserved.
Revert the Task 9 deletion. The guard's primary case (mutation-after-.tagify()) is now structurally impossible thanks to immutable tagified containers, BUT the guard still catches two other cases worth defending against: 1. A caller invoking .get_html_string() directly on a buildable tree (the normal .render() path tagifies first, but a direct call bypasses that — common in tests and ad-hoc rendering). 2. Type-system bypasses (cast, __dict__ manipulation) that smuggle an un-tagified Tagifiable into a tagified container's internal storage. Error message updated to point at calling .tagify() / .render() rather than at the now-impossible mutation case. Test added (test_render_guard_catches_untagified_tagifiable) using the buildable-TagList-direct-get_html_string case — a clean trigger that doesn't require type-system bypass machinery. CHANGELOG and decision doc updated to reflect the kept-as-safety- net framing instead of the prior "deleted" framing.
The previous 0.7.0 section contained bullets that described the journey through the refactor rather than the net effect on a v0.6.1 user. Audit findings: - "Tag and TagList are no longer generic" dropped: they weren't generic in v0.6.1 either; the intermediate generic parameterization (#105's Tag[X]/TagList[X]) never shipped, so the migration recipe applied to zero users. - "TagifiedTag/TagifiedTagList are immutable" reframed as "the result of .tagify() is immutable" — those class names are internal-only (not exported), so users see the immutability through .tagify() return values, not as named classes. - "Render-time RuntimeError kept as defense-in-depth" reframed: the guard existed in v0.6.1; only the message changed. - "Tag.tagify() returns TagifiedTag — sibling, not subclass" tightened: the user-visible point is that the return is no longer a Tag, period. The class name is incidental. - Tagified alias, is_tagified helper, TypeError boundary, and typing-extensions floor bump kept (all true relative to v0.6.1). Net: 0.7.0 section now describes the actual differences between v0.6.1 and the upcoming release rather than the in-progress shape of the refactor.
Fix "from of" typo and tighten the breaking-changes prose for the 0.7.0 section. Each bullet now reads as a single coherent paragraph without the inconsistent line wrapping the previous pass left behind. is_tagified bullet leads with the symbol name and keeps the TypeIs note as a short follow-up sentence — matches the cadence of the "Exported is_tag_node()/is_tag_child()" bullet under 0.6.0.
7 tasks
There was a problem hiding this comment.
Pull request overview
This PR refactors the “tagified” runtime model so .tagify() returns immutable sibling classes (TagifiedTag / TagifiedTagList) rather than subclasses/aliases of Tag / TagList, and adds the public is_tagified() helper for runtime/type-narrowing checks.
Changes:
- Introduces internal immutable sibling runtime classes and shared plumbing (
_TagBase,_TagListBase) to support rendering/equality across buildable vs tagified forms. - Updates JSX and HTMLDocument paths to work with sibling classes (including thawing during head-hoisting).
- Updates and adds tests (including new
test_sibling_disjoint.py) and documentation/CHANGELOG to lock in the new invariants.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
htmltools/_core.py |
Core implementation of sibling tagified classes, new is_tagified/is_tag_like helpers, and updated tagify/render plumbing. |
htmltools/_jsx.py |
Updates JSXTag tagging/render logic to account for TagifiedTag and shared tag-like narrowing. |
htmltools/__init__.py |
Exports new is_tagified helper from the public package namespace. |
tests/test_types.py |
Updates static-type assertions for sibling-class disjointness and lack of mutators on tagified values. |
tests/test_tags.py |
Adjusts runtime tests for immutability/deep-copy semantics and sibling-class equality behavior. |
tests/test_tagify.py |
Updates render-time guard test to focus on direct .get_html_string() calls on buildable trees with Tagifiables. |
tests/test_sibling_disjoint.py |
New tests locking in sibling-class invariants (disjointness, immutability, slice behavior, idempotent .tagify(), is_tagified). |
tests/test_html_document.py |
Aligns a fixture Tagifiable .tagify() implementation to return fully-tagified output. |
decisions/2026-05-20-tagified-as-classes.md |
New decision record explaining the sibling-class approach and rejected alternatives. |
decisions/2026-05-18-tagify-returns-tagified.md |
Removed (superseded by the new decision). |
decisions/2026-05-18-tag-mutation-wide-tagchild.md |
Removed (superseded by the new decision). |
CHANGELOG.md |
Documents breaking changes, new Tagified alias semantics, immutability, and new is_tagified(). |
Comments suppressed due to low confidence (1)
htmltools/_core.py:636
- In
TagList.tagify(), theelsenormalization path runs_tagchilds_to_tagnodes([tagified_child]). Iftagified_childis aSequencethat contains aTagifiedTagList(allowed by the publicTagified = ... | Sequence[Tagified]contract),flatten()will not expandTagifiedTagListand the nested list can survive intonew_data. That will later blow up at render time because_TagListBase.get_html_string()raises onTagifiablechildren likeTagifiedTagList. Suggest extending this normalization step to unwrap/flatten anyTagifiedTagListoccurrences inside returned sequences (or making_tagchilds_to_tagnodes/flattenunderstandTagifiedTagList).
if isinstance(child, Tagifiable):
tagified_child = child.tagify()
# A .tagify() may return a TagList or TagifiedTagList; both
# should be flattened into this list. _tagchilds_to_tagnodes
# / flatten understand TagList but not the new sibling
# TagifiedTagList, so unwrap it to its contents before
# passing through.
if isinstance(tagified_child, TagifiedTagList):
items_to_insert: list[Any] = list(tagified_child)
else:
items_to_insert = _tagchilds_to_tagnodes(
cast("Iterable[TagChild]", [tagified_child])
)
new_data[i : i + 1] = items_to_insert
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Two threads (PRRT_kwDOF6n1D86Dm2il, PRRT_kwDOF6n1D86Dm2jH): 1. TagifiedNode includes TagifiedTagList for parity with TagNode's structural inclusion of TagList via Tagifiable. Type-level parity was correct, but the flatten/normalize pipeline only knew about TagList — a TagifiedTagList nested inside a returned Sequence could survive into a child slot and crash render. Extend `_flatten_recurse` to recognize TagifiedTagList alongside list, tuple, and TagList. Drop the inline TagifiedTagList-unwrap that Task 6 added in TagList.tagify (now redundant since flatten handles it natively). 2. TagifiedTagList.__add__ / __radd__ splat *item into the constructor. If item is a str, str's Iterable nature would iterate it character-by-character. Mirror TagList's str guard so `ttl + "bar"` adds "bar" as one child, not 'b','a','r'. Tests added in tests/test_sibling_disjoint.py: - test_TagifiedTagList_add_str_not_iterated - test_tagify_flattens_TagifiedTagList_returned_by_child
This was referenced May 20, 2026
Merged
…forms) has_class is a read-only query — it inspects whether a class is present in the tag's "class" attribute and doesn't mutate. Earlier I parked it next to add_class/remove_class on Tag, but that left TagifiedTag without it, which forces downstream code to either (a) re-tagify back to Tag before querying, or (b) duck-type via attrs lookup. Both awkward. Move has_class to _TagBase so both Tag and TagifiedTag inherit it. add_class / remove_class stay on Tag (they mutate). Update the test_TagifiedTag_has_no_buildtime_helpers assertion to expect has_class to be present on a tagified value.
Reverses the earlier "keep internal" decision. Realistic downstream code that walks a tagified tree (e.g., py-shiny's sidebar tests) needs to isinstance-check the elements, and forcing those sites to import from htmltools._core (or to duck-type via `is_tagified` + hasattr) is strictly worse than exporting the class names. The construction-confusion concern that originally motivated keeping them internal is adequately addressed by documenting `.tagify()` as the canonical idiom — exposing the type names doesn't change the construction path. The constructors already narrow input via the `Tagified` type, so direct instantiation isn't a footgun beyond what `.tagify()` already enables. is_tag_like / is_taglist_like stay internal — those exist for the rendering plumbing's "either-form-of-Tag" branching, which is a smell at user-code sites. Users wanting that surface should use is_tagified or isinstance against the now-exported sibling classes. Spec, plan, decision doc, and CHANGELOG updated to reflect.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tag.tagify()/TagList.tagify()now return immutable, sibling runtime types (not subclasses ofTag/TagList). The sibling classes are internal-only; users see them through.tagify()return values and the newis_tagified(x)helper.Tagifiedis added as a public type alias — the union of fully-tagified shapes — for use as the return annotation of custom.tagify()implementations.TagList[TagifiedNode]viaself-typed overloads #115 and Investigate: makeTagifiedTagLista real subclass instead of a type alias #116.Why siblings (vs the #118 subclass approach)
The subclass approach in #118 needed
# pyright: ignore[reportIncompatibleMethodOverride]on every narrowed mutator plus an_LSPNarrowingCanarytripwire — input narrowing in a subclass is contravariantly LSP-unsafe. With disjoint sibling classes, there is no parent contract to narrow:TagifiedTag.appenddoesn't exist, period. The diagnostic users get is the cleanreportAttributeAccessIssuerather than an LSP-suppressed narrowing.A generic
TagChild[TagNodeT]parameterization was also attempted (preserved on branchschloerke/spike-tagnodeT-append-narrowingat commit `79a266b`). Rejected because pyright 1.1.409 leaks `Sequence[Unknown]` across module boundaries when a recursive `TypeAliasType` is used in strict-mode downstream code — the same failure that motivated #105's choice of a non-generic `TagChild` in the first place. The PEP 695 `type` syntax handles this correctly but requires Python 3.12+.The full chain of design considerations (5 rejected alternatives + the final pick) is recorded in
decisions/2026-05-20-tagified-as-classes.md.What's in this PR
TagifiedTag(_TagBase)andTagifiedTagList(_TagListBase, Sequence[TagifiedNode])— immutable sibling classes. No `append`/`extend`/`insert`/`setitem`/`add_class`/`enter`. `TagifiedTag.tagify()` / `TagifiedTagList.tagify()` return `self`.Companion issues
Test plan
def f(t: Tag)signatures that receive tagified outputCloses
Closes #115. Closes #116. Supersedes #118.