From e3de1483127ee0a2d6c9d1e4ad4724399ba69623 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 20 May 2026 13:39:15 -0400 Subject: [PATCH 01/23] docs(decisions): supersede 2026-05-18 type-system decisions with sibling-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. --- .../2026-05-18-tag-mutation-wide-tagchild.md | 82 -------------- .../2026-05-18-tagify-returns-tagified.md | 90 ---------------- decisions/2026-05-20-tagified-as-classes.md | 100 ++++++++++++++++++ 3 files changed, 100 insertions(+), 172 deletions(-) delete mode 100644 decisions/2026-05-18-tag-mutation-wide-tagchild.md delete mode 100644 decisions/2026-05-18-tagify-returns-tagified.md create mode 100644 decisions/2026-05-20-tagified-as-classes.md diff --git a/decisions/2026-05-18-tag-mutation-wide-tagchild.md b/decisions/2026-05-18-tag-mutation-wide-tagchild.md deleted file mode 100644 index c255b94..0000000 --- a/decisions/2026-05-18-tag-mutation-wide-tagchild.md +++ /dev/null @@ -1,82 +0,0 @@ -# Tag / TagList mutation methods accept wide `TagChild`, not `TagNodeT` - -- **Date:** 2026-05-18 -- **Context:** PR posit-dev/py-htmltools#106 (issue #105) — introduced the - Tagified type system: `Tag` and `TagList` are generic in their child - type `TagNodeT` (default `TagNode`), and `Tagifiable.tagify()` returns - the tighter `Tagified` union. -- **Status:** Accepted - -## Decision - -`Tag.append` / `Tag.insert` / `Tag.extend` / `TagList.append` / -`TagList.insert` / `TagList.extend` / `TagList.__add__` / -`TagList.__radd__` all keep their parameter type as the wide -`TagChild`, even though `Tag` and `TagList` are generic in `TagNodeT`. - -The obvious-looking alternative — narrowing mutation to `TagNodeT` — is -rejected. - -## Why not `TagNodeT`? - -`TagChild` is **not** just the element type. It is the recursive -sequence-flattening alias: - -```python -TagChild = Union[ - Tag, TagList, Tagifiable, MetadataNode, ReprHtml, - str, HTML, int, float, None, - Sequence["TagChild"], # <-- the flattening arm -] -``` - -The `Sequence["TagChild"]` arm is what lets every caller write: - -```python -tag.append([a, b, [c, d]]) # nested lists flatten -tl.extend([[x, y], z]) # mixed flat + nested -TagList(a, [b, c], d) # constructor too -``` - -`_tagchilds_to_tagnodes` walks the structure inside each mutation and -returns a flat `list[TagNode]`. Narrowing `append(x: TagNodeT)` would -type-reject every nested-list mutation, including in the *default* -`Tag[TagNode]` case, which is the call site for ~all existing user -code. - -## Why doesn't narrowing buy us static safety either? - -The hoped-for win of narrowing — statically rejecting -`Tag[TagifiedNode].append(some_tagifiable)` — collapses for the common -case: - -- Default `Tag` is `Tag[TagNode]`. -- `TagNode = Tagifiable | TagifiedNode`. -- So `TagNodeT = TagNode` still includes `Tagifiable`. - -Narrowing only changes behavior for the rare `Tag[TagifiedNode]` / -`TagList[TagifiedNode]` case (i.e. a post-`.tagify()` reference being -mutated). That case is already covered by a runtime boundary check -inside `TagList.tagify()`, which raises `TypeError` naming the -offending class and slot index. Better diagnostics than a static error -pointing at a single `append` call. - -## Trade-off - -`TagList[TagifiedNode].append(some_tagifiable)` no longer static-errors. -We accept this and document the trade-off in -`tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable` -(read that test's docstring for the full rationale and the conditions -under which we'd reverse this decision). - -Pyright currently accepts these signatures cleanly, so no -`# pyright: ignore` comments are needed at the mutation sites. If -some future pyright version starts flagging them as -`reportArgumentType` again, the conventional fix is to silence each -site with `# pyright: ignore[reportArgumentType]` rather than reverse -this decision. - -## Related - -- `2026-05-18-tagify-returns-tagified.md` -- `tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable` diff --git a/decisions/2026-05-18-tagify-returns-tagified.md b/decisions/2026-05-18-tagify-returns-tagified.md deleted file mode 100644 index e4ecfce..0000000 --- a/decisions/2026-05-18-tagify-returns-tagified.md +++ /dev/null @@ -1,90 +0,0 @@ -# `Tagifiable.tagify()` returns `Tagified`, not a narrower specific type - -- **Date:** 2026-05-18 -- **Context:** PR posit-dev/py-htmltools#106 (issue #105) introduced the - Tagified type system, then needed downstream rollout to `shinychat`, - `chatlas`, `brand-yml`, and `py-shiny`. Each downstream had to choose - an annotation for its custom `.tagify()` methods. -- **Status:** Accepted - -## Decision - -The `Tagifiable` protocol's return type is `Tagified`, the broad union -of all post-`.tagify()` shapes: - -```python -Tagified = TypeAliasType( - "Tagified", - "Tag[TagifiedNode] | TagList[TagifiedNode] | TagLeaf", -) -``` - -Downstream `.tagify()` implementations annotate `-> Tagified`. The -narrower `TagifiedNode`, `TagifiedTagList`, and `TagNodeLeaf` aliases -live in `htmltools._core` for internal use only — they are -deliberately **not** exported from `htmltools`. - -## Why `Tagified` instead of the narrower internal aliases? - -### Single concept to learn - -Every downstream `.tagify()` author writes the same annotation. The -contract is "I return something fully tagified" — they don't need to -classify whether their implementation returns a `Tag`, a `TagList`, or -a leaf. The protocol stays uniform. - -### `.tagify()` results are immediately serialized, not branched on - -Surveying actual consumers (across `htmltools`, `shiny`, `shinychat`, -`chatlas`, `brand-yml`): - -- Internal: `TagList.tagify()` recurses; `get_html_string` / `render` / - `save_html` walk the result for HTML serialization. -- External: `_repr_html_`-style shims call `str(self.tagify())`. -- Shiny: `App.__init__`'s `ui` arg is fed straight back into htmltools - for rendering. - -None of these consumers branch on which arm of the union came back. -The one observed exception is `tests/pytest/test_sidebar.py`, which -unpacks `sb.tagify()` as a 2-tuple; that single site casts to `TagList` -before unpacking. Cheap. - -### Narrow types caused more pyright noise than they prevented - -During the implementation we explored returning the narrow internal -shapes directly (e.g. `Tag[TagifiedNode]` / `TagList[TagifiedNode]`). -The narrower returns surfaced `Tag[Unknown]` / `TagList[Unknown]` -leaks in downstream pyright runs (notably py-shiny's accordion / -navset / sidebar / card paths), each of which required its own `cast` -to widen back. The bookkeeping cost exceeded the static-safety win. - -### The exhaustively-tagified contract is what matters - -`Tagified` excludes the `Tagifiable` arm of `TagNode`. That is the -property `.tagify()` actually promises: the returned tree contains no -un-resolved `Tagifiable` objects. Whether the root is a `Tag`, a -`TagList`, or a leaf is a structural detail; the "fully tagified" -guarantee is the same. - -## Trade-off - -Consumers that *do* need a specific arm (rare — currently only test -code) must `cast` or `isinstance`-narrow. Acceptable: it's one line at -each site, and `isinstance` is what we already do for runtime safety. - -## Affected packages - -This decision was applied to: - -- `htmltools.Tagifiable.tagify` (the protocol) -- `htmltools._jsx.JSXTag.tagify` (narrow `Tag[TagifiedNode]` retained - — it's internal, not part of the downstream-author API) -- `shinychat._chat_bookmark`, `shinychat._chat_normalize_chatlas` -- `chatlas._content` (three `tagify` methods) -- `brand_yml.logo` (two `Logo.tagify` methods) -- `shiny.ui._accordion.AccordionPanel.tagify`, `_card.CardItem.tagify`, - `_sidebar.Sidebar.tagify`, `_navs.NavSet.tagify` - -## Related - -- `2026-05-18-tag-mutation-wide-tagchild.md` diff --git a/decisions/2026-05-20-tagified-as-classes.md b/decisions/2026-05-20-tagified-as-classes.md new file mode 100644 index 0000000..516c780 --- /dev/null +++ b/decisions/2026-05-20-tagified-as-classes.md @@ -0,0 +1,100 @@ +# `TagifiedTag` and `TagifiedTagList` are immutable classes, siblings of `Tag` / `TagList` + +- **Date:** 2026-05-20 +- **Context:** Issue posit-dev/py-htmltools#116. Several earlier attempts (the alias form on `main`, the subclass-overrides form in PR #118, and a generic-`TagChild[TagNodeT]` spike preserved on branch `schloerke/spike-tagnodeT-append-narrowing`) each had blocking flaws. This decision records the final design and explains why each alternative was rejected. +- **Status:** Accepted + +This decision supersedes two earlier accepted decisions from 2026-05-18 (`tag-mutation-wide-tagchild.md` and `tagify-returns-tagified.md`), both of which were framed around `TagNodeT` generics on `Tag`/`TagList`. Those generics are removed by this refactor; their conclusions no longer apply. + +## Decision + +`TagifiedTag` and `TagifiedTagList` are **immutable runtime classes**, modeled as **siblings of**, not subclasses of, `Tag` / `TagList`. `Tag` and `TagList` are no longer generic in an element type — `TagNodeT` is removed. + +``` + UserList[TagNode] Sequence[TagifiedNode] + │ │ + ▼ ▼ + TagList TagifiedTagList + (mutable, mutators take (immutable, no mutators, + TagChild) tuple storage) + .tagify() ─────▶ TagifiedTagList + + _TagBase (shared render plumbing) + ┌─────────────┴──────────────┐ + ▼ ▼ + Tag TagifiedTag + (mutable, mutators (immutable, no mutators, + take TagChild, no add_class, no + has add_class, ctx-mgr) __enter__/__exit__) + .tagify() ──────▶ TagifiedTag +``` + +`.tagify()` on `Tag` / `TagList` constructs a new sibling instance. `.tagify()` on `TagifiedTag` / `TagifiedTagList` returns `self`. + +`Tagifiable.tagify()` still returns the broad `Tagified` union — downstream `.tagify()` implementations annotate `-> Tagified`. The *shape* of `Tagified` changes: + +```python +TagifiedNode = Union[TagifiedTag, TagNodeLeaf] +Tagified = Union[TagifiedNode, float, None, Sequence[Tagified]] +``` + +Non-generic, recursive `Union` — the form pyright handles cleanly cross-module. `TagifiedTagList` is structurally `Sequence[TagifiedNode] <: Sequence[Tagified]` and matches the recursive arm. + +## Rejected alternatives + +### 1. Alias-only (the `main`-branch state before #116) + +`TagifiedTag` and `TagifiedTagList` are `TypeAliasType`s for `Tag[TagifiedNode]` / `TagList[TagifiedNode]`. + +**Why rejected:** No runtime distinguishability. `isinstance(x, TagifiedTag)` doesn't work — the alias dissolves into the underlying generic, and `isinstance(x, TagList)` is `True` for both buildable and tagified containers. `.append(some_tagifiable)` on a tagified container is silent at type-check time. The static-input gap of #115/#116 is wide open. + +### 2. Subclasses with input-narrowed mutator overrides (PR #118 approach) + +Real subclasses (`TagifiedTag(Tag["TagifiedNode"])`), with `.append/.extend/.insert` overridden to a narrow `Tagified`-only signature. + +**Why rejected:** Input narrowing in a subclass is contravariantly LSP-unsafe — pyright flags every override with `reportIncompatibleMethodOverride`. Each override needs a `# pyright: ignore` suppression, plus an `_LSPNarrowingCanary` tripwire test to detect future pyright behavior changes that would invalidate the suppression. Source-cost of the suppressions and canary outweighs the win; the architecture is harder to read than the result. + +### 3. Generic `TagChild[TagNodeT]` recursive alias + +Promote `TagChild` from a non-generic Union to a generic `TypeAliasType` parameterized on `TagNodeT` so mutator input narrows by substitution. Attempted in spike `schloerke/spike-tagnodeT-append-narrowing` commit `79a266b`. + +**Why rejected:** Pyright 1.1.409 has a cross-module bug — the recursive `Sequence[TagChild[TagNodeT]]` arm renders as `Sequence[Unknown]` when an external module imports the alias in strict mode. This is the same failure that motivated #105's choice of a non-generic `TagChild` originally. It triggers thousands of `reportUnknownMemberType` errors in downstream strict-mode CI (e.g. Shiny). Verified by reproducing the leak in a 30-line cross-module test fixture during the spike. + +### 4. PEP 695 `type` syntax instead of `TypeAliasType` + +Same shape as (3) but using `type TagChild[T] = T | float | None | Sequence[TagChild[T]]`. Pyright handles this correctly cross-module — verified. + +**Why rejected:** PEP 695 `type` syntax requires Python 3.12+. htmltools supports Python 3.10+. Out of scope until the minimum-Python bump. + +### 5. Mutable tagified containers with narrow mutator signatures + +The sibling design but with `.append(item: Tagified)` on the tagified side — narrow input, no LSP question (no parent contract to narrow), no `TagNodeT` generics. + +**Why rejected:** "Tagified" semantically means frozen-final-shape. Allowing mutation undermines the invariant and requires a render-time `RuntimeError` guard to catch mutation-after-`.tagify()`. Going immutable eliminates the guard, the mutator signatures, and the question of what to do when `tagified.append(some_tag)` is called — it becomes a categorical `AttributeError`, which is the right answer. "Method doesn't exist" is a stronger guarantee than "method narrows input". + +## Why the chosen design wins + +- **Disjoint runtime types.** `isinstance(x, Tag)` and `isinstance(x, TagifiedTag)` are mutually exclusive. `Tag` reliably means "buildable"; `TagifiedTag` reliably means "rendered". The two-step `isinstance(x, Tag) and not isinstance(x, TagifiedTag)` dance that alternative 1 forced disappears. +- **No LSP question** — no parent contract to violate. +- **No `TagNodeT`** → no recursive `TypeAliasType` → no cross-module pyright leak. +- **Static "no mutators" is stronger than "narrow mutators"** — categorical, not signature-dependent. Pyright reports `reportAttributeAccessIssue` (cleaner diagnostic) instead of `reportArgumentType`. +- **Render-time `RuntimeError` guard becomes dead code** and is deleted along with mutators. Mutation-after-`.tagify()` is structurally impossible. +- **Boundary `TypeError` in `TagList.tagify()` stays** — that one guards the *construction* contract (a child's `.tagify()` returning un-tagified content), not mutation. + +## Cost accepted + +`def f(t: Tag)` accepting a `tagified` value is now a real static type error. (It was permissive under PR #118's subclass form — pyright treated the `TagifiedTag(Tag["TagifiedNode"])` flow into `Tag` as assignable. With siblings, it's a clean rejection.) This is the variance break that issue #116 originally documented as the "intentional cost" of distinguishing the two kinds at the type level. + +Downstream fix recipes (also in `CHANGELOG.md` for 0.7.0): + +- **Widen the parameter:** `def f(t: Tag | TagifiedTag): ...` — minimal and explicit. Best for short, render-only signatures. +- **Use a Protocol or the shared `_TagBase`** if the function only needs render-time methods. +- **Cast at the call site:** `cast("Tag", tagified)` — escape hatch for one-off mismatches. + +## Related + +- Issue #115 — https://github.com/posit-dev/py-htmltools/issues/115 (Self-typed overload alternative, abandoned) +- Issue #116 — https://github.com/posit-dev/py-htmltools/issues/116 +- Issue #105 — https://github.com/posit-dev/py-htmltools/issues/105 (original Tagified type system; documented the `Sequence[Unknown]` leak) +- PR #118 — https://github.com/posit-dev/py-htmltools/pull/118 (subclass-overrides approach, superseded by this decision) +- Spike branch `schloerke/spike-tagnodeT-append-narrowing` commit `79a266b` — generic-`TagChild` attempt with documented cross-module leak. From f84b42320a18275acb4759377537f78e9965703d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 20 May 2026 13:42:24 -0400 Subject: [PATCH 02/23] refactor(_core): non-generic recursive Tagified; forward-ref TagifiedTag class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- htmltools/_core.py | 46 ++++++++++++---------------------------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index cefc0ee..d996378 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, @@ -110,22 +110,6 @@ class MetadataNode: unnamed arguments to Tag functions like `div()`. """ -# ----------------------------------------------------------------------------- -# Tagified shape aliases -# ----------------------------------------------------------------------------- -# `TagifiedTagList` uses `TypeAliasType` so that the alias *name* -# survives in pyright diagnostics (users see `TagifiedTagList` rather -# than the expanded structural union) and so its forward reference to -# `TagifiedNode` (defined later in the file) is resolved lazily. -# (Contrast `Tagified` below, which is a plain `Union` because -# `TypeAliasType` over its recursive arm leaks `Unknown` through -# downstream pyright analysis — see the comment above that definition.) -TagifiedTagList = TypeAliasType("TagifiedTagList", "TagList[TagifiedNode]") -""" -A `TagList` whose items are all tagified. This is the return type of -`TagList.tagify()`. -""" - # Kept as a plain `Union` (not `TypeAliasType`) so the arms are visible # in pyright diagnostics — a value typed as `TagNodeLeaf` shows up as # `MetadataNode | ReprHtml | str | HTML` directly instead of as an @@ -142,22 +126,23 @@ 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] # noqa: F821 """ -A fully-tagified child-slot type. Members never include an un-resolved -`Tagifiable`; calling `.tagify()` on a node tree returns a structure whose -slot items are all `TagifiedNode`. +A fully-tagified child-slot type. References the `TagifiedTag` class by +forward reference (defined below). Calling `.tagify()` on a node tree +returns a structure whose slot items are all `TagifiedNode`. """ # Kept as a plain `Union` (not `TypeAliasType`) because pyright's # 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[TagifiedNode, float, None, Sequence["Tagified"]] """ -Anything `.tagify()` is permitted to return: either a top-level -`TagifiedTagList`, or one of the `TagifiedNode` shapes (a fully-tagified -`Tag` or a leaf). +Anything `.tagify()` is permitted to return: a fully-tagified node, a +numeric/None leaf, or a recursive sequence thereof. `TagifiedTagList` +(defined below) is structurally `Sequence[TagifiedNode]` and therefore +matches the recursive `Sequence[Tagified]` arm — no explicit arm needed. """ @@ -197,13 +182,7 @@ class MetadataNode: # still catches it at render time. See # `tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable` # for the full rationale. -TagChild = Union[ - TagNode, - "TagList", - float, - None, - Sequence["TagChild"], -] +TagChild = Union[TagNode, float, None, Sequence["TagChild"]] """ Types of objects that can be passed as children to Tag functions like `div()`. The `Tag` functions and the `TagList()` constructor can accept @@ -273,7 +252,6 @@ def is_tag_child(x: object) -> TypeIs[TagChild]: x, ( # TagNode, # Handled above - TagList, float, # None, # Handled above Sequence, @@ -391,7 +369,7 @@ def __radd__(self, item: Iterable[TagChild]) -> "TagList[TagNodeT]": return TagList(*item, self) - def tagify(self) -> "TagifiedTagList": + def tagify(self) -> "TagifiedTagList": # noqa: F821 """ Convert any tagifiable children to Tag/TagList objects. From db29da82bcfbbda14b70dd7d93556c429bdfd0dc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 20 May 2026 13:46:11 -0400 Subject: [PATCH 03/23] feat(_core): introduce _TagBase and immutable TagifiedTag skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- htmltools/_core.py | 163 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 2 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index d996378..7631c33 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -126,7 +126,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["TagifiedTag", TagNodeLeaf] # noqa: F821 +TagifiedNode = Union["TagifiedTag", TagNodeLeaf] """ A fully-tagified child-slot type. References the `TagifiedTag` class by forward reference (defined below). Calling `.tagify()` on a node tree @@ -677,7 +677,22 @@ def _normalize_attr_value(x: TagAttrValue) -> str | HTML | None: # ============================================================================= # Tag class # ============================================================================= -class Tag(Generic[TagNodeT]): +class _TagBase: + """Shared state between Tag (buildable) and TagifiedTag (rendered). + + Both subclasses carry the same surface attributes (name, attrs, + add_ws, children). The children attribute is narrowed to the + concrete TagList / TagifiedTagList type in each subclass. + """ + + name: str + attrs: "TagAttrDict" + add_ws: bool + # children is declared in subclasses with its concrete type + # (TagList for Tag, TagifiedTagList for TagifiedTag). + + +class Tag(Generic[TagNodeT], _TagBase): """ The HTML tag class. @@ -1057,6 +1072,150 @@ def _repr_html_(self) -> str: return str(self) +class TagifiedTag(_TagBase): + """ + A fully-tagified `Tag`. Immutable: no mutators, no add_class, no + context-manager use. Construct via `Tag.tagify()` or directly with + pre-tagified arguments. + """ + + children: "TagifiedTagList" # noqa: F821 + + def __init__( + self, + _name: str, + *args: "Tagified | TagAttrs", + _add_ws: TagAttrValue = True, + **kwargs: TagAttrValue, + ) -> None: + self.name = _name + + # Note that _add_ws is marked as a TagAttrValue for the sake of static type + # checking, but it must in fact be a bool. + if not isinstance(_add_ws, bool): + raise TypeError("`_add_ws` must be `True` or `False`") + + self.add_ws = _add_ws + + attrs = [x for x in args if isinstance(x, dict)] + self.attrs = TagAttrDict(*attrs, **kwargs) + + kids = [x for x in args if not isinstance(x, dict)] + # TagifiedTagList isn't defined until Task 4; construct a TagList + # and re-wrap once TagifiedTagList exists. Task 4 will swap this + # for `self.children = TagifiedTagList(*kids)`. + self.children = cast( + "TagifiedTagList", # noqa: F821 + TagList(*cast("list[TagChild]", kids)), + ) + + def tagify(self) -> "TagifiedTag": + return self + + # --- Render / equality / repr methods: duplicate from Tag for now --- + # (Task 10 dedupes by extracting to free fns or moving to _TagBase.) + + def get_html_string(self, indent: int = 0, eol: str = "\n") -> str: + """ + Get the HTML string representation of the tag. + + Parameters + ---------- + indent + The number of spaces to indent the tag. + eol + The end-of-line character(s). + """ + + indent_str = " " * indent + html_ = indent_str + "<" + self.name + + # Write attributes + for key, val in self.attrs.items(): + if not isinstance(val, HTML): + val = html_escape(val, attr=True) + html_ += f' {key}="{val}"' + + # Dependencies are ignored in the HTML output + children = [x for x in self.children if not isinstance(x, MetadataNode)] + + # Don't enclose JSX/void elements if there are no children + if len(children) == 0 and self.name in _VOID_TAG_NAMES: + return html_ + "/>" + + # Other empty tags are enclosed + html_ += ">" + close = "" + if len(children) == 0: + return html_ + close + + # Inline a single/empty child text node + if len(children) == 1 and isinstance(children[0], (str, HTML)): + if self.name in _NO_ESCAPE_TAG_NAMES: + return html_ + str(children[0]) + close + else: + return html_ + _normalize_text(children[0]) + close + + # Write children + if self.add_ws: + html_ += eol + + html_ += self.children.get_html_string( + indent=indent + 1, + eol=eol, + add_ws=self.add_ws, + _escape_strings=(self.name not in _NO_ESCAPE_TAG_NAMES), + ) + + if self.add_ws: + html_ += eol + indent_str + + return html_ + close + + def get_dependencies(self, *, dedup: bool = True) -> list["HTMLDependency"]: + """ + Get any HTML dependencies. + """ + return self.children.get_dependencies(dedup=dedup) + + def render(self) -> RenderedHTML: + """ + Get string representation as well as its HTML dependencies. + """ + # tagify() returns self (already in final form) + cp = self + deps = cp.get_dependencies() + return {"dependencies": deps, "html": cp.get_html_string()} + + def save_html( + self, file: str, *, libdir: Optional[str] = "lib", include_version: bool = True + ) -> str: + """ + Save to a HTML file. + """ + return HTMLDocument(self).save_html( + file, libdir=libdir, include_version=include_version + ) + + def show(self, renderer: Literal["auto", "ipython", "browser"] = "auto") -> object: + """ + Preview as a complete HTML document. + """ + _tag_show(self, renderer) + + def __eq__(self, other: Any) -> bool: + return _equals_impl(self, other) + + def __str__(self) -> str: + return _render_tag_or_taglist(self) + + def __repr__(self) -> str: + return str(self) + + def _repr_html_(self) -> str: + return str(self) + + # Tags that have the form _VOID_TAG_NAMES = { "area", From 3d4209d9d52edd8b1950d026104056874ad12a5b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 20 May 2026 13:49:28 -0400 Subject: [PATCH 04/23] feat(_core): introduce immutable TagifiedTagList (Sequence-backed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- htmltools/_core.py | 222 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 213 insertions(+), 9 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index 7631c33..5df35a0 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -20,6 +20,7 @@ Dict, Generic, Iterable, + Iterator, Mapping, Optional, Sequence, @@ -369,7 +370,7 @@ def __radd__(self, item: Iterable[TagChild]) -> "TagList[TagNodeT]": return TagList(*item, self) - def tagify(self) -> "TagifiedTagList": # noqa: F821 + def tagify(self) -> "TagifiedTagList": """ Convert any tagifiable children to Tag/TagList objects. @@ -596,6 +597,215 @@ def _repr_html_(self) -> str: return str(self) +# ============================================================================= +# TagifiedTagList class +# ============================================================================= +class TagifiedTagList(Sequence["TagifiedNode"]): + """ + A fully-tagified `TagList`. Immutable: no append / extend / insert + / __setitem__ / pop / etc. Construct via `TagList.tagify()` or + directly with pre-tagified arguments; once constructed the + contents are frozen. + + Storage is an internal tuple. The `Sequence` ABC gives read-only + indexing, iteration, `len()`, `__contains__`, `__reversed__`, + `index`, and `count` — all that's needed for render-time access. + """ + + _data: "tuple[TagifiedNode, ...]" + + def __init__(self, *args: "Tagified") -> None: + # Flatten/normalize input through the same pipeline TagList + # uses, so float/None/nested Sequence behave consistently + # between the two sides. Cast: _tagchilds_to_tagnodes expects + # an iterable of TagChild; Tagified is a subset of TagChild + # (TagifiedNode <: TagNode), so the cast is sound. + normalized = _tagchilds_to_tagnodes(cast("tuple[TagChild, ...]", args)) + self._data = tuple(cast("list[TagifiedNode]", normalized)) + + # Sequence ABC requirements ------------------------------------------------ + + @overload + def __getitem__(self, i: SupportsIndex) -> "TagifiedNode": ... + @overload + def __getitem__(self, i: slice) -> "TagifiedTagList": ... + def __getitem__( + self, i: "SupportsIndex | slice" + ) -> "TagifiedNode | TagifiedTagList": + if isinstance(i, slice): + sliced = TagifiedTagList.__new__(TagifiedTagList) + sliced._data = self._data[i] + return sliced + return self._data[i] + + def __len__(self) -> int: + return len(self._data) + + def __iter__(self) -> "Iterator[TagifiedNode]": + return iter(self._data) + + # Construction-not-mutation arithmetic -------------------------------------- + + def __add__(self, item: "Iterable[Tagified]") -> "TagifiedTagList": + return TagifiedTagList(*self._data, *item) + + def __radd__(self, item: "Iterable[Tagified]") -> "TagifiedTagList": + return TagifiedTagList(*item, *self._data) + + # Idempotent tagify -------------------------------------------------------- + + def tagify(self) -> "TagifiedTagList": + return self + + # Render / repr (duplicate from TagList for now; Task 10 dedupes) ---------- + + def get_html_string( + self, + indent: int = 0, + eol: str = "\n", + *, + add_ws: bool = True, + _escape_strings: bool = True, + ) -> str: + """ + Return the HTML string for this tag list. + + Parameters + ---------- + indent + Number of spaces to indent each line of the HTML. + eol + End-of-line character(s). + add_ws: + Whether to add whitespace between the opening tag and the first child. If + either this is True, or the child's add_ws attribute is True, then + whitespace will be added; if they are both False, then no whitespace will be + added. + """ + + html_ = "" + first_child = True + prev_was_add_ws = add_ws + + for child in self: + if isinstance(child, MetadataNode): + continue + + # True if the previous and current node are inline; False otherwise. This + # affects whether or not we add whitespace and indentation. + prev_or_current_add_ws = prev_was_add_ws or ( + isinstance(child, Tag) and child.add_ws + ) + + if first_child: + first_child = False + elif prev_or_current_add_ws: + html_ += eol + + if isinstance(child, Tag): + # Note that we don't pass _escape_strings along, because that should + # only be set to True when