You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Following #105/#106 (PR that made Tag/TagList generic in TagNodeT) and #112 / commit a9ec156 (which added a runtime TypeError at the TagList.tagify() boundary when a child's .tagify() returned un-tagified content), there is a remaining static-typing gap:
t: TagList[TagifiedNode] =some_taglist.tagify()
t.append(div("hi")) # silently allowed by pyright todayt.get_html_string() # works (div is already tagified — happens to be fine)t.append(SomeTagifiable()) # also silently allowed at .append() timet.get_html_string() # raises RuntimeError at render
The append of an un-tagified Tagifiable to a TagList[TagifiedNode] is statically silent. It only surfaces at render time, or — if .tagify() is re-run — at the boundary check from #112.
Proposal
Keep TagNodeT = TypeVar(\"TagNodeT\", bound=TagNode, default=TagNode) as-is (do not narrow to a constrained TypeVar(..., TagNode, TagifiedNode)), and add Self-typed @overload signatures to TagList's input methods:
TagifiedChild=Union[
TagifiedNode,
\"TagList[TagifiedNode]\",
float,
None,
Sequence[\"TagifiedChild\"],
] # non-generic alias parallel to TagChild — avoids the recursive-generic# pyright leak that motivated keeping TagChild non-generic in #105.classTagList(UserList[TagNodeT]):
@overloaddefappend(self: \"TagList[TagifiedNode]\", item: TagifiedChild, *args: TagifiedChild) ->None: ...
@overloaddefappend(self: \"TagList[TagNode]\", item: TagChild, *args: TagChild) ->None: ...
defappend(self, item, *args): ...
Apply to: __init__, append, extend, insert, __add__, __radd__ (and consider __setitem__ for completeness).
Decisions reached in design discussion
Keep bound=TagNode (not constrained). Survey of 9 Posit Python repos (py-shiny, py-shinywidgets, py-shinyswatch, shinychat, chatlas, querychat, py-shinylive, py-shiny-templates, py-shiny-output-binding-example) found 0 in-the-wild parameterizations of TagList[T] or Tag[T] — so a constrained TypeVar would have no practical cost today, but keeping bound preserves theoretical room for TagList[MySpecificTag] at zero extra design cost.
TagList only — not Tag.Tag(Generic[TagNodeT]) has children: TagList[TagNodeT], so overloads on TagList cover tag.children.append(...) transitively. Direct construction of Tag[TagifiedNode](...) only happens internally via casts, never via user code.
Static-only enforcement. No new runtime TypeError on .append(). The existing render-time RuntimeError and the feat: TagList.tagify() raises TypeError on un-tagified content #112 tagify-boundary TypeError remain the runtime safety net. Users who # type: ignore the static error are knowingly opting out.
The note at _core.py:187-199 explains: the previous attempt made TagChild itself a generic TypeAliasType with a recursive Sequence[\"TagChild[TagNodeT]\"] arm, which leaked Sequence[Unknown] into every Tag function signature in downstream strict-mode pyright (2500+ reports in Shiny CI). A non-generic parallel alias TagifiedChild sidesteps that — it's a separate union, not a parameterization of TagChild. Worth verifying against a fresh Shiny strict-mode CI run before merging.
Open question — subclass vs. alias and the variance trap
We discussed replacing it with a real subclass: class TagifiedTagList(TagList[TagifiedNode]): .... Possible, but TagList is invariant (mutable container), so TagList[TagifiedNode] is not assignable to TagList[TagNode]. With the subclass approach, every function annotated def f(t: TagList) (= TagList[TagNode]) would reject the output of .tagify().
The alias should have the same variance issue in principle — but in practice it may not surface because most consumers use bare TagList, which pyright resolves via the default. Needs spot-check of real consumers of .tagify()'s return before deciding whether subclass would be a net improvement, or whether the alias is correct as-is.
Subclassing the union types themselves (TagifiedNode, Tagified) is not possible — you can't subclass a Union.
Scope checklist
Define non-generic TagifiedChild alias next to TagChild.
Update tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable — that test's premise (referenced in _core.py:198) inverts under this proposal: appending a Tagifiable to a TagList[TagifiedNode] should now be a static error. Decide whether to flip the test to use assert_type / reveal_type patterns or a pyright-on-fixture test.
Verify no Sequence[Unknown] regression in Shiny strict-mode CI from the new TagifiedChild alias.
Investigate the variance question above and decide subclass vs. alias for TagifiedTagList (potentially out-of-scope as a follow-up).
Context
Following #105/#106 (PR that made
Tag/TagListgeneric inTagNodeT) and #112 / commit a9ec156 (which added a runtimeTypeErrorat theTagList.tagify()boundary when a child's.tagify()returned un-tagified content), there is a remaining static-typing gap:The append of an un-tagified
Tagifiableto aTagList[TagifiedNode]is statically silent. It only surfaces at render time, or — if.tagify()is re-run — at the boundary check from #112.Proposal
Keep
TagNodeT = TypeVar(\"TagNodeT\", bound=TagNode, default=TagNode)as-is (do not narrow to a constrainedTypeVar(..., TagNode, TagifiedNode)), and addSelf-typed@overloadsignatures toTagList's input methods:Apply to:
__init__,append,extend,insert,__add__,__radd__(and consider__setitem__for completeness).Decisions reached in design discussion
bound=TagNode(not constrained). Survey of 9 Posit Python repos (py-shiny, py-shinywidgets, py-shinyswatch, shinychat, chatlas, querychat, py-shinylive, py-shiny-templates, py-shiny-output-binding-example) found 0 in-the-wild parameterizations ofTagList[T]orTag[T]— so a constrained TypeVar would have no practical cost today, but keepingboundpreserves theoretical room forTagList[MySpecificTag]at zero extra design cost.TagListonly — notTag.Tag(Generic[TagNodeT])haschildren: TagList[TagNodeT], so overloads onTagListcovertag.children.append(...)transitively. Direct construction ofTag[TagifiedNode](...)only happens internally via casts, never via user code.TypeErroron.append(). The existing render-timeRuntimeErrorand the feat: TagList.tagify() raises TypeError on un-tagified content #112 tagify-boundaryTypeErrorremain the runtime safety net. Users who# type: ignorethe static error are knowingly opting out.Why this wasn't done in #105
The note at
_core.py:187-199explains: the previous attempt madeTagChilditself a genericTypeAliasTypewith a recursiveSequence[\"TagChild[TagNodeT]\"]arm, which leakedSequence[Unknown]into everyTagfunction signature in downstream strict-mode pyright (2500+ reports in Shiny CI). A non-generic parallel aliasTagifiedChildsidesteps that — it's a separate union, not a parameterization ofTagChild. Worth verifying against a fresh Shiny strict-mode CI run before merging.Open question — subclass vs. alias and the variance trap
TagifiedTagListis currently a type alias:We discussed replacing it with a real subclass:
class TagifiedTagList(TagList[TagifiedNode]): .... Possible, butTagListis invariant (mutable container), soTagList[TagifiedNode]is not assignable toTagList[TagNode]. With the subclass approach, every function annotateddef f(t: TagList)(=TagList[TagNode]) would reject the output of.tagify().The alias should have the same variance issue in principle — but in practice it may not surface because most consumers use bare
TagList, which pyright resolves via the default. Needs spot-check of real consumers of.tagify()'s return before deciding whether subclass would be a net improvement, or whether the alias is correct as-is.Subclassing the union types themselves (
TagifiedNode,Tagified) is not possible — you can't subclass aUnion.Scope checklist
TagifiedChildalias next toTagChild.Self-typed@overloads toTagList.{__init__, append, extend, insert, __add__, __radd__}. Evaluate__setitem__.tests/test_types.py::test_TagifiedTagList_append_accepts_Tagifiable— that test's premise (referenced in_core.py:198) inverts under this proposal: appending aTagifiableto aTagList[TagifiedNode]should now be a static error. Decide whether to flip the test to useassert_type/reveal_typepatterns or a pyright-on-fixture test.Sequence[Unknown]regression in Shiny strict-mode CI from the newTagifiedChildalias.TagifiedTagList(potentially out-of-scope as a follow-up).Related
Tag/TagListgeneric in their child type so.tagify()can statically guarantee a fully-tagified tree #105 — original design issue (genericTag/TagList)TypeErrorat tagify boundary