From 9fba4b3d34c67b276e1a1fca1467d59e3bd6f599 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 27 Jul 2025 12:51:35 +0200 Subject: [PATCH] Initial commit --- .gitignore | 46 ++++ pyproject.toml | 170 ++++++++++++++ src/__init__.py | 0 src/node.py | 17 ++ src/node_helpers.py | 51 +++++ src/parser.py | 415 +++++++++++++++++++++++++++++++++++ src/qualifier.py | 19 ++ src/tokenizer.py | 156 +++++++++++++ tests/__init__.py | 0 tests/config.py | 0 tests/hypot.py | 70 ++++++ tests/test_matching.py | 443 +++++++++++++++++++++++++++++++++++++ tests/test_parser.py | 474 ++++++++++++++++++++++++++++++++++++++++ tests/test_qualifier.py | 335 ++++++++++++++++++++++++++++ tests/test_stringify.py | 143 ++++++++++++ tests/test_tokenizer.py | 200 +++++++++++++++++ uv.lock | 400 +++++++++++++++++++++++++++++++++ 17 files changed, 2939 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/node.py create mode 100644 src/node_helpers.py create mode 100644 src/parser.py create mode 100644 src/qualifier.py create mode 100644 src/tokenizer.py create mode 100644 tests/__init__.py create mode 100644 tests/config.py create mode 100644 tests/hypot.py create mode 100644 tests/test_matching.py create mode 100644 tests/test_parser.py create mode 100644 tests/test_qualifier.py create mode 100644 tests/test_stringify.py create mode 100644 tests/test_tokenizer.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9efe9a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Cache directories from various tools +.hypothesis/ +.pytest_cache/ +.mypy_cache/ + +# Virtual environments +.venv/ + +# Python packaging / distribution files +dist/ + +# Pytest coverage reports +htmlcov/ +.coverage* +coverage.xml + +# Local version information +.python-version + +# Editor generated files +.idea/ +.vscode/ +.spyproject/ +.spyderproject/ +.replit +.neoconf.json + +# Folder attributes / configuration files on various platforms +.DS_STORE +[Dd]esktop.ini +.directory + +# Trash directories +.Trash-* +$RECYCLE.BIN/ + +# Environmental, backup and personal files +.env +*.bak +TODO + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..729a51d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,170 @@ +[project] +name = "pydis-qualifier-25" +version = "0.1.0" +description = "Solution for the Python Discord 2025 Code Jam qualifier problem" +authors = [{ name = "ItsDrike", email = "itsdrike@protonmail.com" }] +readme = "README.md" +requires-python = ">=3.13" +dependencies = [] +license = "MIT" + +[tool.uv] +default-groups = ["dev", "lint", "test"] + +[dependency-groups] +dev = ["poethepoet>=0.34.0"] +lint = [ + "basedpyright>=1.29.1", + "pre-commit>=4.2.0", + "ruff>=0.11.9", + "typing-extensions>=4.13.2", +] +test = [ + "pytest>=8.3.5", + "pytest-asyncio>=0.26.0", + "pytest-cov>=6.1.1", + "coverage>=7.8.0", + "hypothesis>=6.136.4", +] + +[tool.ruff] +target-version = "py313" +line-length = 119 + +[tool.ruff.lint] +select = ["ALL"] + +ignore = [ + "C90", # mccabe + "FBT", # flake8-boolean-trap + "CPY", # flake8-copyright + "EM", # flake8-errmsg + "SLF", # flake8-self + "ARG", # flake8-unused-arguments + "TD", # flake8-todos + "FIX", # flake8-fixme + + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "D203", # Blank line required before class docstring + "D213", # Multi-line summary should start at second line (incompatible with D212) + "D301", # Use r""" if any backslashes in a docstring + "D401", # First line should be in imperative mood + "D405", # Section name should be properly capitalized + "D406", # Section name should end with a newline + "D407", # Missing dashed underline after section + "D408", # Section underline should be in the line following the section's name + "D409", # Section underline should match the length of its name + "D410", # Missing blank line after section + "D411", # Missing blank line before section + "D412", # No blank lines allowed between a section header and its content + "D413", # Missing blank line after last section + "D414", # Section has no content + "D416", # Section name should end with a colon + "D417", # Missing argument descrition in the docstring + + "ANN002", # Missing type annotation for *args + "ANN003", # Missing type annotation for **kwargs + "ANN204", # Missing return type annotation for special method + "ANN401", # Dynamically typed expressions (typing.Any) disallowed + + "SIM102", # use a single if statement instead of nested if statements + "SIM108", # Use ternary operator {contents} instead of if-else-block + + "COM812", # Missing trailing comma (conflicts with auto-formatter) + + "TRY003", # No f-strings in raise statements + "UP024", # Using errors that alias OSError + "PLR2004", # Using unnamed numerical constants + "PGH003", # Using specific rule codes in type ignores + "E731", # Don't asign a lambda expression, use a def + "B904", # Raise without `from` within an `except` clause + "G004", # Logging statement uses f-strings +] + +[tool.ruff.lint.per-file-ignores] +"tests/**.py" = [ + "D1", # Missing docstrings + "S101", # Use of assert + "S105", # Hard-coded secrets + "S106", # Hard-coded passwords +] + +[tool.ruff.format] +line-ending = "lf" + +[tool.basedpyright] +pythonPlatform = "All" +pythonVersion = "3.13" +typeCheckingMode = "all" + +# Diagnostic behavior settings +strictListInference = false +strictDictionaryInference = false +strictSetInference = false +analyzeUnannotatedFunctions = true +strictParameterNoneValue = true +enableTypeIgnoreComments = true +deprecateTypingAliases = true +enableExperimentalFeatures = false +disableBytesTypePromotions = true + +# Diagnostic rules +reportAny = false +reportExplicitAny = false +reportImplicitStringConcatenation = false +reportUnreachable = "hint" +reportUnusedParameter = "hint" +reportUnannotatedClassAttribute = false +reportMissingTypeStubs = "hint" +reportUninitializedInstanceVariable = true +reportMissingParameterType = false # ruff's flake8-annotations (ANN) already covers this + gives us more control + +executionEnvironments = [ + { root = "tests", extraPaths = [ + ".", + ], reportUnknownLambdaType = false, reportUnknownArgumentType = false }, +] + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = ["tests"] +addopts = "--strict-markers --cov --no-cov-on-fail" +markers = [ + "basic: Covers the basic qualifier problem", + "bonus: Covers the bonus qualifier problem", + "extra: Covers some additional things on top of even the bonus", +] + +[tool.coverage.report] +precision = 2 +fail_under = 0 +show_missing = true +skip_covered = false +skip_empty = false +sort = "cover" +exclude_lines = [ + "\\#\\s*pragma: no cover", + "^\\s*if (typing\\.)?TYPE_CHECKING:", + "^\\s*@(abc\\.)?abstractmethod", + "^\\s*@(typing\\.)?overload", + "^\\s*def __repr__\\(", + "^\\s*class .*\\bProtocol\\):", + "^\\s*raise NotImplementedError", + "^\\s*return NotImplemented", + "^\\s*\\.\\.\\.", +] + +[tool.coverage.run] +relative_files = true +parallel = true +branch = true +timid = false +source = ["src"] + + +[tool.poe.tasks] +test = "pytest -vv --failed-first" +retest = "pytest -vv --last-failed" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/node.py b/src/node.py new file mode 100644 index 0000000..45cfc39 --- /dev/null +++ b/src/node.py @@ -0,0 +1,17 @@ +"""The original node.py file from python discord qualifier. + +This file should not be modified. +""" +# ruff: noqa: D101 + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Node: + tag: str + attributes: dict[str, str] = field(default_factory=dict) + children: list[Node] = field(default_factory=list) + text: str = "" diff --git a/src/node_helpers.py b/src/node_helpers.py new file mode 100644 index 0000000..035e6b5 --- /dev/null +++ b/src/node_helpers.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from .node import Node + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@dataclass +class ParentAwareNode(Node): + """Extension of the Node class to introduce parent-awareness. + + The original nodes are preserved under the `node` attribute. + """ + + parent: ParentAwareNode | None = field(default=None, repr=False) + children: list[ParentAwareNode] = field(default_factory=list) # pyright: ignore[reportIncompatibleVariableOverride] + original: Node = field(repr=False, kw_only=True) + + @classmethod + def from_node(cls, node: Node, parent: ParentAwareNode | None = None) -> ParentAwareNode: + """Convert given node (and all it's subnodes) into a ParentAwareNode.""" + aware_node = cls( + tag=node.tag, + original=node, + parent=parent, + attributes=node.attributes, + children=[], + text=node.text, + ) + aware_node.children = [cls.from_node(n, aware_node) for n in node.children] + return aware_node + + +def walk_nodes(node: ParentAwareNode) -> Iterator[ParentAwareNode]: + """Yields every node in the tree, including the root one (pre-order DFS).""" + stack: list[ParentAwareNode] = [node] + while len(stack) > 0: + cur_node = stack.pop() + yield cur_node + stack.extend(reversed(cur_node.children)) # reverse for pre-order + + +def walk_parents(node: ParentAwareNode) -> Iterator[ParentAwareNode]: + """Yields every parent node in the tree of given node.""" + while node.parent: + yield node.parent + node = node.parent diff --git a/src/parser.py b/src/parser.py new file mode 100644 index 0000000..be86700 --- /dev/null +++ b/src/parser.py @@ -0,0 +1,415 @@ +"""Selector Abstract Syntax Tree classes alongside the parsing logic. + +The individual classes also contain logic to handle node matching. +""" + +# ruff: noqa: D101,D102,D103 +from __future__ import annotations + +import warnings +from collections import deque +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal, cast, overload, override + +from .node_helpers import walk_parents +from .tokenizer import Token, TokenStream, TokenType + +if TYPE_CHECKING: + from .node_helpers import ParentAwareNode + + +class SelectorParseError(Exception): ... + + +class InvalidSelectorError(ValueError): ... + + +@dataclass(frozen=True) +class SimpleSelector: + tag: str | None = None + classes: list[str] = field(default_factory=list) + ids: list[str] = field(default_factory=list) + + @classmethod + def parse_tokens(cls, tokens: TokenStream) -> SimpleSelector: + accepted_token_types = {TokenType.TAG, TokenType.CLASS, TokenType.ID} + relevant_tokens = deque(tokens.consume_while(lambda tok: tok.type in accepted_token_types)) + + if len(relevant_tokens) == 0: + raise InvalidSelectorError(f"Unable to parse simple selector (no accepted tokens): {tokens!r}") + + tag: str | None = None + tok = relevant_tokens[0] + if tok.type is TokenType.TAG: + tag = tok.value + _ = relevant_tokens.popleft() # pop the token off, we used it + + attrs: dict[TokenType, list[str]] = {} + for tok in relevant_tokens: + if tok.type is TokenType.TAG: + raise InvalidSelectorError(f"Found multiple tag selectors for a single simple tag: {tokens!r}") + attrs.setdefault(tok.type, []).append(tok.value[1:]) # omits first char (# or .) + + cls_selectors = attrs.get(TokenType.CLASS, []) + id_selectors = attrs.get(TokenType.ID, []) + + if len(id_selectors) > 1: + warnings.warn( + "Simple selector contains multiple IDs. The CSS spec doesn't forbid this, but it will never match.", + stacklevel=2, + ) + + return cls(tag, cls_selectors, id_selectors) + + @staticmethod + def _match_attrs(attr: Literal["class", "id"], sel: set[str], node: ParentAwareNode) -> bool: + """Check that a given node contains all selected attributes of given kind.""" + attr_str = node.attributes.get(attr) + attrs: set[str] = set(attr_str.split(" ")) if attr_str else set() + return sel.issubset(attrs) + + def match_node(self, node: ParentAwareNode) -> bool: + if self.tag and node.tag != self.tag: + return False + + if not self._match_attrs("class", set(self.classes), node): + return False + + return self._match_attrs("id", set(self.ids), node) + + @override + def __str__(self) -> str: + classes = ".".join(cls_name for cls_name in self.classes) + classes = "." + classes if classes else "" + ids = "#".join(id_name for id_name in self.ids) + ids = "#" + ids if ids else "" + return f"{self.tag if self.tag else ''}{classes}{ids}" + + +type ConcretePseudoClassSelector = NotPseudoClassSelector | NthChildPseudoClassSelector + + +@dataclass(frozen=True) +class PseudoClassSelector: + pseudo_class: str + selector: SimpleSelector | ConcretePseudoClassSelector | None + argument: list[Token] | None # This will be processed later, by a concrete selector class + + @staticmethod + def _parse_argument(tokens: TokenStream) -> list[Token] | None: + tok = tokens.peek() + if tok is None: + return None + + if tok.type is not TokenType.LPARENS: + return None + + _ = tokens.pop() # consume the token + + paren_count = cast("int", 1) # pyright annoyingly assumes Literal[1] and doesn't see the mutation in func + + def until_parens_close(tok: Token) -> bool: + """Keep reading until RPARENS (non-nested).""" + nonlocal paren_count + + if tok.type is TokenType.LPARENS: + paren_count += 1 + if tok.type is TokenType.RPARENS: + paren_count -= 1 + + return paren_count != 0 + + inner_tokens = list(tokens.consume_while(until_parens_close)) + + # Make sure that we did in fact stop on a RPARENS and not just ran out of tokens + if paren_count != 0: + raise InvalidSelectorError(f"Pseudo-class selector parenthesis were never closed: {tokens!r}") + + # Consume the RPARENS token + assert tokens.pop().type is TokenType.RPARENS # noqa: S101 + + return inner_tokens + + @classmethod + def parse_tokens( + cls, + tokens: TokenStream, + selector: SimpleSelector | ConcretePseudoClassSelector | None, + ) -> PseudoClassSelector: + tok = tokens.peek() + if tok is None: + raise SelectorParseError(f"Unable to parse pseudo-class, no tokens remaining: {tokens!r}") + + if tok.type is not TokenType.PSEUDO_CLASS: + raise SelectorParseError( # pragma: no cover + f"Unable to parse pseudo-class, expected {TokenType.PSEUDO_CLASS.name} token, got: {tokens!r}" + ) + + _ = tokens.pop() # consume the token + + pseudo_class = tok.value.removeprefix(":") + + arg = cls._parse_argument(tokens) + return cls(pseudo_class, selector, arg) + + +@dataclass(frozen=True) +class DescendantSelector: + parent: SimpleSelector | ConcretePseudoClassSelector + child: SimpleSelector | ConcretePseudoClassSelector | DescendantSelector | SiblingSelector + direct: bool # descendant (" ") vs direct child (">") + + def match_node(self, node: ParentAwareNode) -> bool: + if not self.child.match_node(node): + return False + + if not node.parent: + return False + + if self.direct: + return self.parent.match_node(node.parent) + + return any(self.parent.match_node(parent) for parent in walk_parents(node)) + + @override + def __str__(self) -> str: + symbol = " > " if self.direct else " " + return f"{self.parent!s}{symbol}{self.child!s}" + + +@dataclass(frozen=True) +class SiblingSelector: + sibling_selector: SimpleSelector | ConcretePseudoClassSelector + selector: SimpleSelector | ConcretePseudoClassSelector | DescendantSelector | SiblingSelector + is_adjacent: bool # adjacent sibling ("+"), subsequent sibling ("~") + + def match_node(self, node: ParentAwareNode) -> bool: + if not self.selector.match_node(node): + return False + + if not node.parent: + return False + + for i, sibling in enumerate(node.parent.children): + if sibling == node: # NOTE: is check might be safer here + child_index = i + break + else: # nobreak + raise AssertionError("Parent node doesn't contain it's child") # pragma: no cover + + if child_index == 0: + return False # no previous siblings + + if self.is_adjacent: + sibling = node.parent.children[child_index - 1] + return self.sibling_selector.match_node(sibling) + + return any(self.sibling_selector.match_node(sibling) for sibling in node.parent.children[:child_index]) + + @override + def __str__(self) -> str: + symbol = " + " if self.is_adjacent else " ~ " + return f"{self.sibling_selector!s}{symbol}{self.selector!s}" + + +type NonMultiSelector = SimpleSelector | ConcretePseudoClassSelector | DescendantSelector | SiblingSelector + + +@dataclass(frozen=True) +class MultiSelector: + selectors: list[NonMultiSelector] + + def match_node(self, node: ParentAwareNode) -> bool: + return any(selector.match_node(node) for selector in self.selectors) + + @override + def __str__(self) -> str: + return ", ".join(str(sel) for sel in self.selectors) + + +type AnySelector = NonMultiSelector | MultiSelector + + +@dataclass(frozen=True) +class NotPseudoClassSelector: + selector: SimpleSelector | ConcretePseudoClassSelector | None + not_selector: AnySelector + + @classmethod + def from_pseudo_cls(cls, sel: PseudoClassSelector) -> NotPseudoClassSelector: + if sel.pseudo_class != "not": + raise SelectorParseError( # pragma: no cover + f"Attempted to interpret {sel.pseudo_class} pseudo-class as not" + ) + + if sel.argument is None: + raise InvalidSelectorError(f"Got a not pseudo-class selector without an argument: {sel!r}") + + not_selector = parse_tokens(TokenStream(sel.argument)) + return cls(sel.selector, not_selector) + + def match_node(self, node: ParentAwareNode) -> bool: + if self.selector and not self.selector.match_node(node): + return False + + return not self.not_selector.match_node(node) + + @override + def __str__(self) -> str: + sel = str(self.selector) if self.selector else "" + return f"{sel}:not({self.not_selector!s})" + + +@dataclass(frozen=True) +class NthChildPseudoClassSelector: + selector: SimpleSelector | ConcretePseudoClassSelector | None + n: int + + @classmethod + def from_pseudo_cls(cls, sel: PseudoClassSelector) -> NthChildPseudoClassSelector: + if sel.pseudo_class not in {"nth-child", "last-child", "first-child"}: + raise SelectorParseError( # pragma: no cover + f"Attempted to interpret {sel.pseudo_class} pseudo-class as nth-child" + ) + + if sel.pseudo_class == "nth-child": + if sel.argument is None: + raise InvalidSelectorError(f"Got nth-child pseudo-class without an argument: {sel!r}") + + if len(sel.argument) != 1: + raise InvalidSelectorError(f"Got nth-child pseudo-class with a multi-token argument: {sel!r}") + + arg = sel.argument[0] + if arg.type is not TokenType.NUMBER: + raise InvalidSelectorError(f"Got nth-child pseudo-class with a non-numer token: {sel!r}") + + n = int(arg.value) + else: + if sel.argument is not None: + raise InvalidSelectorError(f"Got {sel.pseudo_class} pseudo-class with an argument: {sel!r}") + + if sel.pseudo_class == "first-child": + n = 1 + else: # last-child + n = -1 + + return cls(sel.selector, n) + + def match_node(self, node: ParentAwareNode) -> bool: + if node.parent is None: + return False + + # The N indicates n-th child, but python indexes start at 0, subtract 1. + # Unless it's negative, in which case stay as-is (allowing n=-1) + child_index = self.n - 1 if self.n > 0 else self.n + + try: + nth_child = node.parent.children[child_index] + except IndexError: + return False + + if nth_child != node: + return False + + return not (self.selector and not self.selector.match_node(node)) + + @override + def __str__(self) -> str: + if self.n == 1: + pseudo_sel = "first-child" + elif self.n == -1: + pseudo_sel = "last-child" + else: + pseudo_sel = f"nth-child({self.n})" + + sel = str(self.selector) if self.selector else "" + return f"{sel}:{pseudo_sel}" + + +def handle_pseudo_class_selector(sel: PseudoClassSelector) -> ConcretePseudoClassSelector: + match sel.pseudo_class: + case "not": + return NotPseudoClassSelector.from_pseudo_cls(sel) + case "nth-child" | "first-child" | "last-child": + return NthChildPseudoClassSelector.from_pseudo_cls(sel) + case _: + raise InvalidSelectorError(f"Unknown pseudo-class: {sel.pseudo_class} ({sel!r})") + + +@overload +def parse_tokens(tokens: TokenStream, root: Literal[True] = True) -> AnySelector: ... + + +@overload +def parse_tokens(tokens: TokenStream, root: Literal[False]) -> NonMultiSelector: ... + + +def parse_tokens(tokens: TokenStream, root: bool = True) -> AnySelector: + if (tok := tokens.peek()) and tok.type is TokenType.PSEUDO_CLASS: + s = PseudoClassSelector.parse_tokens(tokens, None) + s = handle_pseudo_class_selector(s) + else: + s = SimpleSelector.parse_tokens(tokens) + + if not tokens.has_more(): + return s + + while tok := tokens.peek(): + match tok.type: + case TokenType.PSEUDO_CLASS: + if isinstance(s, (DescendantSelector, SiblingSelector)): + # Should never happen; this is here primarily as a sanity check + for type safety + raise AssertionError("Found pseudoclass token after parsed descendant selector") # noqa: TRY004 # pragma: no cover + pseudo = PseudoClassSelector.parse_tokens(tokens, s) + s = handle_pseudo_class_selector(pseudo) + case TokenType.DIRECT_CHILD | TokenType.DESCENDANT: + _ = tokens.pop() # consume the token + + if isinstance(s, (DescendantSelector, SiblingSelector)): + # Should never happen; this is here primarily as a sanity check + for type safety + raise AssertionError(f"Found descendant/child token with parent {s!r}") # noqa: TRY004 # pragma: no cover + + child = parse_tokens(tokens, root=False) + is_direct = tok.type is TokenType.DIRECT_CHILD + s = DescendantSelector(s, child, is_direct) + case TokenType.ADJACENT_SIBLING | TokenType.SUBSEQUENT_SIBLING: + _ = tokens.pop() # consume the token + + if isinstance(s, (DescendantSelector, SiblingSelector)): + # Should never happen; this is here primarily as a sanity check + for type safety + raise AssertionError(f"Found descendant/child token with parent {s!r}") # noqa: TRY004 # pragma: no cover + + child = parse_tokens(tokens, root=False) + is_adjacent = tok.type is TokenType.ADJACENT_SIBLING + s = SiblingSelector(s, child, is_adjacent) + case TokenType.COMMA: + # If this isn't the root parser, don't consume comma separations + # leave the token in the stream and finish + if not root: + return s + + selectors = [s] + while (cur_tok := tokens.peek()) and cur_tok.type == TokenType.COMMA: + _ = tokens.pop() # consume the comma token + next_selector = parse_tokens(tokens, root=False) + selectors.append(next_selector) + + s = MultiSelector(selectors) + break + case TokenType.PSEUDO_ELEMENT: + raise NotImplementedError("The parser doesn't (yet) support pseudo-elements") + case ( + TokenType.TAG + | TokenType.CLASS + | TokenType.ID + | TokenType.NUMBER + | TokenType.LPARENS + | TokenType.RPARENS + | TokenType.UNKNOWN + ): # we're using an exhaustive case to allow static analysis to catch any missing enum variants + raise InvalidSelectorError(f"Unexpected token while parsing selector: {tokens!r}") + + if root and tokens.has_more(): + raise SelectorParseError(f"Some tokens were left unparsed: {tokens!r}") # pragma: no cover + + return s diff --git a/src/qualifier.py b/src/qualifier.py new file mode 100644 index 0000000..8465d07 --- /dev/null +++ b/src/qualifier.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .node_helpers import ParentAwareNode, walk_nodes +from .parser import parse_tokens +from .tokenizer import TokenStream, tokenize_selector + +if TYPE_CHECKING: + from .node import Node + + +def query_selector_all(node: Node, selector: str) -> list[Node]: + """Find all matching nodes, searching recursively across the whole node tree from given `node`.""" + root = ParentAwareNode.from_node(node) + toks = TokenStream(tokenize_selector(selector)) + sel = parse_tokens(toks) + + return [n.original for n in walk_nodes(root) if sel.match_node(n)] diff --git a/src/tokenizer.py b/src/tokenizer.py new file mode 100644 index 0000000..b5b728b --- /dev/null +++ b/src/tokenizer.py @@ -0,0 +1,156 @@ +# ruff: noqa: D101,D102,D103 +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import StrEnum, auto +from typing import TYPE_CHECKING, cast, override + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Iterator + + +class TokenType(StrEnum): + TAG = auto() + ID = auto() + CLASS = auto() + NUMBER = auto() + COMMA = auto() + UNKNOWN = auto() + + # Extra (bonus task) + DIRECT_CHILD = auto() + DESCENDANT = auto() + PSEUDO_CLASS = auto() + LPARENS = auto() + RPARENS = auto() + + # Extra (outside of bonus task, but part of actual CSS selector spec) + PSEUDO_ELEMENT = auto() + ADJACENT_SIBLING = auto() + SUBSEQUENT_SIBLING = auto() + + +@dataclass(frozen=True) +class Token: + type: TokenType + raw_value: str + + @property + def value(self) -> str: + """Get cleaned value (with stripped white-space).""" + # Types where simple stripping wouldn't be the idiomatic/correct representation + if self.type is TokenType.DESCENDANT: + return " " + if self.type is TokenType.DIRECT_CHILD: + return " > " + if self.type is TokenType.COMMA: + return ", " + if self.type is TokenType.ADJACENT_SIBLING: + return " + " + if self.type is TokenType.SUBSEQUENT_SIBLING: + return " ~ " + + return self.raw_value.strip() + + @override + def __repr__(self) -> str: + return f"{self.__class__.__qualname__}({self.type.name}, {self.value!r})" + + +IDENT = r"[_a-zA-Z][_a-zA-Z0-9-]*" +TOKENS: list[tuple[str, str]] = [ + (TokenType.TAG.value, IDENT), + (TokenType.ID.value, f"#{IDENT}"), + (TokenType.CLASS.value, rf"\.{IDENT}"), + (TokenType.PSEUDO_ELEMENT.value, rf"\::{IDENT}"), + (TokenType.PSEUDO_CLASS.value, rf"\:{IDENT}"), + (TokenType.LPARENS.value, r"\( *"), + (TokenType.RPARENS.value, r" *\)"), + (TokenType.COMMA.value, " *, *"), + (TokenType.ADJACENT_SIBLING.value, r" *\+ *"), + (TokenType.SUBSEQUENT_SIBLING.value, r" *\~ *"), + (TokenType.DIRECT_CHILD.value, " *> *"), + (TokenType.DESCENDANT.value, " +"), + (TokenType.NUMBER.value, r"\d+"), +] +TOKEN_RE = re.compile("|".join(f"(?P<{name}>{pattern})" for name, pattern in TOKENS)) + + +def tokenize_selector(selector: str) -> Iterator[Token]: + pos = 0 + while pos < len(selector): + re_match = TOKEN_RE.match(selector, pos) + if re_match: + token_type = TokenType(cast("str", re_match.lastgroup)) + yield Token(token_type, re_match.group()) + pos = re_match.end() + else: + # Capture the next character as unknown and advance + yield Token(TokenType.UNKNOWN, selector[pos]) + pos += 1 + + +class TokenStream: + def __init__(self, tokens: Iterable[Token]) -> None: + self.tokens = list(tokens) + self.pos = 0 + + def peek(self) -> Token | None: + """Obtain the next token without consuming it. + + If a token isn't available, this will return `None`. + """ + return self.tokens[self.pos] if self.pos < len(self.tokens) else None + + def peek_trusted(self) -> Token: + """Same as `peek`, but assumes that a token is available. + + This is primarily useful for type-checking purposes, as it avoids needing + to cast or otherwise check that a token was returned (instead of `None`). + + On runtime, this uses an assertion to verify the token wasn't `None`. This + means the check can be optimized away with the -O flag. + """ + peeked = self.peek() + assert peeked is not None # noqa: S101 + return peeked + + def pop(self) -> Token: + tok = self.peek() + if tok is None: + raise StopIteration("No more tokens") + self.pos += 1 + return tok + + def has_more(self) -> bool: + """Check if there are still some more unconsumed tokens.""" + return self.pos < len(self.tokens) + + def consume_while(self, until: Callable[[Token], bool]) -> Iterator[Token]: + """Consume tokens until the given predicate function returns False, or tokens run out.""" + while self.has_more(): + tok = self.peek_trusted() + + if not until(tok): + break + + yield tok + self.pos += 1 + + def reset(self) -> None: + """Reset the token stream position, marking all tokens as unconsumed.""" + self.pos = 0 + + @override + def __repr__(self) -> str: + cls_name = self.__class__.__qualname__ + return f"{cls_name}(pos={self.pos}, tokens={self.tokens!r}, cur_tok={self.peek()!r})" + + @override + def __str__(self) -> str: + return "".join(tok.value for tok in self.tokens) + + @property + def raw_str(self) -> str: + return "".join(tok.raw_value for tok in self.tokens) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config.py b/tests/config.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hypot.py b/tests/hypot.py new file mode 100644 index 0000000..4c4b275 --- /dev/null +++ b/tests/hypot.py @@ -0,0 +1,70 @@ +"""Reusable hypothesis strategies for CSS selectors.""" + +from typing import cast + +from hypothesis import strategies as st + +# CSS identifier (used for tag, class, ID names) +css_ident = st.from_regex(r"[_a-zA-Z][_a-zA-Z0-9-]*", fullmatch=True) + +css_tag = css_ident +css_class = css_ident.map(lambda x: f".{x}") +css_class_multi = st.lists(css_class, min_size=1).map("".join) +css_id = css_ident.map(lambda x: f"#{x}") + +# Any amount of whitespace, including none (spaces only) +_whitespace = st.from_regex(r" *", fullmatch=True) + + +def simple_selector() -> st.SearchStrategy[str]: + """Returns a strategy for simple seletors: tag, .class, #id (all optional, at least one).""" + + # Optional tag + (one or more classes) + optional id, at least one item present + parts = st.tuples( + st.none() | css_tag, + st.none() | css_class_multi, + st.none() | css_id, + ).filter(lambda parts: any(parts)) + + return parts.map(lambda parts: "".join(p for p in parts if p)) + + +# nth-child pseudo-classes +nth_child_suffix = st.from_regex(r":nth-child\([1-9][0-9]*\)", fullmatch=True) +_specific_child_pseudo_classes = st.sampled_from([":first-child", ":last-child"]) +any_nth_child_suffix = st.one_of(nth_child_suffix, _specific_child_pseudo_classes) + + +def not_selector(inner: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + return st.tuples(st.just(":not("), _whitespace, inner, _whitespace, st.just(")")).map("".join) + + +def pseudo_suffixes(inner: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + """Generate a single random pseudo-class suffix.""" + return st.one_of( + not_selector(inner), + any_nth_child_suffix, + ) + + +combinator = st.tuples(_whitespace, st.sampled_from([">", " ", "+", ",", "~"]), _whitespace).map("".join) + +selector = st.recursive( + # Base: simple selector + pseudo-classes + base=simple_selector(), + extend=lambda s: st.one_of( + # Add combinator + selector sequence + cast( + "st.SearchStrategy[str]", + st.builds( + lambda left, comb, right: left + comb + right, + s, + combinator, + s, + ), + ), + # Apply pseudo-suffix + st.tuples(s, pseudo_suffixes(s)).map("".join), + ), + max_leaves=10, +) diff --git a/tests/test_matching.py b/tests/test_matching.py new file mode 100644 index 0000000..d08ddc2 --- /dev/null +++ b/tests/test_matching.py @@ -0,0 +1,443 @@ +import pytest + +from src.node import Node +from src.node_helpers import ParentAwareNode +from src.parser import ( + DescendantSelector, + MultiSelector, + NonMultiSelector, + NotPseudoClassSelector, + NthChildPseudoClassSelector, + SiblingSelector, + SimpleSelector, +) + +# This test tree matches the one from python discord's original test suite +# +# Structure: +#
+#
+#

