From 56487c6a320d7d49460efbdac15dbd0ed49e2047 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:24:11 -0400 Subject: [PATCH 01/18] Convert `TagList` to inherit `collections.UserList`, instead of `typing.List` --- htmltools/_core.py | 29 ++++++++++++++--------------- htmltools/_util.py | 6 +++++- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index d70c103..b2e12f8 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) @@ -275,26 +274,26 @@ class TagList(List[TagNode]): 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(x)) + super().extend(_tagchilds_to_tagnodes(other)) - 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.data[i:i] = _tagchilds_to_tagnodes([item]) def tagify(self) -> "TagList": """ @@ -306,16 +305,16 @@ def tagify(self) -> "TagList": # Iterate backwards because if we hit a Tagifiable object, it may be replaced # with 0, 1, or more items (if it returns TagList). for i in reversed(range(len(cp))): - child = cp[i] + child = cp.data[i] if isinstance(child, Tagifiable): tagified_child = child.tagify() if isinstance(tagified_child, TagList): # If the Tagifiable object returned a TagList, flatten it into this # one. - cp[i : i + 1] = _tagchilds_to_tagnodes(tagified_child) + cp.data[i : i + 1] = _tagchilds_to_tagnodes(tagified_child) else: - cp[i] = tagified_child + cp.data[i] = tagified_child elif isinstance(child, MetadataNode): cp[i] = copy(child) @@ -342,7 +341,7 @@ def save_html( The path to the generated HTML file. """ - return HTMLDocument(self).save_html( + return HTMLDocument(self.data).save_html( file, libdir=libdir, include_version=include_version ) @@ -382,7 +381,7 @@ def get_html_string( first_child = True prev_was_add_ws = add_ws - for child in self: + for child in self.data: if isinstance(child, MetadataNode): continue @@ -447,7 +446,7 @@ def get_dependencies(self, *, dedup: bool = True) -> list["HTMLDependency"]: """ deps: list[HTMLDependency] = [] - for x in self: + for x in self.data: if isinstance(x, HTMLDependency): deps.append(x) elif isinstance(x, Tag): diff --git a/htmltools/_util.py b/htmltools/_util.py index e43c88b..68183e3 100644 --- a/htmltools/_util.py +++ b/htmltools/_util.py @@ -86,8 +86,12 @@ 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, TagList): + _flatten_recurse(item, result) + elif isinstance(item, (list, tuple)): # 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] From 5484dcf227a24271d451cdffb86fe32d761efd51 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:27:52 -0400 Subject: [PATCH 02/18] Update _util.py --- htmltools/_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htmltools/_util.py b/htmltools/_util.py index 68183e3..9be84e6 100644 --- a/htmltools/_util.py +++ b/htmltools/_util.py @@ -90,7 +90,7 @@ def _flatten_recurse(x: Iterable[T | None], result: list[T]) -> None: for item in x: if isinstance(item, TagList): - _flatten_recurse(item, result) + _flatten_recurse(item.data, result) # pyright: ignore[reportArgumentType] elif isinstance(item, (list, tuple)): # Don't yet know how to specify recursive generic types, so we'll tell # the type checker to ignore this line. From d8ba0c250ed25a9a5f7057179cbca4cd9d469a71 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:31:43 -0400 Subject: [PATCH 03/18] Remove many `.data` property calls --- htmltools/_core.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index b2e12f8..8226024 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -293,7 +293,7 @@ def insert(self, i: SupportsIndex, item: TagChild) -> None: Insert tag children before a given index. """ - self.data[i:i] = _tagchilds_to_tagnodes([item]) + self[i:i] = _tagchilds_to_tagnodes([item]) def tagify(self) -> "TagList": """ @@ -305,16 +305,16 @@ def tagify(self) -> "TagList": # Iterate backwards because if we hit a Tagifiable object, it may be replaced # with 0, 1, or more items (if it returns TagList). for i in reversed(range(len(cp))): - child = cp.data[i] + child = cp[i] if isinstance(child, Tagifiable): tagified_child = child.tagify() if isinstance(tagified_child, TagList): # If the Tagifiable object returned a TagList, flatten it into this # one. - cp.data[i : i + 1] = _tagchilds_to_tagnodes(tagified_child) + cp[i : i + 1] = _tagchilds_to_tagnodes(tagified_child) else: - cp.data[i] = tagified_child + cp[i] = tagified_child elif isinstance(child, MetadataNode): cp[i] = copy(child) @@ -341,7 +341,7 @@ def save_html( The path to the generated HTML file. """ - return HTMLDocument(self.data).save_html( + return HTMLDocument(self).save_html( file, libdir=libdir, include_version=include_version ) @@ -381,7 +381,7 @@ def get_html_string( first_child = True prev_was_add_ws = add_ws - for child in self.data: + for child in self: if isinstance(child, MetadataNode): continue @@ -446,7 +446,7 @@ def get_dependencies(self, *, dedup: bool = True) -> list["HTMLDependency"]: """ deps: list[HTMLDependency] = [] - for x in self.data: + for x in self: if isinstance(x, HTMLDependency): deps.append(x) elif isinstance(x, Tag): From 60803c34dba11eb3e66180da8e8ce77eeb087e64 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:31:54 -0400 Subject: [PATCH 04/18] Consolidate if instance check --- htmltools/_util.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/htmltools/_util.py b/htmltools/_util.py index 9be84e6..3138ea2 100644 --- a/htmltools/_util.py +++ b/htmltools/_util.py @@ -89,12 +89,13 @@ def _flatten_recurse(x: Iterable[T | None], result: list[T]) -> None: from ._core import TagList for item in x: - if isinstance(item, TagList): - _flatten_recurse(item.data, result) # pyright: ignore[reportArgumentType] - elif 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) From c75e6f4040680c6ef982ec7ef923181810ceba1f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:34:23 -0400 Subject: [PATCH 05/18] Update _core.py --- htmltools/_core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index 8226024..f645633 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -254,7 +254,10 @@ def _repr_html_(self) -> str: ... # ============================================================================= # TagList class # ============================================================================= -class TagList(UserList[TagNode]): +_TagListParentClass = UserList if sys.version_info <= (3, 8) else UserList[TagNode] + + +class TagList(_TagListParentClass): """ Create an HTML tag list (i.e., a fragment of HTML) From 68fe80dcad924da029858803736a010965e20e11 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:40:23 -0400 Subject: [PATCH 06/18] Reverse testing order --- .github/workflows/pytest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 231dbac..4c175f6 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", "3.8"] os: [ubuntu-latest, windows-latest, macOS-latest] fail-fast: false defaults: From a2773232eeee6703a27c987c226d5f5972893c7b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:40:36 -0400 Subject: [PATCH 07/18] Rearrange problematic code to a multi line if --- htmltools/_core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index f645633..9ab8a40 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -254,7 +254,11 @@ def _repr_html_(self) -> str: ... # ============================================================================= # TagList class # ============================================================================= -_TagListParentClass = UserList if sys.version_info <= (3, 8) else UserList[TagNode] +if sys.version_info > (3, 8): + _TagListParentClass = UserList[TagNode] +else: + # In Python 3.8 and earlier, `UserList` does not like to be subclassed + _TagListParentClass = UserList class TagList(_TagListParentClass): From 7e3a342ac52efaf09b35cd9c5e950fda532d1595 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:47:21 -0400 Subject: [PATCH 08/18] Drop support for python 3.8 --- .github/workflows/pytest.yaml | 2 +- htmltools/_core.py | 9 +---- pyproject.toml | 63 +++++++++++++++-------------------- 3 files changed, 29 insertions(+), 45 deletions(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 4c175f6..4d8fb68 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.12", "3.11", "3.10", "3.9", "3.8"] + python-version: ["3.12", "3.11", "3.10", "3.9", "3.9"] os: [ubuntu-latest, windows-latest, macOS-latest] fail-fast: false defaults: diff --git a/htmltools/_core.py b/htmltools/_core.py index 9ab8a40..8226024 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -254,14 +254,7 @@ def _repr_html_(self) -> str: ... # ============================================================================= # TagList class # ============================================================================= -if sys.version_info > (3, 8): - _TagListParentClass = UserList[TagNode] -else: - # In Python 3.8 and earlier, `UserList` does not like to be subclassed - _TagListParentClass = UserList - - -class TagList(_TagListParentClass): +class TagList(UserList[TagNode]): """ Create an HTML tag list (i.e., a fragment of HTML) 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"] From a007e91e9997d2d583fec88a50a4d8296b1a5098 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 15:53:32 -0400 Subject: [PATCH 09/18] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466b9b5..f6645da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ 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) From ab01d5da0b8a12eb3745f55e706811cb46734b78 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 27 Sep 2024 16:49:45 -0400 Subject: [PATCH 10/18] Update .github/workflows/pytest.yaml --- .github/workflows/pytest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 4d8fb68..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.12", "3.11", "3.10", "3.9", "3.9"] + python-version: ["3.12", "3.11", "3.10", "3.9"] os: [ubuntu-latest, windows-latest, macOS-latest] fail-fast: false defaults: From e2c8446e146224133e2da801eb430ed8544c33aa Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 11:14:34 -0400 Subject: [PATCH 11/18] Remove dup entry --- .github/workflows/pytest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 4d8fb68..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.12", "3.11", "3.10", "3.9", "3.9"] + python-version: ["3.12", "3.11", "3.10", "3.9"] os: [ubuntu-latest, windows-latest, macOS-latest] fail-fast: false defaults: From b990e1d4e5edd72085cb510e5064b545f35f3ac4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 11:14:55 -0400 Subject: [PATCH 12/18] Bump to dev version 0.5.3.9002 --- htmltools/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a61037da2a64a3513eb2261223d5a38485d4e693 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 11:48:31 -0400 Subject: [PATCH 13/18] While a str is Iterable, it is not a list. Wrap it up --- htmltools/_core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/htmltools/_core.py b/htmltools/_core.py index 8226024..24751c3 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -279,6 +279,10 @@ def extend(self, other: Iterable[TagChild]) -> None: Extend the children by appending an iterable of children. """ + # If other is a string (an iterable), convert it to a list of s. + if isinstance(other, str): + other = [other] + super().extend(_tagchilds_to_tagnodes(other)) def append(self, item: TagChild, *args: TagChild) -> None: From 4ba67404673b5664766b5b2356f46eccb2f01534 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 11:54:09 -0400 Subject: [PATCH 14/18] Add `__add__` and `__radd__` methods to TagList --- htmltools/_core.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/htmltools/_core.py b/htmltools/_core.py index 24751c3..c648e2e 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -299,6 +299,28 @@ def insert(self, i: SupportsIndex, item: TagChild) -> None: 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. + """ + + should_not_expand = isinstance(item, str) + if should_not_expand: + 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. + """ + + should_not_expand = isinstance(item, str) + if should_not_expand: + return TagList(item, self) + + return TagList(*item, self) + def tagify(self) -> "TagList": """ Convert any tagifiable children to Tag/TagList objects. From 988acc5dab3030ad869911bbe8ed54962ce85f0d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 11:54:25 -0400 Subject: [PATCH 15/18] Update CHANGELOG.md --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6645da..27b89ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `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 From c92fbe15fbb709594d6031d5973e58699ecd513a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 15:39:24 -0400 Subject: [PATCH 16/18] Add unit tests on contructor, add/radd, and extend --- tests/test_tags.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/test_tags.py b/tests/test_tags.py index 5acc4e8..1a37d97 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -698,6 +698,97 @@ 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_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"] From 39790378906b6da59fdeabf2af069f4a1f3d4187 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 15:39:47 -0400 Subject: [PATCH 17/18] safe-guard str not _really_ intending to be`Iterable` --- htmltools/_core.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/htmltools/_core.py b/htmltools/_core.py index c648e2e..405b6ce 100644 --- a/htmltools/_core.py +++ b/htmltools/_core.py @@ -271,6 +271,12 @@ class TagList(UserList[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)) @@ -278,11 +284,6 @@ def extend(self, other: Iterable[TagChild]) -> None: """ Extend the children by appending an iterable of children. """ - - # If other is a string (an iterable), convert it to a list of s. - if isinstance(other, str): - other = [other] - super().extend(_tagchilds_to_tagnodes(other)) def append(self, item: TagChild, *args: TagChild) -> None: @@ -304,8 +305,7 @@ def __add__(self, item: Iterable[TagChild]) -> TagList: Return a new TagList with the item added at the end. """ - should_not_expand = isinstance(item, str) - if should_not_expand: + if self._should_not_expand(item): return TagList(self, item) return TagList(self, *item) @@ -315,8 +315,7 @@ def __radd__(self, item: Iterable[TagChild]) -> TagList: Return a new TagList with the item added to the beginning. """ - should_not_expand = isinstance(item, str) - if should_not_expand: + if self._should_not_expand(item): return TagList(item, self) return TagList(*item, self) @@ -1926,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)): From ff0a562731b3eb06ca6ba3f5fede61cd50121dea Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 30 Sep 2024 15:54:54 -0400 Subject: [PATCH 18/18] Add more taglist test methods --- tests/test_tags.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_tags.py b/tests/test_tags.py index 1a37d97..fba506b 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -763,6 +763,56 @@ def assert_tag_list(x: TagList, contents: list[str]) -> None: 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"]