Skip to content

Commit

Permalink
feat(TagList)!: TagList now inherits from collections.UserList, i…
Browse files Browse the repository at this point in the history
…nstead of `typing.List` (#97)
  • Loading branch information
schloerke authored Sep 30, 2024
1 parent 81e0749 commit c5083e0
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 51 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion htmltools/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
47 changes: 37 additions & 10 deletions htmltools/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@
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 (
Any,
Callable,
Dict,
Iterable,
List,
Mapping,
Optional,
Sequence,
Expand Down Expand Up @@ -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)
Expand All @@ -272,29 +271,54 @@ class TagList(List[TagNode]):
<div id="foo" class="bar"></div>
"""

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":
"""
Expand Down Expand Up @@ -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)):
Expand Down
9 changes: 7 additions & 2 deletions htmltools/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
63 changes: 27 additions & 36 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"}]
authors = [{ name = "Carson Sievert", email = "[email protected]" }]
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]
Expand All @@ -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"]
Expand Down
141 changes: 141 additions & 0 deletions tests/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down

0 comments on commit c5083e0

Please sign in to comment.