Skip to content

feat(types): TagifiedTagList/TagifiedTag as real subclasses (#116)#118

Closed
schloerke wants to merge 26 commits into
mainfrom
schloerke/fix-issue-116
Closed

feat(types): TagifiedTagList/TagifiedTag as real subclasses (#116)#118
schloerke wants to merge 26 commits into
mainfrom
schloerke/fix-issue-116

Conversation

@schloerke
Copy link
Copy Markdown
Collaborator

@schloerke schloerke commented May 19, 2026

Summary

  • TagifiedTagList / TagifiedTag are real subclasses now. Replaces TagifiedTagList = TypeAliasType(...) with class TagifiedTagList(TagList["TagifiedNode"]). Adds parallel class TagifiedTag(Tag["TagifiedNode"]). Both are runtime-isinstance-checkable and are constructed (not cast) by TagList.tagify() / Tag.tagify() / JSXTag.tagify(). The classes stay internal to htmltools._core — they are NOT re-exported from the top-level htmltools namespace. Code that wants to isinstance-check imports them explicitly from htmltools._core.
  • Narrow-signature mutator overrides on both subclasses. __init__ / append / extend / insert take Tagified (a non-generic union that excludes the un-resolved Tagifiable arm of TagChild). Pyright rejects un-tagified inputs to tagified containers at the call site. Subsumes Static input enforcement on TagList[TagifiedNode] via self-typed overloads #115 — the parallel session exploring Self-typed overloads was abandoned because pyright couldn't make that approach work; this PR closes the same static-input gap via the subclass overrides instead.
  • Tagified collapses what was previously Tagified + TagifiedChild into one alias. Now mirrors TagChild's shape (excludes Tagifiable, includes the flattening conveniences float, None, Sequence[Tagified]). Custom .tagify() implementations may return any of those shapes.
  • Closes TagList.tagify() doesn't normalize child.tagify() returns — None/float/Sequence slip past the boundary check #117 in the same PR. TagList.tagify() now routes every child's .tagify() return through _tagchilds_to_tagnodes, uniformly handling 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 crashed the render path (NoneTypeError deep in html_escape) or silently corrupted the tag tree. Without this, the widened Tagified static contract above would create a static/runtime gap.
  • Bonus fix: _equals_impl is now subclass-symmetric so TagifiedTag == Tag (and TagifiedTagList == TagList) compares structurally when __dict__s match. Caught a regression in test_taglist_tagifiable that surfaced once Tag.tagify() started returning the subclass.

Closes #116. Closes #115 (subsumed). Closes #117.

Public surface added by this PR

Just the widened semantics of Tagified (already an exported name pre-#116; now includes float/None/Sequence[Tagified] and serves as both the .tagify() return type and the input type for tagified-container mutators). The new classes TagifiedTag, TagifiedTagList, and the recursive helper alias TagifiedNode stay internal to htmltools._core. The normal authoring path (def tagify(self) -> Tagified: ...) only needs the one alias.

Migration impact (smaller than feared)

The "Open question" in #116 — whether the alias was silently relaxing variance — has an empirical answer in this PR's fixture tests: pyright accepts TagifiedTagList → TagList (bare and TagList[TagNode]) and TagifiedTag → Tag thanks to nominal-subclass + TypeVar(default=TagNode) handling. So downstream consumers of .tagify() mostly do NOT need migration. A test in tests/test_types.py locks in this observed permissive behavior so a future pyright change that flips it is caught.

The narrow-signature overrides DO surface intentional static errors at one place: appending an un-tagified Tagifiable to a TagifiedTagList / TagifiedTag. That's the deliberate gap-closing. Users who need to bypass it can # pyright: ignore and rely on the existing tagify-boundary TypeError / render-time RuntimeError as the runtime safety net.

Test plan

  • make check is clean — ruff format + pyright (0 errors) + pytest (106 passing across 11 test files)
  • Runtime smoke: TagList("hi", div()).tagify() returns a TagifiedTagList, its Tag children are TagifiedTag instances, full tree renders to HTML
  • New pyright-fixture tests verify static rejection of Tagifiable inputs to TagifiedTagList.append/extend/insert and to TagifiedTag.append/extend/insert
  • New pyright-fixture tests lock in the permissive variance behavior (TagifiedTagList → TagList, TagifiedTag → Tag) — if pyright ever flips, the fixture catches it
  • New tests for TagList.tagify() doesn't normalize child.tagify() returns — None/float/Sequence slip past the boundary check #117 (.tagify() returning None/float/Sequence is normalized at the boundary)
  • _equals_impl subclass symmetry verified by the existing test_taglist_tagifiable (now passing)
  • Verify downstream py-shiny CI does not regress against a wheel built from this branch

schloerke added 15 commits May 19, 2026 12:32
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.
…#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.
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.
…#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.
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.
…ifiedTagList (#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.
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.
…#116)

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.
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.
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.
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 turns TagifiedTagList (and new TagifiedTag) into real runtime subclasses returned by .tagify(), enabling isinstance checks and allowing narrower mutator signatures so pyright can reject un-tagified inputs to tagified containers.

Changes:

  • Replace the TagifiedTagList = TypeAliasType(...) alias with class TagifiedTagList(TagList["TagifiedNode"]), and add class TagifiedTag(Tag["TagifiedNode"]).
  • Update TagList.tagify(), Tag.tagify(), and JSXTag.tagify() to construct/return these subclasses at runtime.
  • Add TagifiedChild and narrow mutator overrides (__init__/append/extend/insert) for static input enforcement; adjust equality to be subclass-symmetric; add runtime tests and expanded pyright fixture tests.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
htmltools/_core.py Defines TagifiedChild, TagifiedTagList, TagifiedTag, updates tagify() to return real subclasses, and adjusts equality symmetry.
htmltools/_jsx.py Switches JSXTag.tagify() to return a TagifiedTag constructed directly.
htmltools/init.py Exports TagifiedChild, TagifiedTag, and TagifiedTagList as public API.
tests/test_types.py Updates/extends pyright fixture tests for the new subclass types and narrowed mutator signatures.
tests/test_tagified_subclasses.py Adds runtime isinstance/type(...) is ... assertions for the new subclasses and .tagify() returns.
CHANGELOG.md Documents the new subclasses, narrowed mutator typing, and migration implications.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread htmltools/_core.py Outdated
),
)
else:
cp[i] = tagified_child
Comment thread htmltools/_core.py
schloerke added 11 commits May 19, 2026 14:31
…fy() returns

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.
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).
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.
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.
…Child

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.
- 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.
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.
@schloerke
Copy link
Copy Markdown
Collaborator Author

Closing in favor of #120

@schloerke schloerke closed this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants