Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(TagList)!: TagList now inherits from collections.UserList, instead of typing.List #97

Merged
merged 19 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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