From 36e61571974cd614110a39dfa37e028899d1e463 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Tue, 1 Jul 2025 01:00:27 -0400 Subject: [PATCH] Improve Wikicode/Node typing --- CHANGELOG | 1 + docs/changelog.rst | 1 + docs/integration.rst | 4 +- mypy.ini | 9 - pyproject.toml | 6 + src/mwparserfromhell/nodes/external_link.py | 2 +- src/mwparserfromhell/nodes/heading.py | 2 +- src/mwparserfromhell/nodes/template.py | 4 +- src/mwparserfromhell/nodes/wikilink.py | 2 +- src/mwparserfromhell/py.typed | 0 src/mwparserfromhell/string_mixin.py | 313 ++++++++++++++++++--- src/mwparserfromhell/wikicode.py | 416 ++++++++++++++++++++++++---- tests/test_string_mixin.py | 2 +- tests/test_wikicode.py | 21 +- 14 files changed, 663 insertions(+), 120 deletions(-) delete mode 100644 mypy.ini create mode 100644 src/mwparserfromhell/py.typed diff --git a/CHANGELOG b/CHANGELOG index c9204ab..378251f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ v0.7.2 (unreleased): - Fix building on non-CPython implementations like PyPy. (#341) +- Improve typing. v0.7.1 (released June 30, 2025): diff --git a/docs/changelog.rst b/docs/changelog.rst index 4fe3949..465871f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,7 @@ Unreleased - Fix building on non-CPython implementations like PyPy. (`#341 `_) +- Improve typing. v0.7.1 ------ diff --git a/docs/integration.rst b/docs/integration.rst index 8054d9f..9ed7aae 100644 --- a/docs/integration.rst +++ b/docs/integration.rst @@ -7,7 +7,7 @@ Integration :func:`mwparserfromhell.parse() ` on :meth:`~earwigbot.wiki.page.Page.get`. -If you're using Pywikibot_, your code might look like this: +If you're using Pywikibot_, your code might look like this:: import mwparserfromhell import pywikibot @@ -19,7 +19,7 @@ If you're using Pywikibot_, your code might look like this: return mwparserfromhell.parse(text) If you're not using a library, you can parse any page with the following -Python 3 code (using the API_ and the requests_ library): +Python 3 code (using the API_ and the requests_ library):: import mwparserfromhell import requests diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 622c3c6..0000000 --- a/mypy.ini +++ /dev/null @@ -1,9 +0,0 @@ -# Configuration file for mypy -# https://mypy.readthedocs.io/en/stable/config_file.html - -[mypy] -disallow_incomplete_defs = true -;disallow_untyped_defs = True -warn_return_any = true -warn_unused_configs = true -warn_unused_ignores = true diff --git a/pyproject.toml b/pyproject.toml index 565e720..6381526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,3 +72,9 @@ extend-select = [ "I", "UP", ] + +[tool.mypy] +disallow_incomplete_defs = true +warn_return_any = true +warn_unused_configs = true +warn_unused_ignores = true diff --git a/src/mwparserfromhell/nodes/external_link.py b/src/mwparserfromhell/nodes/external_link.py index b380d30..8a8ee5c 100644 --- a/src/mwparserfromhell/nodes/external_link.py +++ b/src/mwparserfromhell/nodes/external_link.py @@ -44,7 +44,7 @@ class ExternalLink(Node): ): super().__init__() self.url = url - self.title = title + self.title = title # pyright: ignore[reportIncompatibleMethodOverride] self.brackets = brackets self.suppress_space = suppress_space diff --git a/src/mwparserfromhell/nodes/heading.py b/src/mwparserfromhell/nodes/heading.py index b3b0a1c..15cd344 100644 --- a/src/mwparserfromhell/nodes/heading.py +++ b/src/mwparserfromhell/nodes/heading.py @@ -35,7 +35,7 @@ class Heading(Node): def __init__(self, title: Any, level: int): super().__init__() - self.title = title + self.title = title # pyright: ignore[reportIncompatibleMethodOverride] self.level = level def __str__(self) -> str: diff --git a/src/mwparserfromhell/nodes/template.py b/src/mwparserfromhell/nodes/template.py index 0ea061f..9386dff 100644 --- a/src/mwparserfromhell/nodes/template.py +++ b/src/mwparserfromhell/nodes/template.py @@ -260,7 +260,9 @@ class Template(Node): raise ValueError(name) return default - def __getitem__(self, name: str | Any) -> Parameter: + def __getitem__( # pyright: ignore[reportIncompatibleMethodOverride] + self, name: str | Any + ) -> Parameter: return self.get(name) def add( diff --git a/src/mwparserfromhell/nodes/wikilink.py b/src/mwparserfromhell/nodes/wikilink.py index f367985..c5d04b2 100644 --- a/src/mwparserfromhell/nodes/wikilink.py +++ b/src/mwparserfromhell/nodes/wikilink.py @@ -37,7 +37,7 @@ class Wikilink(Node): def __init__(self, title: Any, text: Any = None): super().__init__() - self.title = title + self.title = title # pyright: ignore[reportIncompatibleMethodOverride] self.text = text def __str__(self) -> str: diff --git a/src/mwparserfromhell/py.typed b/src/mwparserfromhell/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/mwparserfromhell/string_mixin.py b/src/mwparserfromhell/string_mixin.py index d3d03d1..511de4a 100644 --- a/src/mwparserfromhell/string_mixin.py +++ b/src/mwparserfromhell/string_mixin.py @@ -25,7 +25,9 @@ interface for the ``str`` type in a dynamic manner. from __future__ import annotations -from sys import getdefaultencoding +import sys +from collections.abc import Iterable, Iterator, Mapping +from typing import Any, SupportsIndex __all__ = ["StringMixIn"] @@ -49,59 +51,294 @@ class StringMixIn: immutable ``self`` like the regular ``str`` type. """ - def __str__(self): + # This is based on collections.UserString, but: + # - Requires overriding __str__ instead of setting .data + # - Returns new strings as strs instead of StringMixIns + + __slots__ = () + + def __str__(self) -> str: raise NotImplementedError() - def __bytes__(self): - return bytes(self.__str__(), getdefaultencoding()) + def __bytes__(self) -> bytes: + return bytes(str(self), sys.getdefaultencoding()) + + def __repr__(self) -> str: + return repr(str(self)) + + def __lt__(self, other: str | StringMixIn) -> bool: + return str(self) < other + + def __le__(self, other: str | StringMixIn) -> bool: + return str(self) <= other + + def __eq__(self, other: Any) -> bool: + return str(self) == other + + def __ne__(self, other: Any) -> bool: + return str(self) != other + + def __gt__(self, other: str | StringMixIn) -> bool: + return str(self) > other + + def __ge__(self, other: str | StringMixIn) -> bool: + return str(self) >= other + + def __bool__(self) -> bool: + return bool(str(self)) + + def __len__(self) -> int: + return len(str(self)) + + def __iter__(self) -> Iterator[str]: + yield from str(self) + + def __getitem__(self, key: SupportsIndex | slice) -> str: + return str(self)[key] + + def __reversed__(self) -> Iterator[str]: + return reversed(str(self)) + + def __contains__(self, item: Any) -> bool: + return str(item) in str(self) + + @inheritdoc + def capitalize(self) -> str: + return str(self).capitalize() + + @inheritdoc + def casefold(self) -> str: + return str(self).casefold() + + @inheritdoc + def center(self, width: int, fillchar: str = " ") -> str: + return str(self).center(width, fillchar) + + @inheritdoc + def count( + self, + sub: str | StringMixIn, + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> int: + if isinstance(sub, StringMixIn): + sub = str(sub) + return str(self).count(sub, start, end) + + @inheritdoc + def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes: + return str(self).encode(encoding, errors) + + @inheritdoc + def endswith( + self, + suffix: str | tuple[str, ...], + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> bool: + return str(self).endswith(suffix, start, end) + + @inheritdoc + def expandtabs(self, tabsize: int = 8) -> str: + return str(self).expandtabs(tabsize) + + @inheritdoc + def find( + self, + sub: str | StringMixIn, + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> int: + if isinstance(sub, StringMixIn): + sub = str(sub) + return str(self).find(sub, start, end) + + @inheritdoc + def format(self, /, *args: Any, **kwds: Any) -> str: + return str(self).format(*args, **kwds) + + @inheritdoc + def format_map(self, mapping: Mapping[str, Any]) -> str: + return str(self).format_map(mapping) + + @inheritdoc + def index( + self, + sub: str, + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> int: + return str(self).index(sub, start, end) + + @inheritdoc + def isalpha(self) -> bool: + return str(self).isalpha() + + @inheritdoc + def isalnum(self) -> bool: + return str(self).isalnum() + + @inheritdoc + def isascii(self) -> bool: + return str(self).isascii() + + @inheritdoc + def isdecimal(self) -> bool: + return str(self).isdecimal() + + @inheritdoc + def isdigit(self) -> bool: + return str(self).isdigit() + + @inheritdoc + def isidentifier(self) -> bool: + return str(self).isidentifier() + + @inheritdoc + def islower(self) -> bool: + return str(self).islower() + + @inheritdoc + def isnumeric(self) -> bool: + return str(self).isnumeric() + + @inheritdoc + def isprintable(self) -> bool: + return str(self).isprintable() + + @inheritdoc + def isspace(self) -> bool: + return str(self).isspace() + + @inheritdoc + def istitle(self) -> bool: + return str(self).istitle() + + @inheritdoc + def isupper(self) -> bool: + return str(self).isupper() + + @inheritdoc + def join(self, seq: Iterable[str]) -> str: + return str(self).join(seq) + + @inheritdoc + def ljust(self, width: int, fillchar: str = " ") -> str: + return str(self).ljust(width, fillchar) + + @inheritdoc + def lower(self) -> str: + return str(self).lower() + + @inheritdoc + def lstrip(self, chars: str | None = None) -> str: + return str(self).lstrip(chars) + + maketrans = str.maketrans + + @inheritdoc + def partition(self, sep: str) -> tuple[str, str, str]: + return str(self).partition(sep) + + @inheritdoc + def removeprefix(self, prefix: str | StringMixIn, /) -> str: + if isinstance(prefix, StringMixIn): + prefix = str(prefix) + return str(self).removeprefix(prefix) - def __repr__(self): - return repr(self.__str__()) + @inheritdoc + def removesuffix(self, suffix: str | StringMixIn, /) -> str: + if isinstance(suffix, StringMixIn): + suffix = str(suffix) + return str(self).removesuffix(suffix) - def __lt__(self, other): - return self.__str__() < other + @inheritdoc + def replace( + self, + old: str | StringMixIn, + new: str | StringMixIn, + /, + count: SupportsIndex = -1, + ): + if isinstance(old, StringMixIn): + old = str(old) + if isinstance(new, StringMixIn): + new = str(new) + return str(self).replace(old, new, count) - def __le__(self, other): - return self.__str__() <= other + @inheritdoc + def rfind( + self, + sub: str | StringMixIn, + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> int: + if isinstance(sub, StringMixIn): + sub = str(sub) + return str(self).rfind(sub, start, end) - def __eq__(self, other): - return self.__str__() == other + @inheritdoc + def rindex( + self, + sub: str, + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> int: + return str(self).rindex(sub, start, end) - def __ne__(self, other): - return self.__str__() != other + @inheritdoc + def rjust(self, width: int, fillchar: str = " ") -> str: + return str(self).rjust(width, fillchar) - def __gt__(self, other): - return self.__str__() > other + @inheritdoc + def rpartition(self, sep: str) -> tuple[str, str, str]: + return str(self).rpartition(sep) - def __ge__(self, other): - return self.__str__() >= other + @inheritdoc + def rstrip(self, chars: str | None = None) -> str: + return str(self).rstrip(chars) - def __bool__(self): - return bool(self.__str__()) + @inheritdoc + def split(self, sep: str | None = None, maxsplit: SupportsIndex = -1) -> list[str]: + return str(self).split(sep, maxsplit) - def __len__(self): - return len(self.__str__()) + @inheritdoc + def rsplit(self, sep: str | None = None, maxsplit: SupportsIndex = -1) -> list[str]: + return str(self).rsplit(sep, maxsplit) - def __iter__(self): - yield from self.__str__() + @inheritdoc + def splitlines(self, keepends: bool = False) -> list[str]: + return str(self).splitlines(keepends) - def __getitem__(self, key): - return self.__str__()[key] + @inheritdoc + def startswith( + self, + prefix: str | tuple[str, ...], + start: SupportsIndex | None = None, + end: SupportsIndex | None = None, + ) -> bool: + return str(self).startswith(prefix, start, end) - def __reversed__(self): - return reversed(self.__str__()) + @inheritdoc + def strip(self, chars: str | None = None) -> str: + return str(self).strip(chars) - def __contains__(self, item): - return str(item) in self.__str__() + @inheritdoc + def swapcase(self) -> str: + return str(self).swapcase() - def __getattr__(self, attr): - if not hasattr(str, attr): - raise AttributeError( - f"{type(self).__name__!r} object has no attribute {attr!r}" - ) - return getattr(self.__str__(), attr) + @inheritdoc + def title(self) -> str: + return str(self).title() - maketrans = str.maketrans # Static method can't rely on __getattr__ + @inheritdoc + def translate(self, *args: Any) -> str: + return str(self).translate(*args) + @inheritdoc + def upper(self) -> str: + return str(self).upper() -del inheritdoc + @inheritdoc + def zfill(self, width: int) -> str: + return str(self).zfill(width) diff --git a/src/mwparserfromhell/wikicode.py b/src/mwparserfromhell/wikicode.py index 160fd61..59e60a8 100644 --- a/src/mwparserfromhell/wikicode.py +++ b/src/mwparserfromhell/wikicode.py @@ -22,8 +22,9 @@ from __future__ import annotations import re from collections.abc import Generator, Iterable +from enum import Enum from itertools import chain -from typing import Any, Callable, TypeVar, cast, overload +from typing import Any, Callable, Literal, TypeVar, cast, overload from .nodes import ( Argument, @@ -48,6 +49,10 @@ FLAGS = re.IGNORECASE | re.DOTALL N = TypeVar("N", bound=Node) +class Recurse(Enum): + RECURSE_OTHERS = 2 + + class Wikicode(StringMixIn): """A ``Wikicode`` is a container for nodes that operates like a string. @@ -59,7 +64,7 @@ class Wikicode(StringMixIn): over, for example, all of the templates in the object. """ - RECURSE_OTHERS = 2 + RECURSE_OTHERS = Recurse.RECURSE_OTHERS def __init__(self, nodes: list[Node]): super().__init__() @@ -68,6 +73,24 @@ class Wikicode(StringMixIn): def __str__(self) -> str: return "".join([str(node) for node in self.nodes]) + @overload + @staticmethod + def _get_children( + node: Node, + contexts: Literal[False] = False, + restrict: type | None = None, + parent: Wikicode | None = None, + ) -> Generator[Node]: ... + + @overload + @staticmethod + def _get_children( + node: Node, + contexts: Literal[True], + restrict: type | None = None, + parent: Wikicode | None = None, + ) -> Generator[tuple[Wikicode | None, Node]]: ... + @staticmethod def _get_children( node: Node, @@ -112,7 +135,7 @@ class Wikicode(StringMixIn): def _indexed_ifilter( self, - recursive: bool = True, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, matches: Callable[[N], bool] | re.Pattern | str | None = None, flags: int = FLAGS, forcetype: type[N] | None = None, @@ -182,7 +205,7 @@ class Wikicode(StringMixIn): raise ValueError(obj) return obj, slice(0, len(obj.nodes)) - if isinstance(obj, Node): + elif isinstance(obj, Node): def mkslice(i): return slice(i, i + 1) @@ -197,7 +220,8 @@ class Wikicode(StringMixIn): return context, mkslice(context.index(child)) raise ValueError(obj) - raise TypeError(obj) + else: + raise TypeError(obj) def _do_weak_search( self, obj: Any, recursive: bool @@ -278,39 +302,6 @@ class Wikicode(StringMixIn): node.__showtree__(write, get, mark) return lines - @classmethod - def _build_filter_methods(cls, **meths): - """Given Node types, build the corresponding i?filter shortcuts. - - The should be given as keys storing the method's base name paired with - values storing the corresponding :class:`.Node` type. For example, the - dict may contain the pair ``("templates", Template)``, which will - produce the methods :meth:`ifilter_templates` and - :meth:`filter_templates`, which are shortcuts for - :meth:`ifilter(forcetype=Template) ` and - :meth:`filter(forcetype=Template) `, respectively. These - shortcuts are added to the class itself, with an appropriate docstring. - """ - doc = """Iterate over {0}. - - This is equivalent to :meth:`{1}` with *forcetype* set to - :class:`~{2.__module__}.{2.__name__}`. - """ - - def make_ifilter(ftype): - return lambda self, *a, **kw: self.ifilter(forcetype=ftype, *a, **kw) - - def make_filter(ftype): - return lambda self, *a, **kw: self.filter(forcetype=ftype, *a, **kw) - - for name, ftype in meths.items(): - ifilt = make_ifilter(ftype) - filt = make_filter(ftype) - ifilt.__doc__ = doc.format(name, "ifilter", ftype) - filt.__doc__ = doc.format(name, "filter", ftype) - setattr(cls, "ifilter_" + name, ifilt) - setattr(cls, "filter_" + name, filt) - @property def nodes(self) -> list[Node]: """A list of :class:`.Node` objects. @@ -369,7 +360,9 @@ class Wikicode(StringMixIn): return False return True - def index(self, obj: Node | Wikicode | str, recursive: bool = False) -> int: + def index( # pyright: ignore[reportIncompatibleMethodOverride] + self, obj: Node | Wikicode | str, recursive: bool = False + ) -> int: """Return the index of *obj* in the list of nodes. Raises :exc:`ValueError` if *obj* is not found. If *recursive* is @@ -496,7 +489,7 @@ class Wikicode(StringMixIn): obj = str(obj) self._slice_replace(context, index, obj, obj + str(value)) - def replace( + def replace( # pyright: ignore[reportIncompatibleMethodOverride] self, obj: Node | Wikicode | str, value: Any, recursive: bool = True ) -> None: """Replace *obj* with *value*. @@ -585,13 +578,32 @@ class Wikicode(StringMixIn): return True return False + @overload def ifilter( self, - recursive: bool = True, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, matches: Callable[[Node], bool] | re.Pattern | str | None = None, flags: int = FLAGS, - forcetype: type | None = None, - ) -> Generator[Node]: + forcetype: None = None, + ) -> Generator[Node]: ... + + @overload + def ifilter( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[N], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + *, + forcetype: type[N], + ) -> Generator[N]: ... + + def ifilter( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[N], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + forcetype: type[N] | None = None, + ) -> Generator[N]: """Iterate over nodes in our list matching certain conditions. If *forcetype* is given, only nodes that are instances of this type (or @@ -618,12 +630,313 @@ class Wikicode(StringMixIn): gen = self._indexed_ifilter(recursive, matches, flags, forcetype) return (node for i, node in gen) - def filter(self, *args: Any, **kwargs: Any) -> list[Node]: + def ifilter_arguments( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Argument], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[Argument]: + """Iterate over arguments. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~argument.Argument`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=Argument + ) + + def ifilter_comments( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Comment], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[Comment]: + """Iterate over comments. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~comment.Comment`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=Comment + ) + + def ifilter_external_links( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[ExternalLink], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[ExternalLink]: + """Iterate over external links. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~external_link.ExternalLink`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=ExternalLink + ) + + def ifilter_headings( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Heading], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[Heading]: + """Iterate over headings. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~heading.Heading`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=Heading + ) + + def ifilter_html_entities( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[HTMLEntity], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[HTMLEntity]: + """Iterate over HTML entities. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~html_entity.HTMLEntity`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=HTMLEntity + ) + + def ifilter_tags( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Tag], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[Tag]: + """Iterate over tags. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~tag.Tag`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=Tag + ) + + def ifilter_templates( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Template], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[Template]: + """Iterate over templates. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~template.Template`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=Template + ) + + def ifilter_text( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Text], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[Text]: + """Iterate over text. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~text.Text`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=Text + ) + + def ifilter_wikilinks( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Wikilink], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> Generator[Wikilink]: + """Iterate over wikilinks. + + This is equivalent to :meth:`ifilter` with *forcetype* set to + :class:`~wikilink.Wikilink`. + """ + return self.ifilter( + recursive=recursive, matches=matches, flags=flags, forcetype=Wikilink + ) + + @overload + def filter( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Node], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + forcetype: None = None, + ) -> list[Node]: ... + + @overload + def filter( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[N], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + *, + forcetype: type[N], + ) -> list[N]: ... + + def filter( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[N], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + forcetype: type[N] | None = None, + ) -> list[N]: """Return a list of nodes within our list matching certain conditions. This is equivalent to calling :func:`list` on :meth:`ifilter`. """ - return list(self.ifilter(*args, **kwargs)) + gen = self.ifilter( # pyright: ignore[reportCallIssue] + recursive=recursive, + matches=matches, + flags=flags, + forcetype=forcetype, # pyright: ignore[reportArgumentType] + ) + return list(gen) + + def filter_arguments( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Argument], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[Argument]: + """Iterate over arguments. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~argument.Argument`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=Argument + ) + + def filter_comments( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Comment], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[Comment]: + """Iterate over comments. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~comment.Comment`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=Comment + ) + + def filter_external_links( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[ExternalLink], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[ExternalLink]: + """Iterate over external links. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~external_link.ExternalLink`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=ExternalLink + ) + + def filter_headings( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Heading], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[Heading]: + """Iterate over headings. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~heading.Heading`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=Heading + ) + + def filter_html_entities( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[HTMLEntity], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[HTMLEntity]: + """Iterate over HTML entities. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~html_entity.HTMLEntity`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=HTMLEntity + ) + + def filter_tags( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Tag], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[Tag]: + """Iterate over tags. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~tag.Tag`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=Tag + ) + + def filter_templates( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Template], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[Template]: + """Iterate over templates. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~template.Template`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=Template + ) + + def filter_text( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Text], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[Text]: + """Iterate over text. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~text.Text`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=Text + ) + + def filter_wikilinks( + self, + recursive: bool | Literal[Recurse.RECURSE_OTHERS] = True, + matches: Callable[[Wikilink], bool] | re.Pattern | str | None = None, + flags: int = FLAGS, + ) -> list[Wikilink]: + """Iterate over wikilinks. + + This is equivalent to :meth:`filter` with *forcetype* set to + :class:`~wikilink.Wikilink`. + """ + return self.filter( + recursive=recursive, matches=matches, flags=flags, forcetype=Wikilink + ) def get_sections( self, @@ -768,16 +1081,3 @@ class Wikicode(StringMixIn): """ marker = object() # Random object we can find with certainty in a list return "\n".join(self._get_tree(self, [], marker, 0)) - - -Wikicode._build_filter_methods( - arguments=Argument, - comments=Comment, - external_links=ExternalLink, - headings=Heading, - html_entities=HTMLEntity, - tags=Tag, - templates=Template, - text=Text, - wikilinks=Wikilink, -) diff --git a/tests/test_string_mixin.py b/tests/test_string_mixin.py index 12caeea..a49028c 100644 --- a/tests/test_string_mixin.py +++ b/tests/test_string_mixin.py @@ -271,7 +271,7 @@ def test_other_methods(): assert "fake string" == str1.format_map({}) assert "foobarbaz" == str6.format_map({"abc": "bar"}) with pytest.raises(ValueError): - str5.format_map({0: "abc"}) + str5.format_map({0: "abc"}) # type: ignore assert 3 == str1.index("e") with pytest.raises(ValueError): diff --git a/tests/test_wikicode.py b/tests/test_wikicode.py index 515a7b1..2ba23ac 100644 --- a/tests/test_wikicode.py +++ b/tests/test_wikicode.py @@ -28,6 +28,7 @@ import pickle import re from functools import partial from types import GeneratorType +from typing import cast import pytest @@ -123,11 +124,11 @@ def test_index(): code = parse("{{foo}}{{bar|{{baz}}}}") assert 1 == code.index("{{bar|{{baz}}}}") assert 1 == code.index("{{baz}}", recursive=True) - assert 1 == code.index(code.get(1).get(1).value, recursive=True) + assert 1 == code.index(cast(Template, code.get(1)).get(1).value, recursive=True) with pytest.raises(ValueError): code.index("{{baz}}", recursive=False) with pytest.raises(ValueError): - code.index(code.get(1).get(1).value, recursive=False) + code.index(cast(Template, code.get(1)).get(1).value, recursive=False) def test_get_ancestors_parent(): @@ -207,7 +208,7 @@ def _test_search(meth, expected): code3 = parse("{{a|{{b}}|{{c|d={{f}}}}}}") func = partial(meth, code3) - obj = code3.get(0).params[0].value.get(0) + obj = cast(Template, code3.get(0)).params[0].value.get(0) with pytest.raises(ValueError): func(obj, "x", recursive=False) func(obj, "x", recursive=True) @@ -238,7 +239,11 @@ def _test_search(meth, expected): with pytest.raises(ValueError): func("{{b}}{{c}}", "x", recursive=False) func("{{b}}{{c}}", "x", recursive=True) - obj = code5.get(0).params[1].value.get(0).params[0].value + obj = ( + cast(Template, cast(Template, code5.get(0)).params[1].value.get(0)) + .params[0] + .value + ) with pytest.raises(ValueError): func(obj, "y", recursive=False) func(obj, "y", recursive=True) @@ -434,7 +439,7 @@ def test_filter_family(): assert list("abcdefg") == func(forcetype=Text) assert [] == func(forcetype=Heading) with pytest.raises(TypeError): - func(forcetype=True) + func(forcetype=True) # type: ignore funcs = [ lambda name, **kw: getattr(code, "filter_" + name)(**kw), @@ -497,11 +502,11 @@ def test_filter_family(): assert [] == code3.filter_tags(matches=r"^{{b.*?z") assert [] == code3.filter_tags(matches=r"^{{b.*?z", flags=0) with pytest.raises(TypeError): - code.filter_templates(a=42) + code.filter_templates(a=42) # type: ignore with pytest.raises(TypeError): - code.filter_templates(forcetype=Template) + code.filter_templates(forcetype=Template) # type: ignore with pytest.raises(TypeError): - code.filter_templates(1, 0, 0, Template) + code.filter_templates(1, 0, 0, Template) # type: ignore code4 = parse("{{foo}}{{foo|{{bar}}}}") actual1 = code4.filter_templates(recursive=code4.RECURSE_OTHERS)