diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index ffa1123..231dbac 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -21,9 +21,9 @@ jobs: shell: bash steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -39,16 +39,15 @@ jobs: - name: pyright, flake8, black and isort run: | make check - deploy: name: "Deploy to PyPI" runs-on: ubuntu-latest if: github.event_name == 'release' needs: [build] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: "Set up Python 3.10" - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install dependencies diff --git a/.github/workflows/shiny.yaml b/.github/workflows/shiny.yaml new file mode 100644 index 0000000..eb2f016 --- /dev/null +++ b/.github/workflows/shiny.yaml @@ -0,0 +1,40 @@ +name: Bleeding Edge Shiny + +on: + push: + branches: "shiny-**" + pull_request: + +jobs: + htmltools-pr: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + + steps: + - name: Checkout py-shiny@main + uses: actions/checkout@v4 + with: + repository: posit-dev/py-shiny + ref: main + fetch-depth: 0 # Required for shiny version + - name: Setup py-shiny@main + uses: posit-dev/py-shiny/.github/py-shiny/setup@main + with: + python-version: "3.12" + + - name: Checkout dev branch of py-htmltools + uses: actions/checkout@v4 + with: + path: _dev/htmltools + + - name: Install dev py-htmltools htmltools dependencies + run: | + cd _dev/htmltools + pip uninstall -y htmltools + pip install -e ".[dev,test]" + make install + + - name: Check py-shiny@main + uses: posit-dev/py-shiny/.github/py-shiny/check@main diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7f620..466b9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,27 @@ All notable changes to htmltools for Python will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [UNRELEASED] +## [Unreleased] YYYY-MM-DD + +### Breaking changes + +* `HTML` no longer inherits from `str`. It now inherits from `collections.UserString`. This was done to avoid confusion between `str` and `HTML` objects. (#86) + +* `Tag` and `TagList`'s method `.get_html_string()` now both return `str` instead of `HTML`. (#86) + +* Strings added to `HTML` objects, now return `HTML` objects. E.g. `HTML_value + str_value` and `str_value_ + HTML_value` both return `HTML` objects. To maintain a `str` result, call `str()` on your `HTML` objects before adding them to strings. (#86) + +### New features + +* Exported `ReprHtml` protocol class. If an object has a `_repr_html_` method, then it is of instance `ReprHtml`. (#86) + +* Exported `is_tag_node()` and `is_tag_child()` functions that utilize `typing.TypeIs` to narrow `TagNode` and `TagChild` type variables, respectively. (#86) + +* 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) + +* The `Tag` method `.add_style(style=)` added support for `HTML` objects in addition to `str` values. (#86) + +### Bug fixes * Fixed an issue with `HTMLTextDocument()` returning extracted `HTMLDependency()`s in a non-determistic order. (#95) diff --git a/Makefile b/Makefile index 768ab24..2a93dcd 100644 --- a/Makefile +++ b/Makefile @@ -95,3 +95,10 @@ check: pyright lint ## check code quality with pyright, flake8, black and isort black --check . echo "Sorting imports with isort." isort --check-only --diff . + +check-fix: ## check/fix code quality with pyright, flake8, black and isort + @echo "Fixing code with black." + black . + @echo "Sorting imports with isort." + isort . + $(MAKE) pyright lint diff --git a/htmltools/__init__.py b/htmltools/__init__.py index e9e243e..034d906 100644 --- a/htmltools/__init__.py +++ b/htmltools/__init__.py @@ -1,8 +1,8 @@ -__version__ = "0.5.3.9000" +__version__ = "0.5.3.9001" from . import svg, tags -from ._core import TagAttrArg # pyright: ignore[reportUnusedImport] # noqa: F401 -from ._core import TagChildArg # pyright: ignore[reportUnusedImport] # noqa: F401 +from ._core import TagAttrArg # pyright: ignore[reportUnusedImport] # noqa: F401 +from ._core import TagChildArg # pyright: ignore[reportUnusedImport] # noqa: F401 from ._core import ( HTML, HTMLDependency, @@ -10,6 +10,7 @@ HTMLTextDocument, MetadataNode, RenderedHTML, + ReprHtml, Tag, TagAttrs, TagAttrValue, @@ -18,7 +19,10 @@ Tagifiable, TagList, TagNode, + consolidate_attrs, head_content, + is_tag_child, + is_tag_node, wrap_displayhook_handler, ) from ._util import css, html_escape @@ -59,7 +63,11 @@ "Tagifiable", "TagList", "TagNode", + "ReprHtml", + "consolidate_attrs", "head_content", + "is_tag_child", + "is_tag_node", "wrap_displayhook_handler", "css", "html_escape", diff --git a/htmltools/_core.py b/htmltools/_core.py index 68c5499..d70c103 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -11,6 +11,7 @@ import tempfile import urllib.parse import webbrowser +from collections import UserString from copy import copy, deepcopy from pathlib import Path from typing import ( @@ -25,6 +26,7 @@ TypeVar, Union, cast, + overload, ) # Even though TypedDict is available in Python 3.8, because it's used with NotRequired, @@ -35,6 +37,11 @@ else: from typing_extensions import Never, NotRequired, TypedDict +if sys.version_info >= (3, 13): + from typing import TypeIs +else: + from typing_extensions import TypeIs + from typing import Literal, Protocol, SupportsIndex, runtime_checkable from packaging.version import Version @@ -62,7 +69,10 @@ "TagNode", "TagFunction", "Tagifiable", + "consolidate_attrs", "head_content", + "is_tag_child", + "is_tag_node", "wrap_displayhook_handler", ) @@ -87,7 +97,7 @@ class MetadataNode: TagT = TypeVar("TagT", bound="Tag") -TagAttrValue = Union[str, float, bool, None] +TagAttrValue = Union[str, float, bool, "HTML", None] """ Types that can be passed in as attributes to `Tag` functions. These values will be converted to strings before being stored as tag attributes. @@ -99,13 +109,24 @@ class MetadataNode: unnamed arguments to Tag functions like `div()`. """ -TagNode = Union["Tagifiable", "Tag", MetadataNode, "ReprHtml", str] +# NOTE: If this type is updated, please update `is_tag_node()` +TagNode = Union[ + "Tagifiable", + # "Tag", # Tag is Tagifiable, do not include here + # "TagList" is Tagifiable, so it is included in practice. + # But in reality it should be excluded because a TagList cannot contain a TagList. + MetadataNode, + "ReprHtml", + str, + "HTML", +] """ Types of objects that can be a node in a `Tag` tree. Equivalently, these are the valid elements of a `TagList`. Note that this type represents the internal structure of items in a `TagList`; the user-facing type is `TagChild`. """ +# NOTE: If this type is updated, please update `is_tag_child()` TagChild = Union[ TagNode, "TagList", @@ -119,6 +140,7 @@ class MetadataNode: will be flattened and normalized to `TagNode` objects. """ + # These two types existed in htmltools 0.14.0 and earlier. They are here so that # existing versions of Shiny will be able to load, but users of those existing packages # will see type errors, which should encourage them to upgrade Shiny. @@ -126,6 +148,77 @@ class MetadataNode: TagAttrArg = Never +# # No use yet, so keeping code commented for now +# TagNodeT = TypeVar("TagNodeT", bound=TagNode) +# """ +# Type variable for `TagNode`. +# """ + +TagChildT = TypeVar("TagChildT", bound=TagChild) +""" +Type variable for `TagChild`. +""" + + +def is_tag_node(x: object) -> TypeIs[TagNode]: + """ + Check if an object is a `TagNode`. + + Note: The type hint is `TypeIs[TagNode]` to allow for type checking of the + return value. (`TypeIs` is imported from `typing_extensions` for Python < 3.13.) + + Parameters + ---------- + x + Object to check. + + Returns + ------- + : + `True` if the object is a `TagNode`, `False` otherwise. + """ + # Note: Tag and TagList are both Tagifiable + return isinstance(x, (Tagifiable, MetadataNode, ReprHtml, str, HTML)) + + +def is_tag_child(x: object) -> TypeIs[TagChild]: + """ + Check if an object is a `TagChild`. + + Note: The type hint is `TypeIs[TagChild]` to allow for type checking of the + return value. (`TypeIs` is imported from `typing_extensions` for Python < 3.13.) + + Parameters + ---------- + x + Object to check. + + Returns + ------- + : + `True` if the object is a `TagChild`, `False` otherwise. + """ + + if is_tag_node(x): + return True + if x is None: + return True + if isinstance( + x, + ( + # TagNode, # Handled above + TagList, + float, + # None, # Handled above + Sequence, + ), + ): + return True + + # Could not determine the type + return False + + @runtime_checkable class Tagifiable(Protocol): """ @@ -133,7 +226,7 @@ class Tagifiable(Protocol): returns a `TagList`, the children of the `TagList` must also be tagified. """ - def tagify(self) -> "TagList | Tag | MetadataNode | str": ... + def tagify(self) -> "TagList | Tag | MetadataNode | str | HTML": ... @runtime_checkable @@ -268,7 +361,7 @@ def get_html_string( *, add_ws: bool = True, _escape_strings: bool = True, - ) -> "HTML": + ) -> str: """ Return the HTML string for this tag list. @@ -320,7 +413,7 @@ def get_html_string( if prev_was_add_ws: html_ += " " * indent - html_ += child._repr_html_() # type: ignore + html_ += child._repr_html_() # pyright: ignore[reportPrivateUsage] prev_was_add_ws = False @@ -341,7 +434,7 @@ def get_html_string( prev_was_add_ws = False - return HTML(html_) + return html_ def get_dependencies(self, *, dedup: bool = True) -> list["HTMLDependency"]: """ @@ -394,7 +487,7 @@ def _repr_html_(self) -> str: # ============================================================================= # TagAttrDict class # ============================================================================= -class TagAttrDict(Dict[str, str]): +class TagAttrDict(Dict[str, "str | HTML"]): """ A dictionary-like object that can be used to store attributes for a tag. All attribute values will be stored as strings. @@ -437,9 +530,8 @@ def update( # type: ignore[reportIncompatibleMethodOverride] # TODO-future: fix continue nm = self._normalize_attr_name(k) - # Preserve the HTML() when combining two HTML() attributes if nm in attrz: - val = attrz[nm] + HTML(" ") + val + val = attrz[nm] + " " + val attrz[nm] = val @@ -453,15 +545,17 @@ def _normalize_attr_name(x: str) -> str: return x.replace("_", "-") @staticmethod - def _normalize_attr_value(x: TagAttrValue) -> Optional[str]: + def _normalize_attr_value(x: TagAttrValue) -> str | HTML | None: if x is None or x is False: return None if x is True: return "" - if isinstance(x, (int, float)): - return str(x) - if isinstance(x, (HTML, str)): # type: ignore[reportUnnecessaryIsInstance] + # Return both str and HTML objects as is. + # HTML objects will handle value escaping when added to other values + if isinstance(x, (str, HTML)): return x + if isinstance(x, (int, float)): # pyright: ignore[reportUnnecessaryIsInstance] + return str(x) raise TypeError( f"Invalid type for attribute: {type(x)}." + "Consider calling str() on this value before treating it as a tag attribute." @@ -689,7 +783,7 @@ def has_class(self, class_: str) -> bool: else: return False - def add_style(self: TagT, style: str, *, prepend: bool = False) -> TagT: + def add_style(self: TagT, style: str | HTML, *, prepend: bool = False) -> TagT: """ Add a style value(s) to the HTML style attribute. @@ -713,7 +807,7 @@ def add_style(self: TagT, style: str, *, prepend: bool = False) -> TagT: """ if isinstance( # type: ignore[reportUnnecessaryIsInstance] - style, str + style, (str, HTML) ) and not style.endswith(";"): raise ValueError("`Tag.add_style(style=)` must end with a semicolon") @@ -732,7 +826,7 @@ def tagify(self: TagT) -> TagT: cp.children = cp.children.tagify() return cp - def get_html_string(self, indent: int = 0, eol: str = "\n") -> "HTML": + def get_html_string(self, indent: int = 0, eol: str = "\n") -> str: """ Get the HTML string representation of the tag. @@ -758,20 +852,20 @@ def get_html_string(self, indent: int = 0, eol: str = "\n") -> "HTML": # Don't enclose JSX/void elements if there are no children if len(children) == 0 and self.name in _VOID_TAG_NAMES: - return HTML(html_ + "/>") + return html_ + "/>" # Other empty tags are enclosed html_ += ">" close = "" if len(children) == 0: - return HTML(html_ + close) + return html_ + close # Inline a single/empty child text node - if len(children) == 1 and isinstance(children[0], str): + if len(children) == 1 and isinstance(children[0], (str, HTML)): if self.name in _NO_ESCAPE_TAG_NAMES: - return HTML(html_ + children[0] + close) + return html_ + str(children[0]) + close else: - return HTML(html_ + _normalize_text(children[0]) + close) + return html_ + _normalize_text(children[0]) + close # Write children if self.add_ws: @@ -787,7 +881,7 @@ def get_html_string(self, indent: int = 0, eol: str = "\n") -> "HTML": if self.add_ws: html_ += eol + indent_str - return HTML(html_ + close) + return html_ + close def render(self) -> RenderedHTML: """ @@ -908,8 +1002,8 @@ def wrap_displayhook_handler( def handler_wrapper(value: object) -> None: if isinstance(value, (Tag, TagList, Tagifiable)): handler(value) - elif hasattr(value, "_repr_html_"): - handler(HTML(value._repr_html_())) # pyright: ignore + elif isinstance(value, ReprHtml): + handler(HTML(value._repr_html_())) # pyright: ignore[reportPrivateUsage] elif value not in (None, ...): handler(value) @@ -1240,7 +1334,9 @@ def _static_extract_serialized_html_deps( # ============================================================================= # HTML strings # ============================================================================= -class HTML(str): + + +class HTML(UserString): """ Mark a string as raw HTML. This will prevent the string from being escaped when rendered inside an HTML tag. @@ -1254,13 +1350,42 @@ class HTML(str):

