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 = "" + self.name + ">" 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