diff --git a/CHANGELOG.md b/CHANGELOG.md index f19866d..05fbad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,39 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking changes -* `Tagifiable.tagify()` now returns `Tagified`, a tighter type that - excludes the un-resolved `Tagifiable` arm of `TagNode`. Custom - `.tagify()` implementations annotated with bare `TagList` or `Tag` - return types will fail static type checking; update them to - `-> Tagified` (or omit the return annotation). Runtime behavior of - correct `.tagify()` implementations is unchanged. (#105) +* `Tagifiable.tagify()`'s return annotation is now `Tagified`, a new type alias for the union of fully-tagified shapes (`TagifiedNode | float | None | Sequence[Tagified]`, mirroring `TagChild`). Custom `.tagify()` implementations annotated with bare `TagList` / `Tag` return types should switch to `-> Tagified`, or drop the annotation. Runtime behavior of correct implementations is unchanged. (#105, #116) -* `Tag.tagify()` no longer preserves the caller's `Tag` subclass in - its return type. Code relying on the previous subclass-preserving - signature should `cast` the result. (#105) +* The result of `.tagify()` is now **immutable**. Calling `.append`, `.extend`, `.insert`, `.add_class`, `__setitem__`, or the context-manager `with` form on a tagified value raises `AttributeError` and is a static type error. Mutate on the buildable `Tag` / `TagList` side, then call `.tagify()` once to produce the render-ready result. (#116) -### New features - -* `Tag` and `TagList` are now generic in their child type, defaulting - to `TagNode`. Bare `Tag` / `TagList` retain today's meaning. Mutation - methods (`append` / `extend` / `insert`) still accept `Tagifiable` at - static-type-check time even on tagified containers — the invariant - is enforced at runtime instead (`TagList.tagify()` raises `TypeError` - and `get_html_string` raises `RuntimeError` for an un-tagified - subtree). See `tests/test_types.py` for the rationale. (#105) +* `TagList.tagify()` now raises `TypeError` at the boundary when a child's `.tagify()` returns un-tagified content (e.g. a bare `TagList` containing a still-`Tagifiable` object). The error names the offending class and slot index so buggy `.tagify()` implementations surface at the source. The render-time `RuntimeError` for the same family of violations has been clarified. (#7, #105, #112, #116) -* Added the public type alias `Tagified` — the union of all - fully-tagified shapes — for use as the return annotation of - `Tagifiable.tagify()` implementations. (#105) +### New features -### Bug fixes +* Exported the new tagified sibling classes `TagifiedTag` and `TagifiedTagList`. Use them in narrow annotations (`def f(t: TagifiedTag): ...`) and `isinstance` checks on `.tagify()` output. (#116) -* `TagList.tagify()` now raises `TypeError` at the boundary when a child's `.tagify()` returns a `TagList` containing an un-tagified `Tagifiable` object. The error names the offending class and slot index so buggy `.tagify()` implementations surface at the source rather than later at render time. The render-time `RuntimeError` raised by `get_html_string()` for an un-tagified child has also been clarified to include the offending class name and a hint that the tree was likely mutated after `.tagify()` was called. (#7, #105, #112) +* Added `is_tagified(x)` for runtime distinguishability between buildable `Tag` / `TagList` and their tagified counterparts. Returns a `TypeIs[...]` so pyright narrows at call sites. (#116) ### Dependencies -* Bumped `typing_extensions` floor to `>=4.12.0`, required for - PEP 696 `TypeVar(default=...)` support on Python 3.13+. +* Bumped `typing-extensions` floor to `>=4.12.0`. ### Other changes 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..d653cb9 --- /dev/null +++ b/decisions/2026-05-20-tagified-as-classes.md @@ -0,0 +1,117 @@ +# `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 kept as defense-in-depth.** Its original case — mutation-after-`.tagify()` — is now structurally impossible. The guard remains as a belt-and-suspenders catch for direct `.get_html_string()` calls on a buildable tree (which the normal `.render()` path avoids by tagifying first) and for type-system bypasses (`cast`, `__dict__` manipulation). Its error message points at calling `.tagify()` / `.render()` first rather than at the now-impossible mutation case. +- **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. + +## Public surface + +`TagifiedTag` and `TagifiedTagList` ARE exported from `htmltools/__init__.py` (symmetric with `Tag` and `TagList`). Downstream code needs the class names for: + +- `isinstance` checks after `.tagify()` (e.g., narrowing a Sequence-unpacked element to a known concrete type before reading its `.attrs`). +- Narrow type annotations for functions that specifically receive tagified inputs (`def f(t: TagifiedTag): ...`). + +The recommended public-facing path is still: + +- Construct buildable forms (`Tag` / `TagList`) and call `.tagify()` rather than constructing tagified instances directly. (Direct construction works — the constructor's `*args: Tagified | TagAttrs` narrows input — but `.tagify()` is the canonical idiom.) +- Annotate `.tagify()` return types in custom `Tagifiable` classes as `Tagified` (the broad union) rather than the concrete `TagifiedTag` / `TagifiedTagList`. Concrete-class annotations work too but are unnecessarily narrow. +- Use `is_tagified(x)` for runtime distinguishability when the concrete arm doesn't matter — exported, returns `TypeIs[TagifiedTag | TagifiedTagList]` so pyright narrows at call sites. + +`is_tag_like` and `is_taglist_like` exist inside `htmltools._core` for internal use (the rendering plumbing that has to operate on either form) but are deliberately **not** exported. Code outside `htmltools` should distinguish between buildable and tagified forms via `is_tagified` (or via direct `isinstance` against the now-exported sibling classes), not via the `*_like` helpers. + +History: an earlier version of this decision kept `TagifiedTag` / `TagifiedTagList` internal on the theory that exposing them invited confusion about direct construction. We reversed that when integrating the downstream py-shiny PR — every realistic downstream code pattern that walks a tagified tree needs to `isinstance`-check the elements at some point, and forcing those sites to import from `htmltools._core` (or duck-type) was strictly worse than just exporting. The construction-confusion concern is adequately addressed by documenting `.tagify()` as the canonical idiom rather than by hiding the class names. + +## 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. diff --git a/htmltools/__init__.py b/htmltools/__init__.py index 8fc57b9..3f02ebf 100644 --- a/htmltools/__init__.py +++ b/htmltools/__init__.py @@ -18,12 +18,15 @@ TagFunction, Tagifiable, Tagified, + TagifiedTag, + TagifiedTagList, TagList, TagNode, consolidate_attrs, head_content, is_tag_child, is_tag_node, + is_tagified, wrap_displayhook_handler, ) from ._util import css, html_escape @@ -63,6 +66,8 @@ "TagFunction", "Tagifiable", "Tagified", + "TagifiedTag", + "TagifiedTagList", "TagList", "TagNode", "ReprHtml", @@ -70,6 +75,7 @@ "head_content", "is_tag_child", "is_tag_node", + "is_tagified", "wrap_displayhook_handler", "css", "html_escape", diff --git a/htmltools/_core.py b/htmltools/_core.py index cefc0ee..df37e98 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -18,8 +18,8 @@ Any, Callable, Dict, - Generic, Iterable, + Iterator, Mapping, Optional, Sequence, @@ -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, @@ -70,10 +70,15 @@ "TagFunction", "Tagifiable", "Tagified", + "TagifiedTag", + "TagifiedTagList", "consolidate_attrs", "head_content", "is_tag_child", + "is_tag_like", "is_tag_node", + "is_tagified", + "is_taglist_like", "wrap_displayhook_handler", ) @@ -110,22 +115,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 @@ -140,29 +129,37 @@ class MetadataNode: # A node that has already been fully tagified: no Tagifiable objects whose # .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] +# are themselves tagified. +TagifiedNode = Union["TagifiedTag", "TagifiedTagList", TagNodeLeaf] """ -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` and +`TagifiedTagList` classes by forward reference (defined below). Calling +`.tagify()` on a node tree returns a structure whose slot items are all +`TagifiedNode`. + +`TagifiedTagList` is a type-level member here for parity with how +`TagNode` contains `Tagifiable` (which subsumes both `Tag` and +`TagList`). At runtime a `TagifiedTagList` never appears as a child +slot of another `TagifiedTagList` — `_tagchilds_to_tagnodes` flattens +nested lists — but the type allows it. """ # 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. `Tagified` mirrors +`TagChild`'s structural shape (both are `Element | float | None | +Sequence[recursive]`); the element-type unions (`TagifiedNode` / +`TagNode`) carry the tagified-vs-buildable distinction. """ # ----------------------------------------------------------------------------- -# TagNode / TagChild (generic) and the TagNodeT TypeVar +# TagNode / TagChild # ----------------------------------------------------------------------------- # NOTE: If this type is updated, please update `is_tag_node()` TagNode = Union["Tagifiable", TagNodeLeaf] @@ -178,12 +175,6 @@ class MetadataNode: explicitly. """ -TagNodeT = TypeVar("TagNodeT", bound=TagNode, default=TagNode) -""" -Type parameter for `Tag` and `TagList`. Defaults to `TagNode`, so bare -`Tag` / `TagList` keep their pre-#105 meaning. -""" - # NOTE: If this type is updated, please update `is_tag_child()`. # # `TagChild` is intentionally NOT generic. Making it a generic @@ -192,18 +183,12 @@ class MetadataNode: # function signature when inspected from a downstream module in # strict mode (e.g. Shiny's CI reported 2500+ # `reportUnknownMemberType` errors). The trade-off is that -# `TagList[TagifiedNode].append(some_tagifiable)` no longer -# static-errors — the runtime guard in `TagList.get_html_string` +# `TagList.append(some_tagifiable)` on a tagified-flavored list no +# longer static-errors — the runtime guard in `TagList.get_html_string` # still catches it at render time. See # `tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable` # for the full rationale. -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 +258,6 @@ def is_tag_child(x: object) -> TypeIs[TagChild]: x, ( # TagNode, # Handled above - TagList, float, # None, # Handled above Sequence, @@ -285,6 +269,39 @@ def is_tag_child(x: object) -> TypeIs[TagChild]: return False +def is_tag_like(x: object) -> TypeIs["Tag | TagifiedTag"]: + """ + True if `x` is either a buildable `Tag` or a tagified `TagifiedTag`. + + Both classes share the `_TagBase` plumbing (name, attrs, children, + rendering). Use this helper at call sites that handle either form so + the "either flavor" intent is explicit and the narrowing is expressed + through public types rather than the private `_TagBase`. + """ + return isinstance(x, (Tag, TagifiedTag)) + + +def is_taglist_like(x: object) -> TypeIs["TagList | TagifiedTagList"]: + """ + True if `x` is either a buildable `TagList` or a tagified `TagifiedTagList`. + + Both classes share the `_TagListBase` render plumbing. Use this helper + at call sites that handle either form. + """ + return isinstance(x, (TagList, TagifiedTagList)) + + +def is_tagified(x: object) -> TypeIs["TagifiedTag | TagifiedTagList"]: + """ + True if `x` is a fully-tagified container (`TagifiedTag` or `TagifiedTagList`). + + Useful for distinguishing post-`.tagify()` values from buildable + `Tag` / `TagList` instances at runtime. Symmetric with `is_tag_like` + and `is_taglist_like`. + """ + return isinstance(x, (TagifiedTag, TagifiedTagList)) + + @runtime_checkable class Tagifiable(Protocol): """ @@ -319,10 +336,204 @@ class ReprHtml(Protocol): def _repr_html_(self) -> str: ... +# ============================================================================= +# _TagListBase mixin (shared between TagList and TagifiedTagList) +# ============================================================================= +class _TagListBase: + """ + Render plumbing shared between `TagList` (buildable, `UserList`-backed) + and `TagifiedTagList` (immutable, `Sequence`-backed). Both subclasses + support iteration over their elements, which is all the bodies below + need. + + This is the `TagList`-side analog of `_TagBase`: a methods-only mixin + that does NOT inherit from `UserList` or `Sequence`. Subclasses bring + their own iteration / indexing / mutation surface. + """ + + def tagify(self) -> "TagifiedTagList": + """ + Return a fully-tagified form of this tag list. Implemented by subclasses. + """ + raise NotImplementedError + + 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 cast(Iterable[Any], 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 ( + is_tag_like(child) and child.add_ws + ) + + if first_child: + first_child = False + elif prev_or_current_add_ws: + html_ += eol + + if is_tag_like(child): + # Note that we don't pass _escape_strings along, because that should + # only be set to True when