Initial commit
This commit is contained in:
commit
9fba4b3d34
17 changed files with 2939 additions and 0 deletions
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
170
pyproject.toml
Normal 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
0
src/__init__.py
Normal file
17
src/node.py
Normal file
17
src/node.py
Normal 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
51
src/node_helpers.py
Normal 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
415
src/parser.py
Normal 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
19
src/qualifier.py
Normal 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
156
src/tokenizer.py
Normal 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
0
tests/__init__.py
Normal file
0
tests/config.py
Normal file
0
tests/config.py
Normal file
70
tests/hypot.py
Normal file
70
tests/hypot.py
Normal 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
443
tests/test_matching.py
Normal 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
474
tests/test_parser.py
Normal 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
335
tests/test_qualifier.py
Normal 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
143
tests/test_stringify.py
Normal 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
200
tests/test_tokenizer.py
Normal 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
400
uv.lock
generated
Normal 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" },
|
||||
]
|
Loading…
Add table
Add a link
Reference in a new issue