Skip to content

feat(types): tagified content as immutable sibling classes (#116)#120

Merged
schloerke merged 23 commits into
mainfrom
schloerke/issue-116-sibling-classes
May 21, 2026
Merged

feat(types): tagified content as immutable sibling classes (#116)#120
schloerke merged 23 commits into
mainfrom
schloerke/issue-116-sibling-classes

Conversation

@schloerke
Copy link
Copy Markdown
Collaborator

Summary

Why siblings (vs the #118 subclass approach)

The subclass approach in #118 needed # pyright: ignore[reportIncompatibleMethodOverride] on every narrowed mutator plus an _LSPNarrowingCanary tripwire — input narrowing in a subclass is contravariantly LSP-unsafe. With disjoint sibling classes, there is no parent contract to narrow: TagifiedTag.append doesn't exist, period. The diagnostic users get is the clean reportAttributeAccessIssue rather than an LSP-suppressed narrowing.

A generic TagChild[TagNodeT] parameterization was also attempted (preserved on branch schloerke/spike-tagnodeT-append-narrowing at 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) and TagifiedTagList(_TagListBase, Sequence[TagifiedNode]) — immutable sibling classes. No `append`/`extend`/`insert`/`setitem`/`add_class`/`enter`. `TagifiedTag.tagify()` / `TagifiedTagList.tagify()` return `self`.
  • Shared plumbing factored into `_TagBase` (Tag side) and `_TagListBase` (TagList side, used as a mixin alongside `UserList[TagNode]` / `Sequence[TagifiedNode]`).
  • `Tag.tagify()` and `TagList.tagify()` construct fresh tagified instances via `new` + field population; the boundary `TypeError` from feat: TagList.tagify() raises TypeError on un-tagified content #112 / TagList.tagify() doesn't normalize child.tagify() returns — None/float/Sequence slip past the boundary check #117 is preserved.
  • `HTMLDocument._hoist_head_content` thaws the top-level `TagifiedTag` wrapper into a buildable `Tag` for splicing the hoisted dependencies, then returns it; the caller's `.render()` re-tagifies.
  • Render-time `RuntimeError` guard kept as defense-in-depth (its primary case — mutation-after-`.tagify()` — is now structurally impossible; what remains is direct `.get_html_string()` calls on buildable trees that contain unresolved `Tagifiable`s).
  • New helper `is_tagified(x) -> TypeIs[...]` exported from the package.
  • Internal helpers `is_tag_like`, `is_taglist_like` live in `htmltools._core` but are not exported.
  • Decision doc `decisions/2026-05-20-tagified-as-classes.md` replaces the two 2026-05-18 docs.

Companion issues

Test plan

  • `make check` clean (ruff + pyright + pytest, 101 tests pass)
  • Strict-mode external probe (`# pyright: strict` consumer importing from `htmltools`) reports 0 errors and no `Sequence[Unknown]` leaks
  • New `tests/test_sibling_disjoint.py` covers isinstance disjointness, absence of mutators, slice-preserves-type, `.tagify()` idempotence on already-tagified values, and the `is_tagified` helper
  • `tests/test_types.py` static-error assertions (with `# pyright: ignore` markers) for: `TagifiedTag.append` / `TagifiedTagList.append` / `def f(t: Tag); f(div().tagify())`
  • Spot-check against py-shiny / shinychat / chatlas / brand-yml — downstream consumers may need migration for def f(t: Tag) signatures that receive tagified output

Closes

Closes #115. Closes #116. Supersedes #118.

schloerke added 20 commits May 20, 2026 13:39
…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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(), the else normalization path runs _tagchilds_to_tagnodes([tagified_child]). If tagified_child is a Sequence that contains a TagifiedTagList (allowed by the public Tagified = ... | Sequence[Tagified] contract), flatten() will not expand TagifiedTagList and the nested list can survive into new_data. That will later blow up at render time because _TagListBase.get_html_string() raises on Tagifiable children like TagifiedTagList. Suggest extending this normalization step to unwrap/flatten any TagifiedTagList occurrences inside returned sequences (or making _tagchilds_to_tagnodes/flatten understand TagifiedTagList).
            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.

Comment thread htmltools/_core.py
Comment thread htmltools/_core.py
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
…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.
@schloerke schloerke merged commit 08b2198 into main May 21, 2026
11 of 12 checks passed
@schloerke schloerke deleted the schloerke/issue-116-sibling-classes branch May 21, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate: make TagifiedTagList a real subclass instead of a type alias Static input enforcement on TagList[TagifiedNode] via self-typed overloads

2 participants