diff --git a/htmltools/core.py b/htmltools/core.py index 84bd007..119566c 100644 --- a/htmltools/core.py +++ b/htmltools/core.py @@ -70,6 +70,9 @@ class MetadataNode: TagT = TypeVar("TagT", bound="Tag") +# Types that can be passed in as attributes to tag functions. +TagAttrArg = Union[str, float, bool, None] + # Types of objects that can be a child of a tag. TagChild = Union["Tagifiable", "Tag", MetadataNode, str] @@ -79,13 +82,11 @@ class MetadataNode: "TagList", float, None, + Dict[str, TagAttrArg], # i.e., tag attrbutes (e.g., {"id": "foo"}) List["TagChildArg"], Tuple["TagChildArg", ...], ] -# Types that can be passed in as attributes to tag functions. -TagAttrArg = Union[str, int, float, bool, None] - # Objects with tagify() methods are considered Tagifiable. Note that an object returns a # TagList, the children of the TagList must also be tagified. @@ -245,9 +246,9 @@ def _repr_html_(self) -> str: # TagAttrs class # ============================================================================= class TagAttrs(Dict[str, str]): - def __init__(self, **kwargs: TagAttrArg) -> None: + def __init__(self, *args: Mapping[str, TagAttrArg], **kwargs: TagAttrArg) -> None: super().__init__() - self.update(**kwargs) + self.update(*args, **kwargs) def __setitem__(self, name: str, value: TagAttrArg) -> None: val = self._normalize_attr_value(value) @@ -258,19 +259,26 @@ def __setitem__(self, name: str, value: TagAttrArg) -> None: # Note: typing is ignored because the type checker thinks this is an incompatible # override. It's possible that we could find a way to override so that it's happy. def update( # type: ignore - self, __m: Mapping[str, TagAttrArg] = {}, **kwargs: TagAttrArg + self, *args: Mapping[str, TagAttrArg], **kwargs: TagAttrArg ) -> None: - self._update(__m) - self._update(kwargs) - - def _update(self, __m: Mapping[str, TagAttrArg]) -> None: - attrs: Dict[str, str] = {} - for key, val in __m.items(): - val_ = self._normalize_attr_value(val) - if val_ is None: - continue - attrs[self._normalize_attr_name(key)] = val_ - super().update(**attrs) + if kwargs: + args = args + (kwargs,) + + attrz: Dict[str, Union[str, HTML]] = {} + for arg in args: + for k, v in arg.items(): + val = self._normalize_attr_value(v) + if val is None: + continue + nm = self._normalize_attr_name(k) + + # Preserve the HTML() when combining two HTML() attributes + if nm in attrz: + val = attrz[nm] + HTML(" ") + val + + attrz[nm] = val + + super().update(attrz) @staticmethod def _normalize_attr_name(x: str) -> str: @@ -339,12 +347,19 @@ def __init__( **kwargs: TagAttrArg, ) -> None: self.name: str = _name - self.attrs: TagAttrs = TagAttrs(**kwargs) - self.children: TagList = TagList() - self.children.extend(args) + # As a workaround for Python not allowing for numerous keyword + # arguments of the same name, we treat any dictionaries that appear + # within children as attributes (i.e., treat them like kwargs). + arguments = _flatten(args) if children: - self.children.extend(children) + arguments.extend(_flatten(children)) + + attrs = [x for x in arguments if isinstance(x, dict)] + self.attrs: TagAttrs = TagAttrs(*attrs, **kwargs) + + kids = [x for x in arguments if not isinstance(x, dict)] + self.children: TagList = TagList(*kids) def __call__(self, *args: TagChildArg, **kwargs: TagAttrArg) -> "Tag": self.children.extend(args) diff --git a/tests/test_tags.py b/tests/test_tags.py index ce25760..535774d 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -77,6 +77,26 @@ def test_tag_attrs_update(): assert x.attrs == {"a": "1", "b": "2", "c": "C"} +def test_tag_multiple_repeated_attrs(): + x = div({"class": "foo", "class_": "bar"}, class_="baz") + y = div({"class": "foo"}, {"class_": "bar"}, class_="baz") + z = div({"class": "foo"}, {"class": "bar"}, class_="baz") + w = div({"class": "foo"}, children=[{"class_": "bar"}], class_="baz") + assert x.attrs == {"class": "foo bar baz"} + assert y.attrs == {"class": "foo bar baz"} + assert z.attrs == {"class": "foo bar baz"} + assert w.attrs == {"class": "foo bar baz"} + x.attrs.update({"class": "bap", "class_": "bas"}, class_="bat") + assert x.attrs == {"class": "bap bas bat"} + x.attrs.update({"class": HTML("&")}, class_=HTML("<")) + assert str(x) == '
' + x.attrs.update({"class": HTML("&")}, class_="<") + # Combining HTML() with non-HTML() currently forces everything to be escaped, + # but it'd be good to change this behavior if we can manage to change it + # in general https://github.com/rstudio/py-htmltools/issues/15 + assert str(x) == '
' + + def test_tag_shallow_copy(): dep = HTMLDependency( "a", "1.1", source={"package": None, "subdir": "foo"}, script={"src": "a1.js"} @@ -363,9 +383,8 @@ def test_attr_vals(): def test_tag_normalize_attr(): - # Note that x_ maps to x, and it gets replaced by the latter. x = div(class_="class_", x__="x__", x_="x_", x="x") - assert x.attrs == {"class": "class_", "x-": "x__", "x": "x"} + assert x.attrs == {"class": "class_", "x-": "x__", "x": "x_ x"} x = div(foo_bar="baz") assert x.attrs == {"foo-bar": "baz"}