diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 231dbac..eec2410 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.12", "3.11", "3.10", "3.9"] os: [ubuntu-latest, windows-latest, macOS-latest] fail-fast: false defaults: diff --git a/CHANGELOG.md b/CHANGELOG.md index 466b9b5..27b89ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `HTML` no longer inherits from `str`. It now inherits from `collections.UserString`. This was done to avoid confusion between `str` and `HTML` objects. (#86) +* `TagList` no longer inherits from `list`. It now inherits from `collections.UserList`. This was done to avoid confusion between `list` and `TagList` objects. (#97) + * `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) +* 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 other strings values. (#86) + +* Items added to `TagList` objects, now return `TagList` objects. E.g. `TagList_value + arr_value` and `arr_value + TagList_value` both return new `TagList` objects. To maintain a `list` result, call `list()` on your `TagList` objects before combining them to other list objects. (#97) ### New features diff --git a/htmltools/__init__.py b/htmltools/__init__.py index 034d906..cfe1bb6 100644 --- a/htmltools/__init__.py +++ b/htmltools/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.5.3.9001" +__version__ = "0.5.3.9002" from . import svg, tags from ._core import TagAttrArg # pyright: ignore[reportUnusedImport] # noqa: F401 diff --git a/htmltools/_core.py b/htmltools/_core.py index d70c103..405b6ce 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -11,7 +11,7 @@ import tempfile import urllib.parse import webbrowser -from collections import UserString +from collections import UserList, UserString from copy import copy, deepcopy from pathlib import Path from typing import ( @@ -19,7 +19,6 @@ Callable, Dict, Iterable, - List, Mapping, Optional, Sequence, @@ -255,7 +254,7 @@ def _repr_html_(self) -> str: ... # ============================================================================= # TagList class # ============================================================================= -class TagList(List[TagNode]): +class TagList(UserList[TagNode]): """ Create an HTML tag list (i.e., a fragment of HTML) @@ -272,29 +271,54 @@ class TagList(List[TagNode]):
""" + def _should_not_expand(self, x: object) -> TypeIs[str]: + """ + Check if an object should not be expanded into a list of children. + """ + return isinstance(x, str) + def __init__(self, *args: TagChild) -> None: super().__init__(_tagchilds_to_tagnodes(args)) - def extend(self, x: Iterable[TagChild]) -> None: + def extend(self, other: Iterable[TagChild]) -> None: """ Extend the children by appending an iterable of children. """ + super().extend(_tagchilds_to_tagnodes(other)) - super().extend(_tagchilds_to_tagnodes(x)) - - def append(self, *args: TagChild) -> None: + def append(self, item: TagChild, *args: TagChild) -> None: """ Append tag children to the end of the list. """ - self.extend(args) + self.extend([item, *args]) - def insert(self, index: SupportsIndex, x: TagChild) -> None: + def insert(self, i: SupportsIndex, item: TagChild) -> None: """ Insert tag children before a given index. """ - self[index:index] = _tagchilds_to_tagnodes([x]) + self[i:i] = _tagchilds_to_tagnodes([item]) + + def __add__(self, item: Iterable[TagChild]) -> TagList: + """ + Return a new TagList with the item added at the end. + """ + + if self._should_not_expand(item): + return TagList(self, item) + + return TagList(self, *item) + + def __radd__(self, item: Iterable[TagChild]) -> TagList: + """ + Return a new TagList with the item added to the beginning. + """ + + if self._should_not_expand(item): + return TagList(item, self) + + return TagList(*item, self) def tagify(self) -> "TagList": """ @@ -1901,6 +1925,9 @@ def consolidate_attrs( # Convert a list of TagChild objects to a list of TagNode objects. Does not alter input # object. def _tagchilds_to_tagnodes(x: Iterable[TagChild]) -> list[TagNode]: + if isinstance(x, str): + return [x] + result = flatten(x) for i, item in enumerate(result): if isinstance(item, (int, float)): diff --git a/htmltools/_util.py b/htmltools/_util.py index e43c88b..3138ea2 100644 --- a/htmltools/_util.py +++ b/htmltools/_util.py @@ -86,11 +86,16 @@ def flatten(x: Iterable[Union[T, None]]) -> list[T]: # Having this separate function and passing along `result` is faster than defining # a closure inside of `flatten()` (and not passing `result`). def _flatten_recurse(x: Iterable[T | None], result: list[T]) -> None: + from ._core import TagList + for item in x: - if isinstance(item, (list, tuple)): + if isinstance(item, (list, tuple, TagList)): # Don't yet know how to specify recursive generic types, so we'll tell # the type checker to ignore this line. - _flatten_recurse(item, result) # pyright: ignore[reportUnknownArgumentType] + _flatten_recurse( + item, # pyright: ignore[reportUnknownArgumentType] + result, # pyright: ignore[reportArgumentType] + ) elif item is not None: result.append(item) diff --git a/pyproject.toml b/pyproject.toml index 99c9aeb..3d3a42b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,56 +1,47 @@ [build-system] -requires = [ - "setuptools", - "wheel" -] +requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] name = "htmltools" dynamic = ["version"] -authors = [{name = "Carson Sievert", email = "carson@rstudio.com"}] +authors = [{ name = "Carson Sievert", email = "carson@rstudio.com" }] description = "Tools for HTML generation and output." readme = "README.md" -license = {file = "LICENSE"} +license = { file = "LICENSE" } keywords = ["html"] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Text Processing :: Markup :: HTML" -] -dependencies = [ - "typing-extensions>=3.10.0.0", - "packaging>=20.9", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Text Processing :: Markup :: HTML", ] -requires-python = ">=3.8" +dependencies = ["typing-extensions>=3.10.0.0", "packaging>=20.9"] +requires-python = ">=3.9" [project.urls] "Bug Tracker" = "https://github.com/rstudio/py-htmltools/issues" Source = "https://github.com/rstudio/py-htmltools" [project.optional-dependencies] -test = [ - "pytest>=6.2.4", - "syrupy>=4.6.0" -] +test = ["pytest>=6.2.4", "syrupy>=4.6.0"] dev = [ - "black>=24.2.0", - "flake8>=6.0.0", - "Flake8-pyproject", - "isort>=5.11.2", - "pyright>=1.1.348", - "pre-commit>=2.15.0", - "wheel", - "build" + "black>=24.2.0", + "flake8>=6.0.0", + "Flake8-pyproject", + "isort>=5.11.2", + "pyright>=1.1.348", + "pre-commit>=2.15.0", + "wheel", + "build", ] [tool.setuptools] @@ -59,7 +50,7 @@ include-package-data = true zip-safe = false [tool.setuptools.dynamic] -version = {attr = "htmltools.__version__"} +version = { attr = "htmltools.__version__" } [tool.setuptools.package-data] htmltools = ["py.typed"] diff --git a/tests/test_tags.py b/tests/test_tags.py index 5acc4e8..fba506b 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -698,6 +698,147 @@ def alter(x: TagNode) -> TagNode: assert cast_tag(x.children[1]).children[0] == "WORLD" +def test_taglist_constructor(): + + # From docs.python.org/3/library/collections.html#collections.UserList: + # > Subclasses of UserList are expected to offer a constructor which can be called + # > with either no arguments or one argument. List operations which return a new + # > sequence attempt to create an instance of the actual implementation class. To do + # > so, it assumes that the constructor can be called with a single parameter, which + # > is a sequence object used as a data source. + + x = TagList() + assert isinstance(x, TagList) + assert len(x) == 0 + assert x.get_html_string() == "" + + x = TagList("foo") + assert isinstance(x, TagList) + assert len(x) == 1 + assert x.get_html_string() == "foo" + + x = TagList(["foo", "bar"]) + assert isinstance(x, TagList) + assert len(x) == 2 + assert x.get_html_string() == "foobar" + + # Also support multiple inputs + x = TagList("foo", "bar") + assert isinstance(x, TagList) + assert len(x) == 2 + assert x.get_html_string() == "foobar" + + +def test_taglist_add(): + + # Similar to `HTML(UserString)`, a `TagList(UserList)` should be the result when + # adding to anything else. + + empty_arr = [] + int_arr = [1] + tl_foo = TagList("foo") + tl_bar = TagList("bar") + + def assert_tag_list(x: TagList, contents: list[str]) -> None: + assert isinstance(x, TagList) + assert len(x) == len(contents) + for i, content_item in enumerate(contents): + assert x[i] == content_item + + # Make sure the TagLists are not altered over time + assert len(empty_arr) == 0 + assert len(int_arr) == 1 + assert len(tl_foo) == 1 + assert len(tl_bar) == 1 + assert int_arr[0] == 1 + assert tl_foo[0] == "foo" + assert tl_bar[0] == "bar" + + assert_tag_list(empty_arr + tl_foo, ["foo"]) + assert_tag_list(tl_foo + empty_arr, ["foo"]) + assert_tag_list(int_arr + tl_foo, ["1", "foo"]) + assert_tag_list(tl_foo + int_arr, ["foo", "1"]) + assert_tag_list(tl_foo + tl_bar, ["foo", "bar"]) + assert_tag_list(tl_foo + "bar", ["foo", "bar"]) + assert_tag_list("foo" + tl_bar, ["foo", "bar"]) + + +def test_taglist_methods(): + # Testing methods from https://docs.python.org/3/library/stdtypes.html#common-sequence-operations + # + # Operation | Result | Notes + # --------- | ------ | ----- + # x in s | True if an item of s is equal to x, else False | (1) + # x not in s | False if an item of s is equal to x, else True | (1) + # s + t | the concatenation of s and t | (6)(7) + # s * n or n * s | equivalent to adding s to itself n times | (2)(7) + # s[i] | ith item of s, origin 0 | (3) + # s[i:j] | slice of s from i to j | (3)(4) + # s[i:j:k] | slice of s from i to j with step k | (3)(5) + # len(s) | length of s + # min(s) | smallest item of s + # max(s) | largest item of s + # s.index(x[, i[, j]]) | index of the first occurrence of x in s (at or after index i and before index j) | (8) + # s.count(x) | total number of occurrences of x in s + + x = TagList("foo", "bar", "foo", "baz") + y = TagList("a", "b", "c") + + assert "bar" in x + assert "qux" not in x + + add = x + y + assert isinstance(add, TagList) + assert list(add) == ["foo", "bar", "foo", "baz", "a", "b", "c"] + + mul = x * 2 + assert isinstance(mul, TagList) + assert list(mul) == ["foo", "bar", "foo", "baz", "foo", "bar", "foo", "baz"] + + assert x[1] == "bar" + assert x[1:3] == TagList("bar", "foo") + assert mul[1:6:2] == TagList("bar", "baz", "bar") + + assert len(x) == 4 + + assert min(x) == "bar" # pyright: ignore[reportArgumentType] + assert max(x) == "foo" # pyright: ignore[reportArgumentType] + + assert x.index("foo") == 0 + assert x.index("foo", 1) == 2 + with pytest.raises(ValueError): + x.index("foo", 1, 1) + + assert x.count("foo") == 2 + assert mul.count("foo") == 4 + + +def test_taglist_extend(): + x = TagList("foo") + y = ["bar", "baz"] + x.extend(y) + assert isinstance(x, TagList) + assert list(x) == ["foo", "bar", "baz"] + assert y == ["bar", "baz"] + + x = TagList("foo") + y = TagList("bar", "baz") + x.extend(y) + assert isinstance(x, TagList) + assert list(x) == ["foo", "bar", "baz"] + assert list(y) == ["bar", "baz"] + + x = TagList("foo") + y = "bar" + x.extend(y) + assert list(x) == ["foo", "bar"] + assert y == "bar" + + x = TagList("foo") + x.extend(TagList("bar")) + assert list(x) == ["foo", "bar"] + + def test_taglist_flatten(): x = div(1, TagList(2, TagList(span(3), 4))) assert list(x.children) == ["1", "2", span("3"), "4"]