Initial commit

This commit is contained in:
ItsDrike 2025-07-27 12:51:35 +02:00
commit 9fba4b3d34
Signed by: ItsDrike
GPG key ID: FA2745890B7048C0
17 changed files with 2939 additions and 0 deletions

46
.gitignore vendored Normal file
View file

@ -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

170
pyproject.toml Normal file
View file

@ -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"

0
src/__init__.py Normal file
View file

17
src/node.py Normal file
View file

@ -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 = ""

51
src/node_helpers.py Normal file
View file

@ -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

415
src/parser.py Normal file
View file

@ -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

19
src/qualifier.py Normal file
View file

@ -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)]

156
src/tokenizer.py Normal file
View file

@ -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)

0
tests/__init__.py Normal file
View file

0
tests/config.py Normal file
View file

70
tests/hypot.py Normal file
View file

@ -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,
)

443
tests/test_matching.py Normal file
View file

@ -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:
# <div id="topDiv">
# <div class="container colour-primary" id="innerDiv">
# <h1>This is a heading!</h1>
# <p class="colour-secondary" id="innerContent">
# I have some content within this container also!
# </p>
# <p class="colour-secondary" id="two">
# This is another paragraph.
# </p>
# <p class="colour-secondary important">
# This is a third paragraph.
# </p>
# <a class="colour-primary button" id="home-link">
# This is a button link.
# </a>
# </div>
# <div class="container colour-secondary">
# <p class="colour-primary">This is a paragraph in a secondary container.</p>
# </div>
# </div>
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

474
tests/test_parser.py Normal file
View file

@ -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"]

335
tests/test_qualifier.py Normal file
View file

@ -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:
# <div id="topDiv">
# <div class="container colour-primary" id="innerDiv">
# <h1>This is a heading!</h1>
# <p class="colour-secondary" id="innerContent">
# I have some content within this container also!
# </p>
# <p class="colour-secondary" id="two">
# This is another paragraph.
# </p>
# <p class="colour-secondary important">
# This is a third paragraph.
# </p>
# <a class="colour-primary button" id="home-link">
# This is a button link.
# </a>
# </div>
# <div class="container colour-secondary">
# <p class="colour-primary">This is a paragraph in a secondary container.</p>
# </div>
# </div>
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)

143
tests/test_stringify.py Normal file
View file

@ -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

200
tests/test_tokenizer.py Normal file
View file

@ -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

400
uv.lock generated Normal file
View file

@ -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" },
]