From ff2f88f355a86f2c5bd2084adcba02b51dccba8b Mon Sep 17 00:00:00 2001 From: linuxdaemon Date: Thu, 4 Apr 2024 01:00:00 +0000 Subject: [PATCH] Add typing, remove old pickle format, expand testing --- .devcontainer/devcontainer.json | 11 ++- polymatch/__init__.py | 27 ++++-- polymatch/base.py | 162 ++++++++++++++++++++------------ polymatch/error.py | 19 ++-- polymatch/matchers/glob.py | 20 ++-- polymatch/matchers/regex.py | 63 ++++--------- polymatch/matchers/standard.py | 40 +++++--- polymatch/py.typed | 0 polymatch/registry.py | 71 +++++++------- pyproject.toml | 32 +++++-- tests/__init__.py | 0 tests/base_test.py | 45 +++++++++ tests/test_glob.py | 23 +++-- tests/test_pickling.py | 59 ++++++------ tests/test_regex.py | 24 +++-- tests/test_registry.py | 73 ++++++++++++++ tests/test_standard.py | 101 ++++++++++++++++++-- 17 files changed, 523 insertions(+), 247 deletions(-) create mode 100644 polymatch/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/base_test.py create mode 100644 tests/test_registry.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 42abbf0..e4a5123 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -34,17 +34,22 @@ "EditorConfig.EditorConfig", "GitHub.vscode-pull-request-github", "github.vscode-github-actions", - "redhat.vscode-yaml" + "redhat.vscode-yaml", + "ms-python.black-formatter", + "ms-python.isort" ], "settings": { - "python.pythonPath": "~/.virtualenvs/polymatch/bin/python", + "python.defaultInterpreterPath": "~/.virtualenvs/polymatch/bin/python", "python.testing.pytestArgs": ["--no-cov"], "terminal.integrated.profiles.linux": { "zsh": { "path": "/usr/bin/zsh" } }, - "terminal.integrated.defaultProfile.linux": "zsh" + "terminal.integrated.defaultProfile.linux": "zsh", + "[python]": { + "editor.defaultFormatter": "ms-python.python" + } } } } diff --git a/polymatch/__init__.py b/polymatch/__init__.py index a0c1580..7e0b07b 100644 --- a/polymatch/__init__.py +++ b/polymatch/__init__.py @@ -1,9 +1,24 @@ -from . import error -from .base import CaseAction, PolymorphicMatcher -from .error import * -from .registry import pattern_registry +from polymatch.base import CaseAction, PolymorphicMatcher +from polymatch.error import ( + DuplicateMatcherRegistrationError, + NoMatchersAvailableError, + NoSuchMatcherError, + PatternCompileError, + PatternNotCompiledError, + PatternTextTypeMismatchError, +) +from polymatch.registry import pattern_registry __version__ = "0.2.0" -__all__ = ("PolymorphicMatcher", "CaseAction", "pattern_registry") -__all__ += error.__all__ +__all__ = [ + "PolymorphicMatcher", + "CaseAction", + "pattern_registry", + "NoSuchMatcherError", + "NoMatchersAvailableError", + "PatternNotCompiledError", + "PatternCompileError", + "PatternTextTypeMismatchError", + "DuplicateMatcherRegistrationError", +] diff --git a/polymatch/base.py b/polymatch/base.py index 38ecf8f..b0a7191 100644 --- a/polymatch/base.py +++ b/polymatch/base.py @@ -1,5 +1,6 @@ from abc import ABCMeta, abstractmethod from enum import Enum +from typing import AnyStr, Callable, Generic, Optional, Tuple, Type, TypeVar import polymatch from polymatch.error import ( @@ -16,22 +17,45 @@ class CaseAction(Enum): CASEFOLD = "casefold", "cf" # Force case-folded comparison -class PolymorphicMatcher(metaclass=ABCMeta): +AnyPattern = TypeVar("AnyPattern") + +TUPLE_V1 = Tuple[AnyStr, CaseAction, bool, AnyPattern, Type[AnyStr], object] +TUPLE_V2 = Tuple[ + str, AnyStr, CaseAction, bool, Optional[AnyPattern], Type[AnyStr], object +] + +CompileFunc = Callable[[AnyStr], AnyPattern] +MatchFunc = Callable[[AnyPattern, AnyStr], bool] + +FuncTuple = Tuple[CompileFunc[AnyStr, AnyPattern], MatchFunc[AnyPattern, AnyStr]] + + +class PolymorphicMatcher(Generic[AnyStr, AnyPattern], metaclass=ABCMeta): _empty = object() - def __init__(self, pattern, case_action=CaseAction.NONE, invert=False): - self._raw_pattern = pattern - self._str_type = type(pattern) - self._compiled_pattern = self._empty + def __init__( + self, + pattern: AnyStr, + /, + case_action: CaseAction = CaseAction.NONE, + *, + invert: bool = False, + ) -> None: + self._raw_pattern: AnyStr = pattern + self._str_type: Type[AnyStr] = type(pattern) + self._compiled_pattern: Optional[AnyPattern] = None self._case_action = case_action self._invert = invert - self._compile_func, self._match_func = self._get_case_functions() + funcs: FuncTuple[AnyStr, AnyPattern] = self._get_case_functions() + self._compile_func: CompileFunc[AnyStr, AnyPattern] = funcs[0] + self._match_func: MatchFunc[AnyPattern, AnyStr] = funcs[1] if self._case_action is CaseAction.CASEFOLD and self._str_type is bytes: - raise TypeError("Case-folding is not supported with bytes patterns") + msg = "Case-folding is not supported with bytes patterns" + raise TypeError(msg) - def try_compile(self): + def try_compile(self) -> bool: try: self.compile() except PatternCompileError: @@ -39,78 +63,84 @@ def try_compile(self): return True - def compile(self): + def compile(self) -> None: # noqa: A003 try: - self._compiled_pattern = self._compile_func(self._raw_pattern) - except Exception as e: - raise PatternCompileError( - f"Failed to compile pattern {self._raw_pattern!r}" - ) from e + self._compiled_pattern = self._compile_func(self.pattern) + except Exception as e: # noqa: BLE001 + msg = f"Failed to compile pattern {self.pattern!r}" + raise PatternCompileError(msg) from e - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if isinstance(other, self._str_type): return self.match(other) return NotImplemented - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if isinstance(other, self._str_type): return not self.match(other) return NotImplemented - def match(self, text): + def match(self, text: AnyStr) -> bool: if not isinstance(text, self._str_type): raise PatternTextTypeMismatchError(self._str_type, type(text)) - if not self.is_compiled(): + if self._compiled_pattern is None: # If it wasn't compiled - raise PatternNotCompiledError("Pattern must be compiled.") + msg = "Pattern must be compiled." + raise PatternNotCompiledError(msg) out = self._match_func(self._compiled_pattern, text) - if self._invert: + if self.inverted: return not out return out - def is_compiled(self): - return self._compiled_pattern is not self._empty + def is_compiled(self) -> bool: + return self._compiled_pattern is not None @abstractmethod - def compile_pattern(self, raw_pattern): + def compile_pattern(self, raw_pattern: AnyStr) -> AnyPattern: raise NotImplementedError @abstractmethod - def compile_pattern_cs(self, raw_pattern): + def compile_pattern_cs(self, raw_pattern: AnyStr) -> AnyPattern: """Matchers should override this to compile their pattern with case-sensitive options""" raise NotImplementedError @abstractmethod - def compile_pattern_ci(self, raw_pattern): + def compile_pattern_ci(self, raw_pattern: AnyStr) -> AnyPattern: """Matchers should override this to compile their pattern with case-insensitive options""" raise NotImplementedError @abstractmethod - def compile_pattern_cf(self, raw_pattern): + def compile_pattern_cf(self, raw_pattern: AnyStr) -> AnyPattern: """Matchers should override this to compile their pattern with case-folding options""" raise NotImplementedError @abstractmethod - def match_text(self, pattern, text): + def match_text(self, pattern: AnyPattern, text: AnyStr) -> bool: raise NotImplementedError - def match_text_cs(self, pattern, text): + def match_text_cs(self, pattern: AnyPattern, text: AnyStr) -> bool: return self.match_text(pattern, text) - def match_text_ci(self, pattern, text): + def match_text_ci(self, pattern: AnyPattern, text: AnyStr) -> bool: return self.match_text(pattern, text.lower()) - def match_text_cf(self, pattern, text): + def match_text_cf(self, pattern: AnyPattern, text: AnyStr) -> bool: + if isinstance(text, bytes): + msg = "Casefold is not supported on bytes patterns" + raise TypeError(msg) + return self.match_text(pattern, text.casefold()) - def _get_case_functions(self): - suffix = self._case_action.value[1] + def _get_case_functions( + self, + ) -> Tuple[CompileFunc[AnyStr, AnyPattern], MatchFunc[AnyPattern, AnyStr]]: + suffix = self.case_action.value[1] if suffix: suffix = "_" + suffix @@ -121,62 +151,74 @@ def _get_case_functions(self): @classmethod @abstractmethod - def get_type(cls): + def get_type(cls) -> str: raise NotImplementedError @property - def pattern(self): + def pattern(self) -> AnyStr: return self._raw_pattern @property - def case_action(self): + def case_action(self) -> CaseAction: return self._case_action @property - def inverted(self): + def inverted(self) -> bool: return self._invert - def __str__(self): - return "{}{}:{}:{}".format( - "~" if self._invert else "", - self.get_type(), - self._case_action.value[1], - self._raw_pattern, - ) + def to_string(self) -> AnyStr: + if isinstance(self.pattern, str): + return "{}{}:{}:{}".format( + "~" if self.inverted else "", + self.get_type(), + self.case_action.value[1], + self.pattern, + ) - def __repr__(self): - return "{}(pattern={!r}, case_action={!r}, invert={!r})".format( - type(self).__name__, - self._raw_pattern, - self._case_action, - self._invert, + return ( + "{}{}:{}:".format( + "~" if self.inverted else "", self.get_type(), self.case_action.value[1] + ) + ).encode() + self.pattern + + def __str__(self) -> str: + res = self.to_string() + if isinstance(res, str): + return res + + return res.decode() + + def __repr__(self) -> str: + return "{}(pattern={!r}, case_action={}, invert={!r})".format( + type(self).__name__, self.pattern, self.case_action, self.inverted ) - def __getstate__(self): + def __getstate__(self) -> TUPLE_V2[AnyStr, AnyPattern]: return ( polymatch.__version__, - self._raw_pattern, - self._case_action, - self._invert, + self.pattern, + self.case_action, + self.inverted, self._compiled_pattern, self._str_type, self._empty, ) - def __setstate__(self, state): - if len(state) > 6: - version, *state = state - else: - version = "0.0.0" - + def __setstate__(self, state: TUPLE_V2[AnyStr, AnyPattern]) -> None: ( + version, self._raw_pattern, self._case_action, self._invert, - self._compiled_pattern, + _compiled_pattern, self._str_type, self._empty, ) = state + # This is compatibility code, we can't serialize a pickled object to match this + if _compiled_pattern is self._empty: # pragma: no cover + _compiled_pattern = None + + self._compiled_pattern = _compiled_pattern self._compile_func, self._match_func = self._get_case_functions() if version != polymatch.__version__ and self.is_compiled(): diff --git a/polymatch/error.py b/polymatch/error.py index ec940d1..98e34dc 100644 --- a/polymatch/error.py +++ b/polymatch/error.py @@ -1,11 +1,16 @@ -__all__ = ( +from typing import TYPE_CHECKING, AnyStr, Type + +if TYPE_CHECKING: + from polymatch.base import AnyPattern + +__all__ = [ "PatternCompileError", "PatternNotCompiledError", "PatternTextTypeMismatchError", "DuplicateMatcherRegistrationError", "NoSuchMatcherError", - "NoMatchersAvailable", -) + "NoMatchersAvailableError", +] class PatternCompileError(ValueError): @@ -17,7 +22,9 @@ class PatternNotCompiledError(ValueError): class PatternTextTypeMismatchError(TypeError): - def __init__(self, pattern_type, text_type): + def __init__( + self, pattern_type: "Type[AnyPattern]", text_type: Type[AnyStr] + ) -> None: super().__init__( "Pattern of type {!r} can not match text of type {!r}".format( pattern_type.__name__, text_type.__name__ @@ -26,7 +33,7 @@ def __init__(self, pattern_type, text_type): class DuplicateMatcherRegistrationError(ValueError): - def __init__(self, name): + def __init__(self, name: str) -> None: super().__init__(f"Attempted o register a duplicate matcher {name!r}") @@ -34,5 +41,5 @@ class NoSuchMatcherError(LookupError): pass -class NoMatchersAvailable(ValueError): +class NoMatchersAvailableError(ValueError): pass diff --git a/polymatch/matchers/glob.py b/polymatch/matchers/glob.py index 399d179..69fc814 100644 --- a/polymatch/matchers/glob.py +++ b/polymatch/matchers/glob.py @@ -1,20 +1,26 @@ from fnmatch import translate +from typing import TYPE_CHECKING, AnyStr from polymatch.matchers.regex import RegexMatcher +if TYPE_CHECKING: + import regex -class GlobMatcher(RegexMatcher): - def compile_pattern(self, raw_pattern, flags=0): - if isinstance(raw_pattern, bytes): + +class GlobMatcher(RegexMatcher[AnyStr]): + def compile_pattern( + self, raw_pattern: AnyStr, *, flags: int = 0 + ) -> "regex.Pattern[AnyStr]": + if isinstance(raw_pattern, str): + res = translate(raw_pattern) + else: # Mimic how fnmatch handles bytes patterns pat_str = str(raw_pattern, "ISO-8859-1") res_str = translate(pat_str) res = bytes(res_str, "ISO-8859-1") - else: - res = translate(raw_pattern) - return super().compile_pattern(res, flags) + return RegexMatcher.compile_pattern(self, res, flags=flags) @classmethod - def get_type(cls): + def get_type(cls) -> str: return "glob" diff --git a/polymatch/matchers/regex.py b/polymatch/matchers/regex.py index ab5b3a4..f160d0b 100644 --- a/polymatch/matchers/regex.py +++ b/polymatch/matchers/regex.py @@ -1,62 +1,39 @@ -from polymatch import PolymorphicMatcher - -try: - import regex -except ImportError as e: - try: - _exc = globals()["ModuleNotFoundError"] - except LookupError: - pass - else: - if not isinstance(e, _exc): - raise - - if e.name != "regex": - raise - - regex = None +from typing import AnyStr - import re - - IGNORECASE = re.IGNORECASE -else: - IGNORECASE = regex.IGNORECASE +import regex +from polymatch import PolymorphicMatcher -class RegexMatcher(PolymorphicMatcher): - def compile_pattern(self, raw_pattern, flags=0): - if regex: - return regex.compile(raw_pattern, flags) - return re.compile(raw_pattern, flags) +class RegexMatcher(PolymorphicMatcher[AnyStr, "regex.Pattern[AnyStr]"]): + def compile_pattern( + self, raw_pattern: AnyStr, *, flags: int = 0 + ) -> "regex.Pattern[AnyStr]": + return regex.compile(raw_pattern, flags) - def compile_pattern_cs(self, raw_pattern): + def compile_pattern_cs(self, raw_pattern: AnyStr) -> "regex.Pattern[AnyStr]": return self.compile_pattern(raw_pattern) - def compile_pattern_ci(self, raw_pattern): - return self.compile_pattern(raw_pattern, IGNORECASE) + def compile_pattern_ci(self, raw_pattern: AnyStr) -> "regex.Pattern[AnyStr]": + return self.compile_pattern(raw_pattern, flags=regex.IGNORECASE) - def compile_pattern_cf(self, raw_pattern): - if not regex: - raise NotImplementedError + def compile_pattern_cf(self, raw_pattern: AnyStr) -> "regex.Pattern[AnyStr]": + return self.compile_pattern( + raw_pattern, flags=regex.FULLCASE | regex.IGNORECASE + ) - return self.compile_pattern(raw_pattern, regex.FULLCASE | IGNORECASE) - - def match_text(self, pattern, text): + def match_text(self, pattern: "regex.Pattern[AnyStr]", text: AnyStr) -> bool: return bool(pattern.match(text)) - def match_text_cf(self, pattern, text): - if not regex: - raise NotImplementedError - + def match_text_cf(self, pattern: "regex.Pattern[AnyStr]", text: AnyStr) -> bool: return self.match_text(pattern, text) - def match_text_ci(self, pattern, text): + def match_text_ci(self, pattern: "regex.Pattern[AnyStr]", text: AnyStr) -> bool: return self.match_text(pattern, text) - def match_text_cs(self, pattern, text): + def match_text_cs(self, pattern: "regex.Pattern[AnyStr]", text: AnyStr) -> bool: return self.match_text(pattern, text) @classmethod - def get_type(cls): + def get_type(cls) -> str: return "regex" diff --git a/polymatch/matchers/standard.py b/polymatch/matchers/standard.py index 4c624ee..9af8d46 100644 --- a/polymatch/matchers/standard.py +++ b/polymatch/matchers/standard.py @@ -1,43 +1,53 @@ +from typing import AnyStr + from polymatch import PolymorphicMatcher -class ExactMatcher(PolymorphicMatcher): - def compile_pattern(self, raw_pattern): +class ExactMatcher(PolymorphicMatcher[AnyStr, AnyStr]): + def compile_pattern(self, raw_pattern: AnyStr) -> AnyStr: return raw_pattern - def compile_pattern_cs(self, raw_pattern): + def compile_pattern_cs(self, raw_pattern: AnyStr) -> AnyStr: return raw_pattern - def compile_pattern_ci(self, raw_pattern): + def compile_pattern_ci(self, raw_pattern: AnyStr) -> AnyStr: return raw_pattern.lower() - def compile_pattern_cf(self, raw_pattern): - return raw_pattern.casefold() + def compile_pattern_cf(self, raw_pattern: AnyStr) -> AnyStr: + if isinstance(raw_pattern, str): + return raw_pattern.casefold() - def match_text(self, pattern, text): + msg = "Casefold is not supported on bytes patterns" + raise TypeError(msg) + + def match_text(self, pattern: AnyStr, text: AnyStr) -> bool: return text == pattern @classmethod - def get_type(cls): + def get_type(cls) -> str: return "exact" -class ContainsMatcher(PolymorphicMatcher): - def compile_pattern(self, raw_pattern): +class ContainsMatcher(PolymorphicMatcher[AnyStr, AnyStr]): + def compile_pattern(self, raw_pattern: AnyStr) -> AnyStr: return raw_pattern - def compile_pattern_cs(self, raw_pattern): + def compile_pattern_cs(self, raw_pattern: AnyStr) -> AnyStr: return raw_pattern - def compile_pattern_ci(self, raw_pattern): + def compile_pattern_ci(self, raw_pattern: AnyStr) -> AnyStr: return raw_pattern.lower() - def compile_pattern_cf(self, raw_pattern): + def compile_pattern_cf(self, raw_pattern: AnyStr) -> AnyStr: + if isinstance(raw_pattern, bytes): + msg = "Casefold is not supported on bytes patterns" + raise TypeError(msg) + return raw_pattern.casefold() - def match_text(self, pattern, text): + def match_text(self, pattern: AnyStr, text: AnyStr) -> bool: return pattern in text @classmethod - def get_type(cls): + def get_type(cls) -> str: return "contains" diff --git a/polymatch/py.typed b/polymatch/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/polymatch/registry.py b/polymatch/registry.py index ca8d24d..646efd7 100644 --- a/polymatch/registry.py +++ b/polymatch/registry.py @@ -1,9 +1,10 @@ from collections import OrderedDict +from typing import Any, AnyStr, Dict, Optional, Tuple, Type from polymatch.base import CaseAction, PolymorphicMatcher from polymatch.error import ( DuplicateMatcherRegistrationError, - NoMatchersAvailable, + NoMatchersAvailableError, NoSuchMatcherError, ) from polymatch.matchers.glob import GlobMatcher @@ -11,7 +12,9 @@ from polymatch.matchers.standard import ContainsMatcher, ExactMatcher -def _opt_split(text, delim=":", empty="", invchar="~"): +def _opt_split( + text: AnyStr, delim: AnyStr, empty: AnyStr, invchar: AnyStr +) -> Tuple[bool, AnyStr, AnyStr, AnyStr]: if text.startswith(invchar): invert = True text = text[len(invchar) :] @@ -32,75 +35,69 @@ def _opt_split(text, delim=":", empty="", invchar="~"): return invert, name, opts, text -def _parse_pattern_string(text): +def _parse_pattern_string(text: AnyStr) -> Tuple[bool, str, str, AnyStr]: if isinstance(text, str): - invert, name, opts, pattern = _opt_split(text) - + invert, name, opts, pattern = _opt_split(text, ":", "", "~") return invert, name, opts, pattern - elif isinstance(text, bytes): + + if isinstance(text, bytes): invert, name, opts, pattern = _opt_split(text, b":", b"", b"~") return invert, name.decode(), opts.decode(), pattern - else: - raise TypeError( - f"Unable to parse pattern string of type {type(text).__name__!r}" - ) + + msg = f"Unable to parse pattern string of type {type(text).__name__!r}" + raise TypeError(msg) class PatternMatcherRegistry: - def __init__(self): - self._matchers = OrderedDict() + def __init__(self) -> None: + self._matchers: Dict[str, Type[PolymorphicMatcher[Any, Any]]] = OrderedDict() + + def register(self, cls: Type[Any]) -> None: + if not issubclass(cls, PolymorphicMatcher): + msg = "Pattern matcher must be of type {!r} not {!r}".format( + PolymorphicMatcher.__name__, cls.__name__ + ) + raise TypeError(msg) - def register(self, cls): name = cls.get_type() if name in self._matchers: raise DuplicateMatcherRegistrationError(name) - if not issubclass(cls, PolymorphicMatcher): - raise TypeError( - "Pattern matcher must be of type {!r} not {!r}".format( - PolymorphicMatcher.__name__, cls.__name__ - ) - ) - self._matchers[name] = cls - def remove(self, name): + def remove(self, name: str) -> None: del self._matchers[name] - def __getitem__(self, item): + def __getitem__(self, item: str) -> Type[PolymorphicMatcher[Any, Any]]: return self.get_matcher(item) - def get_matcher(self, name): + def get_matcher(self, name: str) -> Type[PolymorphicMatcher[Any, Any]]: try: return self._matchers[name] except LookupError as e: raise NoSuchMatcherError(name) from e - def get_default_matcher(self): + def get_default_matcher(self) -> Type[PolymorphicMatcher[Any, Any]]: if self._matchers: - return list(self._matchers.values())[0] - else: - raise NoMatchersAvailable() + return next(iter(self._matchers.values())) - def pattern_from_string(self, text): + raise NoMatchersAvailableError + + def pattern_from_string(self, text: AnyStr) -> PolymorphicMatcher[Any, Any]: invert, name, opts, pattern = _parse_pattern_string(text) - if not name: - match_cls = self.get_default_matcher() - else: - match_cls = self.get_matcher(name) + match_cls = self.get_default_matcher() if not name else self.get_matcher(name) - case_action = None + case_action: Optional[CaseAction] = None for action in CaseAction: if action.value[1] == opts: case_action = action break if case_action is None: - raise LookupError( - f"Unable to find CaseAction for options: {opts!r}" - ) + msg = f"Unable to find CaseAction for options: {opts!r}" + raise LookupError(msg) - return match_cls(pattern, case_action, invert) + return match_cls(pattern, case_action, invert=invert) pattern_registry = PatternMatcherRegistry() diff --git a/pyproject.toml b/pyproject.toml index 4b293ff..20f7031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,10 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = [] - -[project.optional-dependencies] -regex = ['regex'] +dependencies = ["typing-extensions", "regex"] [project.urls] Homepage = "https://github.com/TotallyNotRobots/polymatch" @@ -36,8 +33,13 @@ path = "polymatch/__init__.py" requires = ["hatch-containers"] [tool.hatch.envs.default] -dependencies = ["coverage[toml]>=6.5", "pytest>=6.0", "pre-commit", "mypy>=1.8"] -features = ["regex"] +dependencies = [ + "coverage[toml]>=6.5", + "pytest>=6.0", + "pre-commit", + "mypy>=1.8", + "types-regex", +] [tool.hatch.envs.default.scripts] test = "pytest" @@ -64,19 +66,19 @@ python = ["3.8", "3.9", "3.10", "3.11", "3.12"] [tool.isort] profile = "black" -line_length = 80 +line_length = 88 include_trailing_comma = true use_parentheses = true known_first_party = ["polymatch"] float_to_top = true [tool.black] -line-length = 80 +line-length = 88 target-version = ["py38"] include = '\.pyi?$' [tool.ruff] -line-length = 80 +line-length = 88 target-version = 'py38' [tool.ruff.format] @@ -86,7 +88,15 @@ skip-magic-trailing-comma = true [tool.ruff.lint] ignore-init-module-imports = false -extend-safe-fixes = ["EM101", 'EM102', "TCH001", "SIM117", "SIM108", "ANN201"] +extend-safe-fixes = [ + "EM101", + "EM102", + "EM103", + "TCH001", + "SIM117", + "SIM108", + "ANN201", +] ignore = [ "D", "TRY003", # TODO(aspen): Switch to custom exceptions @@ -103,6 +113,7 @@ select = ["ALL"] "tests/*.py" = [ "PLR2004", # Allow "magic values" in tests -aspen "S101", # Allow asserts in tests + "S301", # Allow pickle in tests "SIM201", # We need to test weird comparison operators "SIM202", # We need to test weird comparison operstors "SIM300", # We need to test both forward and reverse comparisons @@ -161,6 +172,7 @@ exclude_lines = [ "raise AssertionError", "raise NotImplementedError", 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', ] [tool.coverage.run] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/base_test.py b/tests/base_test.py new file mode 100644 index 0000000..9b5fd94 --- /dev/null +++ b/tests/base_test.py @@ -0,0 +1,45 @@ +import pytest + +from polymatch import pattern_registry +from polymatch.base import CaseAction +from polymatch.error import PatternNotCompiledError, PatternTextTypeMismatchError +from polymatch.matchers.standard import ExactMatcher + + +def test_case_action_validate() -> None: + with pytest.raises( + TypeError, match="Case-folding is not supported with bytes patterns" + ): + _ = ExactMatcher(b"foo", CaseAction.CASEFOLD) + + +def test_type_mismatch() -> None: + matcher = ExactMatcher(b"foo", CaseAction.CASEINSENSITIVE) + with pytest.raises(PatternTextTypeMismatchError): + matcher.match("foo") # type: ignore[arg-type] + + +def test_compare() -> None: + matcher = pattern_registry.pattern_from_string("exact:ci:foo") + matcher.compile() + res = matcher == 123 + assert not res + res = matcher != "aaaaa" + assert res + res = matcher != "foo" + assert not res + res = matcher != 123 + assert res + + +def test_compare_invert() -> None: + matcher = pattern_registry.pattern_from_string("~exact:ci:foo") + matcher.compile() + assert matcher == "lekndlwkn" + assert matcher != "FOO" + + +def test_compare_no_compile() -> None: + matcher = pattern_registry.pattern_from_string("~exact:ci:foo") + with pytest.raises(PatternNotCompiledError): + matcher.match("foo") diff --git a/tests/test_glob.py b/tests/test_glob.py index 2d79c27..b85a1c6 100644 --- a/tests/test_glob.py +++ b/tests/test_glob.py @@ -1,23 +1,26 @@ +import pytest + +from polymatch import pattern_registry + data = ( ("glob::*", "", True), ("glob::*?", "", False), ("glob::*?", "a", True), ("glob:cf:*!*@thing", "itd!a@thing", True), + (b"glob:ci:*!*@thing", b"itd!a@thing", True), + (b"glob:*!*@thing", b"itd!a@thing", True), + (b"glob:*!*@thing", b"itd!a@THING", False), ) -def test_patterns(): - from polymatch import pattern_registry - - for pattern, text, result in data: - matcher = pattern_registry.pattern_from_string(pattern) - matcher.compile() - assert bool(matcher == text) is result - +@pytest.mark.parametrize(("pattern", "text", "result"), data) +def test_patterns(pattern: str, text: str, result: bool) -> None: + matcher = pattern_registry.pattern_from_string(pattern) + matcher.compile() + assert bool(matcher == text) is result -def test_invert(): - from polymatch import pattern_registry +def test_invert() -> None: pattern = pattern_registry.pattern_from_string("~glob::beep") pattern.compile() assert pattern.inverted diff --git a/tests/test_pickling.py b/tests/test_pickling.py index 4d152ac..be14666 100644 --- a/tests/test_pickling.py +++ b/tests/test_pickling.py @@ -1,35 +1,33 @@ +import itertools import pickle +from typing import Any, TypeVar, cast -patterns = ( - "regex::test", - "exact::test", - "contains:cf:test", - "glob::beep", -) +import pytest +import polymatch +from polymatch import pattern_registry +from polymatch.base import PolymorphicMatcher -class C: - def __init__(self, pat): - self.patterns = [pat] +patterns = ("regex::test", "exact::test", "contains:cf:test", "glob::beep") -def pytest_generate_tests(metafunc): - if "pattern" in metafunc.fixturenames: - metafunc.parametrize("pattern", patterns) +class C: + def __init__(self, pat: PolymorphicMatcher[Any, Any]) -> None: + self.patterns = [pat] - if "pickle_proto" in metafunc.fixturenames: - metafunc.parametrize( - "pickle_proto", list(range(pickle.HIGHEST_PROTOCOL + 1)) - ) +T = TypeVar("T") -def cycle_pickle(obj, proto): - return pickle.loads(pickle.dumps(obj, proto)) +def cycle_pickle(obj: T, proto: int) -> T: + return cast(T, pickle.loads(pickle.dumps(obj, proto))) -def test_compile_state(pattern, pickle_proto): - from polymatch import pattern_registry +@pytest.mark.parametrize( + ("pattern", "pickle_proto"), + itertools.product(patterns, range(pickle.HIGHEST_PROTOCOL + 1)), +) +def test_compile_state(pattern: str, pickle_proto: int) -> None: compiled_pattern = pattern_registry.pattern_from_string(pattern) compiled_pattern.compile() @@ -39,18 +37,18 @@ def test_compile_state(pattern, pickle_proto): assert not uncompiled_pattern.is_compiled() - pat1, pat2 = cycle_pickle( - (compiled_pattern, uncompiled_pattern), pickle_proto - ) + pat1, pat2 = cycle_pickle((compiled_pattern, uncompiled_pattern), pickle_proto) assert pat1.is_compiled() is compiled_pattern.is_compiled() assert pat2.is_compiled() is uncompiled_pattern.is_compiled() -def test_properties(pattern, pickle_proto): - from polymatch import pattern_registry - +@pytest.mark.parametrize( + ("pattern", "pickle_proto"), + itertools.product(patterns, range(pickle.HIGHEST_PROTOCOL + 1)), +) +def test_properties(pattern: str, pickle_proto: int) -> None: pat = pattern_registry.pattern_from_string(pattern) pat.compile() @@ -73,10 +71,11 @@ def test_properties(pattern, pickle_proto): assert not _pat.inverted -def test_version_checks(pattern, pickle_proto): - import polymatch - from polymatch import pattern_registry - +@pytest.mark.parametrize( + ("pattern", "pickle_proto"), + itertools.product(patterns, range(pickle.HIGHEST_PROTOCOL + 1)), +) +def test_version_checks(pattern: str, pickle_proto: int) -> None: pat = pattern_registry.pattern_from_string(pattern) pat.compile() diff --git a/tests/test_regex.py b/tests/test_regex.py index b5f95c5..678cb6c 100644 --- a/tests/test_regex.py +++ b/tests/test_regex.py @@ -1,23 +1,27 @@ +import pytest + +from polymatch import pattern_registry + data = ( (r"regex::\btest\b", "test", True), (r"regex::\btest\b", "test1", False), (r"regex::\btest\b", "test response", True), (r"regex:cf:\btest\b", "TEST", True), + (r"regex:cs:foo", "FOO", False), + (r"regex:cs:foo", "foo", True), + (r"regex:ci:foo", "FOO", True), + (r"regex:ci:foo", "foo", True), ) -def test_patterns(): - from polymatch import pattern_registry - - for pattern, text, result in data: - matcher = pattern_registry.pattern_from_string(pattern) - matcher.compile() - assert bool(matcher == text) is result - +@pytest.mark.parametrize(("pattern", "text", "result"), data) +def test_patterns(pattern: str, text: str, result: bool) -> None: + matcher = pattern_registry.pattern_from_string(pattern) + matcher.compile() + assert bool(matcher == text) is result -def test_invert(): - from polymatch import pattern_registry +def test_invert() -> None: pattern = pattern_registry.pattern_from_string("~regex::beep") pattern.compile() assert pattern.inverted diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..272965c --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,73 @@ +import pytest + +from polymatch import pattern_registry +from polymatch.base import CaseAction +from polymatch.error import ( + DuplicateMatcherRegistrationError, + NoMatchersAvailableError, + NoSuchMatcherError, +) +from polymatch.matchers.glob import GlobMatcher +from polymatch.matchers.standard import ExactMatcher +from polymatch.registry import PatternMatcherRegistry + + +def test_register_duplicate() -> None: + registry = PatternMatcherRegistry() + registry.register(GlobMatcher) + with pytest.raises(DuplicateMatcherRegistrationError): + registry.register(GlobMatcher) + + +def test_register_non_pattern() -> None: + registry = PatternMatcherRegistry() + with pytest.raises(TypeError): + registry.register(object) + + +def test_parse() -> None: + matcher = pattern_registry.pattern_from_string("foo") + matcher.compile() + assert matcher.match("foo") + assert not matcher.match("Foo") + assert isinstance(matcher, ExactMatcher) + assert not matcher.inverted + assert matcher.case_action == CaseAction.NONE + + +def test_parse_error_bad_type() -> None: + with pytest.raises(TypeError, match="Unable to parse pattern string of type 'int'"): + pattern_registry.pattern_from_string(27) # type: ignore[type-var] + + +def test_parse_error_no_matcher() -> None: + with pytest.raises(LookupError, match="av"): + pattern_registry.pattern_from_string("av:cs:foo") + + +def test_parse_error_no_case_action() -> None: + with pytest.raises(LookupError, match="av"): + pattern_registry.pattern_from_string("regex:av:foo") + + +def test_parse_error_no_default_matcher() -> None: + registry = PatternMatcherRegistry() + with pytest.raises(NoMatchersAvailableError): + registry.pattern_from_string("foo") + + +@pytest.mark.parametrize( + ("pattern", "success"), [("regex:foo\\\\a[-{", False), ("foo", True)] +) +def test_parse_error_bad_regex(pattern: str, success: bool) -> None: + matcher = pattern_registry.pattern_from_string(pattern) + assert matcher.try_compile() is success + + +def test_remove() -> None: + registry = PatternMatcherRegistry() + registry.register(GlobMatcher) + assert registry["glob"] is GlobMatcher + registry.remove("glob") + with pytest.raises(NoSuchMatcherError): + registry["glob"] diff --git a/tests/test_standard.py b/tests/test_standard.py index 482a294..7a6b0e9 100644 --- a/tests/test_standard.py +++ b/tests/test_standard.py @@ -1,24 +1,105 @@ +import pytest + +from polymatch import pattern_registry + data = ( ("exact::a", "a", True), ("exact::b", "n", False), ("exact::cc", "cc", True), ("contains::air", "i", False), ("contains::i", "air", True), + ("exact:a", "a", True), + ("exact:a", "A", False), + ("exact:cs:b", "n", False), + ("exact:cs:cc", "cc", True), + ("exact:cs:cc", "cc", True), + ("contains:cs:air", "i", False), + ("contains:cs:air", "I", False), + ("contains:cs:i", "air", True), + ("contains::i", "AIR", False), + ("contains:ci:i", "AIR", True), + ("contains:cf:i", "AIR", True), ) -def test_patterns(): - from polymatch import pattern_registry - - for pattern, text, result in data: - matcher = pattern_registry.pattern_from_string(pattern) - matcher.compile() - assert bool(matcher == text) is result - +@pytest.mark.parametrize(("pattern", "text", "result"), data) +def test_patterns(pattern: str, text: str, result: bool) -> None: + matcher = pattern_registry.pattern_from_string(pattern) + matcher.compile() + assert bool(matcher == text) is result -def test_invert(): - from polymatch import pattern_registry +def test_invert() -> None: pattern = pattern_registry.pattern_from_string("~exact::beep") pattern.compile() assert pattern.inverted + + +def test_repr() -> None: + pattern = pattern_registry.pattern_from_string("~exact:ci:beep") + pattern.compile() + assert ( + repr(pattern) + == "ExactMatcher(pattern='beep', case_action=CaseAction.CASEINSENSITIVE, invert=True)" + ) + + +def test_repr_bytes() -> None: + pattern = pattern_registry.pattern_from_string(b"~exact:ci:beep") + pattern.compile() + assert ( + repr(pattern) + == "ExactMatcher(pattern=b'beep', case_action=CaseAction.CASEINSENSITIVE, invert=True)" + ) + + +def test_str() -> None: + pattern = pattern_registry.pattern_from_string("~exact:ci:beep") + pattern.compile() + assert str(pattern) == "~exact:ci:beep" + + +def test_str_bytes() -> None: + pattern = pattern_registry.pattern_from_string(b"~exact:ci:beep") + pattern.compile() + assert str(pattern) == "~exact:ci:beep" + + +def test_to_string() -> None: + pattern = pattern_registry.pattern_from_string("~exact:ci:beep") + pattern.compile() + assert pattern.to_string() == "~exact:ci:beep" + + +def test_to_string_bytes() -> None: + pattern = pattern_registry.pattern_from_string(b"~exact:ci:beep") + pattern.compile() + assert pattern.to_string() == b"~exact:ci:beep" + + +def test_cf_match_bytes() -> None: + matcher = pattern_registry.pattern_from_string("~exact:ci:foo") + matcher.compile() + with pytest.raises(TypeError): + matcher.match_text_cf(b"cc", b"foo") + + +def test_cf_compile_bytes_bypass() -> None: + matcher = pattern_registry.pattern_from_string("~exact:cf:foo") + matcher.compile() + with pytest.raises(TypeError): + matcher.compile_pattern_cf(b"foo") + + +def test_contains_cf_match_bytes() -> None: + matcher = pattern_registry.pattern_from_string("contains:cf:foo") + matcher.compile() + with pytest.raises(TypeError): + matcher.match_text_cf(b"cc", b"foo") + + +def test_contains_cf_compile_bytes_bypass() -> None: + matcher = pattern_registry.pattern_from_string("contains:cf:foo") + matcher.compile() + with pytest.raises(TypeError): + matcher.compile_pattern_cf(b"foo")