This is a heading!

+#

+# I have some content within this container also! +#

+#

+# This is another paragraph. +#

+#

+# This is a third paragraph. +#

+# +# This is a button link. +# +#
+#
+#

This is a paragraph in a secondary container.

+#
+#
+TEST_TREE = ParentAwareNode.from_node( + Node( + tag="div", + attributes={"id": "topDiv"}, + children=[ + Node( + tag="div", + attributes={"id": "innerDiv", "class": "container colour-primary"}, + children=[ + Node(tag="h1", text="This is a heading!"), + Node( + tag="p", + attributes={"class": "colour-secondary", "id": "innerContent"}, + text="I have some content within this container also!", + ), + Node( + tag="p", + attributes={"class": "colour-secondary", "id": "two"}, + text="This is another paragraph.", + ), + Node( + tag="p", + attributes={"class": "colour-secondary important"}, + text="This is a third paragraph.", + ), + Node( + tag="a", + attributes={"id": "home-link", "class": "colour-primary button"}, + text="This is a button link.", + ), + ], + ), + Node( + tag="div", + attributes={"class": "container colour-secondary"}, + children=[ + Node( + tag="p", + attributes={"class": "colour-primary"}, + text="This is a paragraph in a secondary container.", + ), + ], + ), + ], + ) +) + +# Get references to important nodes +TOP_DIV = TEST_TREE +INNER_DIV = TOP_DIV.children[0] +H1 = INNER_DIV.children[0] +P1 = INNER_DIV.children[1] +P2 = INNER_DIV.children[2] +P3 = INNER_DIV.children[3] +A_LINK = INNER_DIV.children[4] +SECOND_DIV = TEST_TREE.children[1] +SECOND_P = SECOND_DIV.children[0] + + +@pytest.mark.parametrize( + ("selector", "node", "expected"), + [ + (SimpleSelector(tag="div"), TOP_DIV, True), + (SimpleSelector(tag="div"), H1, False), + (SimpleSelector(classes=["colour-primary"]), P1, False), + (SimpleSelector(classes=["colour-secondary"]), P1, True), + (SimpleSelector(ids=["home-link"]), A_LINK, True), + (SimpleSelector(tag="a", classes=["button"], ids=["home-link"]), A_LINK, True), + (SimpleSelector(tag="p", classes=["important"]), P3, True), + (SimpleSelector(tag="p", classes=["nonexistent"]), P3, False), + (SimpleSelector(tag="p", ids=["innerContent"]), P1, True), + (SimpleSelector(tag="h1", ids=["innerContent"]), P1, False), + ], +) +def test_simple_selector(selector: SimpleSelector, node: ParentAwareNode, expected: bool) -> None: + assert selector.match_node(node) is expected + + +@pytest.mark.parametrize( + ("selector", "node", "expected"), + [ + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div", classes=["container"]), + child=SimpleSelector(tag="h1"), + direct=True, + ), + H1, + True, + id="div.container > h1 (on h1)", + ), + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div"), + child=DescendantSelector( + parent=SimpleSelector(tag="div"), + child=SimpleSelector(tag="p", classes=["important"]), + direct=True, + ), + direct=True, + ), + P3, + True, + id="div > div > p.important (on p3)", + ), + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div", classes=["container"]), + child=SimpleSelector(tag="p", classes=["colour-secondary"]), + direct=False, + ), + P1, + True, + id="div.container p.colour-secondary (on p1)", + ), + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div", ids=["topDiv"]), + child=SimpleSelector(tag="p", classes=["colour-secondary"]), + direct=False, + ), + P1, + True, + id="div#topDiv p.colour-secondary (on p1)", + ), + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div", classes=["container"]), + child=SimpleSelector(tag="a", classes=["colour-secondary"]), + direct=False, + ), + P1, + False, + id="div.container a.colour-secondary (on p1)", + ), + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div", classes=["nonexistent"]), + child=SimpleSelector(tag="p", classes=["colour-secondary"]), + direct=False, + ), + P1, + False, + id="div.nonexistent p.colour-secondary (on p1)", + ), + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div", ids=["topDiv"]), + child=SimpleSelector(tag="div", ids=["topDiv"]), + direct=False, + ), + TOP_DIV, + False, + id="div#topDiv div#topDiv (on top_div)", + ), + pytest.param( + DescendantSelector( + parent=SimpleSelector(tag="div"), + child=SimpleSelector(tag="div", classes=["container"]), + direct=False, + ), + A_LINK, + True, + id="div#topDiv > div.container > a (on a_link)", + ), + ], +) +def test_descendant_selector_matching(selector: DescendantSelector, node: ParentAwareNode, expected: bool) -> None: + assert selector.match_node(node) is expected + + +@pytest.mark.parametrize( + ("selector", "node", "expected"), + [ + pytest.param( + SiblingSelector( + sibling_selector=SimpleSelector(tag="p", classes=["important"]), + selector=SimpleSelector(tag="a", classes=["button"]), + is_adjacent=True, + ), + A_LINK, + True, + id="p.colour-secondary + a.button (on a_link)", + ), + pytest.param( + SiblingSelector( + sibling_selector=SimpleSelector(tag="p", classes=["colour-secondary"]), + selector=SimpleSelector(tag="a", classes=["button"]), + is_adjacent=False, + ), + A_LINK, + True, + id="p.colour-secondary ~ a.button (on a_link)", + ), + pytest.param( + SiblingSelector( + sibling_selector=SimpleSelector(tag="p", ids=["innerContent"]), + selector=SimpleSelector(tag="p", ids=["two"]), + is_adjacent=True, + ), + P2, + True, + id="p + p (on p2)", + ), + pytest.param( + SiblingSelector( + sibling_selector=SimpleSelector(tag="h1"), + selector=SimpleSelector(tag="p", ids=["innerContent"]), + is_adjacent=True, + ), + P1, + True, + id="h1 + p (on p1)", + ), + pytest.param( + SiblingSelector( + sibling_selector=SimpleSelector(tag="h1"), + selector=SimpleSelector(tag="p", ids=["innerContent"]), + is_adjacent=True, + ), + P2, + False, + id="h1 + p (on p2)", + ), + pytest.param( + SiblingSelector( + sibling_selector=SimpleSelector(tag="div"), + selector=SimpleSelector(tag="h1"), + is_adjacent=True, + ), + H1, + False, + id="div + h1 (on h1)", + ), + ], +) +def test_sibling_selector_matching(selector: SiblingSelector, node: ParentAwareNode, expected: bool) -> None: + assert selector.match_node(node) is expected + + +@pytest.mark.parametrize( + ("n", "node", "expected"), + [ + (1, H1, True), # first-child + (2, P1, True), + (3, P2, True), + (4, P3, True), + (5, A_LINK, True), + (6, A_LINK, False), + (-1, A_LINK, True), # last-child + (-1, P3, False), + (1, TOP_DIV, False), + (1, SECOND_P, True), + (-1, SECOND_P, True), + (2, SECOND_P, False), + ], +) +def test_nth_child_selector(n: int, node: ParentAwareNode, expected: bool) -> None: + selector = NthChildPseudoClassSelector(selector=None, n=n) + assert selector.match_node(node) is expected + + +@pytest.mark.parametrize( + ("selector", "node", "expected"), + [ + pytest.param( + NotPseudoClassSelector( + selector=SimpleSelector("p"), + not_selector=SimpleSelector(classes=["important"]), + ), + P1, + True, + id="p:not(.important) (on p1)", + ), + pytest.param( + NotPseudoClassSelector( + selector=SimpleSelector("p"), + not_selector=SimpleSelector(classes=["important"]), + ), + P3, + False, + id="p:not(.important) (on p3)", + ), + pytest.param( + NotPseudoClassSelector( + selector=SimpleSelector("div"), + not_selector=SimpleSelector(classes=["important"]), + ), + P1, + False, + id="div:not(.important) (on p1)", + ), + pytest.param( + NotPseudoClassSelector( + selector=None, + not_selector=SimpleSelector(classes=["important"]), + ), + P1, + True, + id=":not(.important) (on p1)", + ), + pytest.param( + NotPseudoClassSelector( + selector=SimpleSelector("div"), + not_selector=SimpleSelector(ids=["innerDiv"]), + ), + SECOND_DIV, + True, + id="div:not(#innerDiv) (on second_div)", + ), + pytest.param( + NotPseudoClassSelector( + selector=SimpleSelector("div"), + not_selector=SimpleSelector(ids=["innerDiv"]), + ), + INNER_DIV, + False, + id="div:not(#innerDiv) (on inner_div)", + ), + pytest.param( + NotPseudoClassSelector( + selector=SimpleSelector("p"), + not_selector=DescendantSelector( + SimpleSelector(tag="div", classes=["colour-secondary"]), SimpleSelector(tag="p"), direct=False + ), + ), + P2, + True, + id="p:not(div.colour-secondary p) (on p2)", + ), + pytest.param( + NotPseudoClassSelector( + selector=SimpleSelector("p"), + not_selector=DescendantSelector( + SimpleSelector(tag="div", classes=["container"]), + SimpleSelector(tag="p"), + direct=False, + ), + ), + SECOND_P, + False, + id="p:not(div.container p) (on second_p)", + ), + ], +) +def test_not_selector(selector: NotPseudoClassSelector, node: ParentAwareNode, expected: bool) -> None: + assert selector.match_node(node) is expected + + +@pytest.mark.parametrize( + ("selectors", "node", "expected"), + [ + pytest.param( + [ + SimpleSelector(tag="a"), + SimpleSelector(tag="p", ids=["innerContent"]), + ], + A_LINK, + True, + id="a, p#innerContent (on a_link)", + ), + pytest.param( + [ + SimpleSelector(tag="a"), + SimpleSelector(tag="p", ids=["innerContent"]), + ], + P1, + True, + id="a, p#innerContent (on p1)", + ), + pytest.param( + [ + SimpleSelector(tag="a"), + SimpleSelector(tag="p", ids=["innerContent"]), + ], + P2, + False, + id="a, p#innerContent (on p2)", + ), + pytest.param( + [ + SimpleSelector(tag="h1"), + SimpleSelector(classes=["colour-primary"]), + SimpleSelector(ids=["home-link"]), + ], + TOP_DIV, + False, + id="h1, .colour-primary, #home-link (on top_div)", + ), + pytest.param( + [ + NotPseudoClassSelector( + selector=None, + not_selector=SimpleSelector(tag="h1"), + ), + SimpleSelector(tag="p"), + ], + TOP_DIV, + True, + id=":not(h1), p", + ), + ], +) +def test_multi_selector_matching(selectors: list[NonMultiSelector], node: ParentAwareNode, expected: bool) -> None: + selector = MultiSelector(selectors=selectors) + assert selector.match_node(node) is expected diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..056a1b0 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,474 @@ +from typing import cast + +import pytest + +from src.parser import ( + DescendantSelector, + InvalidSelectorError, + MultiSelector, + NotPseudoClassSelector, + NthChildPseudoClassSelector, + PseudoClassSelector, + SiblingSelector, + SimpleSelector, + parse_tokens, +) +from src.tokenizer import Token, TokenStream, TokenType + + +def test_parse_simple_tag_class_id() -> None: + tokens = TokenStream( + [ + Token(TokenType.TAG, "div"), + Token(TokenType.CLASS, ".foo"), + Token(TokenType.ID, "#bar"), + ] + ) + sel = SimpleSelector.parse_tokens(tokens) + assert sel.tag == "div" + assert sel.classes == ["foo"] + assert sel.ids == ["bar"] + + +def test_parse_simple_selector_id_only() -> None: + tokens = TokenStream([Token(TokenType.ID, "#foo")]) + sel = SimpleSelector.parse_tokens(tokens) + assert sel.tag is None + assert sel.classes == [] + assert sel.ids == ["foo"] + + +def test_parse_simple_selector_class_only() -> None: + tokens = TokenStream([Token(TokenType.CLASS, ".foo")]) + sel = SimpleSelector.parse_tokens(tokens) + assert sel.tag is None + assert sel.classes == ["foo"] + assert sel.ids == [] + + +def test_parse_simple_selector_multi_class_only() -> None: + tokens = TokenStream([Token(TokenType.CLASS, ".foo"), Token(TokenType.CLASS, ".bar")]) + sel = SimpleSelector.parse_tokens(tokens) + assert sel.tag is None + assert sel.classes == ["foo", "bar"] + assert sel.ids == [] + + +def test_parse_simple_selector_multiple_ids_warns() -> None: + tokens = TokenStream( + [ + Token(TokenType.ID, "#one"), + Token(TokenType.ID, "#two"), + ] + ) + with pytest.warns(UserWarning, match="multiple IDs"): + sel = SimpleSelector.parse_tokens(tokens) + assert sel.ids == ["one", "two"] + + +def test_parse_simple_selector_invalid_double_tag_raises() -> None: + # This should be impossible to tokenize anyways, but still, let's + # make sure the parser correctly handles it with an exception + tokens = TokenStream( + [ + Token(TokenType.TAG, "div"), + Token(TokenType.TAG, "span"), + ] + ) + with pytest.raises(InvalidSelectorError, match="multiple tag"): + _ = SimpleSelector.parse_tokens(tokens) + + +@pytest.mark.parametrize( + "extra_tokens", + [ + [Token(TokenType.PSEUDO_CLASS, ":first-child")], + [ + Token(TokenType.DESCENDANT, " "), + Token(TokenType.TAG, "p"), + ], + [ + Token(TokenType.COMMA, ", "), + Token(TokenType.TAG, "p"), + ], + ], +) +def test_parse_simple_tag_leaves_extra_tokens(extra_tokens: list[Token]) -> None: + tokens = TokenStream( + [ + Token(TokenType.TAG, "div"), + Token(TokenType.CLASS, ".foo"), + Token(TokenType.ID, "#bar"), + *extra_tokens, + ] + ) + _ = SimpleSelector.parse_tokens(tokens) + + assert tokens.peek() == extra_tokens[0] + + +def test_parse_descendant_selector() -> None: + tokens = TokenStream( + [ + Token(TokenType.TAG, "div"), + Token(TokenType.DESCENDANT, " "), + Token(TokenType.CLASS, ".foo"), + ] + ) + sel = parse_tokens(tokens) + assert isinstance(sel, DescendantSelector) + assert isinstance(sel.parent, SimpleSelector) + assert sel.parent.tag == "div" + assert isinstance(sel.child, SimpleSelector) + assert sel.child.classes == ["foo"] + + +def test_parse_direct_child_selector() -> None: + tokens = TokenStream( + [ + Token(TokenType.TAG, "div"), + Token(TokenType.DIRECT_CHILD, ">"), + Token(TokenType.CLASS, ".bar"), + ] + ) + sel = parse_tokens(tokens) + assert isinstance(sel, DescendantSelector) + assert sel.direct is True + assert isinstance(sel.parent, SimpleSelector) + assert sel.parent.tag == "div" + assert isinstance(sel.child, SimpleSelector) + assert sel.child.classes == ["bar"] + + +def test_parse_sibling_selector_adjacent() -> None: + tokens = TokenStream( + [ + Token(TokenType.CLASS, ".a"), + Token(TokenType.ADJACENT_SIBLING, "+"), + Token(TokenType.CLASS, ".b"), + ] + ) + sel = parse_tokens(tokens) + assert isinstance(sel, SiblingSelector) + assert sel.is_adjacent is True + assert isinstance(sel.sibling_selector, SimpleSelector) + assert sel.sibling_selector.classes == ["a"] + assert isinstance(sel.selector, SimpleSelector) + assert sel.selector.classes == ["b"] + + +def test_parse_sibling_selector_subsequent() -> None: + tokens = TokenStream( + [ + Token(TokenType.CLASS, ".a"), + Token(TokenType.SUBSEQUENT_SIBLING, "~"), + Token(TokenType.CLASS, ".b"), + ] + ) + sel = parse_tokens(tokens) + assert isinstance(sel, SiblingSelector) + assert sel.is_adjacent is False + assert isinstance(sel.sibling_selector, SimpleSelector) + assert sel.sibling_selector.classes == ["a"] + assert isinstance(sel.selector, SimpleSelector) + assert sel.selector.classes == ["b"] + + +def test_parse_multi_selector() -> None: + tokens = TokenStream( + [ + Token(TokenType.CLASS, ".a"), + Token(TokenType.COMMA, ","), + Token(TokenType.CLASS, ".b"), + Token(TokenType.COMMA, ","), + Token(TokenType.CLASS, ".c"), + ] + ) + sel = parse_tokens(tokens) + assert isinstance(sel, MultiSelector) + assert len(sel.selectors) == 3 + assert all(isinstance(subsel, SimpleSelector) for subsel in sel.selectors) + sels = cast("list[SimpleSelector]", sel.selectors) + assert sels[0].classes == ["a"] + assert sels[1].classes == ["b"] + assert sels[2].classes == ["c"] + + +def test_pseudo_class_without_arguments() -> None: + base = SimpleSelector(tag="div") + tokens = TokenStream([Token(TokenType.PSEUDO_CLASS, ":first-child")]) + pseudo = PseudoClassSelector.parse_tokens(tokens, base) + assert pseudo.pseudo_class == "first-child" + assert pseudo.selector == base + assert pseudo.argument is None + + +@pytest.mark.parametrize( + "extra_tokens", + [ + [Token(TokenType.PSEUDO_CLASS, ":first-child")], + [ + Token(TokenType.DESCENDANT, " "), + Token(TokenType.TAG, "p"), + ], + [ + Token(TokenType.COMMA, ", "), + Token(TokenType.TAG, "p"), + ], + ], +) +def test_pseudo_class_without_arguments_leaves_extra_tokens(extra_tokens: list[Token]) -> None: + base = SimpleSelector(tag="div") + tokens = TokenStream( + [ + Token(TokenType.PSEUDO_CLASS, ":first-child"), + *extra_tokens, + ] + ) + _ = PseudoClassSelector.parse_tokens(tokens, base) + assert tokens.peek() == extra_tokens[0] + + +def test_pseudo_class_with_nested_argument() -> None: + base = SimpleSelector(tag="div") + arg_tokens = [ + Token(TokenType.TAG, "span"), + Token(TokenType.CLASS, ".foo"), + ] + tokens = TokenStream( + [ + Token(TokenType.PSEUDO_CLASS, ":not"), + Token(TokenType.LPARENS, "("), + *arg_tokens, + Token(TokenType.RPARENS, ")"), + ] + ) + pseudo = PseudoClassSelector.parse_tokens(tokens, base) + assert pseudo.pseudo_class == "not" + assert pseudo.selector == base + assert pseudo.argument == arg_tokens + + +def test_parse_pseudo_class_nested_parens() -> None: + base = SimpleSelector(tag="div") + arg_tokens = [ + Token(TokenType.PSEUDO_CLASS, ":nth-child"), + Token(TokenType.LPARENS, "("), + Token(TokenType.NUMBER, "2"), + Token(TokenType.RPARENS, ")"), + ] + tokens = TokenStream( + [ + Token(TokenType.PSEUDO_CLASS, ":not"), + Token(TokenType.LPARENS, "("), + *arg_tokens, + Token(TokenType.RPARENS, ")"), + ] + ) + pseudo = PseudoClassSelector.parse_tokens(tokens, base) + assert pseudo.pseudo_class == "not" + assert pseudo.argument == arg_tokens + + +@pytest.mark.parametrize( + "extra_tokens", + [ + [Token(TokenType.PSEUDO_CLASS, ":first-child")], + [ + Token(TokenType.DESCENDANT, " "), + Token(TokenType.TAG, "p"), + ], + [ + Token(TokenType.COMMA, ", "), + Token(TokenType.TAG, "p"), + ], + ], +) +def test_pseudo_class_with_nested_argument_leaves_extra_tokens(extra_tokens: list[Token]) -> None: + base = SimpleSelector(tag="div") + arg_tokens = [ + Token(TokenType.TAG, "span"), + Token(TokenType.CLASS, ".foo"), + ] + tokens = TokenStream( + [ + Token(TokenType.PSEUDO_CLASS, ":not"), + Token(TokenType.LPARENS, "("), + *arg_tokens, + Token(TokenType.RPARENS, ")"), + *extra_tokens, + ] + ) + _ = PseudoClassSelector.parse_tokens(tokens, base) + assert tokens.peek() == extra_tokens[0] + + +def test_pseudo_class_unbalanced_parens() -> None: + base = SimpleSelector(tag="div") + tokens = TokenStream( + [ + Token(TokenType.PSEUDO_CLASS, ":not"), + Token(TokenType.LPARENS, "("), + Token(TokenType.TAG, "span"), + Token(TokenType.CLASS, ".foo"), + Token(TokenType.PSEUDO_CLASS, ":nth-child"), + Token(TokenType.LPARENS, "("), + Token(TokenType.NUMBER, "2"), + Token(TokenType.RPARENS, ")"), + ] + ) + with pytest.raises(InvalidSelectorError): + _ = PseudoClassSelector.parse_tokens(tokens, base) + + +def test_nth_child_valid() -> None: + base = SimpleSelector(tag="li") + arg_tokens = [Token(TokenType.NUMBER, "3")] + pseudo = PseudoClassSelector("nth-child", base, arg_tokens) + + sel = NthChildPseudoClassSelector.from_pseudo_cls(pseudo) + assert sel.n == 3 + assert sel.selector == base + + +@pytest.mark.parametrize( + "argument_tokens", + [ + pytest.param([Token(TokenType.CLASS, ".bad")], id="bad-type"), + pytest.param( + [ + Token(TokenType.TAG, "div"), + Token(TokenType.CLASS, ".main"), + ], + id="multi-token", + ), + pytest.param( + [ + Token(TokenType.NUMBER, "5"), + Token(TokenType.TAG, "div"), + ], + id="multi-token-number-first", + ), + ], +) +def test_nth_child_parsing_invalid_argument(argument_tokens: list[Token]) -> None: + base = SimpleSelector(tag="li") + pseudo = PseudoClassSelector("nth-child", base, argument_tokens) + + with pytest.raises(InvalidSelectorError): + _ = NthChildPseudoClassSelector.from_pseudo_cls(pseudo) + + +def test_nth_child_parsing_missing_argument() -> None: + base = SimpleSelector(tag="li") + pseudo = PseudoClassSelector("nth-child", base, None) + + with pytest.raises(InvalidSelectorError): + _ = NthChildPseudoClassSelector.from_pseudo_cls(pseudo) + + +@pytest.mark.parametrize( + ("selector", "n"), + [ + ("first-child", 1), + ("last-child", -1), + ], +) +def test_specific_nth_child(selector: str, n: int) -> None: + base = SimpleSelector(tag="li") + pseudo = PseudoClassSelector(selector, base, None) + + sel = NthChildPseudoClassSelector.from_pseudo_cls(pseudo) + assert sel.n == n + assert sel.selector == base + + +def test_specific_nth_child_with_argument() -> None: + base = SimpleSelector(tag="li") + arg_tokens = [Token(TokenType.NUMBER, "4")] + pseudo = PseudoClassSelector("first-child", base, arg_tokens) + + with pytest.raises(InvalidSelectorError): + _ = NthChildPseudoClassSelector.from_pseudo_cls(pseudo) + + +def test_last_child_parsing() -> None: + base = SimpleSelector(tag="li") + pseudo = PseudoClassSelector("last-child", base, None) + + sel = NthChildPseudoClassSelector.from_pseudo_cls(pseudo) + assert sel.n == -1 + assert sel.selector == base + + +def test_parse_not_valid() -> None: + base = SimpleSelector(tag="div") + arg_tokens = [ + Token(TokenType.TAG, "span"), + Token(TokenType.CLASS, ".foo"), + ] + pseudo = PseudoClassSelector("not", base, arg_tokens) + + sel = NotPseudoClassSelector.from_pseudo_cls(pseudo) + assert isinstance(sel.not_selector, SimpleSelector) + assert sel.not_selector.tag == "span" + assert sel.not_selector.classes == ["foo"] + assert sel.selector == base + + +def test_parse_not_with_missing_argument() -> None: + base = SimpleSelector(tag="div") + pseudo = PseudoClassSelector("not", base, None) + + with pytest.raises(InvalidSelectorError): + _ = NotPseudoClassSelector.from_pseudo_cls(pseudo) + + +def test_parse_multiple_combinators() -> None: + # div .parent > .child + .sibling:not(.bar):first-child + tokens = TokenStream( + [ + Token(TokenType.TAG, "div"), + Token(TokenType.DESCENDANT, " "), + Token(TokenType.CLASS, ".parent"), + Token(TokenType.DIRECT_CHILD, ">"), + Token(TokenType.CLASS, ".child"), + Token(TokenType.ADJACENT_SIBLING, "+"), + Token(TokenType.CLASS, ".sibling"), + Token(TokenType.PSEUDO_CLASS, ":not"), + Token(TokenType.LPARENS, "("), + Token(TokenType.CLASS, ".bar"), + Token(TokenType.RPARENS, ")"), + Token(TokenType.PSEUDO_CLASS, ":first-child"), + ] + ) + sel = parse_tokens(tokens) + assert isinstance(sel, DescendantSelector) + + assert isinstance(sel.parent, SimpleSelector) + assert sel.parent.tag == "div" + + assert isinstance(sel.child, DescendantSelector) + + assert isinstance(sel.child.parent, SimpleSelector) + assert sel.child.parent.classes == ["parent"] + + sibling = sel.child.child # .child + .sibling:not(.bar):first-child + assert isinstance(sibling, SiblingSelector) + assert sibling.is_adjacent is True + + assert isinstance(sibling.sibling_selector, SimpleSelector) + assert sibling.sibling_selector.classes == ["child"] + + nth_child = sibling.selector # .sibling:not(.bar):first-child + assert isinstance(nth_child, NthChildPseudoClassSelector) + assert nth_child.n == 1 + + not_selector = nth_child.selector # .sibling:not(.bar) + assert isinstance(not_selector, NotPseudoClassSelector) + + assert isinstance(not_selector.not_selector, SimpleSelector) + assert not_selector.not_selector.classes == ["bar"] + + assert isinstance(not_selector.selector, SimpleSelector) + assert not_selector.selector.classes == ["sibling"] diff --git a/tests/test_qualifier.py b/tests/test_qualifier.py new file mode 100644 index 0000000..c37d963 --- /dev/null +++ b/tests/test_qualifier.py @@ -0,0 +1,335 @@ +"""Test suite which tests the entire logic together (tokenization, parsing, matching). + +This suite doesn't focus on any individual components, only going from selector string -> matching. +Some of the test cases in this suite will technically overlap those in the other suites, this +is intentional, as this suite is also useful for testing other implementations of the matching +(not just my specific one), unlike with the other suites, which work with my specific AST/parser. + +This suite is written with pytest to allow for easier parametrizations and provide better output. +Generally, this suite contains the same tests as the original python discord's test suite, with +some additional ones to test out various edge cases (and it's rewritten from pure unit-test suite). + +On top of the original simple tests for the basic implementation, this suite also includes tests +with the 'bonus' marker, which cover the bonus task. (The basic implementation tests have the 'basic' +marker). + +Finally, this suite also contains some extra cases for sibling matching (+ and ~). This is a part of +the CSS specification, however, it was not a part of even the bonus task. These tests are marked with +the 'extra' marker. + +If you'd like to port this test-suite to your code, you'll want to change the src.qualifier imports to +just qualifier as that's what the original implementation expects. +""" + +import unittest +from collections.abc import Iterable + +import pytest + +from src.node import Node +from src.qualifier import query_selector_all as solution + +# This test tree matches the one from python discord's original test suite +# +# Structure: +#
+#
+#