Hello

""" + def __init__(self, html: object) -> None: + super().__init__(str(html)) + def __str__(self) -> str: return self.as_string() - # HTML() + HTML() should return HTML() - def __add__(self, other: "str| HTML") -> str: - res = str.__add__(self, other) - return HTML(res) if isinstance(other, HTML) else res + # DEV NOTE: 2024/09 - + # This class is a building block for other classes, therefore it should not + # tagifiable! If this method is added, HTML strings are escaped within Shiny and + # not kept "as is" + # def tagify(self) -> Tag: + # return self.as_string() + + # Cases: + # * `str + str` should return str # Not HTML's responsibility! + # * `str + HTML()` should return HTML() # Handled by HTML.__radd__() + # * `HTML() + str` should return HTML() + # * `HTML() + HTML()` should return HTML() + def __add__(self, other: object) -> HTML: + if isinstance(other, HTML): + # HTML strings should be concatenated without escaping + # Convert each element to strings, then concatenate them, and return HTML + # Case: `HTML() + HTML()` + return HTML(self.as_string() + other.as_string()) + + # Non-HTML text added to HTML should be escaped before being added + # Convert each element to strings, then concatenate them, and return HTML + # Case: `HTML() + str` + return HTML(self.as_string() + html_escape(str(other))) + + # Right side addition for when types are: `str + HTML()` or `unknown + HTML()` + def __radd__(self, other: object) -> HTML: + # Non-HTML text added to HTML should be escaped before being added + # Convert each element to strings, then concatenate them, and return HTML + # Case: `str + HTML()` + return HTML(html_escape(str(other)) + self.as_string()) def __repr__(self) -> str: return self.as_string() @@ -1269,7 +1394,8 @@ def _repr_html_(self) -> str: return self.as_string() def as_string(self) -> str: - return self + "" + # Returns a new string + return self.data + "" # ============================================================================= @@ -1712,6 +1838,61 @@ def head_content(*args: TagChild) -> HTMLDependency: return HTMLDependency(name=name, version="0.0", head=head) +# If no children are provided, it will not be able to infer the type of `TagChildT`. +# Using `TagChild`, even though the list will be empty. +@overload +def consolidate_attrs( + *args: TagAttrs, + **kwargs: TagAttrValue, +) -> tuple[TagAttrs, list[TagChild]]: ... + + +# Same as original definition +@overload +def consolidate_attrs( + *args: TagChildT | TagAttrs, + **kwargs: TagAttrValue, +) -> tuple[TagAttrs, list[TagChildT]]: ... + + +def consolidate_attrs( + *args: TagChildT | TagAttrs, + **kwargs: TagAttrValue, +) -> tuple[TagAttrs, list[TagChildT]]: + """ + Consolidate attributes and children into a single tuple. + + Convenience function to consolidate attributes and children into a single tuple. All + `args` that are not dictionaries are considered children. This helps preserve the + non-attribute elements within `args`. To extract the attributes, all `args` and + `kwargs` are passed to `Tag` function and the attributes (`.attrs`) are extracted + from the resulting `Tag` object. + + Parameters + ---------- + *args + Child elements to this tag and attribute dictionaries. + **kwargs + Named attributes to this tag. + + Returns + ------- + : + A tuple of attributes and children. The attributes are a dictionary of combined + named attributes, and the children are a list of unaltered child elements. + """ + tag = Tag("consolidate_attrs", *args, **kwargs) + + # Convert to a plain dict to avoid getting custom methods from TagAttrDict + # Cast to `TagAttrs` as that is the common type used by py-shiny + attrs = cast(TagAttrs, dict(tag.attrs)) + + # Do not alter/flatten children structure (like `TagList` does) + # Instead, return all `args` who are not dictionaries + children = [child for child in args if not isinstance(child, dict)] + return (attrs, children) + + # ============================================================================= # Utility functions # ============================================================================= @@ -1724,7 +1905,7 @@ def _tagchilds_to_tagnodes(x: Iterable[TagChild]) -> list[TagNode]: for i, item in enumerate(result): if isinstance(item, (int, float)): result[i] = str(item) - elif not isinstance(item, (Tagifiable, Tag, MetadataNode, ReprHtml, str)): + elif not is_tag_node(item): raise TypeError( f"Invalid tag item type: {type(item)}. " + "Consider calling str() on this value before treating it as a tag item." @@ -1745,7 +1926,7 @@ def _tag_show( import IPython # pyright: ignore[reportUnknownVariableType] ipy = ( # pyright: ignore[reportUnknownVariableType] - IPython.get_ipython() # pyright: ignore[reportUnknownMemberType, reportPrivateImportUsage] + IPython.get_ipython() # pyright: ignore[reportUnknownMemberType, reportPrivateImportUsage, reportAttributeAccessIssue] ) renderer = "ipython" if ipy else "browser" except ImportError: @@ -1776,9 +1957,9 @@ def _tag_show( raise Exception(f"Unknown renderer {renderer}") -def _normalize_text(txt: str) -> str: +def _normalize_text(txt: str | HTML) -> str: if isinstance(txt, HTML): - return txt + return txt.as_string() else: return html_escape(txt, attr=False) diff --git a/htmltools/_util.py b/htmltools/_util.py index 0a943c7..e43c88b 100644 --- a/htmltools/_util.py +++ b/htmltools/_util.py @@ -16,7 +16,10 @@ HashableT = TypeVar("HashableT", bound=Hashable) -__all__ = ("css",) +__all__ = ( + "css", + "html_escape", +) def css( diff --git a/tests/test_consolidate_attrs.py b/tests/test_consolidate_attrs.py new file mode 100644 index 0000000..5e9d5df --- /dev/null +++ b/tests/test_consolidate_attrs.py @@ -0,0 +1,18 @@ +from htmltools import HTML, consolidate_attrs + + +def test_consolidate_attrs(): + + (attrs, children) = consolidate_attrs( + {"class": "&c1"}, + 0, + # This tests `__radd__` method of `HTML` class + {"id": "foo", "class_": HTML("&c2")}, + [1, [2]], + 3, + class_=HTML("&c3"), + other_attr="other", + ) + + assert attrs == {"id": "foo", "class": "&c1 &c2 &c3", "other-attr": "other"} + assert children == [0, [1, [2]], 3] diff --git a/tests/test_is.py b/tests/test_is.py new file mode 100644 index 0000000..c5e7874 --- /dev/null +++ b/tests/test_is.py @@ -0,0 +1,87 @@ +from typing import List + +import pytest + +from htmltools import ( + HTML, + HTMLDependency, + ReprHtml, + Tag, + TagAttrs, + TagChild, + TagList, + TagNode, + div, + is_tag_child, + is_tag_node, +) + +tag_attr_obj: TagAttrs = {"test_key": "test_value"} + + +class ReprClass: + + def _repr_html_(self) -> str: + return "repr_html" + + +repr_obj = ReprClass() + + +class OtherObj: + pass + + +class TagifiableClass: + def tagify(self) -> Tag: + return Tag("test_element").tagify() + + +def test_is_repr_html(): + assert isinstance(repr_obj, ReprHtml) + + +tag_node_objs: List[TagNode] = [ + TagifiableClass(), + Tag("test_element2"), + TagList([div("div_content")]), + HTMLDependency("test_dependency", version="1.0.0"), + repr_obj, + "test_string", + HTML("test_html"), +] +tag_child_only_objs: List[TagChild] = [ + # *tag_node_objs, + # [*tag_node_objs], + [Tag("test_element3")], + None, + [], +] + +not_tag_child_objs = [ + OtherObj(), +] + + +@pytest.mark.parametrize("obj", tag_node_objs) +def test_is_tag_node(obj): + assert is_tag_node(obj) + + +@pytest.mark.parametrize( + "obj", [[*tag_node_objs], *tag_child_only_objs, *not_tag_child_objs] +) +def test_not_is_tag_node(obj): + assert not is_tag_node(obj) + + +@pytest.mark.parametrize( + "obj", [*tag_node_objs, [*tag_node_objs], *tag_child_only_objs] +) +def test_is_tag_child(obj): + assert is_tag_child(obj) + + +@pytest.mark.parametrize("obj", not_tag_child_objs) +def test_not_is_tag_child(obj): + assert not is_tag_child(obj) diff --git a/tests/test_tags.py b/tests/test_tags.py index 29476bd..5acc4e8 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -79,7 +79,7 @@ def test_basic_tag_api(): and not x1.has_class("missing") ) # Add odd white space - x1.attrs["class"] = " " + x1.attrs["class"] + " " + x1.attrs["class"] = " " + str(x1.attrs["class"]) + " " x1.remove_class(" foo") # leading space assert x1.has_class("bar") and not x1.has_class("foo") and x1.has_class("baz") x1.remove_class("baz ") # trailing space @@ -108,6 +108,25 @@ def test_basic_tag_api(): x4.add_style("color: blue;", prepend=True) assert x4.attrs["style"] == "color: blue; color: red; color: green;" + x5 = div() + x5.add_style("color: &purple;") + assert isinstance(x5.attrs["style"], str) + assert x5.attrs["style"] == "color: &purple;" + x5.add_style(HTML("color: &green;")) + assert isinstance(x5.attrs["style"], HTML) + assert x5.attrs["style"] == HTML("color: &purple; color: &green;") + + x6 = div() + x6.add_style("color: &red;") + assert isinstance(x6.attrs["style"], str) + assert x6.attrs["style"] == "color: &red;" + x6.add_style(HTML("color: &green;"), prepend=True) + assert isinstance(x6.attrs["style"], HTML) + assert x6.attrs["style"] == HTML("color: &green; color: &red;") + assert isinstance(x6.attrs["style"], HTML) + x6.add_style(HTML("color: &blue;")) + assert x6.attrs["style"] == HTML("color: &green; color: &red; color: &blue;") + def test_tag_list_dict(): # Dictionaries allowed at top level @@ -156,13 +175,50 @@ def test_tag_multiple_repeated_attrs(): assert z.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_non_escaped_text_is_escaped_when_added_to_html(): + x = HTML("&") + " &" + x_str = str(x) + assert isinstance(x, HTML) + assert x_str == "& &" + + +def test_html_equals_html(): + x = "

a top level

\n" + a = HTML(x) + b = HTML(x) + assert a == b + assert a == x + assert x == b + assert x == x # for completeness + + +def test_html_adds_str_or_html(): + # first = "first" + # second = "second" + # firstsecond = first + second + + amp = "&" + esc_amp = "&" + + none = amp + amp + first_html = HTML(amp) + amp + second_html = amp + HTML(amp) + + both_html = HTML(amp) + HTML(amp) + + assert TagList(none).get_html_string() == f"{esc_amp}{esc_amp}" + assert isinstance(none, str) + + assert TagList(first_html).get_html_string() == f"{amp}{esc_amp}" + assert isinstance(first_html, HTML) + + assert TagList(second_html).get_html_string() == f"{esc_amp}{amp}" + assert isinstance(second_html, HTML) + + assert TagList(both_html).get_html_string() == f"{amp}{amp}" + assert isinstance(both_html, HTML) def test_tag_shallow_copy(): @@ -505,7 +561,7 @@ def test_tag_escaping(): # Attributes are HTML escaped expect_html(div("text", class_=""), '
text
') expect_html(div("text", class_="'ab'"), '
text
') - # Unless they are wrapped in HTML() + # Attributes support `HTML()` values expect_html(div("text", class_=HTML("")), '
text
') # script and style tags are not escaped @@ -611,7 +667,7 @@ def _walk_mutate(x: TagNode, fn: Callable[[TagNode], TagNode]) -> TagNode: x.children[i] = _walk_mutate(child, fn) elif isinstance(x, list): for i, child in enumerate(x): - x[i] = _walk_mutate(child, fn) + x[i] = _walk_mutate(child, fn) # pyright: ignore[reportArgumentType] return x