From e59a608cd5505f273d5b6279105d010eb2124771 Mon Sep 17 00:00:00 2001 From: Alex Rudy Date: Tue, 26 Mar 2024 20:07:45 -0700 Subject: [PATCH] Typing stub package for dominate (#5) Provides types internally used from dominate to increase mypy coverage --- .flake8 | 1 + pyproject.toml | 14 ++++-------- requirements/dev.txt | 4 ++-- src/bootlace/breadcrumbs.py | 5 +++-- src/bootlace/icon.py | 4 ++-- src/bootlace/nav/bar.py | 7 +++--- src/bootlace/nav/core.py | 17 +++++++++------ src/bootlace/nav/nav.py | 14 +++++++++--- src/bootlace/table/base.py | 3 ++- src/bootlace/table/columns.py | 7 +++--- src/bootlace/util.py | 11 +++++----- stubs/dominate-stubs/__init__.pyi | 0 stubs/dominate-stubs/dom_tag.pyi | 19 ++++++++++++++++ stubs/dominate-stubs/svg.pyi | 4 ++++ stubs/dominate-stubs/tags.pyi | 36 +++++++++++++++++++++++++++++++ stubs/dominate-stubs/util.pyi | 4 ++++ 16 files changed, 113 insertions(+), 37 deletions(-) create mode 100644 stubs/dominate-stubs/__init__.pyi create mode 100644 stubs/dominate-stubs/dom_tag.pyi create mode 100644 stubs/dominate-stubs/svg.pyi create mode 100644 stubs/dominate-stubs/tags.pyi create mode 100644 stubs/dominate-stubs/util.pyi diff --git a/.flake8 b/.flake8 index 94a5077..1fe233e 100644 --- a/.flake8 +++ b/.flake8 @@ -12,3 +12,4 @@ ignore = B902 max-line-length = 120 min_python_version = 3.11 +exclude = src/dominate-stubs diff --git a/pyproject.toml b/pyproject.toml index 7673023..c83861d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,15 +32,13 @@ source = "vcs" version-file = "src/bootlace/_version.py" [project.urls] -Documentation = "https://github.com/unknown/bootlace#readme" -Issues = "https://github.com/unknown/bootlace/issues" -Source = "https://github.com/unknown/bootlace" - +Documentation = "https://github.com/alexrudy/bootlace#readme" +Issues = "https://github.com/alexrudy/bootlace/issues" +Source = "https://github.com/alexrudy/bootlace" [tool.hatch.build.targets.sdist] include = ["src/"] - [tool.pytest.ini_options] testpaths = "tests" filterwarnings = "error" @@ -48,7 +46,6 @@ addopts = [ "--cov-report=term-missing", "--cov-config=pyproject.toml", "--cov=bootlace", - "--ignore=setup.py", ] log_level = "NOTSET" @@ -83,7 +80,7 @@ omit = ["src/bootlace/_version.py", "src/bootlace/testing/*"] line-length = 120 [tool.mypy] -files = "src/bootlace" +files = "src/bootlace,stubs/dominate-stubs" python_version = "3.11" show_error_codes = true allow_redefinition = true @@ -106,9 +103,6 @@ ignore_missing_imports = true module = "tests.*" disallow_subclassing_any = false -[[tool.mypy.overrides]] -module = "dominate.*" -ignore_missing_imports = true [[tool.mypy.overrides]] module = "html5lib.*" diff --git a/requirements/dev.txt b/requirements/dev.txt index 8be5dce..820d1dc 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -28,7 +28,7 @@ colorama==0.4.6 # via tox distlib==0.3.8 # via virtualenv -filelock==3.13.2 +filelock==3.13.3 # via # tox # virtualenv @@ -69,7 +69,7 @@ more-itertools==10.2.0 # via # jaraco-classes # jaraco-functools -nh3==0.2.15 +nh3==0.2.17 # via readme-renderer nodeenv==1.8.0 # via pre-commit diff --git a/src/bootlace/breadcrumbs.py b/src/bootlace/breadcrumbs.py index 0e3ba6a..c37bf10 100644 --- a/src/bootlace/breadcrumbs.py +++ b/src/bootlace/breadcrumbs.py @@ -7,6 +7,7 @@ import attrs from dominate import tags +from dominate.dom_tag import dom_tag from dominate.util import text from flask import Blueprint from flask import current_app @@ -129,7 +130,7 @@ def url(self) -> str: """The URL for the breadcrumb""" return self.link.url - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: if self.active: return text(self.title) @@ -161,7 +162,7 @@ def push(self, crumb: Breadcrumb) -> None: """Add a new crumb to the beginning of the list""" self.crumbs.insert(0, crumb) - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: if not self.crumbs: return text("") diff --git a/src/bootlace/icon.py b/src/bootlace/icon.py index ba6d18a..d0adfae 100644 --- a/src/bootlace/icon.py +++ b/src/bootlace/icon.py @@ -2,7 +2,7 @@ import attrs from dominate import svg -from dominate import tags +from dominate.dom_tag import dom_tag from flask import url_for @@ -36,7 +36,7 @@ def url(self) -> str: """The URL for the SVG source for the icon""" return url_for(self.endpoint, filename=self.filename, _anchor=self.name) - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: classes = ["bi", "me-1", "pe-none", "align-self-center"] return svg.svg( svg.use(xlink_href=self.url), diff --git a/src/bootlace/nav/bar.py b/src/bootlace/nav/bar.py index f545b49..0cb1e03 100644 --- a/src/bootlace/nav/bar.py +++ b/src/bootlace/nav/bar.py @@ -1,5 +1,6 @@ import attrs from dominate import tags +from dominate.dom_tag import dom_tag from dominate.util import container from .core import Link @@ -67,7 +68,7 @@ class Brand(Link): #: The ID of the brand id: str = attrs.field(factory=element_id.factory("navbar-brand")) - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: a = as_tag(self.link) a["class"] = "navbar-brand" a["id"] = self.id @@ -80,7 +81,7 @@ class NavBarCollapse(SubGroup): id: str = attrs.field(factory=element_id.factory("navbar-collapse")) - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: button = tags.button( type="button", cls="navbar-toggler", @@ -121,7 +122,7 @@ class NavBarSearch(NavElement): method: str = "GET" button: str | None = None - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: form = tags.form(id=self.id) form.classes.add("d-flex") form["role"] = "search" diff --git a/src/bootlace/nav/core.py b/src/bootlace/nav/core.py index 01e5dbd..a6a16d6 100644 --- a/src/bootlace/nav/core.py +++ b/src/bootlace/nav/core.py @@ -4,6 +4,7 @@ import attrs from dominate import tags +from dominate.dom_tag import dom_tag from bootlace import links from bootlace.image import Image @@ -42,12 +43,15 @@ def enabled(self) -> bool: """Whether the element is enabled""" return True - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: warnings.warn(BootlaceWarning(f"Unhandled element {self.__class__.__name__}"), stacklevel=2) return tags.comment(f"unhandled element {self.__class__.__name__}") - def element_state(self, tag: tags.html_tag) -> tags.html_tag: + def element_state(self, tag: dom_tag) -> dom_tag: """Apply :attr:`active` and :attr:`enabled` states to the tag.""" + if not isinstance(tag, tags.html_tag): + return tag + if self.active: tag.classes.add("active") tag.attributes["aria-current"] = "page" @@ -93,10 +97,11 @@ def url(self) -> str: """The URL for the link.""" return self.link.url - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: a = as_tag(self.link) - a["id"] = self.id - a.classes.add("nav-link") + if isinstance(a, tags.html_tag): + a["id"] = self.id + a.classes.add("nav-link") return self.element_state(a) @@ -119,7 +124,7 @@ class Text(NavElement): def enabled(self) -> bool: return False - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: tag = tags.span(self.text, cls="nav-link") return self.element_state(tag) diff --git a/src/bootlace/nav/nav.py b/src/bootlace/nav/nav.py index 3f30a0f..36d7bd4 100644 --- a/src/bootlace/nav/nav.py +++ b/src/bootlace/nav/nav.py @@ -1,3 +1,5 @@ +import warnings + import attrs from dominate import tags @@ -5,6 +7,7 @@ from .core import NavStyle from .core import SubGroup from bootlace.util import as_tag +from bootlace.util import BootlaceWarning from bootlace.util import ids as element_id @@ -66,9 +69,14 @@ def __tag__(self) -> tags.html_tag: menu = tags.ul(cls="dropdown-menu", aria_labelledby=self.id) for item in self.items: tag = as_tag(item) - tag.classes.remove("nav-link") - if not any(cls.startswith("dropdown-") for cls in tag.classes): - tag.classes.add("dropdown-item") + if isinstance(tag, tags.html_tag): + tag.classes.remove("nav-link") + if not any(cls.startswith("dropdown-") for cls in tag.classes): + tag.classes.add("dropdown-item") + else: + warnings.warn( + BootlaceWarning(f"Item {item!r} is not an html tag, may not display properly"), stacklevel=2 + ) menu.add(tags.li(tag, __pretty=False)) div.add(menu) return div diff --git a/src/bootlace/table/base.py b/src/bootlace/table/base.py index bf1028e..35194a0 100644 --- a/src/bootlace/table/base.py +++ b/src/bootlace/table/base.py @@ -8,6 +8,7 @@ import attrs from dominate import tags +from dominate.dom_tag import dom_tag from bootlace.icon import Icon from bootlace.util import as_tag @@ -54,7 +55,7 @@ def attribute(self) -> str: return self._attribute @abstractmethod - def cell(self, value: Any) -> tags.html_tag: + def cell(self, value: Any) -> dom_tag: """Return the cell for the column as an HTML tag.""" raise NotImplementedError("Subclasses must implement this method") diff --git a/src/bootlace/table/columns.py b/src/bootlace/table/columns.py index da3a10c..55fd89d 100644 --- a/src/bootlace/table/columns.py +++ b/src/bootlace/table/columns.py @@ -2,6 +2,7 @@ import attrs from dominate import tags +from dominate.dom_tag import dom_tag from dominate.util import text from flask import url_for @@ -19,7 +20,7 @@ class Column(ColumnBase): No special formatting is applied to the attribute, it is rendered as text.""" - def cell(self, value: Any) -> tags.html_tag: + def cell(self, value: Any) -> dom_tag: """Return the cell for the column as an HTML tag.""" return text(str(getattr(value, self.attribute))) @@ -49,7 +50,7 @@ class CheckColumn(ColumnBase): #: The icon for a false value no: Icon = attrs.field(default=Icon("x", width=16, height=16)) - def cell(self, value: Any) -> tags.html_tag: + def cell(self, value: Any) -> dom_tag: """Return the cell for the column as an HTML tag.""" if getattr(value, self.attribute): return as_tag(self.yes) @@ -60,6 +61,6 @@ def cell(self, value: Any) -> tags.html_tag: class Datetime(ColumnBase): """A column which shows a datetime attribute as an ISO formatted string.""" - def cell(self, value: Any) -> tags.html_tag: + def cell(self, value: Any) -> dom_tag: """Return the cell for the column as an HTML tag.""" return text(getattr(value, self.attribute).isoformat()) diff --git a/src/bootlace/util.py b/src/bootlace/util.py index 4598666..42349a6 100644 --- a/src/bootlace/util.py +++ b/src/bootlace/util.py @@ -13,6 +13,7 @@ import attrs from dominate import tags +from dominate.dom_tag import dom_tag from dominate.util import container from dominate.util import text from flask import request @@ -41,13 +42,13 @@ class BootlaceWarning(UserWarning): def _monkey_patch_dominate() -> None: """Monkey patch the dominate tags to support class attribute manipulation""" - tags.html_tag.classes = property(lambda self: Classes(self)) + tags.html_tag.classes = property(lambda self: Classes(self)) # type: ignore class Taggable(Protocol): """Protocol for objects that can be converted to a tag.""" - def __tag__(self) -> tags.html_tag: + def __tag__(self) -> dom_tag: """Convert the object to a dominate tag. This method gives objects control over how they are processed by :func:`as_tag`. It should return a @@ -61,13 +62,13 @@ def __tag__(self) -> tags.html_tag: #: A type that can be converted to a tag -IntoTag: TypeAlias = Taggable | tags.html_tag +IntoTag: TypeAlias = Taggable | dom_tag #: A type that can be converted to a tag via :func:`as_tag` -MaybeTaggable: TypeAlias = IntoTag | str | Iterable[Taggable | tags.html_tag] +MaybeTaggable: TypeAlias = IntoTag | str | Iterable[Taggable | dom_tag] -def as_tag(item: MaybeTaggable) -> tags.html_tag: +def as_tag(item: MaybeTaggable) -> dom_tag: """Convert an item to a dominate tag. :mod:`bootlace` uses :mod:`dominate` to render HTML. To do this, objects implement the :class:`Taggable` protocol, diff --git a/stubs/dominate-stubs/__init__.pyi b/stubs/dominate-stubs/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/dominate-stubs/dom_tag.pyi b/stubs/dominate-stubs/dom_tag.pyi new file mode 100644 index 0000000..7eae15f --- /dev/null +++ b/stubs/dominate-stubs/dom_tag.pyi @@ -0,0 +1,19 @@ +from typing import Any +from typing import overload +from typing import Iterator +from typing import Self + +class dom_tag: + attributes: dict[str, str] + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + @overload + def __getitem__(self, key: str) -> str: ... + @overload + def __getitem__(self, key: int) -> "Self": ... + def __setitem__(self, key: str, value: str) -> None: ... + def add(self, *args: str | int | "dom_tag") -> "dom_tag": ... + def remove(self, *args: "dom_tag") -> "dom_tag": ... + def clear(self) -> None: ... + def __len__(self) -> int: ... + def __iter__(self) -> "Iterator[dom_tag]": ... + def render(self) -> str: ... diff --git a/stubs/dominate-stubs/svg.pyi b/stubs/dominate-stubs/svg.pyi new file mode 100644 index 0000000..8688416 --- /dev/null +++ b/stubs/dominate-stubs/svg.pyi @@ -0,0 +1,4 @@ +from .dom_tag import dom_tag + +class svg(dom_tag): ... +class use(dom_tag): ... diff --git a/stubs/dominate-stubs/tags.pyi b/stubs/dominate-stubs/tags.pyi new file mode 100644 index 0000000..2d4be78 --- /dev/null +++ b/stubs/dominate-stubs/tags.pyi @@ -0,0 +1,36 @@ +from typing import Iterator + +from .dom_tag import dom_tag + +class Classes: + def __contains__(self, cls: str) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[str]: ... + def add(self, *classes: str) -> "html_tag": ... + def remove(self, *classes: str) -> "html_tag": ... + def swap(self, old: str, new: str) -> "html_tag": ... + +class html_tag(dom_tag): + @property + def classes(self) -> Classes: ... + +class a(html_tag): ... +class button(html_tag): ... +class div(html_tag): ... +class form(html_tag): ... +class hr(html_tag): ... +class img(html_tag): ... +class input_(html_tag): ... +class label(html_tag): ... +class li(html_tag): ... +class nav(html_tag): ... +class ol(html_tag): ... +class span(html_tag): ... +class table(html_tag): ... +class tbody(html_tag): ... +class td(html_tag): ... +class th(html_tag): ... +class thead(html_tag): ... +class tr(html_tag): ... +class ul(html_tag): ... +class comment(dom_tag): ... diff --git a/stubs/dominate-stubs/util.pyi b/stubs/dominate-stubs/util.pyi new file mode 100644 index 0000000..bb275c0 --- /dev/null +++ b/stubs/dominate-stubs/util.pyi @@ -0,0 +1,4 @@ +from .dom_tag import dom_tag + +class container(dom_tag): ... +class text(dom_tag): ...