Skip to content

Commit 4ead746

Browse files
committed
Export is_tag_node(), is_tag_child() and consolidate_attrs()
1 parent f67f5dc commit 4ead746

File tree

6 files changed

+268
-3
lines changed

6 files changed

+268
-3
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
* Exported `ReprHtml` protocol class. If an object has a `_repr_html_` method, then it is of instance `ReprHtml`. (#86)
2525

26+
* Exported `is_tag_node()` and `is_tag_child()` functions that utilize `typing.TypeIs` to narrow `TagNode` and `TagChild` type variables, respectively. (#86)
27+
28+
* Exported `consolidate_attrs(*args, **kwargs)` function. This function will combine the `TagAttrs` (supplied in `*args`) with `TagAttrValues` (supplied in `**kwargs`) into a single `TagAttrs` object. In addition, it will also return all `*args` that are not dictionary as a list of unaltered `TagChild` objects. (#86)
29+
* This function takes a list of `TagAttrs` and returns a single `TagAttrs` object. (#86)
30+
2631
### Bug fixes
2732

2833
* Fixed an issue with `HTMLTextDocument()` returning extracted `HTMLDependency()`s in a non-determistic order. (#95)

htmltools/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
Tagifiable,
2020
TagList,
2121
TagNode,
22+
consolidate_attrs,
2223
head_content,
24+
is_tag_child,
25+
is_tag_node,
2326
wrap_displayhook_handler,
2427
)
2528
from ._util import css, html_escape
@@ -61,7 +64,10 @@
6164
"TagList",
6265
"TagNode",
6366
"ReprHtml",
67+
"consolidate_attrs",
6468
"head_content",
69+
"is_tag_child",
70+
"is_tag_node",
6571
"wrap_displayhook_handler",
6672
"css",
6773
"html_escape",

htmltools/_core.py

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
TypeVar,
2727
Union,
2828
cast,
29+
overload,
2930
)
3031

3132
# Even though TypedDict is available in Python 3.8, because it's used with NotRequired,
@@ -36,6 +37,11 @@
3637
else:
3738
from typing_extensions import Never, NotRequired, TypedDict
3839

40+
if sys.version_info >= (3, 13):
41+
from typing import TypeIs
42+
else:
43+
from typing_extensions import TypeIs
44+
3945
from typing import Literal, Protocol, SupportsIndex, runtime_checkable
4046

4147
from packaging.version import Version
@@ -63,7 +69,10 @@
6369
"TagNode",
6470
"TagFunction",
6571
"Tagifiable",
72+
"consolidate_attrs",
6673
"head_content",
74+
"is_tag_child",
75+
"is_tag_node",
6776
"wrap_displayhook_handler",
6877
)
6978

@@ -100,13 +109,23 @@ class MetadataNode:
100109
unnamed arguments to Tag functions like `div()`.
101110
"""
102111

103-
TagNode = Union["Tagifiable", "Tag", MetadataNode, "ReprHtml", str]
112+
# NOTE: If this type is updated, please update `is_tag_node()`
113+
TagNode = Union[
114+
"Tagifiable",
115+
# "Tag", # Tag is Tagifiable, do not include here
116+
# "TagList", # TagList is Tagifiable, do not include here
117+
MetadataNode,
118+
"ReprHtml",
119+
str,
120+
"HTML",
121+
]
104122
"""
105123
Types of objects that can be a node in a `Tag` tree. Equivalently, these are the valid
106124
elements of a `TagList`. Note that this type represents the internal structure of items
107125
in a `TagList`; the user-facing type is `TagChild`.
108126
"""
109127

128+
# NOTE: If this type is updated, please update `is_tag_child()`
110129
TagChild = Union[
111130
TagNode,
112131
"TagList",
@@ -120,13 +139,85 @@ class MetadataNode:
120139
will be flattened and normalized to `TagNode` objects.
121140
"""
122141

142+
123143
# These two types existed in htmltools 0.14.0 and earlier. They are here so that
124144
# existing versions of Shiny will be able to load, but users of those existing packages
125145
# will see type errors, which should encourage them to upgrade Shiny.
126146
TagChildArg = Never
127147
TagAttrArg = Never
128148

129149

150+
# # No use yet, so keeping code commented for now
151+
# TagNodeT = TypeVar("TagNodeT", bound=TagNode)
152+
# """
153+
# Type variable for `TagNode`.
154+
# """
155+
156+
TagChildT = TypeVar("TagChildT", bound=TagChild)
157+
"""
158+
Type variable for `TagChild`.
159+
"""
160+
161+
162+
def is_tag_node(x: object) -> TypeIs[TagNode]:
163+
"""
164+
Check if an object is a `TagNode`.
165+
166+
Note: The type hint is `TypeIs[TagNode]` to allow for type checking of the
167+
return value. (`TypeIs` is imported from `typing_extensions` for Python < 3.13.)
168+
169+
Parameters
170+
----------
171+
x
172+
Object to check.
173+
174+
Returns
175+
-------
176+
:
177+
`True` if the object is a `TagNode`, `False` otherwise.
178+
"""
179+
# Note: Tag and TagList are both Tagifiable
180+
return isinstance(x, (Tagifiable, MetadataNode, ReprHtml, str, HTML))
181+
182+
183+
def is_tag_child(x: object) -> TypeIs[TagChild]:
184+
"""
185+
Check if an object is a `TagChild`.
186+
187+
Note: The type hint is `TypeIs[TagChild]` to allow for type checking of the
188+
return value. (`TypeIs` is imported from `typing_extensions` for Python < 3.13.)
189+
190+
Parameters
191+
----------
192+
x
193+
Object to check.
194+
195+
Returns
196+
-------
197+
:
198+
`True` if the object is a `TagChild`, `False` otherwise.
199+
"""
200+
201+
if is_tag_node(x):
202+
return True
203+
if x is None:
204+
return True
205+
if isinstance(
206+
x,
207+
(
208+
# TagNode, # Handled above
209+
TagList,
210+
float,
211+
# None, # Handled above
212+
Sequence,
213+
),
214+
):
215+
return True
216+
217+
# Could not determine the type
218+
return False
219+
220+
130221
@runtime_checkable
131222
class Tagifiable(Protocol):
132223
"""
@@ -1744,6 +1835,61 @@ def head_content(*args: TagChild) -> HTMLDependency:
17441835
return HTMLDependency(name=name, version="0.0", head=head)
17451836

17461837

1838+
# If no children are provided, it will not be able to infer the type of `TagChildT`.
1839+
# Using `TagChild`, even though the list will be empty.
1840+
@overload
1841+
def consolidate_attrs(
1842+
*args: TagAttrs,
1843+
**kwargs: TagAttrValue,
1844+
) -> tuple[TagAttrs, list[TagChild]]: ...
1845+
1846+
1847+
# Same as original definition
1848+
@overload
1849+
def consolidate_attrs(
1850+
*args: TagChildT | TagAttrs,
1851+
**kwargs: TagAttrValue,
1852+
) -> tuple[TagAttrs, list[TagChildT]]: ...
1853+
1854+
1855+
def consolidate_attrs(
1856+
*args: TagChildT | TagAttrs,
1857+
**kwargs: TagAttrValue,
1858+
) -> tuple[TagAttrs, list[TagChildT]]:
1859+
"""
1860+
Consolidate attributes and children into a single tuple.
1861+
1862+
Convenience function to consolidate attributes and children into a single tuple. All
1863+
`args` that are not dictionaries are considered children. This helps preserve the
1864+
non-attribute elements within `args`. To extract the attributes, all `args` and
1865+
`kwargs` are passed to `Tag` function and the attributes (`.attrs`) are extracted
1866+
from the resulting `Tag` object.
1867+
1868+
Parameters
1869+
----------
1870+
*args
1871+
Child elements to this tag and attribute dictionaries.
1872+
**kwargs
1873+
Named attributes to this tag.
1874+
1875+
Returns
1876+
-------
1877+
:
1878+
A tuple of attributes and children. The attributes are a dictionary of combined
1879+
named attributes, and the children are a list of unaltered child elements.
1880+
"""
1881+
tag = Tag("consolidate_attrs", *args, **kwargs)
1882+
1883+
# Convert to a plain dict to avoid getting custom methods from TagAttrDict
1884+
# Cast to `TagAttrs` as that is the common type used by py-shiny
1885+
attrs = cast(TagAttrs, dict(tag.attrs))
1886+
1887+
# Do not alter/flatten children structure (like `TagList` does)
1888+
# Instead, return all `args` who are not dictionaries
1889+
children = [child for child in args if not isinstance(child, dict)]
1890+
return (attrs, children)
1891+
1892+
17471893
# =============================================================================
17481894
# Utility functions
17491895
# =============================================================================
@@ -1756,7 +1902,7 @@ def _tagchilds_to_tagnodes(x: Iterable[TagChild]) -> list[TagNode]:
17561902
for i, item in enumerate(result):
17571903
if isinstance(item, (int, float)):
17581904
result[i] = str(item)
1759-
elif not isinstance(item, (HTML, Tagifiable, Tag, MetadataNode, ReprHtml, str)):
1905+
elif not is_tag_node(item):
17601906
raise TypeError(
17611907
f"Invalid tag item type: {type(item)}. "
17621908
+ "Consider calling str() on this value before treating it as a tag item."

htmltools/_util.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
HashableT = TypeVar("HashableT", bound=Hashable)
1818

19-
__all__ = ("css",)
19+
__all__ = (
20+
"css",
21+
"html_escape",
22+
)
2023

2124

2225
def css(

tests/test_consolidate_attrs.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from htmltools import HTML, consolidate_attrs
2+
3+
4+
def test_consolidate_attrs():
5+
6+
(attrs, children) = consolidate_attrs(
7+
{"class": "&c1"},
8+
0,
9+
# This tests `__radd__` method of `HTML` class
10+
{"id": "foo", "class_": HTML("&c2")},
11+
[1, [2]],
12+
3,
13+
class_=HTML("&c3"),
14+
other_attr="other",
15+
)
16+
17+
assert attrs == {"id": "foo", "class": "&amp;c1 &c2 &c3", "other-attr": "other"}
18+
assert children == [0, [1, [2]], 3]

tests/test_is.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from typing import List
2+
3+
import pytest
4+
5+
from htmltools import (
6+
HTML,
7+
HTMLDependency,
8+
ReprHtml,
9+
Tag,
10+
TagAttrs,
11+
TagChild,
12+
TagList,
13+
TagNode,
14+
div,
15+
is_tag_child,
16+
is_tag_node,
17+
)
18+
19+
tag_attr_obj: TagAttrs = {"test_key": "test_value"}
20+
21+
22+
class ReprClass:
23+
24+
def _repr_html_(self) -> str:
25+
return "repr_html"
26+
27+
28+
repr_obj = ReprClass()
29+
30+
31+
class OtherObj:
32+
pass
33+
34+
35+
class TagifiableClass:
36+
def tagify(self) -> Tag:
37+
return Tag("test_element").tagify()
38+
39+
40+
def test_is_repr_html():
41+
assert isinstance(repr_obj, ReprHtml)
42+
43+
44+
tag_node_objs: List[TagNode] = [
45+
TagifiableClass(),
46+
Tag("test_element2"),
47+
TagList([div("div_content")]),
48+
HTMLDependency("test_dependency", version="1.0.0"),
49+
repr_obj,
50+
"test_string",
51+
HTML("test_html"),
52+
]
53+
tag_child_only_objs: List[TagChild] = [
54+
# *tag_node_objs,
55+
# [*tag_node_objs],
56+
[Tag("test_element3")],
57+
None,
58+
[],
59+
]
60+
61+
not_tag_child_objs = [
62+
OtherObj(),
63+
]
64+
65+
66+
@pytest.mark.parametrize("obj", tag_node_objs)
67+
def test_is_tag_node(obj):
68+
assert is_tag_node(obj)
69+
70+
71+
@pytest.mark.parametrize(
72+
"obj", [[*tag_node_objs], *tag_child_only_objs, *not_tag_child_objs]
73+
)
74+
def test_not_is_tag_node(obj):
75+
assert not is_tag_node(obj)
76+
77+
78+
@pytest.mark.parametrize(
79+
"obj", [*tag_node_objs, [*tag_node_objs], *tag_child_only_objs]
80+
)
81+
def test_is_tag_child(obj):
82+
assert is_tag_child(obj)
83+
84+
85+
@pytest.mark.parametrize("obj", not_tag_child_objs)
86+
def test_not_is_tag_child(obj):
87+
assert not is_tag_child(obj)

0 commit comments

Comments
 (0)