This is a heading!

+#

+# I have some content within this container also! +#

+#

+# This is another paragraph. +#

+#

+# This is a third paragraph. +#

+# +# This is a button link. +# +#
+#
+#

This is a paragraph in a secondary container.

+#
+#
+TEST_TREE = Node( + tag="div", + attributes={"id": "topDiv"}, + children=[ + Node( + tag="div", + attributes={"id": "innerDiv", "class": "container colour-primary"}, + children=[ + Node(tag="h1", text="This is a heading!"), + Node( + tag="p", + attributes={"class": "colour-secondary", "id": "innerContent"}, + text="I have some content within this container also!", + ), + Node( + tag="p", + attributes={"class": "colour-secondary", "id": "two"}, + text="This is another paragraph.", + ), + Node( + tag="p", + attributes={"class": "colour-secondary important"}, + text="This is a third paragraph.", + ), + Node( + tag="a", + attributes={"id": "home-link", "class": "colour-primary button"}, + text="This is a button link.", + ), + ], + ), + Node( + tag="div", + attributes={"class": "container colour-secondary"}, + children=[ + Node( + tag="p", + attributes={"class": "colour-primary"}, + text="This is a paragraph in a secondary container.", + ), + ], + ), + ], +) + +# Get references to important nodes +TOP_DIV = TEST_TREE +INNER_DIV = TOP_DIV.children[0] +H1 = INNER_DIV.children[0] +P1 = INNER_DIV.children[1] +P2 = INNER_DIV.children[2] +P3 = INNER_DIV.children[3] +A_LINK = INNER_DIV.children[4] +SECOND_DIV = TEST_TREE.children[1] +SECOND_P = SECOND_DIV.children[0] + + +def assert_count_equal[T](first: Iterable[T], second: Iterable[T]) -> None: + case = unittest.TestCase() + case.assertCountEqual(first, second) # noqa: PT009 + + +@pytest.mark.basic +@pytest.mark.parametrize( + ("selector", "expected"), + [ + # matches pydis tests exactly + ("div", [TOP_DIV, INNER_DIV, SECOND_DIV]), + ("#innerDiv", [INNER_DIV]), + (".colour-primary", [INNER_DIV, A_LINK, SECOND_P]), + ("p.colour-secondary", [P1, P2, P3]), + ("div#innerDiv", [INNER_DIV]), + ("#innerContent.colour-secondary", [P1]), + ("div#innerDiv.colour-primary", [INNER_DIV]), + (".colour-primary.button", [A_LINK]), + ("#home-link.colour-primary.button", [A_LINK]), + ("a#home-link.colour-primary.button", [A_LINK]), + ("i", []), + ("#badId", []), + (".missing-class", []), + # some extra tests + ("#home-link", [A_LINK]), + ("a.button#home-link", [A_LINK]), + ("p.important", [P3]), + ("p.nonexistent", []), + ("p#innerContent", [P1]), + ("h1#innerContent", []), + ], +) +def test_simple_selector(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.basic +@pytest.mark.parametrize( + ("selector", "expected"), + [ + # matches pydis tests exactly + ("#topDiv, h1, .colour-primary", [TOP_DIV, H1, INNER_DIV, A_LINK, SECOND_P]), + ("h1, a#home-link.colour-primary.button", [H1, A_LINK]), + ("p#two.colour-secondary, a#home-link.colour-primary.button", [P2, A_LINK]), + ("i, #badID, .missing-class", []), + ("h1, #badID, .missing-class", [H1]), + ("li#random.someclass, a#home-link, div, .colour-primary.badclass", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]), + ], +) +def test_multi_selector(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.bonus +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ("div.container > h1", [H1]), + ("div > div > p.important", [P3]), + ("div.container p.colour-secondary", [P1, P2, P3]), + ("div.container > p.colour-secondary", [P1, P2, P3]), + ("div#topDiv p.colour-secondary", [P1, P2, P3]), + ("div#topDiv p", [P1, P2, P3, SECOND_P]), + ("div#topDiv > p", []), + ("div#innerDiv p.colour-primary", []), + ("div.nonexistent p.colour-secondary", []), + ("div#topDiv div#topDiv", []), + # ("div#topDiv > div.container > a", [A_LINK]), + ], +) +def test_descendant_selector(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.extra +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ("p.important + a.button", [A_LINK]), + ("p.colour-secondary ~ a.button", [A_LINK]), + ("p + p", [P2, P3]), + ("p ~ p", [P2, P3]), + ("h1 + p", [P1]), + ("h1 ~ p", [P1, P2, P3]), + ("div + h1", []), + ("p.nonexistent + p", []), + ("p + h1", []), + ("p ~ h1", []), + ("div + div", [SECOND_DIV]), + ("div ~ div", [SECOND_DIV]), + ("div#innerDiv ~ div", [SECOND_DIV]), + ("div#topDiv ~ div", []), + ], +) +def test_sibling_selector(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.bonus +@pytest.mark.parametrize( + ("selector", "expected"), + [ + (":first-child", [INNER_DIV, H1, SECOND_P]), + (":last-child", [SECOND_DIV, A_LINK, SECOND_P]), + (":nth-child(1)", [INNER_DIV, H1, SECOND_P]), + (":nth-child(2)", [SECOND_DIV, P1]), + (":nth-child(3)", [P2]), + (":nth-child(4)", [P3]), + (":nth-child(5)", [A_LINK]), + (":nth-child(6)", []), + ("p:first-child", [SECOND_P]), + ("p:last-child", [SECOND_P]), + ("p:nth-child(1)", [SECOND_P]), + ("a:first-child", []), + ("a:last-child", [A_LINK]), + ("a:nth-child(5)", [A_LINK]), + ("div:last-child", [SECOND_DIV]), + ("div.nonexistent:last-child", []), + (".colour-primary:first-child", [INNER_DIV, SECOND_P]), + ], +) +def test_nth_child_selector(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.bonus +@pytest.mark.parametrize( + ("selector", "expected"), + [ + (":not(p)", [TOP_DIV, INNER_DIV, H1, A_LINK, SECOND_DIV]), + ("p:not(.important)", [P1, P2, SECOND_P]), + ("div:not(.important)", [TOP_DIV, INNER_DIV, SECOND_DIV]), + (":not(.important)", [TOP_DIV, INNER_DIV, H1, P1, P2, A_LINK, SECOND_DIV, SECOND_P]), + ("div:not(#innerDiv)", [TOP_DIV, SECOND_DIV]), + ("#innerDiv:not(p)", [INNER_DIV]), + ("#innerDiv:not(div)", []), + ("p:not(p)", []), + (":not(div):not(p):not(a)", [H1]), + (":not(div):not(p):not(a):not(h1)", []), + ("p:not(.colour-primary:not(#two))", [P1, P2, P3]), + ("p:not(.colour-secondary:not(#two))", [P2, SECOND_P]), + (":not(:not(div))", [TOP_DIV, INNER_DIV, SECOND_DIV]), + ], +) +def test_not_selector(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.basic +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ("q, b", []), + ], +) +def test_combined_basic(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.bonus +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ("p:first-child:not(:last-child)", []), + ("h1:first-child:not(:last-child)", [H1]), + (":first-child:not(:nth-child(2))", [INNER_DIV, SECOND_P, H1]), + (":first-child:not(:nth-child(2)):not(h1)", [INNER_DIV, SECOND_P]), + (":first-child:not(:nth-child(2), h1)", [INNER_DIV, SECOND_P]), + ("p:not(p:first-child)", [P1, P2, P3]), + ("p:not(p:nth-child(1))", [P1, P2, P3]), + ("p:not(.colour-secondary:not(#two)):not(:first-child)", [P2]), + ("p:not(div.colour-secondary p)", [P1, P2, P3]), + (":not(div, p, a)", [H1]), + (":not(div, p, a, h1)", []), + (":not(div p)", [TOP_DIV, INNER_DIV, H1, A_LINK, SECOND_DIV]), + (".colour-primary > p:not(#two)", [P1, P3]), + (".colour-primary p:not(#two)", [P1, P3]), + ("#topDiv p:not(#two)", [P1, P3, SECOND_P]), + ("#topDiv > p:not(#two)", []), + ], +) +def test_combined_bonus(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.basic +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ("a,div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]), + ("a ,div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]), + ("a, div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]), + ("a , div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]), + ("a , div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]), + ("a#home-link , div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]), + ("a.button#home-link , div.container", [A_LINK, INNER_DIV, SECOND_DIV]), + ], +) +def test_whitespace_basic(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) + + +@pytest.mark.bonus +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ("div.container>h1", [H1]), + ("div.container> h1", [H1]), + ("div.container >h1", [H1]), + ("div.container > h1", [H1]), + ("div.container > h1", [H1]), + # ("div#topDiv >div> a", [A_LINK]), + # ("div#topDiv >div a", [A_LINK]), + # ("div#topDiv >div:not(.colour-secondary) a", [A_LINK]), + ("div:not(.colour-secondary)", [TOP_DIV, INNER_DIV]), + ("div:not( .colour-secondary)", [TOP_DIV, INNER_DIV]), + ("div:not(.colour-secondary )", [TOP_DIV, INNER_DIV]), + ("div:not( .colour-secondary )", [TOP_DIV, INNER_DIV]), + ("div:not( .colour-secondary )", [TOP_DIV, INNER_DIV]), + # ("div#topDiv >div:not( .colour-secondary ) a", [A_LINK]), + ], +) +def test_whitespace_bonus(selector: str, expected: list[Node]) -> None: + assert_count_equal(solution(TEST_TREE, selector), expected) diff --git a/tests/test_stringify.py b/tests/test_stringify.py new file mode 100644 index 0000000..2aa2362 --- /dev/null +++ b/tests/test_stringify.py @@ -0,0 +1,143 @@ +import pytest + +from src.parser import ( + DescendantSelector, + MultiSelector, + NotPseudoClassSelector, + NthChildPseudoClassSelector, + SiblingSelector, + SimpleSelector, +) + + +@pytest.mark.parametrize( + ("selector", "expected"), + [ + (SimpleSelector(tag="div"), "div"), + (SimpleSelector(classes=["main"]), ".main"), + (SimpleSelector(tag="span", classes=["big", "blue"]), "span.big.blue"), + (SimpleSelector(tag="a", ids=["link"]), "a#link"), + (SimpleSelector(classes=["one"], ids=["unique"]), ".one#unique"), + (SimpleSelector(tag="p", classes=["x"], ids=["y"]), "p.x#y"), + (SimpleSelector(ids=["onlyid"]), "#onlyid"), + ], +) +def test_simple_selector_str(selector: SimpleSelector, expected: str) -> None: + assert str(selector) == expected + + +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ( + NotPseudoClassSelector( + selector=SimpleSelector(tag="div"), + not_selector=SimpleSelector(classes=["main"]), + ), + "div:not(.main)", + ), + ( + NotPseudoClassSelector( + selector=None, + not_selector=SimpleSelector(classes=["a", "b"]), + ), + ":not(.a.b)", + ), + ], +) +def test_not_pseudo_class_selector_str(selector: NotPseudoClassSelector, expected: str) -> None: + assert str(selector) == expected + + +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ( + NthChildPseudoClassSelector(selector=None, n=2), + ":nth-child(2)", + ), + ( + NthChildPseudoClassSelector(selector=SimpleSelector(tag="div"), n=2), + "div:nth-child(2)", + ), + ( + NthChildPseudoClassSelector(selector=SimpleSelector(tag="li"), n=1), + "li:first-child", + ), + ( + NthChildPseudoClassSelector(selector=SimpleSelector(tag="li"), n=-1), + "li:last-child", + ), + ], +) +def test_nth_child_pseudo_class_selector_str(selector: NthChildPseudoClassSelector, expected: str) -> None: + assert str(selector) == expected + + +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ( + DescendantSelector( + parent=SimpleSelector(tag="div", classes=["main"]), + child=SimpleSelector(tag="span"), + direct=False, + ), + "div.main span", + ), + ( + DescendantSelector( + parent=SimpleSelector(tag="ul"), + child=SimpleSelector(tag="li", classes=["active"]), + direct=True, + ), + "ul > li.active", + ), + ], +) +def test_descendant_selector_str(selector: DescendantSelector, expected: str) -> None: + assert str(selector) == expected + + +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ( + SiblingSelector( + sibling_selector=SimpleSelector(tag="p"), + selector=SimpleSelector(tag="span"), + is_adjacent=True, + ), + "p + span", + ), + ( + SiblingSelector( + sibling_selector=SimpleSelector(tag="div", classes=["a"]), + selector=SimpleSelector(classes=["b"]), + is_adjacent=False, + ), + "div.a ~ .b", + ), + ], +) +def test_sibling_selector_str(selector: SiblingSelector, expected: str) -> None: + assert str(selector) == expected + + +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ( + MultiSelector( + [ + SimpleSelector("div", classes=["main"]), + SimpleSelector("span"), + SimpleSelector(ids=["x"]), + ] + ), + "div.main, span, #x", + ), + ], +) +def test_multi_selector_str(selector: MultiSelector, expected: str) -> None: + assert str(selector) == expected diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py new file mode 100644 index 0000000..9a6bafd --- /dev/null +++ b/tests/test_tokenizer.py @@ -0,0 +1,200 @@ +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from src.tokenizer import Token, TokenStream, TokenType, tokenize_selector +from tests.hypot import css_class, css_class_multi, css_id, css_tag, selector + +# region: Tokenization tests + + +@pytest.mark.parametrize( + ("input_str", "expected_type", "expected_value"), + [ + ("div", TokenType.TAG, "div"), + ("#id", TokenType.ID, "#id"), + (".class", TokenType.CLASS, ".class"), + ("123", TokenType.NUMBER, "123"), + (",", TokenType.COMMA, ", "), + (">", TokenType.DIRECT_CHILD, " > "), + (" ", TokenType.DESCENDANT, " "), + (":hover", TokenType.PSEUDO_CLASS, ":hover"), + ("(", TokenType.LPARENS, "("), + (")", TokenType.RPARENS, ")"), + ("::after", TokenType.PSEUDO_ELEMENT, "::after"), + ("+", TokenType.ADJACENT_SIBLING, " + "), + ("~", TokenType.SUBSEQUENT_SIBLING, " ~ "), + ("$", TokenType.UNKNOWN, "$"), + ], +) +def test_individual_tokens(input_str: str, expected_type: TokenType, expected_value: str) -> None: + """Test each token type in isolation.""" + tokens = list(tokenize_selector(input_str)) + assert len(tokens) == 1 + token = tokens[0] + assert token.type == expected_type + assert token.value == expected_value + + +@pytest.mark.parametrize( + ("selector", "expected"), + [ + ("div.class", [TokenType.TAG, TokenType.CLASS]), + ("div > .class", [TokenType.TAG, TokenType.DIRECT_CHILD, TokenType.CLASS]), + ("div, span", [TokenType.TAG, TokenType.COMMA, TokenType.TAG]), + ("a:b::c", [TokenType.TAG, TokenType.PSEUDO_CLASS, TokenType.PSEUDO_ELEMENT]), + ("a + b", [TokenType.TAG, TokenType.ADJACENT_SIBLING, TokenType.TAG]), + ("a ~ b", [TokenType.TAG, TokenType.SUBSEQUENT_SIBLING, TokenType.TAG]), + ("div (", [TokenType.TAG, TokenType.DESCENDANT, TokenType.LPARENS]), + ], +) +def test_token_combinations(selector: str, expected: list[TokenType]) -> None: + """Test combinations of tokens (not necessarily valid ones).""" + tokens = list(tokenize_selector(selector)) + assert [t.type for t in tokens] == expected + + +def test_empty_string() -> None: + """Test tokenizing empty string returns no tokens.""" + tokens = list(tokenize_selector("")) + assert len(tokens) == 0 + + +@given(css_tag) +def test_valid_tags(tag: str) -> None: + """Test valid tag names.""" + tokens = list(tokenize_selector(tag)) + assert len(tokens) == 1 + assert tokens[0].type == TokenType.TAG + assert tokens[0].value == tag + + +@given(css_id) +def test_valid_ids(id_val: str) -> None: + """Test valid ID values.""" + tokens = list(tokenize_selector(id_val)) + assert len(tokens) == 1 + assert tokens[0].type == TokenType.ID + assert tokens[0].value == id_val + + +@given(css_class) +def test_valid_class(val: str) -> None: + """Test valid single class values.""" + tokens = list(tokenize_selector(val)) + assert len(tokens) == 1 + assert tokens[0].type == TokenType.CLASS + assert tokens[0].value == val + + +@given(css_class_multi) +def test_valid_class_multi(val: str) -> None: + """Test valid multi class values.""" + tokens = list(tokenize_selector(val)) + assert all(tok.type == TokenType.CLASS for tok in tokens) + + +@given(selector) +def test_arbitrary_valid_selector(selector: str) -> None: + """Ensure tokenizer can handle any valid selector string.""" + tokens = list(tokenize_selector(selector)) + tok_types = {tok.type for tok in tokens} + assert TokenType.UNKNOWN not in tok_types + + +@given(st.text()) +def test_no_crashes_on_arbitrary_text(s: str) -> None: + """Ensure tokenizer doesn't crash on any input. + + (We should instead handle this with unknown tokens.) + """ + _ = list(tokenize_selector(s)) + + +# endregion +# region: TokenStream tests + + +def test_peek_and_pop() -> None: + tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main")] + stream = TokenStream(tokens) + + # Initial peek + assert stream.peek() == tokens[0] + + # Pop moves the stream + assert stream.pop() == tokens[0] + assert stream.peek() == tokens[1] + + +def test_peek_trusted() -> None: + stream = TokenStream([Token(TokenType.TAG, "div")]) + tok = stream.peek_trusted() + assert tok.type == TokenType.TAG + + _ = stream.pop() + with pytest.raises(AssertionError): + _ = stream.peek_trusted() + + +def test_has_more() -> None: + stream = TokenStream([Token(TokenType.TAG, "div")]) + assert stream.has_more() + _ = stream.pop() + assert not stream.has_more() + + +def test_pop_exhausted_raises() -> None: + stream = TokenStream([Token(TokenType.TAG, "div")]) + _ = stream.pop() + with pytest.raises(StopIteration): + _ = stream.pop() + + +def test_consume_while() -> None: + tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main"), Token(TokenType.ID, "#id")] + stream = TokenStream(tokens) + + # Consume until we see an ID token + consumed = list(stream.consume_while(lambda t: t.type != TokenType.ID)) + + assert consumed == tokens[:2] + assert stream.peek() == tokens[2] + + +def test_consume_while_all() -> None: + tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main"), Token(TokenType.ID, "#id")] + stream = TokenStream(tokens) + + # Consume until we see an ID token + consumed = list(stream.consume_while(lambda t: t.type != TokenType.LPARENS)) + + assert consumed == tokens + assert stream.peek() is None + + +def test_reset() -> None: + tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main")] + stream = TokenStream(tokens) + + _ = stream.pop() + assert stream.peek() == tokens[1] + + stream.reset() + assert stream.peek() == tokens[0] + + +def test_str_and_raw_str() -> None: + tokens = [ + Token(TokenType.TAG, "div"), + Token(TokenType.CLASS, ".main"), + Token(TokenType.COMMA, ", "), + Token(TokenType.TAG, "a"), + ] + stream = TokenStream(tokens) + + assert str(stream) == "div.main, a" + assert stream.raw_str == "div.main, a" + + +# endregion diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7ca6004 --- /dev/null +++ b/uv.lock @@ -0,0 +1,400 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/23/6dc0df43c62fdec401b1ec3aea698ba50c5abfca25259e9f0208b34d7abe/basedpyright-1.31.0.tar.gz", hash = "sha256:900a573a525a0f66f884075c2a98711bb9478e44dc60ffdf182ef681bf8e2c76", size = 22062384, upload-time = "2025-07-16T11:37:29.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/85/cb46707458c514ae959fe139135d8f7231d95faf1c383a56979a3436b965/basedpyright-1.31.0-py3-none-any.whl", hash = "sha256:d7460ddcd3a2332b1c3fd738735d18bf2966d49aed67237efa1f19635199d414", size = 11538999, upload-time = "2025-07-16T11:37:26.446Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/8f/6ac7fbb29e35645065f7be835bfe3e0cce567f80390de2f3db65d83cb5e3/coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867", size = 819816, upload-time = "2025-07-24T16:53:00.896Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/a7/a47f64718c2229b7860a334edd4e6ff41ec8513f3d3f4246284610344392/coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d", size = 215143, upload-time = "2025-07-24T16:51:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ea/86/14d76a409e9ffab10d5aece73ac159dbd102fc56627e203413bfc6d53b24/coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586", size = 215401, upload-time = "2025-07-24T16:51:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b3/fb5c28148a19035a3877fac4e40b044a4c97b24658c980bcf7dff18bfab8/coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4", size = 245949, upload-time = "2025-07-24T16:51:17.628Z" }, + { url = "https://files.pythonhosted.org/packages/6d/95/357559ecfe73970d2023845797361e6c2e6c2c05f970073fff186fe19dd7/coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6", size = 248295, upload-time = "2025-07-24T16:51:19.46Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/bac5bc43085712af201f76a24733895331c475e5ddda88ac36c1332a65e6/coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32", size = 249733, upload-time = "2025-07-24T16:51:21.518Z" }, + { url = "https://files.pythonhosted.org/packages/b2/db/104b713b3b74752ee365346677fb104765923982ae7bd93b95ca41fe256b/coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd", size = 247943, upload-time = "2025-07-24T16:51:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/32/4f/bef25c797c9496cf31ae9cfa93ce96b4414cacf13688e4a6000982772fd5/coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c", size = 245914, upload-time = "2025-07-24T16:51:24.766Z" }, + { url = "https://files.pythonhosted.org/packages/36/6b/b3efa0b506dbb9a37830d6dc862438fe3ad2833c5f889152bce24d9577cf/coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88", size = 247296, upload-time = "2025-07-24T16:51:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/1f/aa/95a845266aeacab4c57b08e0f4e0e2899b07809a18fd0c1ddef2ac2c9138/coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0", size = 217566, upload-time = "2025-07-24T16:51:28.961Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d1/27b6e5073a8026b9e0f4224f1ac53217ce589a4cdab1bee878f23bff64f0/coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82", size = 218337, upload-time = "2025-07-24T16:51:31.45Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/0e3ba498b11e2245fd96bd7e8dcdf90e1dd36d57f49f308aa650ff0561b8/coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957", size = 216740, upload-time = "2025-07-24T16:51:33.317Z" }, + { url = "https://files.pythonhosted.org/packages/44/8b/11529debbe3e6b39ef6e7c8912554724adc6dc10adbb617a855ecfd387eb/coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3", size = 215866, upload-time = "2025-07-24T16:51:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/d8981310879e395f39af66536665b75135b1bc88dd21c7764e3340e9ce69/coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458", size = 216083, upload-time = "2025-07-24T16:51:36.932Z" }, + { url = "https://files.pythonhosted.org/packages/c3/84/93295402de002de8b8c953bf6a1f19687174c4db7d44c1e85ffc153a772d/coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10", size = 257320, upload-time = "2025-07-24T16:51:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/02/5c/d0540db4869954dac0f69ad709adcd51f3a73ab11fcc9435ee76c518944a/coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157", size = 259182, upload-time = "2025-07-24T16:51:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/59/b2/d7d57a41a15ca4b47290862efd6b596d0a185bfd26f15d04db9f238aa56c/coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0", size = 261322, upload-time = "2025-07-24T16:51:42.44Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/fd828ae411b3da63673305617b6fbeccc09feb7dfe397d164f55a65cd880/coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18", size = 258914, upload-time = "2025-07-24T16:51:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/28/49/4aa5f5464b2e1215640c0400c5b007e7f5cdade8bf39c55c33b02f3a8c7f/coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b", size = 257051, upload-time = "2025-07-24T16:51:45.75Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5a/ded2346098c7f48ff6e135b5005b97de4cd9daec5c39adb4ecf3a60967da/coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460", size = 257869, upload-time = "2025-07-24T16:51:47.41Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/e06cedb8fc7d1c96630b2f549b8cdc084e2623dcc70c900cb3b705a36a60/coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda", size = 218243, upload-time = "2025-07-24T16:51:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1e/e84dd5ff35ed066bd6150e5c26fe0061ded2c59c209fd4f18db0650766c0/coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64", size = 219334, upload-time = "2025-07-24T16:51:50.789Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e0/b7b60b5dbc4e88eac0a0e9d5b4762409a59b29bf4e772b3509c8543ccaba/coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f", size = 217196, upload-time = "2025-07-24T16:51:52.599Z" }, + { url = "https://files.pythonhosted.org/packages/15/c1/597b4fa7d6c0861d4916c4fe5c45bf30c11b31a3b07fedffed23dec5f765/coverage-7.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:1f628d91f941a375b4503cb486148dbeeffb48e17bc080e0f0adfee729361574", size = 215139, upload-time = "2025-07-24T16:51:54.381Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/07973dcad0161355cf01ff0023ab34466b735deb460a178f37163d7c800e/coverage-7.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a0e101d5af952d233557e445f42ebace20b06b4ceb615581595ced5386caa78", size = 215419, upload-time = "2025-07-24T16:51:56.341Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f8/c65127782da312084ef909c1531226c869bfe22dac8b92d9c609d8150131/coverage-7.10.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec4c1abbcc53f9f650acb14ea71725d88246a9e14ed42f8dd1b4e1b694e9d842", size = 245917, upload-time = "2025-07-24T16:51:58.045Z" }, + { url = "https://files.pythonhosted.org/packages/05/97/a7f2fe79b6ae759ccc8740608cf9686ae406cc5e5591947ebbf1d679a325/coverage-7.10.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9c95f3a7f041b4cc68a8e3fecfa6366170c13ac773841049f1cd19c8650094e0", size = 248225, upload-time = "2025-07-24T16:51:59.745Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d3/d2e1496d7ac3340356c5de582e08e14b02933e254924f79d18e9749269d8/coverage-7.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2cd597b69c16d24e310611f2ed6fcfb8f09429316038c03a57e7b4f5345244", size = 249844, upload-time = "2025-07-24T16:52:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7e/e26d966c9cae62500e5924107974ede2e985f7d119d10ed44d102998e509/coverage-7.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5e18591906a40c2b3609196c9879136aa4a47c5405052ca6b065ab10cb0b71d0", size = 247871, upload-time = "2025-07-24T16:52:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/59/95/6a372a292dfb9d6e2cc019fc50878f7a6a5fbe704604018d7c5c1dbffb2d/coverage-7.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:485c55744252ed3f300cc1a0f5f365e684a0f2651a7aed301f7a67125906b80e", size = 245714, upload-time = "2025-07-24T16:52:05.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/7f/63da22b7bc4e82e2c1df7755223291fc94fb01942cfe75e19f2bed96129e/coverage-7.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4dabea1516e5b0e9577282b149c8015e4dceeb606da66fb8d9d75932d5799bf5", size = 247131, upload-time = "2025-07-24T16:52:07.661Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/883272555e34872879f48daea4207489cb36df249e3069e6a8a664dc6ba6/coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017", size = 217804, upload-time = "2025-07-24T16:52:09.328Z" }, + { url = "https://files.pythonhosted.org/packages/90/f6/7afc3439994b7f7311d858438d49eef8b06eadbf2322502d921a110fae1e/coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653", size = 218596, upload-time = "2025-07-24T16:52:11.038Z" }, + { url = "https://files.pythonhosted.org/packages/0b/99/7c715cfa155609ee3e71bc81b4d1265e1a9b79ad00cc3d19917ea736cbac/coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252", size = 216960, upload-time = "2025-07-24T16:52:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5cb476346d3842f2e42cd92614a91921ebad38aa97aba63f2aab51919e35/coverage-7.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0a889ef25215990f65073c32cadf37483363a6a22914186dedc15a6b1a597d50", size = 215881, upload-time = "2025-07-24T16:52:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/80/1b/c066d6836f4c1940a8df14894a5ec99db362838fdd9eee9fb7efe0e561d2/coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483", size = 216087, upload-time = "2025-07-24T16:52:16.216Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/f0996fd468e70d4d24d69eba10ecc2b913c2e85d9f3c1bb2075ad7554c05/coverage-7.10.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2f2c0df0cbcf7dffa14f88a99c530cdef3f4fcfe935fa4f95d28be2e7ebc570", size = 257408, upload-time = "2025-07-24T16:52:18.136Z" }, + { url = "https://files.pythonhosted.org/packages/36/78/c9f308b2b986cc685d4964a3b829b053817a07d7ba14ff124cf06154402e/coverage-7.10.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:048d19a5d641a2296745ab59f34a27b89a08c48d6d432685f22aac0ec1ea447f", size = 259373, upload-time = "2025-07-24T16:52:20.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/13/192827b71da71255d3554cb7dc289bce561cb281bda27e1b0dd19d88e47d/coverage-7.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1209b65d302d7a762004be37ab9396cbd8c99525ed572bdf455477e3a9449e06", size = 261495, upload-time = "2025-07-24T16:52:23.018Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/cf4694353405abbb440a94468df8e5c4dbf884635da1f056b43be7284d28/coverage-7.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e44aa79a36a7a0aec6ea109905a4a7c28552d90f34e5941b36217ae9556657d5", size = 258970, upload-time = "2025-07-24T16:52:25.685Z" }, + { url = "https://files.pythonhosted.org/packages/c7/83/fb45dac65c42eff6ce4153fe51b9f2a9fdc832ce57b7902ab9ff216c3faa/coverage-7.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96124be864b89395770c9a14652afcddbcdafb99466f53a9281c51d1466fb741", size = 257046, upload-time = "2025-07-24T16:52:27.778Z" }, + { url = "https://files.pythonhosted.org/packages/60/95/577dc757c01f493a1951157475dd44561c82084387f12635974fb62e848c/coverage-7.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aad222e841f94b42bd1d6be71737fade66943853f0807cf87887c88f70883a2a", size = 257946, upload-time = "2025-07-24T16:52:29.931Z" }, + { url = "https://files.pythonhosted.org/packages/da/5a/14b1be12e3a71fcf4031464ae285dab7df0939976236d0462c4c5382d317/coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104", size = 218602, upload-time = "2025-07-24T16:52:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/c32890c0f4f7f71b8d4a1074ef8e9ef28e9b9c2f9fd0e2896f2cc32593bf/coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109", size = 219720, upload-time = "2025-07-24T16:52:34.745Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/e5cc13338aa5e2780b6226fb50e9bd8f3f88da85a4b2951447b4b51109a4/coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426", size = 217374, upload-time = "2025-07-24T16:52:36.974Z" }, + { url = "https://files.pythonhosted.org/packages/09/df/7c34bada8ace39f688b3bd5bc411459a20a3204ccb0984c90169a80a9366/coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f", size = 206777, upload-time = "2025-07-24T16:52:59.009Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.136.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/e2/ebee1678151e8f06f84f10e74229060b5db9f16fd4cac35b44a8286aa669/hypothesis-6.136.4.tar.gz", hash = "sha256:56bf16ba20d39ad7c5f8d3c42a5a7503844a7892dbf6f6e6e79b2188319a56d8", size = 457805, upload-time = "2025-07-25T02:38:14.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/95/bcdf24ac537b46d49b73c6a7e9f3b0d66016958299d36c41636afa9187cd/hypothesis-6.136.4-py3-none-any.whl", hash = "sha256:488156d3603a9db6218ab2bf68d75e0fd967671a676ecc478c6b44eb153a7527", size = 524759, upload-time = "2025-07-25T02:38:10.278Z" }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "22.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/86/8962d1d24ff480f4dd31871f42c8e0d8e2c851cd558a07ee689261d310ab/nodejs_wheel_binaries-22.17.0.tar.gz", hash = "sha256:529142012fb8fd20817ef70e2ef456274df4f49933292e312c8bbc7285af6408", size = 8068, upload-time = "2025-06-29T20:24:25.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/53/b942c6da4ff6f87a315033f6ff6fed8fd3c22047d7ff5802badaa5dfc2c2/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6545a6f6d2f736d9c9e2eaad7e599b6b5b2d8fd4cbd2a1df0807cbcf51b9d39b", size = 51003554, upload-time = "2025-06-29T20:23:47.042Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/7184a9ad2364912da22f2fe021dc4a3301721131ef7759aeb4a1f19db0b4/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4bea5b994dd87c20f8260031ea69a97c3d282e2d4472cc8908636a313a830d00", size = 51936848, upload-time = "2025-06-29T20:23:52.064Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7a/0ea425147b8110b8fd65a6c21cfd3bd130cdec7766604361429ef870d799/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:885508615274a22499dd5314759c1cf96ba72de03e6485d73b3e5475e7f12662", size = 57925230, upload-time = "2025-06-29T20:23:56.81Z" }, + { url = "https://files.pythonhosted.org/packages/23/5f/10a3f2ac08a839d065d9ccfd6d9df66bc46e100eaf87a8a5cf149eb3fb8e/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f38ce034a602bcab534d55cbe0390521e73e5dcffdd1c4b34354b932172af2", size = 58457829, upload-time = "2025-06-29T20:24:01.945Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a4/d2ca331e16eef0974eb53702df603c54f77b2a7e2007523ecdbf6cf61162/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5eed087855b644c87001fe04036213193963ccd65e7f89949e9dbe28e7743d9b", size = 59778054, upload-time = "2025-06-29T20:24:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/04e0e7f7305fe2ba30fd4610bfb432516e0f65379fe6c2902f4b7b1ad436/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715f413c81500f0770ea8936ef1fc2529b900da8054cbf6da67cec3ee308dc76", size = 60830079, upload-time = "2025-06-29T20:24:12.21Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/12070b24b88040c2d694883f3dcb067052f748798f4c63f7c865769a5747/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_amd64.whl", hash = "sha256:51165630493c8dd4acfe1cae1684b76940c9b03f7f355597d55e2d056a572ddd", size = 40117877, upload-time = "2025-06-29T20:24:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ec/53ac46af423527c23e40c7343189f2bce08a8337efedef4d8a33392cee23/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_arm64.whl", hash = "sha256:fae56d172227671fccb04461d3cd2b26a945c6c7c7fc29edb8618876a39d8b4a", size = 38865278, upload-time = "2025-06-29T20:24:21.065Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/ac/311c8a492dc887f0b7a54d0ec3324cb2f9538b7b78ea06e5f7ae1f167e52/poethepoet-0.36.0.tar.gz", hash = "sha256:2217b49cb4e4c64af0b42ff8c4814b17f02e107d38bc461542517348ede25663", size = 66854, upload-time = "2025-06-29T19:54:50.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/29/dedb3a6b7e17ea723143b834a2da428a7d743c80d5cd4d22ed28b5e8c441/poethepoet-0.36.0-py3-none-any.whl", hash = "sha256:693e3c1eae9f6731d3613c3c0c40f747d3c5c68a375beda42e590a63c5623308", size = 88031, upload-time = "2025-06-29T19:54:48.884Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pydis-qualifier-25" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "poethepoet" }, +] +lint = [ + { name = "basedpyright" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "typing-extensions" }, +] +test = [ + { name = "coverage" }, + { name = "hypothesis" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "poethepoet", specifier = ">=0.34.0" }] +lint = [ + { name = "basedpyright", specifier = ">=1.29.1" }, + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "ruff", specifier = ">=0.11.9" }, + { name = "typing-extensions", specifier = ">=4.13.2" }, +] +test = [ + { name = "coverage", specifier = ">=7.8.0" }, + { name = "hypothesis", specifier = ">=6.136.4" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=0.26.0" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722, upload-time = "2025-07-24T13:26:37.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133, upload-time = "2025-07-24T13:25:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114, upload-time = "2025-07-24T13:25:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873, upload-time = "2025-07-24T13:26:01.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829, upload-time = "2025-07-24T13:26:03.721Z" }, + { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619, upload-time = "2025-07-24T13:26:06.118Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894, upload-time = "2025-07-24T13:26:08.292Z" }, + { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909, upload-time = "2025-07-24T13:26:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652, upload-time = "2025-07-24T13:26:13.381Z" }, + { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451, upload-time = "2025-07-24T13:26:15.488Z" }, + { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465, upload-time = "2025-07-24T13:26:17.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136, upload-time = "2025-07-24T13:26:20.422Z" }, + { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644, upload-time = "2025-07-24T13:26:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068, upload-time = "2025-07-24T13:26:26.134Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537, upload-time = "2025-07-24T13:26:28.533Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575, upload-time = "2025-07-24T13:26:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, +]