diff options
| author | natsuoto <[email protected]> | 2026-07-16 17:31:49 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-16 17:31:49 +0530 |
| commit | 1b43c8dc09adb9b54609cc8a1916c99eb6fe75c9 (patch) | |
| tree | 7731af50ed9117f8ac3d200591c3be23818d7b19 | |
| parent | b7ae2f3885d5ff84237c9da8bc0c64fe1a509465 (diff) | |
| download | edify-1b43c8dc09adb9b54609cc8a1916c99eb6fe75c9.tar.xz edify-1b43c8dc09adb9b54609cc8a1916c99eb6fe75c9.zip | |
chore: enforce fully strict static typing across library, tests, and tooling with zero suppressions
86 files changed, 879 insertions, 908 deletions
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 0fcb6bf..27048f7 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -15,11 +15,11 @@ jobs: os: 'ubuntu-latest' - name: 'mypy' python: '3.11' - command: 'uv run mypy edify' + command: 'uv run mypy edify tools' os: 'ubuntu-latest' - name: 'pyright' python: '3.11' - command: 'uv run pyright edify' + command: 'uv run pyright' os: 'ubuntu-latest' - name: 'public-surface' python: '3.11' diff --git a/edify/builder/core.py b/edify/builder/core.py index 056e1b1..6ddf91d 100644 --- a/edify/builder/core.py +++ b/edify/builder/core.py @@ -23,28 +23,28 @@ class BuilderCore(BuilderProtocol): """Holds the immutable :class:`BuilderState` and clones it on chain steps.""" def __init__(self) -> None: - self._state: BuilderState = BuilderState() - self._cached_regex: Regex | None = None + self.state: BuilderState = BuilderState() + self.cached_regex: Regex | None = None - def _with_state(self, new_state: BuilderState) -> Self: + def with_state(self, new_state: BuilderState) -> Self: """Return a fresh instance of the same concrete type carrying ``new_state``.""" concrete_class = type(self) new_instance = concrete_class.__new__(concrete_class) - new_instance._state = new_state - new_instance._cached_regex = None + new_instance.state = new_state + new_instance.cached_regex = None return new_instance - def _lazy_regex(self) -> Regex: + def lazy_regex(self) -> Regex: """Return the memoised :class:`Regex` for this builder, compiling once on first call.""" return self.to_regex() def fork(self) -> Self: """Return a fresh builder with the same immutable state.""" - return self._with_state(self._state) + return self.with_state(self.state) def copy(self) -> Self: """Alias for :meth:`fork` — return a fresh builder with the same immutable state.""" - return self._with_state(self._state) + return self.with_state(self.state) def __repr__(self) -> str: """Return ``<ClassName 'pattern-so-far'>`` for interactive display.""" @@ -56,8 +56,8 @@ class BuilderCore(BuilderProtocol): if not isinstance(other, BuilderCore): return NotImplemented problems: list[Problem] = [] - left_problem = diagnose_unfinished(self._state, "left operand") - right_problem = diagnose_unfinished(other._state, "right operand") + left_problem = diagnose_unfinished(self.state, "left operand") + right_problem = diagnose_unfinished(other.state, "right operand") if left_problem is not None: problems.append(left_problem) if right_problem is not None: @@ -66,15 +66,15 @@ class BuilderCore(BuilderProtocol): raise CannotCompareUnfinishedBuilderError(problems) self_source = self.to_regex_string() other_source = other.to_regex_string() - return self_source == other_source and self._state.flags == other._state.flags + return self_source == other_source and self.state.flags == other.state.flags def __hash__(self) -> int: """Return a hash derived from ``(emitted_pattern, flags)``.""" - problem = diagnose_unfinished(self._state, "builder") + problem = diagnose_unfinished(self.state, "builder") if problem is not None: raise CannotHashUnfinishedBuilderError(problem) source = self.to_regex_string() - return hash((source, self._state.flags)) + return hash((source, self.state.flags)) def _rendered_or_unclosed_marker(builder: BuilderCore) -> str: diff --git a/edify/builder/mixins/anchors.py b/edify/builder/mixins/anchors.py index 58b0237..e0a85fe 100644 --- a/edify/builder/mixins/anchors.py +++ b/edify/builder/mixins/anchors.py @@ -23,18 +23,18 @@ class AnchorsMixin(BuilderProtocol): def start_of_input(self) -> Self: """Return a new builder with a leading ``^`` anchor appended.""" - if self._state.has_defined_start: + if self.state.has_defined_start: raise StartInputAlreadyDefinedError() - if self._state.has_defined_end: + if self.state.has_defined_end: raise CannotDefineStartAfterEndError() - state_with_flag = self._state.with_start_defined() + state_with_flag = self.state.with_start_defined() new_state = state_with_flag.with_element_added_to_top(StartOfInputElement()) - return self._with_state(new_state) + return self.with_state(new_state) def end_of_input(self) -> Self: """Return a new builder with a trailing ``$`` anchor appended.""" - if self._state.has_defined_end: + if self.state.has_defined_end: raise EndInputAlreadyDefinedError() - state_with_flag = self._state.with_end_defined() + state_with_flag = self.state.with_end_defined() new_state = state_with_flag.with_element_added_to_top(EndOfInputElement()) - return self._with_state(new_state) + return self.with_state(new_state) diff --git a/edify/builder/mixins/assertions.py b/edify/builder/mixins/assertions.py index 62863e8..4e0d7bc 100644 --- a/edify/builder/mixins/assertions.py +++ b/edify/builder/mixins/assertions.py @@ -25,23 +25,23 @@ class AssertionsMixin(BuilderProtocol): def assert_ahead(self) -> Self: """Return a new builder with a positive-lookahead ``(?=...)`` frame opened.""" new_frame = StackFrame(type_node=AssertAheadElement()) - new_state = self._state.with_frame_pushed(new_frame) - return self._with_state(new_state) + new_state = self.state.with_frame_pushed(new_frame) + return self.with_state(new_state) def assert_not_ahead(self) -> Self: """Return a new builder with a negative-lookahead ``(?!...)`` frame opened.""" new_frame = StackFrame(type_node=AssertNotAheadElement()) - new_state = self._state.with_frame_pushed(new_frame) - return self._with_state(new_state) + new_state = self.state.with_frame_pushed(new_frame) + return self.with_state(new_state) def assert_behind(self) -> Self: """Return a new builder with a positive-lookbehind ``(?<=...)`` frame opened.""" new_frame = StackFrame(type_node=AssertBehindElement()) - new_state = self._state.with_frame_pushed(new_frame) - return self._with_state(new_state) + new_state = self.state.with_frame_pushed(new_frame) + return self.with_state(new_state) def assert_not_behind(self) -> Self: """Return a new builder with a negative-lookbehind ``(?<!...)`` frame opened.""" new_frame = StackFrame(type_node=AssertNotBehindElement()) - new_state = self._state.with_frame_pushed(new_frame) - return self._with_state(new_state) + new_state = self.state.with_frame_pushed(new_frame) + return self.with_state(new_state) diff --git a/edify/builder/mixins/captures.py b/edify/builder/mixins/captures.py index 5841b95..7a9b890 100644 --- a/edify/builder/mixins/captures.py +++ b/edify/builder/mixins/captures.py @@ -19,7 +19,7 @@ from edify.elements.types.captures import ( NamedCaptureElement, ) from edify.errors.captures import InvalidTotalCaptureGroupsIndexError -from edify.errors.input import MustBeAStringError, MustBeOneCharacterError +from edify.errors.input import MustBeOneCharacterError from edify.errors.naming import ( CannotCreateDuplicateNamedGroupError, NamedGroupDoesNotExistError, @@ -34,39 +34,36 @@ class CapturesMixin(BuilderProtocol): def capture(self) -> Self: """Return a new builder with a numbered-capture frame opened.""" new_frame = StackFrame(type_node=CaptureElement()) - state_with_frame = self._state.with_frame_pushed(new_frame) + state_with_frame = self.state.with_frame_pushed(new_frame) state_with_count = state_with_frame.with_capture_group_count_incremented() - return self._with_state(state_with_count) + return self.with_state(state_with_count) def named_capture(self, name: str) -> Self: """Return a new builder with a named-capture frame opened under ``name``.""" - _validate_new_named_group(name, self._state.named_groups) + _validate_new_named_group(name, self.state.named_groups) new_frame = StackFrame(type_node=NamedCaptureElement(name=name)) - state_with_frame = self._state.with_frame_pushed(new_frame) + state_with_frame = self.state.with_frame_pushed(new_frame) state_with_count = state_with_frame.with_capture_group_count_incremented() state_with_name = state_with_count.with_named_group_added(name) - return self._with_state(state_with_name) + return self.with_state(state_with_name) def back_reference(self, index: int) -> Self: """Return a new builder with a numbered back-reference to capture ``index`` appended.""" - _ensure_capture_index_in_range(index, self._state.total_capture_groups) + _ensure_capture_index_in_range(index, self.state.total_capture_groups) element = BackReferenceElement(index=index) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def named_back_reference(self, name: str) -> Self: """Return a new builder with a named back-reference to ``name`` appended.""" - _ensure_named_group_exists(name, self._state.named_groups) + _ensure_named_group_exists(name, self.state.named_groups) element = NamedBackReferenceElement(name=name) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def _validate_new_named_group(name: str, existing_names: tuple[str, ...]) -> None: """Raise the appropriate naming error if ``name`` cannot be declared as a new named group.""" - if not isinstance(name, str): - actual_type_name = type(name).__name__ - raise MustBeAStringError("Name", actual_type_name) if len(name) == 0: raise MustBeOneCharacterError("Name") if name in existing_names: diff --git a/edify/builder/mixins/chain.py b/edify/builder/mixins/chain.py index 767f1f7..950b13e 100644 --- a/edify/builder/mixins/chain.py +++ b/edify/builder/mixins/chain.py @@ -29,11 +29,11 @@ class ChainMixin(BuilderProtocol): def end(self) -> Self: """Return a new builder with the top frame closed and merged into its parent.""" - _ensure_non_root_frame_open(self._state.stack) - state_with_popped, popped_frame = self._state.with_top_frame_popped() + _ensure_non_root_frame_open(self.state.stack) + state_with_popped, popped_frame = self.state.with_top_frame_popped() closed_element = _close_frame(popped_frame) new_state = state_with_popped.with_element_added_to_top(closed_element) - return self._with_state(new_state) + return self.with_state(new_state) def _ensure_non_root_frame_open(stack: tuple[StackFrame, ...]) -> None: diff --git a/edify/builder/mixins/chars.py b/edify/builder/mixins/chars.py index 570493d..7060ac0 100644 --- a/edify/builder/mixins/chars.py +++ b/edify/builder/mixins/chars.py @@ -21,7 +21,6 @@ from edify.elements.types.chars import ( StringElement, ) from edify.errors.input import ( - MustBeAStringError, MustBeOneCharacterError, MustBeSingleCharacterError, MustHaveASmallerValueError, @@ -33,21 +32,19 @@ class CharsMixin(BuilderProtocol): def string(self, value: str) -> Self: """Return a new builder with the literal ``value`` appended.""" - _ensure_is_string("Value", value) _ensure_non_empty("Value", value) escaped_value = escape_special(value) element = _string_or_char_element(escaped_value, source_length=len(value)) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def char(self, value: str) -> Self: """Return a new builder with the single-character literal ``value`` appended.""" - _ensure_is_string("Value", value) _ensure_single_character("Value", value) escaped_value = escape_special(value) element = CharElement(value=escaped_value) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def range(self, start_character: str, end_character: str) -> Self: """Return a new builder with a ``[start-end]`` range appended.""" @@ -55,33 +52,31 @@ class CharsMixin(BuilderProtocol): _ensure_single_character("b", end_character) _ensure_ascending_codepoints(start_character, end_character) element = RangeElement(start=start_character, end=end_character) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def any_of_chars(self, characters: str) -> Self: """Return a new builder with an inline ``[characters]`` class appended.""" escaped_characters = escape_for_char_class(characters) element = AnyOfCharsElement(value=escaped_characters) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def anything_but_string(self, value: str) -> Self: """Return a new builder with a per-character negation of ``value`` appended.""" - _ensure_is_string("Value", value) _ensure_non_empty("Value", value) escaped_value = escape_special(value) element = AnythingButStringElement(value=escaped_value) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def anything_but_chars(self, characters: str) -> Self: """Return a new builder with an inline ``[^characters]`` negation appended.""" - _ensure_is_string("Value", characters) _ensure_non_empty("Value", characters) escaped_characters = escape_for_char_class(characters) element = AnythingButCharsElement(value=escaped_characters) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def anything_but_range(self, start_character: str, end_character: str) -> Self: """Return a new builder with a ``[^start-end]`` negated range appended.""" @@ -89,16 +84,8 @@ class CharsMixin(BuilderProtocol): _ensure_single_character("b", end_character) _ensure_ascending_codepoints(start_character, end_character) element = AnythingButRangeElement(start=start_character, end=end_character) - new_state = self._state.with_element_added_to_top(element) - return self._with_state(new_state) - - -def _ensure_is_string(label: str, value: str) -> None: - """Raise :class:`MustBeAStringError` when ``value`` is not a string.""" - if isinstance(value, str): - return - actual_type_name = type(value).__name__ - raise MustBeAStringError(label, actual_type_name) + new_state = self.state.with_element_added_to_top(element) + return self.with_state(new_state) def _ensure_non_empty(label: str, value: str) -> None: @@ -109,11 +96,10 @@ def _ensure_non_empty(label: str, value: str) -> None: def _ensure_single_character(label: str, value: str) -> None: - """Raise :class:`MustBeSingleCharacterError` when ``value`` is not a length-1 string.""" - if isinstance(value, str) and len(value) == 1: + """Raise :class:`MustBeSingleCharacterError` when ``value`` is not length 1.""" + if len(value) == 1: return - actual_type_name = type(value).__name__ - raise MustBeSingleCharacterError(label, actual_type_name) + raise MustBeSingleCharacterError(label, type(value).__name__) def _ensure_ascending_codepoints(first_character: str, second_character: str) -> None: diff --git a/edify/builder/mixins/classes.py b/edify/builder/mixins/classes.py index 51fb4a0..7749a4d 100644 --- a/edify/builder/mixins/classes.py +++ b/edify/builder/mixins/classes.py @@ -36,85 +36,85 @@ class ClassesMixin(BuilderProtocol): def any_char(self) -> Self: """Return a new builder with a ``.`` (any character) appended.""" - new_state = self._state.with_element_added_to_top(AnyCharElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(AnyCharElement()) + return self.with_state(new_state) def whitespace_char(self) -> Self: """Return a new builder with a ``\\s`` (whitespace) appended.""" - new_state = self._state.with_element_added_to_top(WhitespaceCharElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(WhitespaceCharElement()) + return self.with_state(new_state) def non_whitespace_char(self) -> Self: """Return a new builder with a ``\\S`` (non-whitespace) appended.""" - new_state = self._state.with_element_added_to_top(NonWhitespaceCharElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(NonWhitespaceCharElement()) + return self.with_state(new_state) def digit(self) -> Self: """Return a new builder with a ``\\d`` (digit) appended.""" - new_state = self._state.with_element_added_to_top(DigitElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(DigitElement()) + return self.with_state(new_state) def non_digit(self) -> Self: """Return a new builder with a ``\\D`` (non-digit) appended.""" - new_state = self._state.with_element_added_to_top(NonDigitElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(NonDigitElement()) + return self.with_state(new_state) def word(self) -> Self: """Return a new builder with a ``\\w`` (word character) appended.""" - new_state = self._state.with_element_added_to_top(WordElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(WordElement()) + return self.with_state(new_state) def non_word(self) -> Self: """Return a new builder with a ``\\W`` (non-word character) appended.""" - new_state = self._state.with_element_added_to_top(NonWordElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(NonWordElement()) + return self.with_state(new_state) def word_boundary(self) -> Self: """Return a new builder with a ``\\b`` (word boundary) appended.""" - new_state = self._state.with_element_added_to_top(WordBoundaryElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(WordBoundaryElement()) + return self.with_state(new_state) def non_word_boundary(self) -> Self: """Return a new builder with a ``\\B`` (non-word-boundary) appended.""" - new_state = self._state.with_element_added_to_top(NonWordBoundaryElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(NonWordBoundaryElement()) + return self.with_state(new_state) def new_line(self) -> Self: """Return a new builder with a ``\\n`` (line feed) appended.""" - new_state = self._state.with_element_added_to_top(NewLineElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(NewLineElement()) + return self.with_state(new_state) def carriage_return(self) -> Self: """Return a new builder with a ``\\r`` (carriage return) appended.""" - new_state = self._state.with_element_added_to_top(CarriageReturnElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(CarriageReturnElement()) + return self.with_state(new_state) def tab(self) -> Self: """Return a new builder with a ``\\t`` (tab) appended.""" - new_state = self._state.with_element_added_to_top(TabElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(TabElement()) + return self.with_state(new_state) def null_byte(self) -> Self: """Return a new builder with a ``\\0`` (null byte) appended.""" - new_state = self._state.with_element_added_to_top(NullByteElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(NullByteElement()) + return self.with_state(new_state) def letter(self) -> Self: """Return a new builder with ``[a-zA-Z]`` (ASCII letter) appended.""" - new_state = self._state.with_element_added_to_top(LetterElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(LetterElement()) + return self.with_state(new_state) def uppercase(self) -> Self: """Return a new builder with ``[A-Z]`` (ASCII uppercase letter) appended.""" - new_state = self._state.with_element_added_to_top(UppercaseElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(UppercaseElement()) + return self.with_state(new_state) def lowercase(self) -> Self: """Return a new builder with ``[a-z]`` (ASCII lowercase letter) appended.""" - new_state = self._state.with_element_added_to_top(LowercaseElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(LowercaseElement()) + return self.with_state(new_state) def alphanumeric(self) -> Self: """Return a new builder with ``[a-zA-Z0-9]`` (ASCII alphanumeric) appended.""" - new_state = self._state.with_element_added_to_top(AlphanumericElement()) - return self._with_state(new_state) + new_state = self.state.with_element_added_to_top(AlphanumericElement()) + return self.with_state(new_state) diff --git a/edify/builder/mixins/flags.py b/edify/builder/mixins/flags.py index c46c29e..dd0eecd 100644 --- a/edify/builder/mixins/flags.py +++ b/edify/builder/mixins/flags.py @@ -17,36 +17,36 @@ class FlagsMixin(BuilderProtocol): def ascii_only(self) -> Self: """Return a new builder with the ``re.A`` flag enabled.""" - new_flags = self._state.flags.with_ascii_only() - new_state = self._state.with_flags(new_flags) - return self._with_state(new_state) + new_flags = self.state.flags.with_ascii_only() + new_state = self.state.with_flags(new_flags) + return self.with_state(new_state) def debug(self) -> Self: """Return a new builder with the ``re.DEBUG`` flag enabled.""" - new_flags = self._state.flags.with_debug() - new_state = self._state.with_flags(new_flags) - return self._with_state(new_state) + new_flags = self.state.flags.with_debug() + new_state = self.state.with_flags(new_flags) + return self.with_state(new_state) def ignore_case(self) -> Self: """Return a new builder with the ``re.I`` flag enabled.""" - new_flags = self._state.flags.with_ignore_case() - new_state = self._state.with_flags(new_flags) - return self._with_state(new_state) + new_flags = self.state.flags.with_ignore_case() + new_state = self.state.with_flags(new_flags) + return self.with_state(new_state) def multi_line(self) -> Self: """Return a new builder with the ``re.M`` flag enabled.""" - new_flags = self._state.flags.with_multiline() - new_state = self._state.with_flags(new_flags) - return self._with_state(new_state) + new_flags = self.state.flags.with_multiline() + new_state = self.state.with_flags(new_flags) + return self.with_state(new_state) def dot_all(self) -> Self: """Return a new builder with the ``re.S`` flag enabled.""" - new_flags = self._state.flags.with_dotall() - new_state = self._state.with_flags(new_flags) - return self._with_state(new_state) + new_flags = self.state.flags.with_dotall() + new_state = self.state.with_flags(new_flags) + return self.with_state(new_state) def verbose(self) -> Self: """Return a new builder with the ``re.X`` flag enabled.""" - new_flags = self._state.flags.with_verbose() - new_state = self._state.with_flags(new_flags) - return self._with_state(new_state) + new_flags = self.state.flags.with_verbose() + new_state = self.state.with_flags(new_flags) + return self.with_state(new_state) diff --git a/edify/builder/mixins/groups.py b/edify/builder/mixins/groups.py index ff14140..824e987 100644 --- a/edify/builder/mixins/groups.py +++ b/edify/builder/mixins/groups.py @@ -21,7 +21,6 @@ from edify.elements.types.base import BaseElement from edify.elements.types.chars import CharElement, StringElement from edify.elements.types.groups import AnyOfElement, GroupElement from edify.errors.input import ( - MustBeAStringError, MustBeAtLeastOneLiteralError, MustBeOneCharacterError, ) @@ -61,8 +60,8 @@ class GroupsMixin(BuilderProtocol): def _open_frame(builder: _TBuilder, type_node: BaseElement) -> _TBuilder: """Push a new frame anchored at ``type_node`` and return the updated builder.""" new_frame = StackFrame(type_node=type_node) - new_state = builder._state.with_frame_pushed(new_frame) - return builder._with_state(new_state) + new_state = builder.state.with_frame_pushed(new_frame) + return builder.with_state(new_state) def _add_literal_alternation(builder: _TBuilder, literals: tuple[str, ...]) -> _TBuilder: @@ -70,13 +69,12 @@ def _add_literal_alternation(builder: _TBuilder, literals: tuple[str, ...]) -> _ child_elements = [_literal_to_element(literal) for literal in literals] children = tuple(child_elements) element = AnyOfElement(children=children) - new_state = builder._state.with_element_added_to_top(element) - return builder._with_state(new_state) + new_state = builder.state.with_element_added_to_top(element) + return builder.with_state(new_state) def _literal_to_element(literal: str) -> CharElement | StringElement: """Validate and escape ``literal``, returning the char- or string-shaped element.""" - _ensure_is_string("Literal", literal) _ensure_non_empty("Literal", literal) escaped = escape_special(literal) if len(literal) == 1: @@ -84,14 +82,6 @@ def _literal_to_element(literal: str) -> CharElement | StringElement: return StringElement(value=escaped) -def _ensure_is_string(label: str, value: str) -> None: - """Raise :class:`MustBeAStringError` when ``value`` is not a string.""" - if isinstance(value, str): - return - actual_type_name = type(value).__name__ - raise MustBeAStringError(label, actual_type_name) - - def _ensure_non_empty(label: str, value: str) -> None: """Raise :class:`MustBeOneCharacterError` when ``value`` has length zero.""" if len(value) > 0: diff --git a/edify/builder/mixins/matcher.py b/edify/builder/mixins/matcher.py index 6f35f24..9307ac9 100644 --- a/edify/builder/mixins/matcher.py +++ b/edify/builder/mixins/matcher.py @@ -1,6 +1,6 @@ """The :class:`MatcherMixin` — the closed set of five match verbs on any fluent surface. -Compiles ``self`` on first use via :meth:`edify.builder.core.BuilderCore._lazy_regex` +Compiles ``self`` on first use via :meth:`edify.builder.core.BuilderCore.lazy_regex` and reuses the cached :class:`edify.result.regex.Regex` on every subsequent call, so ``pattern.test("a")`` followed by ``pattern.match("b")`` compiles the underlying regex exactly once. @@ -26,7 +26,7 @@ class MatcherMixin(BuilderProtocol): def test(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> bool: """Return ``True`` when the pattern matches anywhere in ``string``, else ``False``.""" - return self._lazy_regex().search(string, pos, endpos) is not None + return self.lazy_regex().search(string, pos, endpos) is not None def match( self, @@ -37,7 +37,7 @@ class MatcherMixin(BuilderProtocol): timeout: float | None = None, ) -> Match | None: """Delegate to :meth:`re.Pattern.match`, returning an edify :class:`Match`.""" - return self._lazy_regex().match(string, pos, endpos, timeout=timeout) + return self.lazy_regex().match(string, pos, endpos, timeout=timeout) def search( self, @@ -48,7 +48,7 @@ class MatcherMixin(BuilderProtocol): timeout: float | None = None, ) -> Match | None: """Delegate to :meth:`re.Pattern.search`, returning an edify :class:`Match`.""" - return self._lazy_regex().search(string, pos, endpos, timeout=timeout) + return self.lazy_regex().search(string, pos, endpos, timeout=timeout) def findall( self, @@ -59,7 +59,7 @@ class MatcherMixin(BuilderProtocol): timeout: float | None = None, ) -> list[str] | list[tuple[str, ...]]: """Delegate to :meth:`re.Pattern.findall`.""" - return self._lazy_regex().findall(string, pos, endpos, timeout=timeout) + return self.lazy_regex().findall(string, pos, endpos, timeout=timeout) def sub( self, @@ -70,4 +70,4 @@ class MatcherMixin(BuilderProtocol): timeout: float | None = None, ) -> str: """Delegate to :meth:`re.Pattern.sub`; callables receive an edify :class:`Match`.""" - return self._lazy_regex().sub(replacement, string, count=count, timeout=timeout) + return self.lazy_regex().sub(replacement, string, count=count, timeout=timeout) diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py index dada35e..509b1aa 100644 --- a/edify/builder/mixins/quantifiers.py +++ b/edify/builder/mixins/quantifiers.py @@ -107,13 +107,13 @@ def _set_pending( quantifier_name: str, ) -> _TBuilder: """Replace the top frame with one carrying the given pending quantifier.""" - if builder._state.top_frame.quantifier is not None: + if builder.state.top_frame.quantifier is not None: raise StackedQuantifierError() - new_top_frame = builder._state.top_frame.with_quantifier( + new_top_frame = builder.state.top_frame.with_quantifier( pending_quantifier, call_site, quantifier_name ) - new_state = builder._state.with_top_frame_replaced(new_top_frame) - return builder._with_state(new_state) + new_state = builder.state.with_top_frame_replaced(new_top_frame) + return builder.with_state(new_state) def _optional_factory(child: BaseElement) -> OptionalElement: @@ -173,14 +173,14 @@ def _between_lazy_factory(lower: int, upper: int) -> PendingQuantifier: def _ensure_positive_integer(label: str, value: int) -> None: """Raise :class:`MustBePositiveIntegerError` when ``value`` is not a strictly positive int.""" - if isinstance(value, int) and not isinstance(value, bool) and value > 0: + if not isinstance(value, bool) and value > 0: return raise MustBePositiveIntegerError(label) def _ensure_non_negative_integer(label: str, value: int) -> None: """Raise :class:`MustBeIntegerGreaterThanZeroError` when ``value`` is not a non-negative int.""" - if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + if not isinstance(value, bool) and value >= 0: return raise MustBeIntegerGreaterThanZeroError(label) diff --git a/edify/builder/mixins/subexpression.py b/edify/builder/mixins/subexpression.py index bfa006e..91216cd 100644 --- a/edify/builder/mixins/subexpression.py +++ b/edify/builder/mixins/subexpression.py @@ -21,7 +21,6 @@ from edify.builder.merge import MergeContext, merge_element from edify.builder.types.protocol import BuilderProtocol from edify.elements.types.base import BaseElement from edify.elements.types.groups import SubexpressionElement -from edify.errors.input import MustBeInstanceError from edify.errors.structure import CannotCallSubexpressionError @@ -65,34 +64,25 @@ class SubexpressionMixin(BuilderProtocol): no-ops; when False, they merge into the parent (and raise if the parent already declared the same anchor). """ - _ensure_is_builder(expression) _ensure_fully_specified(expression) merged_root_children, captures_added = _merge_expression_children( expression, self, namespace, ignore_start_and_end ) subexpression_element = SubexpressionElement(children=merged_root_children) - state_with_element = self._state.with_element_added_to_top(subexpression_element) + state_with_element = self.state.with_element_added_to_top(subexpression_element) state_with_counts = state_with_element.with_capture_groups_added(captures_added) if ignore_flags: - return self._with_state(state_with_counts) - merged_flags = self._state.flags.with_merged(expression._state.flags) + return self.with_state(state_with_counts) + merged_flags = self.state.flags.with_merged(expression.state.flags) state_with_flags = state_with_counts.with_flags(merged_flags) - return self._with_state(state_with_flags) - - -def _ensure_is_builder(expression: BuilderProtocol) -> None: - """Raise :class:`MustBeInstanceError` when ``expression`` is not a builder.""" - if isinstance(expression, BuilderProtocol): - return - actual_type_name = type(expression).__name__ - raise MustBeInstanceError("Expression", actual_type_name, "RegexBuilder") + return self.with_state(state_with_flags) def _ensure_fully_specified(expression: BuilderProtocol) -> None: """Raise when ``expression`` still has nested frames open beyond the root.""" - if len(expression._state.stack) == 1: + if len(expression.state.stack) == 1: return - open_frames = describe_open_frames(expression._state) + open_frames = describe_open_frames(expression.state) raise CannotCallSubexpressionError(open_frames) @@ -104,13 +94,13 @@ def _merge_expression_children( ) -> tuple[tuple[BaseElement, ...], int]: """Run every root child of ``expression`` through the merge transform.""" context = MergeContext( - capture_index_offset=parent._state.total_capture_groups, + capture_index_offset=parent.state.total_capture_groups, namespace=namespace, ignore_start_and_end=ignore_start_and_end, - parent_has_start=parent._state.has_defined_start, - parent_has_end=parent._state.has_defined_end, + parent_has_start=parent.state.has_defined_start, + parent_has_end=parent.state.has_defined_end, ) - expression_root_children = expression._state.top_frame.children + expression_root_children = expression.state.top_frame.children merged_list: list[BaseElement] = [] total_added = 0 for child in expression_root_children: diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py index 10e22f0..fafe34a 100644 --- a/edify/builder/mixins/terminals.py +++ b/edify/builder/mixins/terminals.py @@ -30,7 +30,7 @@ _RAW_SPACE = " " class TerminalsMixin(BuilderProtocol): """Provides the two pattern-emitting terminal methods on the builder.""" - _cached_regex: Regex | None + cached_regex: Regex | None def to_regex_string(self) -> str: """Return the bare regex string the builder describes. @@ -40,7 +40,7 @@ class TerminalsMixin(BuilderProtocol): """ _ensure_fully_specified(self) _ensure_no_dangling_quantifier(self) - root_element = RootElement(children=self._state.top_frame.children) + root_element = RootElement(children=self.state.top_frame.children) rendered_pattern = render_element(root_element) unescaped_pattern = rendered_pattern.replace(_ESCAPED_SPACE, _RAW_SPACE) if unescaped_pattern == "": @@ -89,12 +89,12 @@ class TerminalsMixin(BuilderProtocol): verbose=verbose, ) can_cache = engine == "re" and kwarg_flags == Flags() - if can_cache and self._cached_regex is not None: - return self._cached_regex + if can_cache and self.cached_regex is not None: + return self.cached_regex pattern_string = self.to_regex_string() - top_frame_children = tuple(self._state.top_frame.children) + top_frame_children = tuple(self.state.top_frame.children) warn_on_redos_constructs(top_frame_children) - effective_flags = self._state.flags.with_merged(kwarg_flags) + effective_flags = self.state.flags.with_merged(kwarg_flags) compiled_pattern = compile_pattern(pattern_string, engine, effective_flags) wrapped = Regex( source=pattern_string, @@ -103,21 +103,21 @@ class TerminalsMixin(BuilderProtocol): engine=engine, ) if can_cache: - self._cached_regex = wrapped + self.cached_regex = wrapped return wrapped def _ensure_fully_specified(builder: BuilderProtocol) -> None: """Raise :class:`CannotCallSubexpressionError` when frames beyond the root remain open.""" - if len(builder._state.stack) == 1: + if len(builder.state.stack) == 1: return - open_frames = describe_open_frames(builder._state) + open_frames = describe_open_frames(builder.state) raise CannotCallSubexpressionError(open_frames) def _ensure_no_dangling_quantifier(builder: BuilderProtocol) -> None: """Raise :class:`DanglingQuantifierError` when any frame carries an unconsumed quantifier.""" - for frame in builder._state.stack: + for frame in builder.state.stack: if frame.quantifier is not None: raise DanglingQuantifierError( pending_quantifier_name=frame.quantifier_name, diff --git a/edify/builder/mixins/testing.py b/edify/builder/mixins/testing.py index ccf84fd..2c4cd69 100644 --- a/edify/builder/mixins/testing.py +++ b/edify/builder/mixins/testing.py @@ -22,7 +22,7 @@ class TestingMixin(BuilderProtocol): def assert_matches(self, inputs: Iterable[str]) -> Self: """Assert every string in ``inputs`` matches this pattern; return ``self``.""" - compiled = self._lazy_regex() + compiled = self.lazy_regex() input_tuple = tuple(inputs) rejected_items = [item for item in input_tuple if compiled.search(item) is None] missing = tuple(rejected_items) @@ -32,7 +32,7 @@ class TestingMixin(BuilderProtocol): def assert_rejects(self, inputs: Iterable[str]) -> Self: """Assert every string in ``inputs`` is rejected by this pattern; return ``self``.""" - compiled = self._lazy_regex() + compiled = self.lazy_regex() input_tuple = tuple(inputs) matched_items = [item for item in input_tuple if compiled.search(item) is not None] matched = tuple(matched_items) diff --git a/edify/builder/reverse.py b/edify/builder/reverse.py index d02977a..acbf313 100644 --- a/edify/builder/reverse.py +++ b/edify/builder/reverse.py @@ -3,46 +3,28 @@ from __future__ import annotations import re -import re._constants as sre_constants -import re._parser as sre_parser -from typing import TypeAlias, cast +from typing import cast import edify.builder.fluent as builder_module - -_SreArgument: TypeAlias = ( - int - | tuple[int, int] - | tuple[int, int, "_SrePattern"] - | tuple[int | None, int, int, "_SrePattern"] - | tuple[None, list["_SrePattern"]] - | tuple[int, "_SrePattern"] - | list["_SreNode"] - | None -) -"""Union of every argument shape :mod:`re._parser` emits on the second slot of a node.""" - -_SreNode: TypeAlias = tuple[int, _SreArgument] -"""One ``(opcode, argument)`` node produced by :func:`re._parser.parse`.""" - -_SrePattern: TypeAlias = list[_SreNode] -"""Sequence of :data:`_SreNode` items.""" +import edify.builder.sre as sre +from edify.builder.sre import SreArgument, SrePattern _ANCHOR_METHOD_BY_CONSTANT: dict[int, str] = { - sre_constants.AT_BEGINNING: "start_of_input", - sre_constants.AT_BEGINNING_STRING: "start_of_input", - sre_constants.AT_END: "end_of_input", - sre_constants.AT_END_STRING: "end_of_input", - sre_constants.AT_BOUNDARY: "word_boundary", - sre_constants.AT_NON_BOUNDARY: "non_word_boundary", + sre.AT_BEGINNING: "start_of_input", + sre.AT_BEGINNING_STRING: "start_of_input", + sre.AT_END: "end_of_input", + sre.AT_END_STRING: "end_of_input", + sre.AT_BOUNDARY: "word_boundary", + sre.AT_NON_BOUNDARY: "non_word_boundary", } _CATEGORY_METHOD_BY_CONSTANT: dict[int, str] = { - sre_constants.CATEGORY_DIGIT: "digit", - sre_constants.CATEGORY_NOT_DIGIT: "non_digit", - sre_constants.CATEGORY_WORD: "word", - sre_constants.CATEGORY_NOT_WORD: "non_word", - sre_constants.CATEGORY_SPACE: "whitespace_char", - sre_constants.CATEGORY_NOT_SPACE: "non_whitespace_char", + sre.CATEGORY_DIGIT: "digit", + sre.CATEGORY_NOT_DIGIT: "non_digit", + sre.CATEGORY_WORD: "word", + sre.CATEGORY_NOT_WORD: "non_word", + sre.CATEGORY_SPACE: "whitespace_char", + sre.CATEGORY_NOT_SPACE: "non_whitespace_char", } @@ -59,8 +41,7 @@ class UnsupportedReverseParseError(ValueError): def build_from_regex(pattern_text: str) -> builder_module.RegexBuilder: """Return a :class:`RegexBuilder` whose emitted pattern is equivalent to ``pattern_text``.""" - parsed_tree = sre_parser.parse(pattern_text) - node_list = cast(_SrePattern, list(parsed_tree)) + node_list = sre.parse(pattern_text) name_by_number = _name_by_group_number(pattern_text) empty_builder = builder_module.RegexBuilder() return _translate_sequence(empty_builder, node_list, name_by_number) @@ -73,14 +54,14 @@ def _name_by_group_number(pattern_text: str) -> dict[int, str]: def _translate_sequence( builder: builder_module.RegexBuilder, - nodes: _SrePattern, + nodes: SrePattern, names: dict[int, str], ) -> builder_module.RegexBuilder: current = builder literal_run: list[str] = [] for node in nodes: opcode, argument = node - if opcode == sre_constants.LITERAL: + if opcode == sre.LITERAL: literal_run.append(chr(cast(int, argument))) continue if literal_run: @@ -104,36 +85,36 @@ def _emit_string( def _translate_node( builder: builder_module.RegexBuilder, opcode: int, - argument: _SreArgument, + argument: SreArgument, names: dict[int, str], ) -> builder_module.RegexBuilder: - if opcode == sre_constants.ANY: + if opcode == sre.ANY: return builder.any_char() - if opcode == sre_constants.AT: + if opcode == sre.AT: return _translate_anchor(builder, cast(int, argument)) - if opcode == sre_constants.IN: - return _translate_character_class(builder, cast(_SrePattern, argument)) - if opcode == sre_constants.MAX_REPEAT: + if opcode == sre.IN: + return _translate_character_class(builder, cast(SrePattern, argument)) + if opcode == sre.MAX_REPEAT: return _translate_repeat( - builder, cast("tuple[int, int, _SrePattern]", argument), names, lazy=False + builder, cast("tuple[int, int, SrePattern]", argument), names, lazy=False ) - if opcode == sre_constants.MIN_REPEAT: + if opcode == sre.MIN_REPEAT: return _translate_repeat( - builder, cast("tuple[int, int, _SrePattern]", argument), names, lazy=True + builder, cast("tuple[int, int, SrePattern]", argument), names, lazy=True ) - if opcode == sre_constants.SUBPATTERN: + if opcode == sre.SUBPATTERN: return _translate_subpattern( - builder, cast("tuple[int | None, int, int, _SrePattern]", argument), names + builder, cast("tuple[int | None, int, int, SrePattern]", argument), names ) - if opcode == sre_constants.BRANCH: - return _translate_branch(builder, cast("tuple[None, list[_SrePattern]]", argument)) - if opcode == sre_constants.ASSERT: + if opcode == sre.BRANCH: + return _translate_branch(builder, cast("tuple[None, list[SrePattern]]", argument)) + if opcode == sre.ASSERT: return _translate_lookaround( - builder, cast("tuple[int, _SrePattern]", argument), names, negative=False + builder, cast("tuple[int, SrePattern]", argument), names, negative=False ) - if opcode == sre_constants.ASSERT_NOT: + if opcode == sre.ASSERT_NOT: return _translate_lookaround( - builder, cast("tuple[int, _SrePattern]", argument), names, negative=True + builder, cast("tuple[int, SrePattern]", argument), names, negative=True ) raise UnsupportedReverseParseError(str(opcode)) @@ -147,14 +128,14 @@ def _translate_anchor( def _translate_character_class( - builder: builder_module.RegexBuilder, members: _SrePattern + builder: builder_module.RegexBuilder, members: SrePattern ) -> builder_module.RegexBuilder: if len(members) == 1: return _translate_single_class_member(builder, members[0]) literal_chars: list[str] = [] for member in members: opcode, argument = member - if opcode == sre_constants.LITERAL: + if opcode == sre.LITERAL: literal_chars.append(chr(cast(int, argument))) continue raise UnsupportedReverseParseError(f"character-class member {member!r}") @@ -163,10 +144,10 @@ def _translate_character_class( def _translate_single_class_member( - builder: builder_module.RegexBuilder, member: tuple[int, _SreArgument] + builder: builder_module.RegexBuilder, member: tuple[int, SreArgument] ) -> builder_module.RegexBuilder: opcode, argument = member - if opcode == sre_constants.RANGE: + if opcode == sre.RANGE: start_codepoint, end_codepoint = cast("tuple[int, int]", argument) return builder.range(chr(start_codepoint), chr(end_codepoint)) method_name = _CATEGORY_METHOD_BY_CONSTANT[cast(int, argument)] @@ -176,7 +157,7 @@ def _translate_single_class_member( def _translate_repeat( builder: builder_module.RegexBuilder, - argument: tuple[int, int, _SrePattern], + argument: tuple[int, int, SrePattern], names: dict[int, str], lazy: bool, ) -> builder_module.RegexBuilder: @@ -191,13 +172,13 @@ def _apply_quantifier( ) -> builder_module.RegexBuilder: if min_count == 0 and max_count == 1: return builder.optional() - if min_count == 0 and max_count == sre_constants.MAXREPEAT: + if min_count == 0 and max_count == sre.MAXREPEAT: return builder.zero_or_more_lazy() if lazy else builder.zero_or_more() - if min_count == 1 and max_count == sre_constants.MAXREPEAT: + if min_count == 1 and max_count == sre.MAXREPEAT: return builder.one_or_more_lazy() if lazy else builder.one_or_more() if min_count == max_count: return builder.exactly(min_count) - if max_count == sre_constants.MAXREPEAT: + if max_count == sre.MAXREPEAT: return builder.at_least(min_count) if lazy: return builder.between_lazy(min_count, max_count) @@ -206,7 +187,7 @@ def _apply_quantifier( def _translate_subpattern( builder: builder_module.RegexBuilder, - argument: tuple[int | None, int, int, _SrePattern], + argument: tuple[int | None, int, int, SrePattern], names: dict[int, str], ) -> builder_module.RegexBuilder: group_number, _in_flags, _out_flags, body = argument @@ -220,7 +201,7 @@ def _translate_subpattern( def _translate_branch( - builder: builder_module.RegexBuilder, argument: tuple[None, list[_SrePattern]] + builder: builder_module.RegexBuilder, argument: tuple[None, list[SrePattern]] ) -> builder_module.RegexBuilder: _leading, branches = argument literal_branches: list[str] = [] @@ -233,11 +214,11 @@ def _translate_branch( return builder.any_of(*literal_branches) -def _branch_as_literal_string(nodes: _SrePattern) -> str | None: +def _branch_as_literal_string(nodes: SrePattern) -> str | None: characters: list[str] = [] for node in nodes: opcode, argument = node - if opcode != sre_constants.LITERAL: + if opcode != sre.LITERAL: return None characters.append(chr(cast(int, argument))) return "".join(characters) @@ -245,7 +226,7 @@ def _branch_as_literal_string(nodes: _SrePattern) -> str | None: def _translate_lookaround( builder: builder_module.RegexBuilder, - argument: tuple[int, _SrePattern], + argument: tuple[int, SrePattern], names: dict[int, str], negative: bool, ) -> builder_module.RegexBuilder: diff --git a/edify/builder/sre.py b/edify/builder/sre.py new file mode 100644 index 0000000..93e7ed8 --- /dev/null +++ b/edify/builder/sre.py @@ -0,0 +1,75 @@ +"""Typed access to the stdlib private ``re`` parser and its opcode constants. + +The stdlib exposes its regex parser under ``re._parser`` and the opcode +constants under ``re._constants``; neither carries type information. This module +loads both through :func:`importlib.import_module` (typed :class:`~types.ModuleType`) +and re-exports exactly the surface the reverse parser needs, with every value +pinned to a concrete type. It is the single place in the package that touches the +private ``re`` internals. +""" + +from __future__ import annotations + +from importlib import import_module +from typing import TypeAlias, cast + +_constants = import_module("re._constants") +_parser = import_module("re._parser") + +SreArgument: TypeAlias = ( + int + | tuple[int, int] + | tuple[int, int, "SrePattern"] + | tuple[int | None, int, int, "SrePattern"] + | tuple[None, list["SrePattern"]] + | tuple[int, "SrePattern"] + | list["SreNode"] + | None +) +"""Union of every argument shape the parser emits on the second slot of a node.""" + +SreNode: TypeAlias = tuple[int, SreArgument] +"""One ``(opcode, argument)`` node produced by :func:`parse`.""" + +SrePattern: TypeAlias = list[SreNode] +"""Sequence of :data:`SreNode` items.""" + + +def _const(name: str) -> int: + return int(getattr(_constants, name)) + + +MAXREPEAT = _const("MAXREPEAT") + +LITERAL = _const("LITERAL") +ANY = _const("ANY") +AT = _const("AT") +IN = _const("IN") +MAX_REPEAT = _const("MAX_REPEAT") +MIN_REPEAT = _const("MIN_REPEAT") +SUBPATTERN = _const("SUBPATTERN") +BRANCH = _const("BRANCH") +ASSERT = _const("ASSERT") +ASSERT_NOT = _const("ASSERT_NOT") +RANGE = _const("RANGE") +CATEGORY = _const("CATEGORY") + +AT_BEGINNING = _const("AT_BEGINNING") +AT_BEGINNING_STRING = _const("AT_BEGINNING_STRING") +AT_END = _const("AT_END") +AT_END_STRING = _const("AT_END_STRING") +AT_BOUNDARY = _const("AT_BOUNDARY") +AT_NON_BOUNDARY = _const("AT_NON_BOUNDARY") + +CATEGORY_DIGIT = _const("CATEGORY_DIGIT") +CATEGORY_NOT_DIGIT = _const("CATEGORY_NOT_DIGIT") +CATEGORY_WORD = _const("CATEGORY_WORD") +CATEGORY_NOT_WORD = _const("CATEGORY_NOT_WORD") +CATEGORY_SPACE = _const("CATEGORY_SPACE") +CATEGORY_NOT_SPACE = _const("CATEGORY_NOT_SPACE") + + +def parse(pattern_text: str) -> SrePattern: + """Return ``pattern_text`` parsed into a list of ``(opcode, argument)`` nodes.""" + raw_result = _parser.parse(pattern_text) + return cast(SrePattern, list(raw_result)) diff --git a/edify/builder/types/protocol.py b/edify/builder/types/protocol.py index c73b9dd..eb60bff 100644 --- a/edify/builder/types/protocol.py +++ b/edify/builder/types/protocol.py @@ -1,7 +1,7 @@ """The :class:`BuilderProtocol` — the contract every mixin assumes about ``self``. -Each mixin defines methods that read ``self._state`` and return a new builder -via ``self._with_state(new_state)``. Typing ``self`` against this protocol +Each mixin defines methods that read ``self.state`` and return a new builder +via ``self.with_state(new_state)``. Typing ``self`` against this protocol gives both type checkers a complete picture of the cross-mixin attribute surface even when individual mixin files are inspected in isolation. @@ -24,14 +24,14 @@ from edify.result.regex import Regex class BuilderProtocol(Protocol): """The shared shape that every builder mixin assumes about ``self``.""" - _state: BuilderState - _cached_regex: Regex | None + state: BuilderState + cached_regex: Regex | None def __init__(self) -> None: ... - def _with_state(self, new_state: BuilderState, /) -> Self: ... + def with_state(self, new_state: BuilderState, /) -> Self: ... - def _lazy_regex(self) -> Regex: ... + def lazy_regex(self) -> Regex: ... def to_regex_string(self) -> str: ... diff --git a/edify/compile/backend.py b/edify/compile/backend.py index d2bae12..6f63d53 100644 --- a/edify/compile/backend.py +++ b/edify/compile/backend.py @@ -11,22 +11,22 @@ from __future__ import annotations import re from importlib import import_module -from types import ModuleType from typing import cast from edify.builder.types.engine import Engine from edify.builder.types.flags import Flags +from edify.compile.types import RegexModule from edify.errors.backend import MissingRegexBackendError, VariableWidthLookbehindNotSupportedError -def load_regex_module() -> ModuleType: +def load_regex_module() -> RegexModule: """Return the third-party ``regex`` module, importing it on first call. Raises: MissingRegexBackendError: when the ``regex`` extra is not installed. """ try: - return import_module("regex") + return cast(RegexModule, import_module("regex")) except ImportError as reason: raise MissingRegexBackendError() from reason @@ -46,9 +46,7 @@ def compile_pattern(pattern: str, engine: Engine, flags: Flags) -> re.Pattern[st """ if engine == "regex": regex_module = load_regex_module() - return cast( - re.Pattern[str], regex_module.compile(pattern, _regex_flag_bitmask(regex_module, flags)) - ) + return regex_module.compile(pattern, _regex_flag_bitmask(regex_module, flags)) try: return re.compile(pattern, flags=_re_flag_bitmask(flags)) except re.error as reason: @@ -74,18 +72,22 @@ def _re_flag_bitmask(flags: Flags) -> int: return bitmask -def _regex_flag_bitmask(regex_module: ModuleType, flags: Flags) -> int: +def _regex_flag(regex_module: RegexModule, name: str) -> int: + return cast(int, getattr(regex_module, name)) + + +def _regex_flag_bitmask(regex_module: RegexModule, flags: Flags) -> int: bitmask = 0 if flags.ascii_only: - bitmask = bitmask | regex_module.A + bitmask = bitmask | _regex_flag(regex_module, "A") if flags.debug: - bitmask = bitmask | regex_module.DEBUG + bitmask = bitmask | _regex_flag(regex_module, "DEBUG") if flags.ignore_case: - bitmask = bitmask | regex_module.I + bitmask = bitmask | _regex_flag(regex_module, "I") if flags.multiline: - bitmask = bitmask | regex_module.M + bitmask = bitmask | _regex_flag(regex_module, "M") if flags.dotall: - bitmask = bitmask | regex_module.S + bitmask = bitmask | _regex_flag(regex_module, "S") if flags.verbose: - bitmask = bitmask | regex_module.X + bitmask = bitmask | _regex_flag(regex_module, "X") return bitmask diff --git a/edify/compile/types.py b/edify/compile/types.py index 56431dd..6e2c895 100644 --- a/edify/compile/types.py +++ b/edify/compile/types.py @@ -1,9 +1,17 @@ -"""Type aliases used across the edify compile path.""" +"""Type aliases and Protocols used across the edify compile path.""" from __future__ import annotations +import re from collections.abc import Callable +from typing import Protocol from edify.elements.types.base import BaseElement ElementRenderer = Callable[[BaseElement], str] + + +class RegexModule(Protocol): + """The subset of the third-party ``regex`` module surface the backend uses.""" + + def compile(self, pattern: str, flags: int = ...) -> re.Pattern[str]: ... diff --git a/edify/errors/context.py b/edify/errors/context.py index 23859df..dd3d497 100644 --- a/edify/errors/context.py +++ b/edify/errors/context.py @@ -2,9 +2,9 @@ from __future__ import annotations +import inspect import linecache import os -import sys from dataclasses import dataclass from functools import cache from types import CodeType, FrameType @@ -41,7 +41,8 @@ def capture_caller_context() -> CallerContext | None: Returns ``None`` when every frame on the stack lives inside the ``edify/`` package tree. """ - current_frame: FrameType | None = sys._getframe(1) + this_frame = inspect.currentframe() + current_frame: FrameType | None = this_frame.f_back if this_frame is not None else None while current_frame is not None: filename = current_frame.f_code.co_filename if not _is_inside_edify(filename): diff --git a/edify/integrations/django.py b/edify/integrations/django.py index be2e966..06581ba 100644 --- a/edify/integrations/django.py +++ b/edify/integrations/django.py @@ -1,21 +1,36 @@ """Django integration — validate model / form fields against an edify :class:`Pattern`. -Requires the ``django`` extra: ``pip install edify[django]``. Importing this module -without the framework installed raises :class:`ImportError` at import time. +Requires the ``django`` extra: ``pip install edify[django]``. Django ships no type +information, so the module is loaded through :func:`importlib.import_module` and its +one used entry point is described by a local :class:`Protocol`. """ from __future__ import annotations -import django.core.validators +import re +from importlib import import_module +from typing import Protocol, cast from edify.pattern.composition import Pattern +class _RegexValidator(Protocol): + regex: re.Pattern[str] + message: str + code: str + + def __call__(self, value: object) -> None: ... + + +class _RegexValidatorFactory(Protocol): + def __call__(self, *, regex: str, message: str, code: str) -> _RegexValidator: ... + + def pattern_validator( pattern: Pattern, message: str | None = None, code: str = "invalid", -) -> django.core.validators.RegexValidator: +) -> _RegexValidator: """Return a :class:`django.core.validators.RegexValidator` pinned to ``pattern``. Args: @@ -24,10 +39,12 @@ def pattern_validator( message that reproduces the pattern's regex string. code: The Django validation-error code the validator raises on rejection. """ + validators_module = import_module("django.core.validators") + regex_validator_factory = cast(_RegexValidatorFactory, validators_module.RegexValidator) resolved_message = ( message if message is not None else f"value does not match {pattern.to_regex_string()}" ) - return django.core.validators.RegexValidator( + return regex_validator_factory( regex=pattern.to_regex_string(), message=resolved_message, code=code, diff --git a/edify/introspect/ascii.py b/edify/introspect/ascii.py index adda379..124277d 100644 --- a/edify/introspect/ascii.py +++ b/edify/introspect/ascii.py @@ -124,13 +124,13 @@ def _element_diagram(element: BaseElement) -> Diagram: def _inline_label(element: BaseElement) -> str | None: """Return a short plain-English label for a leaf or character element, or ``None``.""" - leaf = _leaf_label(element) + leaf = leaf_label(element) if leaf is not None: return leaf - return _char_label(element) + return char_label(element) -def _leaf_label(element: BaseElement) -> str | None: +def leaf_label(element: BaseElement) -> str | None: """Return the plain-English label for a leaf element, or ``None``.""" if isinstance(element, StartOfInputElement): return "text starts here" @@ -175,7 +175,7 @@ def _leaf_label(element: BaseElement) -> str | None: return None -def _char_label(element: BaseElement) -> str | None: +def char_label(element: BaseElement) -> str | None: """Return the plain-English label for a character-shaped element, or ``None``.""" if isinstance(element, CharElement): return f'"{_display_string(element.value)}"' @@ -257,8 +257,6 @@ def _pluralize(label: str) -> str: """Return an English plural of ``label`` (respecting literal-string quotes).""" if label.startswith('"') and label.endswith('"'): return label - if label.endswith(("s", "x", "z", "ch", "sh")): - return label + "es" if len(label) >= 2 and label.endswith("y") and label[-2] not in "aeiou": return label[:-1] + "ies" return label + "s" @@ -356,8 +354,6 @@ def _clip_trunk_below(rows: list[str], last_entry: int) -> list[str]: def _pad_right(diagram: Diagram, target_width: int) -> Diagram: """Return ``diagram`` right-padded with spaces so every row reaches ``target_width``.""" - if diagram.width >= target_width: - return diagram extra = target_width - diagram.width padded_rows_list = [row + " " * extra for row in diagram.rows] new_rows = tuple(padded_rows_list) @@ -377,14 +373,8 @@ def _looks_like_single_box(diagram: Diagram) -> bool: """Return ``True`` when ``diagram`` is a plain three-row ``+---+ | X | +---+`` box.""" if len(diagram.rows) != 3 or diagram.entry_row != 1: return False - top, middle, bottom = diagram.rows - if top != bottom: - return False - if not (top.startswith("+") and top.endswith("+")): - return False - if not (middle.startswith("|") and middle.endswith("|")): - return False - return set(top[1:-1]) == {"-"} + top, _middle, bottom = diagram.rows + return top == bottom and set(top[1:-1]) == {"-"} def _widen_box(diagram: Diagram, target_width: int) -> Diagram: diff --git a/edify/introspect/explain.py b/edify/introspect/explain.py index bb9b6d1..36857b3 100644 --- a/edify/introspect/explain.py +++ b/edify/introspect/explain.py @@ -126,27 +126,6 @@ def _build_lines(elements: tuple[BaseElement, ...]) -> list[str]: return lines -def _wrap_step(step_number: int, description: str) -> str: - """Return the description prefixed with the step number and indented continuation.""" - prefix = f" {step_number}. " - continuation_prefix = " " * len(prefix) - words = description.split() - if not words: - return prefix.rstrip() - lines: list[str] = [] - current_line = prefix + words[0] - max_width = 76 - for word in words[1:]: - candidate = current_line + " " + word - if len(candidate) > max_width: - lines.append(current_line) - current_line = continuation_prefix + word - else: - current_line = candidate - lines.append(current_line) - return "\n".join(lines) - - def _describe_step( element: BaseElement, *, diff --git a/edify/introspect/graphviz.py b/edify/introspect/graphviz.py index b61c482..de0b441 100644 --- a/edify/introspect/graphviz.py +++ b/edify/introspect/graphviz.py @@ -2,6 +2,9 @@ from __future__ import annotations +from importlib import import_module +from typing import cast + from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( BackReferenceElement, @@ -32,22 +35,24 @@ from edify.elements.types.quantifiers import ( ) from edify.elements.types.union import QuantifierElement from edify.errors.introspect import MissingGraphvizDependencyError -from edify.introspect.ascii import _char_label, _leaf_label -from edify.introspect.types import Emission +from edify.introspect.ascii import char_label, leaf_label +from edify.introspect.types import Emission, GraphvizSourceFactory + -try: - import graphviz as _graphviz_module -except ImportError: - _graphviz_module = None +def _load_graphviz_source_factory() -> GraphvizSourceFactory: + try: + graphviz_module = import_module("graphviz") + except ImportError as reason: + raise MissingGraphvizDependencyError() from reason + return cast(GraphvizSourceFactory, graphviz_module.Source) def render_graphviz_svg(elements: tuple[BaseElement, ...]) -> str: """Return an SVG rendering of ``elements`` produced by Graphviz.""" - if _graphviz_module is None: - raise MissingGraphvizDependencyError() + source_factory = _load_graphviz_source_factory() dot_source = render_dot(elements) - source = _graphviz_module.Source(dot_source, format="svg") - piped_bytes: bytes = source.pipe(format="svg") + source = source_factory(dot_source, format="svg") + piped_bytes = source.pipe(format="svg") return piped_bytes.decode("utf-8") @@ -208,10 +213,10 @@ def _emit_quantifier_cluster(element: BaseElement, counter: _Counter) -> Emissio def _inline_label(element: BaseElement) -> str | None: """Return a single-line label for a leaf / char / simple-quantifier element, else ``None``.""" - leaf = _leaf_label(element) + leaf = leaf_label(element) if leaf is not None: return leaf - char = _char_label(element) + char = char_label(element) if char is not None: return char return _simple_quantifier_label(element) @@ -224,7 +229,7 @@ def _simple_quantifier_label(element: BaseElement) -> str | None: return None assert isinstance(element, QuantifierElement) child = element.child - child_label = _leaf_label(child) or _char_label(child) + child_label = leaf_label(child) or char_label(child) if child_label is None: return None return f"{child_label}\\n({quantifier_phrase})" diff --git a/edify/introspect/types.py b/edify/introspect/types.py index 5013c16..b7342f5 100644 --- a/edify/introspect/types.py +++ b/edify/introspect/types.py @@ -1,8 +1,21 @@ -"""Shared dataclasses used by the introspection renderers.""" +"""Shared dataclasses and Protocols used by the introspection renderers.""" from __future__ import annotations from dataclasses import dataclass +from typing import Protocol + + +class GraphvizSource(Protocol): + """The subset of ``graphviz.Source`` the SVG renderer uses.""" + + def pipe(self, format: str = ...) -> bytes: ... + + +class GraphvizSourceFactory(Protocol): + """The ``graphviz.Source`` constructor the SVG renderer calls.""" + + def __call__(self, source: str, format: str = ...) -> GraphvizSource: ... @dataclass(frozen=True) diff --git a/edify/library/auth/password.py b/edify/library/auth/password.py index 788506e..0259d11 100644 --- a/edify/library/auth/password.py +++ b/edify/library/auth/password.py @@ -56,8 +56,6 @@ class _PasswordPattern(Pattern): special_chars: str | None = None, ) -> bool: """Return True when ``value`` meets every configured threshold.""" - if not isinstance(value, str): - return False effective_min_length = self.min_length if min_length is None else min_length effective_max_length = self.max_length if max_length is None else max_length effective_min_upper = self.min_upper if min_upper is None else min_upper diff --git a/edify/library/media/regex.py b/edify/library/media/regex.py index 7a94c65..09c1ddf 100644 --- a/edify/library/media/regex.py +++ b/edify/library/media/regex.py @@ -9,8 +9,6 @@ from edify.pattern.composition import Pattern class _RegexPattern(Pattern): def __call__(self, value: str) -> bool: - if not isinstance(value, str): - return False try: re.compile(value) except re.error: diff --git a/edify/pattern/composition.py b/edify/pattern/composition.py index 9d67d94..d920a76 100644 --- a/edify/pattern/composition.py +++ b/edify/pattern/composition.py @@ -58,7 +58,7 @@ class Pattern( def to_dict(self) -> dict[str, JSONValue]: """Return the canonical dict representation of this pattern.""" - return state_to_dict(self._state) + return state_to_dict(self.state) def to_json(self) -> str: """Return the canonical JSON string for this pattern.""" @@ -70,7 +70,7 @@ class Pattern( """Return a Pattern reconstructed from a canonical dict.""" reconstructed_state = dict_to_state(document) empty_pattern = cls() - return empty_pattern._with_state(reconstructed_state) + return empty_pattern.with_state(reconstructed_state) @classmethod def from_json(cls, blob: str) -> Pattern: diff --git a/edify/pattern/factories/assertions.py b/edify/pattern/factories/assertions.py index fbfe113..a87111a 100644 --- a/edify/pattern/factories/assertions.py +++ b/edify/pattern/factories/assertions.py @@ -45,4 +45,4 @@ def assert_not_behind(operand: BuilderProtocol) -> Pattern: def _operand_children(operand: BuilderProtocol) -> tuple[BaseElement, ...]: """Return ``operand``'s root-frame children as an immutable tuple.""" - return tuple(operand._state.top_frame.children) + return tuple(operand.state.top_frame.children) diff --git a/edify/pattern/factories/groups.py b/edify/pattern/factories/groups.py index 9d18a7e..b889d23 100644 --- a/edify/pattern/factories/groups.py +++ b/edify/pattern/factories/groups.py @@ -64,11 +64,11 @@ def any_of(*operands: BuilderProtocol) -> Pattern: def _operand_children(operand: BuilderProtocol) -> tuple[BaseElement, ...]: """Return ``operand``'s root-frame children as an immutable tuple.""" - return tuple(operand._state.top_frame.children) + return tuple(operand.state.top_frame.children) def _ensure_positive_integer(label: str, value: int) -> None: """Raise :class:`MustBePositiveIntegerError` when ``value`` is not a strictly positive int.""" - if isinstance(value, int) and not isinstance(value, bool) and value > 0: + if not isinstance(value, bool) and value > 0: return raise MustBePositiveIntegerError(label) diff --git a/edify/pattern/factories/quantifiers.py b/edify/pattern/factories/quantifiers.py index 3278dca..fb401e9 100644 --- a/edify/pattern/factories/quantifiers.py +++ b/edify/pattern/factories/quantifiers.py @@ -98,14 +98,14 @@ def between_lazy(lower: int, upper: int, operand: BuilderProtocol) -> Pattern: def _ensure_positive_integer(label: str, value: int) -> None: """Raise :class:`MustBePositiveIntegerError` when ``value`` is not a strictly positive int.""" - if isinstance(value, int) and not isinstance(value, bool) and value > 0: + if not isinstance(value, bool) and value > 0: return raise MustBePositiveIntegerError(label) def _ensure_non_negative_integer(label: str, value: int) -> None: """Raise :class:`MustBeIntegerGreaterThanZeroError` when ``value`` is not a non-negative int.""" - if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + if not isinstance(value, bool) and value >= 0: return raise MustBeIntegerGreaterThanZeroError(label) diff --git a/edify/pattern/factories/values.py b/edify/pattern/factories/values.py index 9aae333..3fa0704 100644 --- a/edify/pattern/factories/values.py +++ b/edify/pattern/factories/values.py @@ -49,4 +49,4 @@ def nonrange(start_character: str, end_character: str) -> Pattern: def _first_child_pattern(built: Pattern) -> Pattern: """Isolate the element the mixin just appended into its own fresh :class:`Pattern`.""" - return pattern_containing(built._state.top_frame.children[-1]) + return pattern_containing(built.state.top_frame.children[-1]) diff --git a/edify/pattern/factories/wrap.py b/edify/pattern/factories/wrap.py index 71a8ea1..b403933 100644 --- a/edify/pattern/factories/wrap.py +++ b/edify/pattern/factories/wrap.py @@ -25,7 +25,7 @@ from edify.pattern.composition import Pattern def target_element(operand: BuilderProtocol) -> BaseElement: """Return the element to wrap: the sole child when there is one, else a bundle.""" - children = operand._state.top_frame.children + children = operand.state.top_frame.children if len(children) == 1: return children[0] return SubexpressionElement(children=children) @@ -34,5 +34,5 @@ def target_element(operand: BuilderProtocol) -> BaseElement: def pattern_containing(element: BaseElement) -> Pattern: """Return a fresh :class:`Pattern` whose root frame holds ``element``.""" fresh_pattern = Pattern() - new_state = fresh_pattern._state.with_element_added_to_top(element) - return fresh_pattern._with_state(new_state) + new_state = fresh_pattern.state.with_element_added_to_top(element) + return fresh_pattern.with_state(new_state) diff --git a/pyproject.toml b/pyproject.toml index b170f3b..580fa33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,23 +135,15 @@ filterwarnings = ["error"] [tool.pyright] pythonVersion = "3.11" -include = ["edify"] +include = ["edify", "tests", "tools"] +stubPath = "stubs" venvPath = "." venv = ".venv" typeCheckingMode = "strict" -reportMissingImports = "none" -reportMissingTypeStubs = "none" -reportPrivateUsage = "none" -reportUnnecessaryIsInstance = "none" -reportUnusedFunction = "none" -reportUnknownVariableType = "none" -reportUnknownMemberType = "none" -reportUnknownArgumentType = "none" -reportAttributeAccessIssue = "none" [tool.mypy] python_version = "3.11" -files = ["edify", "tests", "docs"] +files = ["edify", "tools"] warn_unused_configs = true [[tool.mypy.overrides]] diff --git a/stubs/django/__init__.pyi b/stubs/django/__init__.pyi new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/stubs/django/__init__.pyi diff --git a/stubs/django/core/__init__.pyi b/stubs/django/core/__init__.pyi new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/stubs/django/core/__init__.pyi diff --git a/stubs/django/core/exceptions.pyi b/stubs/django/core/exceptions.pyi new file mode 100644 index 0000000..01a6138 --- /dev/null +++ b/stubs/django/core/exceptions.pyi @@ -0,0 +1 @@ +class ValidationError(Exception): ... diff --git a/stubs/django/core/validators.pyi b/stubs/django/core/validators.pyi new file mode 100644 index 0000000..dd1a79d --- /dev/null +++ b/stubs/django/core/validators.pyi @@ -0,0 +1,15 @@ +import re + +class RegexValidator: + regex: re.Pattern[str] + message: str + code: str + def __init__( + self, + regex: str | None = ..., + message: str | None = ..., + code: str | None = ..., + inverse_match: bool | None = ..., + flags: int | None = ..., + ) -> None: ... + def __call__(self, value: object) -> None: ... diff --git a/tests/bench/compile.py b/tests/bench/compile.py index 4db432a..112e05f 100644 --- a/tests/bench/compile.py +++ b/tests/bench/compile.py @@ -3,17 +3,18 @@ from __future__ import annotations import pytest +from pytest_benchmark.fixture import BenchmarkFixture from tests.bench.cases import BUILDER_FACTORIES @pytest.mark.parametrize("case_name", list(BUILDER_FACTORIES)) -def test_compile_bench(case_name, benchmark): +def test_compile_bench(case_name: str, benchmark: BenchmarkFixture): factory = BUILDER_FACTORIES[case_name] benchmark(lambda: factory().to_regex()) @pytest.mark.parametrize("case_name", list(BUILDER_FACTORIES)) -def test_to_regex_string_bench(case_name, benchmark): +def test_to_regex_string_bench(case_name: str, benchmark: BenchmarkFixture): factory = BUILDER_FACTORIES[case_name] benchmark(lambda: factory().to_regex_string()) diff --git a/tests/bench/match.py b/tests/bench/match.py index 661267e..67097a3 100644 --- a/tests/bench/match.py +++ b/tests/bench/match.py @@ -3,7 +3,9 @@ from __future__ import annotations import pytest +from pytest_benchmark.fixture import BenchmarkFixture +from edify.result import Regex from tests.bench.cases import BUILDER_FACTORIES, MATCH_SCENARIOS _MATCH_KINDS = ("matches", "near_matches", "non_matches") @@ -11,13 +13,13 @@ _MATCH_KINDS = ("matches", "near_matches", "non_matches") @pytest.mark.parametrize("case_name", list(BUILDER_FACTORIES)) @pytest.mark.parametrize("match_kind", _MATCH_KINDS) -def test_match_bench(case_name, match_kind, benchmark): +def test_match_bench(case_name: str, match_kind: str, benchmark: BenchmarkFixture): factory = BUILDER_FACTORIES[case_name] compiled = factory().to_regex() inputs = MATCH_SCENARIOS[case_name][match_kind] benchmark(_match_all_inputs, compiled, inputs) -def _match_all_inputs(compiled, inputs): +def _match_all_inputs(compiled: Regex, inputs: list[str]) -> None: for candidate in inputs: compiled.search(candidate) diff --git a/tests/builder/builder.test.py b/tests/builder/builder.test.py index 5f96b5c..7f58d47 100644 --- a/tests/builder/builder.test.py +++ b/tests/builder/builder.test.py @@ -5,7 +5,7 @@ import pytest from edify import RegexBuilder from edify.errors.anchors import StartInputAlreadyDefinedError from edify.errors.captures import InvalidTotalCaptureGroupsIndexError -from edify.errors.input import MustBeInstanceError, MustBeSingleCharacterError +from edify.errors.input import MustBeSingleCharacterError from edify.errors.naming import ( CannotCreateDuplicateNamedGroupError, NamedGroupDoesNotExistError, @@ -39,13 +39,13 @@ first_layer_se = ( ) -def regex_equality(regex, rb_expression): +def regex_equality(regex: str, rb_expression: RegexBuilder) -> None: regex_str = str(regex) rb_expression_str = rb_expression.to_regex_string() assert regex_str == str(rb_expression_str) -def regex_compilation(regex, rb_expression, f=0): +def regex_compilation(regex: str, rb_expression: RegexBuilder, f: int = 0) -> None: rb_expression_c = rb_expression.to_regex() assert re.compile(regex, flags=f) == rb_expression_c.compiled @@ -458,11 +458,6 @@ def test_range(): regex_compilation("[a-z]", expr) -def test_must_be_instance_error(): - with pytest.raises(MustBeInstanceError): - RegexBuilder().subexpression("nope") - - def test_simple_se(): expr = ( RegexBuilder() diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py index 045e8e9..4211698 100644 --- a/tests/builder/cache.test.py +++ b/tests/builder/cache.test.py @@ -1,6 +1,7 @@ """Tests for the per-instance lazy compile cache — semantics and cold/warm ratio.""" import time +from collections.abc import Callable from edify import Pattern, RegexBuilder @@ -21,7 +22,7 @@ def _hex_number_builder(): ) -def _measure_call_wall_clock(action, iterations): +def _measure_call_wall_clock(action: Callable[[], object], iterations: int) -> float: start = time.perf_counter() for _ in range(iterations): action() diff --git a/tests/builder/diagnose.test.py b/tests/builder/diagnose.test.py index 88c4f03..8b9e4dc 100644 --- a/tests/builder/diagnose.test.py +++ b/tests/builder/diagnose.test.py @@ -6,13 +6,6 @@ from edify import Pattern, RegexBuilder from edify.errors.comparison import CannotCompareUnfinishedBuilderError -def _first_pointer_hint(text: str) -> str | None: - for line in text.splitlines(): - if "->" in line and ".py:" in line: - return line - return None - - def test_two_finished_builders_compare_equal_without_diagnostic(): left = RegexBuilder().digit() right = RegexBuilder().digit() diff --git a/tests/builder/engine.test.py b/tests/builder/engine.test.py index c6a32d1..9fddeb4 100644 --- a/tests/builder/engine.test.py +++ b/tests/builder/engine.test.py @@ -32,7 +32,7 @@ def test_engine_regex_and_engine_re_are_not_equal_for_the_same_source(): assert left != right -def test_engine_regex_raises_clean_import_error_without_the_extra(monkeypatch): +def test_engine_regex_raises_clean_import_error_without_the_extra(monkeypatch: pytest.MonkeyPatch): monkeypatch.setitem(sys.modules, "regex", None) with pytest.raises(MissingRegexBackendError, match="engine='regex'") as excinfo: RegexBuilder().digit().to_regex(engine="regex") @@ -41,7 +41,9 @@ def test_engine_regex_raises_clean_import_error_without_the_extra(monkeypatch): assert "= note:" in text -def test_missing_regex_backend_error_chains_from_underlying_import_error(monkeypatch): +def test_missing_regex_backend_error_chains_from_underlying_import_error( + monkeypatch: pytest.MonkeyPatch, +): monkeypatch.setitem(sys.modules, "regex", None) with pytest.raises(MissingRegexBackendError) as excinfo: RegexBuilder().digit().to_regex(engine="regex") diff --git a/tests/builder/flags.test.py b/tests/builder/flags.test.py index 704065f..03b79f4 100644 --- a/tests/builder/flags.test.py +++ b/tests/builder/flags.test.py @@ -2,6 +2,7 @@ import re import sys +from typing import cast import pytest import regex as regex_module @@ -112,13 +113,15 @@ def test_regex_engine_debug_kwarg_compiles_without_error(): assert compiled.source == "hi" -def test_regex_engine_debug_flag_is_forwarded_to_regex_module_bitmask(monkeypatch): +def test_regex_engine_debug_flag_is_forwarded_to_regex_module_bitmask( + monkeypatch: pytest.MonkeyPatch, +): captured_flags: list[int] = [] original_compile = regex_module.compile - def capturing_compile(pattern, flags=0): + def capturing_compile(pattern: str, flags: int = 0) -> re.Pattern[str]: captured_flags.append(flags) - return original_compile(pattern) + return cast("re.Pattern[str]", original_compile(pattern)) monkeypatch.setattr(regex_module, "compile", capturing_compile) RegexBuilder().string("hi").to_regex(engine="regex", debug=True) diff --git a/tests/builder/lookbehind.test.py b/tests/builder/lookbehind.test.py index 5e101f3..c9a2235 100644 --- a/tests/builder/lookbehind.test.py +++ b/tests/builder/lookbehind.test.py @@ -47,8 +47,8 @@ def test_variable_width_lookbehind_error_chains_the_underlying_pattern_error(): assert isinstance(excinfo.value.__cause__, re.error) -def test_re_engine_still_surfaces_other_pattern_errors_unchanged(monkeypatch): - def raise_other_error(_pattern, flags=0): +def test_re_engine_still_surfaces_other_pattern_errors_unchanged(monkeypatch: pytest.MonkeyPatch): + def raise_other_error(_pattern: str, flags: int = 0) -> re.Pattern[str]: raise re.error("some other syntax error") monkeypatch.setattr(re, "compile", raise_other_error) diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py index 87770fa..5bd9c0c 100644 --- a/tests/builder/properties.test.py +++ b/tests/builder/properties.test.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from hypothesis import given from hypothesis import strategies as st -from edify import Pattern, RegexBuilder +from edify import RegexBuilder @dataclass(frozen=True) @@ -29,28 +29,31 @@ class LeafNode: class GroupNode: """A non-capturing group wrapping ``children``.""" - children: tuple[object, ...] + children: tuple[_Node, ...] @dataclass(frozen=True) class CaptureNode: """An unnamed capture group wrapping ``children``.""" - children: tuple[object, ...] + children: tuple[_Node, ...] @dataclass(frozen=True) class NamedCaptureNode: """A named-capture group wrapping ``children``; the name is assigned deterministically.""" - children: tuple[object, ...] + children: tuple[_Node, ...] @dataclass(frozen=True) class SubexpressionNode: """A subexpression built as a separate ``Pattern`` and merged into the parent.""" - children: tuple[object, ...] + children: tuple[_Node, ...] + + +_Node = LeafNode | GroupNode | CaptureNode | NamedCaptureNode | SubexpressionNode _QUANTIFIER_STRATEGIES: list[st.SearchStrategy[tuple[str, tuple[int, ...], str]]] = [ @@ -121,10 +124,10 @@ _node_strategy = st.recursive(_leaf_node_strategy, _extend, max_leaves=6) def _apply_sequence( - builder, - nodes: list[object], + builder: RegexBuilder, + nodes: list[_Node], name_counter: int, -) -> tuple[object, str, int]: +) -> tuple[RegexBuilder, str, int]: """Apply ``nodes`` to ``builder`` in order; return the updated triple.""" fragments: list[str] = [] for node in nodes: @@ -134,7 +137,9 @@ def _apply_sequence( return builder, combined_regex, name_counter -def _apply_node(builder, node, name_counter: int) -> tuple[object, str, int]: +def _apply_node( + builder: RegexBuilder, node: _Node, name_counter: int +) -> tuple[RegexBuilder, str, int]: """Dispatch on ``node`` type, applying it to ``builder`` and returning the fragment.""" if isinstance(node, LeafNode): return _apply_leaf(builder, node, name_counter) @@ -147,7 +152,9 @@ def _apply_node(builder, node, name_counter: int) -> tuple[object, str, int]: return _apply_subexpression(builder, node, name_counter) -def _apply_leaf(builder, node: LeafNode, name_counter: int) -> tuple[object, str, int]: +def _apply_leaf( + builder: RegexBuilder, node: LeafNode, name_counter: int +) -> tuple[RegexBuilder, str, int]: """Apply a single leaf element, optionally preceded by a quantifier.""" if node.quantifier is None: builder_after_element = getattr(builder, node.element_name)(*node.element_args) @@ -159,7 +166,9 @@ def _apply_leaf(builder, node: LeafNode, name_counter: int) -> tuple[object, str return builder_after_element, fragment, name_counter -def _apply_group(builder, node: GroupNode, name_counter: int) -> tuple[object, str, int]: +def _apply_group( + builder: RegexBuilder, node: GroupNode, name_counter: int +) -> tuple[RegexBuilder, str, int]: """Apply a non-capturing group around ``node.children``.""" builder_opened = builder.group() builder_inner, inner_regex, name_counter = _apply_sequence( @@ -169,7 +178,9 @@ def _apply_group(builder, node: GroupNode, name_counter: int) -> tuple[object, s return builder_closed, f"(?:{inner_regex})", name_counter -def _apply_capture(builder, node: CaptureNode, name_counter: int) -> tuple[object, str, int]: +def _apply_capture( + builder: RegexBuilder, node: CaptureNode, name_counter: int +) -> tuple[RegexBuilder, str, int]: """Apply an unnamed capture group around ``node.children``.""" builder_opened = builder.capture() builder_inner, inner_regex, name_counter = _apply_sequence( @@ -180,10 +191,10 @@ def _apply_capture(builder, node: CaptureNode, name_counter: int) -> tuple[objec def _apply_named_capture( - builder, + builder: RegexBuilder, node: NamedCaptureNode, name_counter: int, -) -> tuple[object, str, int]: +) -> tuple[RegexBuilder, str, int]: """Apply a named-capture group with a deterministically-assigned name.""" assigned_name = f"n{name_counter}" next_counter = name_counter + 1 @@ -196,35 +207,41 @@ def _apply_named_capture( def _apply_subexpression( - builder, + builder: RegexBuilder, node: SubexpressionNode, name_counter: int, -) -> tuple[object, str, int]: - """Apply a subexpression built as an independent ``Pattern`` and merged in.""" - sub_pattern = Pattern() - sub_pattern_finished, sub_regex, next_counter = _apply_sequence( - sub_pattern, list(node.children), name_counter +) -> tuple[RegexBuilder, str, int]: + """Apply a subexpression built as an independent builder and merged in.""" + sub_builder = RegexBuilder() + sub_finished, sub_regex, next_counter = _apply_sequence( + sub_builder, list(node.children), name_counter ) - builder_after_merge = builder.subexpression(sub_pattern_finished) + builder_after_merge = builder.subexpression(sub_finished) return builder_after_merge, sub_regex, next_counter @given(st.lists(_leaf_node_strategy, min_size=1, max_size=8)) -def test_bare_element_chain_emits_the_concatenation_of_element_fragments(nodes): +def test_bare_element_chain_emits_the_concatenation_of_element_fragments( + nodes: list[LeafNode], +) -> None: builder = RegexBuilder() builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0) assert builder_after.to_regex_string() == expected_regex @given(st.lists(_leaf_node_strategy, min_size=1, max_size=8)) -def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(nodes): +def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier( + nodes: list[LeafNode], +) -> None: builder = RegexBuilder() builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0) assert builder_after.to_regex_string() == expected_regex @given(st.lists(_node_strategy, min_size=1, max_size=6)) -def test_composition_over_groups_captures_and_subexpressions_is_faithful(nodes): +def test_composition_over_groups_captures_and_subexpressions_is_faithful( + nodes: list[_Node], +) -> None: builder = RegexBuilder() builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0) assert builder_after.to_regex_string() == expected_regex diff --git a/tests/builder/reverse.test.py b/tests/builder/reverse.test.py index 1ed00ec..bbc7037 100644 --- a/tests/builder/reverse.test.py +++ b/tests/builder/reverse.test.py @@ -41,7 +41,7 @@ from edify.builder.reverse import UnsupportedReverseParseError ("(?<!x)y", "(?<!x)y"), ], ) -def test_from_regex_translates_common_constructs_faithfully(source, expected): +def test_from_regex_translates_common_constructs_faithfully(source: str, expected: str): builder = RegexBuilder.from_regex(source) assert builder.to_regex_string() == expected @@ -58,7 +58,7 @@ def test_from_regex_translates_common_constructs_faithfully(source, expected): r"(?=x)\w+", ], ) -def test_round_trip_compiled_regex_matches_the_same_inputs(source): +def test_round_trip_compiled_regex_matches_the_same_inputs(source: str): original = re.compile(source) reversed_builder = RegexBuilder.from_regex(source) reversed_compiled = reversed_builder.to_regex() diff --git a/tests/builder/validation.test.py b/tests/builder/validation.test.py index a1b57d7..a528aa2 100644 --- a/tests/builder/validation.test.py +++ b/tests/builder/validation.test.py @@ -13,8 +13,6 @@ from edify.errors.anchors import ( StartInputAlreadyDefinedError, ) from edify.errors.input import ( - MustBeAStringError, - MustBeInstanceError, MustBeIntegerGreaterThanZeroError, MustBeLessThanError, MustBeOneCharacterError, @@ -41,51 +39,26 @@ def test_end_of_input_twice_raises(): RegexBuilder().end_of_input().end_of_input() -def test_named_capture_non_string_raises(): - with pytest.raises(MustBeAStringError): - RegexBuilder().named_capture(42) - - def test_named_capture_empty_string_raises(): with pytest.raises(MustBeOneCharacterError): RegexBuilder().named_capture("") -def test_string_non_string_raises(): - with pytest.raises(MustBeAStringError): - RegexBuilder().string(42) - - def test_string_empty_raises(): with pytest.raises(MustBeOneCharacterError): RegexBuilder().string("") -def test_char_non_string_raises(): - with pytest.raises(MustBeAStringError): - RegexBuilder().char(42) - - def test_range_first_codepoint_not_less_than_second_raises(): with pytest.raises(MustHaveASmallerValueError): RegexBuilder().range("z", "a") -def test_anything_but_string_non_string_raises(): - with pytest.raises(MustBeAStringError): - RegexBuilder().anything_but_string(42) - - def test_anything_but_string_empty_raises(): with pytest.raises(MustBeOneCharacterError): RegexBuilder().anything_but_string("") -def test_anything_but_chars_non_string_raises(): - with pytest.raises(MustBeAStringError): - RegexBuilder().anything_but_chars(42) - - def test_anything_but_chars_empty_raises(): with pytest.raises(MustBeOneCharacterError): RegexBuilder().anything_but_chars("") @@ -151,8 +124,3 @@ def test_to_regex_with_open_frame_raises(): unfinished = RegexBuilder().capture().digit() with pytest.raises(CannotCallSubexpressionError): unfinished.to_regex() - - -def test_subexpression_non_builder_raises(): - with pytest.raises(MustBeInstanceError): - RegexBuilder().subexpression("not a builder") diff --git a/tests/builder/varargs.test.py b/tests/builder/varargs.test.py index 6a30636..3fb2254 100644 --- a/tests/builder/varargs.test.py +++ b/tests/builder/varargs.test.py @@ -4,7 +4,6 @@ import pytest from edify import Pattern, RegexBuilder from edify.errors.input import ( - MustBeAStringError, MustBeAtLeastOneLiteralError, MustBeOneCharacterError, ) @@ -65,8 +64,3 @@ def test_one_of_zero_args_raises_must_be_at_least_one_literal(): def test_any_of_varargs_rejects_empty_string_literal(): with pytest.raises(MustBeOneCharacterError): RegexBuilder().any_of("cat", "") - - -def test_one_of_rejects_non_string_literal(): - with pytest.raises(MustBeAStringError): - RegexBuilder().one_of("cat", 42) diff --git a/tests/compile/escape.test.py b/tests/compile/escape.test.py index e9ab657..aeaff93 100644 --- a/tests/compile/escape.test.py +++ b/tests/compile/escape.test.py @@ -91,7 +91,7 @@ def test_char_terminal_still_uses_full_escape_outside_the_class(): @pytest.mark.parametrize("candidate", _MIXED_CORPUS) -def test_normalized_and_over_escaped_char_classes_match_the_same_inputs(candidate): +def test_normalized_and_over_escaped_char_classes_match_the_same_inputs(candidate: str): normalized_pattern = ( RegexBuilder().any_of_chars(_METACHARS_UNRELATED_TO_CLASS).to_regex_string() ) @@ -102,7 +102,7 @@ def test_normalized_and_over_escaped_char_classes_match_the_same_inputs(candidat @pytest.mark.parametrize("candidate", _MIXED_CORPUS) -def test_normalized_and_over_escaped_negated_classes_match_the_same_inputs(candidate): +def test_normalized_and_over_escaped_negated_classes_match_the_same_inputs(candidate: str): normalized_pattern = RegexBuilder().anything_but_chars("aeiou.-").to_regex_string() over_escaped_pattern = "[^aeiou\\.\\-]" normalized_hits = re.findall(normalized_pattern, candidate) diff --git a/tests/compile/redos.test.py b/tests/compile/redos.test.py index 91786bd..97618bb 100644 --- a/tests/compile/redos.test.py +++ b/tests/compile/redos.test.py @@ -41,28 +41,34 @@ def test_warning_message_names_both_quantifiers(): assert "engine='regex'" in message_text -def test_bounded_quantifier_does_not_trigger_the_warning(recwarn): +def test_bounded_quantifier_does_not_trigger_the_warning(recwarn: pytest.WarningsRecorder): builder = RegexBuilder().exactly(3).group().exactly(2).digit().end() builder.to_regex() is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] assert not any(is_redos_flags) -def test_grouped_quantifier_over_a_composite_group_does_not_trigger(recwarn): +def test_grouped_quantifier_over_a_composite_group_does_not_trigger( + recwarn: pytest.WarningsRecorder, +): builder = RegexBuilder().one_or_more().group().digit().letter().end() builder.to_regex() is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] assert not any(is_redos_flags) -def test_grouped_quantifier_over_a_single_non_quantifier_child_does_not_trigger(recwarn): +def test_grouped_quantifier_over_a_single_non_quantifier_child_does_not_trigger( + recwarn: pytest.WarningsRecorder, +): builder = RegexBuilder().one_or_more().group().digit().end() builder.to_regex() is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] assert not any(is_redos_flags) -def test_sequential_unbounded_quantifiers_do_not_trigger_the_warning(recwarn): +def test_sequential_unbounded_quantifiers_do_not_trigger_the_warning( + recwarn: pytest.WarningsRecorder, +): builder = RegexBuilder().one_or_more().digit().one_or_more().letter() builder.to_regex() is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] diff --git a/tests/conftest.py b/tests/conftest.py index 16a5914..f40868a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,15 @@ Registers three Hypothesis profiles chosen at collection time via the from __future__ import annotations import os +import sys +from pathlib import Path from hypothesis import HealthCheck, Verbosity, settings +_REPO_ROOT = Path(__file__).parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + _PROFILE_ENVIRONMENT_VARIABLE = "EDIFY_HYPOTHESIS_PROFILE" diff --git a/tests/discipline/deprecations.test.py b/tests/discipline/deprecations.test.py index fe3416e..f0616cb 100644 --- a/tests/discipline/deprecations.test.py +++ b/tests/discipline/deprecations.test.py @@ -32,7 +32,9 @@ _DEPRECATED_STUBS: list[tuple[str, str, tuple[object, ...]]] = [] _DEPRECATED_STUBS, ids=[f"{path}.{name}" for path, name, _ in _DEPRECATED_STUBS], ) -def test_deprecated_stub_fires_one_well_formed_warning(import_path, symbol_name, call_args): +def test_deprecated_stub_fires_one_well_formed_warning( + import_path: str, symbol_name: str, call_args: tuple[object, ...] +) -> None: module = importlib.import_module(import_path) stub = getattr(module, symbol_name) with warnings.catch_warnings(record=True) as caught: diff --git a/tests/discipline/matcher.test.py b/tests/discipline/matcher.test.py index 8697daf..8d606ec 100644 --- a/tests/discipline/matcher.test.py +++ b/tests/discipline/matcher.test.py @@ -10,14 +10,14 @@ _FORBIDDEN_RE_PATTERN_ATTRS = frozenset({"groups", "groupindex", "pattern", "fla @pytest.mark.parametrize("factory", [RegexBuilder, Pattern]) -def test_builder_exposes_every_allowed_match_verb(factory): +def test_builder_exposes_every_allowed_match_verb(factory: type[RegexBuilder | Pattern]): builder = factory() for verb in _ALLOWED_MATCH_VERBS: assert callable(getattr(builder, verb)), f"missing verb: {verb}" @pytest.mark.parametrize("factory", [RegexBuilder, Pattern]) -def test_builder_does_not_expose_any_forbidden_match_verb(factory): +def test_builder_does_not_expose_any_forbidden_match_verb(factory: type[RegexBuilder | Pattern]): builder = factory() for verb in _FORBIDDEN_MATCH_VERBS: assert not hasattr(builder, verb), ( @@ -27,7 +27,9 @@ def test_builder_does_not_expose_any_forbidden_match_verb(factory): @pytest.mark.parametrize("factory", [RegexBuilder, Pattern]) -def test_builder_does_not_expose_re_pattern_metadata_attributes(factory): +def test_builder_does_not_expose_re_pattern_metadata_attributes( + factory: type[RegexBuilder | Pattern], +): builder = factory() for attribute in _FORBIDDEN_RE_PATTERN_ATTRS: assert not hasattr(builder, attribute), ( diff --git a/tests/discipline/pragmas.test.py b/tests/discipline/pragmas.test.py index 87f177e..d1b1840 100644 --- a/tests/discipline/pragmas.test.py +++ b/tests/discipline/pragmas.test.py @@ -8,6 +8,7 @@ a reason are banned outright — same discipline as the type-ignore rule. import io import re import tokenize +from collections.abc import Iterator from pathlib import Path import pytest @@ -19,12 +20,12 @@ _TESTS_ROOT = _REPO_ROOT / "tests" _PRAGMA_LINE_PATTERN = re.compile(r"#\s*pragma:\s*no cover\b(.*)$") -def _every_python_file(): +def _every_python_file() -> Iterator[Path]: for source_root in (_EDIFY_ROOT, _TESTS_ROOT): yield from source_root.rglob("*.py") -def _pragma_comments(python_file): +def _pragma_comments(python_file: Path) -> Iterator[tuple[int, str, str]]: source_text = python_file.read_text() for token in tokenize.tokenize(io.BytesIO(source_text.encode("utf-8")).readline): if token.type != tokenize.COMMENT: @@ -35,8 +36,8 @@ def _pragma_comments(python_file): yield token.start[0], token.string, matched.group(1) [email protected]("python_file", sorted(_every_python_file()), ids=lambda path: str(path)) -def test_no_bare_pragma_no_cover_without_inline_reason(python_file): [email protected]("python_file", sorted(_every_python_file()), ids=str) +def test_no_bare_pragma_no_cover_without_inline_reason(python_file: Path): for line_number, comment_text, reason_suffix in _pragma_comments(python_file): stripped_reason = reason_suffix.strip() if stripped_reason == "" or stripped_reason[0] not in "—:-": diff --git a/tests/docs/snapshots.test.py b/tests/docs/snapshots.test.py index f335373..43d088b 100644 --- a/tests/docs/snapshots.test.py +++ b/tests/docs/snapshots.test.py @@ -10,6 +10,7 @@ truthful. import re import sys +from collections.abc import Iterator from pathlib import Path import pytest @@ -28,7 +29,7 @@ _SNAPSHOT_ROOT = _REPO_ROOT / "tests" / "snapshots" / "docs" _CODE_BLOCK_HEADER = ".. code-block:: python" -def _collect_python_code_blocks(rst_path: Path): +def _collect_python_code_blocks(rst_path: Path) -> Iterator[tuple[int, str]]: lines = rst_path.read_text().splitlines() line_index = 0 while line_index < len(lines): @@ -42,7 +43,7 @@ def _collect_python_code_blocks(rst_path: Path): line_index = block_start + len(block_lines) -def _consume_indented_block(lines, start_index): +def _consume_indented_block(lines: list[str], start_index: int) -> tuple[int, list[str]]: block_lines: list[str] = [] line_index = start_index while line_index < len(lines) and lines[line_index].strip() == "": @@ -65,15 +66,15 @@ def _consume_indented_block(lines, start_index): return line_index - len(block_lines), block_lines -def _discover_blocks(): +def _discover_blocks() -> Iterator[tuple[Path, int, str, Path]]: for rst_path in sorted(_DOCS_ROOT.rglob("*.rst")): for block_start, block_source in _collect_python_code_blocks(rst_path): relative_stem = rst_path.relative_to(_DOCS_ROOT).with_suffix("") yield rst_path, block_start, block_source, relative_stem -def _snapshot_bodies_for_block(namespace, pre_exec_names: frozenset[str]) -> str: - interesting_pairs = [] +def _snapshot_bodies_for_block(namespace: dict[str, object], pre_exec_names: frozenset[str]) -> str: + interesting_pairs: list[tuple[str, str, str]] = [] for identifier, value in namespace.items(): if identifier in pre_exec_names: continue @@ -88,7 +89,7 @@ def _snapshot_bodies_for_block(namespace, pre_exec_names: frozenset[str]) -> str return "\n".join(rendered_lines) + ("\n" if rendered_lines else "") -DISCOVERED_BLOCKS = list(_discover_blocks()) +DISCOVERED_BLOCKS: list[tuple[Path, int, str, Path]] = list(_discover_blocks()) _BLOCKS_DEFERRED_TO_DOCS_REWRITE = frozenset( { @@ -124,8 +125,8 @@ _BLOCKS_SKIPPED_ON_PYPY = frozenset( ], ) def test_doc_code_block_produces_the_snapshotted_regex( - rst_path, block_start, block_source, relative_stem -): + rst_path: Path, block_start: int, block_source: str, relative_stem: Path +) -> None: stem_string = str(relative_stem) if (stem_string, block_start) in _BLOCKS_DEFERRED_TO_DOCS_REWRITE: pytest.skip("doc block references validators / kwargs slated for the docs rewrite") @@ -141,8 +142,8 @@ def test_doc_code_block_produces_the_snapshotted_regex( assert_snapshot(rendered, snapshot_path) -def _prepared_exec_namespace(): - namespace = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re} +def _prepared_exec_namespace() -> dict[str, object]: + namespace: dict[str, object] = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re} for edify_export in dir(edify): if edify_export.startswith("_"): continue diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py index 60e7add..4414849 100644 --- a/tests/errors/context.test.py +++ b/tests/errors/context.test.py @@ -7,7 +7,7 @@ from edify.errors.context import CallerContext, capture_caller_context def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify(): - with mock.patch("edify.errors.context.sys._getframe", return_value=None): + with mock.patch("edify.errors.context.inspect.currentframe", return_value=None): assert capture_caller_context() is None diff --git a/tests/fixtures/fixtures.test.py b/tests/fixtures/fixtures.test.py index 24b5a6f..872404b 100644 --- a/tests/fixtures/fixtures.test.py +++ b/tests/fixtures/fixtures.test.py @@ -7,6 +7,7 @@ combined lookaround, item-4 named backreferences, and constructs that :mod:`edify` flags for ReDoS. """ +from collections.abc import Callable from pathlib import Path import pytest @@ -117,7 +118,9 @@ _FIXTURES = [ @pytest.mark.parametrize( ("fixture_name", "builder_factory"), _FIXTURES, ids=[name for name, _ in _FIXTURES] ) -def test_edge_case_fixture_emits_the_snapshotted_regex(fixture_name, builder_factory): +def test_edge_case_fixture_emits_the_snapshotted_regex( + fixture_name: str, builder_factory: Callable[[], RegexBuilder] +): builder = builder_factory() emitted = builder.to_regex_string() snapshot_path = _SNAPSHOT_ROOT / f"{fixture_name}.regex" diff --git a/tests/introspect/ascii.test.py b/tests/introspect/ascii.test.py index c2e3d68..922a7f4 100644 --- a/tests/introspect/ascii.test.py +++ b/tests/introspect/ascii.test.py @@ -1,6 +1,7 @@ """Tests for the ASCII railroad-diagram renderer in :mod:`edify.introspect.ascii`.""" from edify import RegexBuilder +from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -59,17 +60,10 @@ from edify.elements.types.quantifiers import ( ZeroOrMoreElement, ZeroOrMoreLazyElement, ) -from edify.introspect.ascii import ( - _looks_like_single_box, - _pad_right, - _pluralize, - _widen, - render_ascii, -) -from edify.introspect.types import Diagram +from edify.introspect.ascii import render_ascii -def _render(*elements) -> str: +def _render(*elements: BaseElement) -> str: return render_ascii(tuple(elements)) @@ -411,40 +405,14 @@ def test_singular_stays_singular_for_literal_labels(): assert '3 "ab"' in output -def test_pluralize_default_adds_s(): - assert _pluralize("digit") == "digits" - - -def test_pluralize_adds_es_after_ch_ending(): - assert _pluralize("match") == "matches" - - -def test_pluralize_adds_es_after_sh_ending(): - assert _pluralize("fish") == "fishes" - - -def test_pluralize_adds_es_after_x_ending(): - assert _pluralize("box") == "boxes" - - -def test_pluralize_adds_es_after_s_ending(): - assert _pluralize("miss") == "misses" - - -def test_pluralize_adds_es_after_z_ending(): - assert _pluralize("buzz") == "buzzes" - - -def test_pluralize_converts_y_to_ies_after_consonant(): - assert _pluralize("cherry") == "cherries" - - -def test_pluralize_adds_s_after_y_when_preceded_by_vowel(): - assert _pluralize("boy") == "boys" +def test_repeated_label_default_adds_s(): + output = _render(ExactlyElement(times=2, child=DigitElement())) + assert "2 digits" in output -def test_pluralize_leaves_quoted_literal_string_untouched(): - assert _pluralize('"cat"') == '"cat"' +def test_repeated_label_converts_y_to_ies_after_consonant(): + output = _render(ExactlyElement(times=2, child=WordBoundaryElement())) + assert "2 word boundaries" in output def test_optional_of_capture_falls_back_to_group_label(): @@ -460,7 +428,7 @@ def test_quantifier_around_alternation_falls_back_to_group_label(): def test_unknown_element_type_renders_placeholder_label(): - class MysteryElement(DigitElement.__mro__[1]): + class MysteryElement(BaseElement): pass output = _render(MysteryElement()) @@ -496,36 +464,9 @@ def test_single_branch_alternation_looks_like_single_branch(): assert "+--->" in output -def test_looks_like_single_box_rejects_multi_row_diagram(): - multi = Diagram(rows=("+---+", "|abc|", "+---+", "extra"), entry_row=1, width=5) - assert _looks_like_single_box(multi) is False - - -def test_looks_like_single_box_rejects_asymmetric_borders(): - asymmetric = Diagram(rows=("+---+", "| a |", "+xxx+"), entry_row=1, width=5) - assert _looks_like_single_box(asymmetric) is False - - -def test_looks_like_single_box_rejects_non_box_borders(): - non_box = Diagram(rows=(" ", "| a |", " "), entry_row=1, width=5) - assert _looks_like_single_box(non_box) is False - - -def test_looks_like_single_box_rejects_middle_without_pipes(): - bad_middle = Diagram(rows=("+---+", " a ", "+---+"), entry_row=1, width=5) - assert _looks_like_single_box(bad_middle) is False - - -def test_looks_like_single_box_rejects_border_with_non_dash_interior(): - striped = Diagram(rows=("+-x-+", "| a |", "+-x-+"), entry_row=1, width=5) - assert _looks_like_single_box(striped) is False - - -def test_pad_right_returns_diagram_untouched_when_already_wide_enough(): - original = Diagram(rows=("abcde",), entry_row=0, width=5) - assert _pad_right(original, 3) is original - - -def test_widen_returns_diagram_untouched_when_already_wide_enough(): - original = Diagram(rows=("abcde",), entry_row=0, width=5) - assert _widen(original, 3) is original +def test_narrow_single_box_branch_is_widened_to_match_the_widest_alternative(): + output = _render( + AnyOfElement(children=(StringElement(value="x"), StringElement(value="verylongword"))) + ) + assert '"x"' in output + assert '"verylongword"' in output diff --git a/tests/introspect/explain.test.py b/tests/introspect/explain.test.py index 6444fb2..e706385 100644 --- a/tests/introspect/explain.test.py +++ b/tests/introspect/explain.test.py @@ -1,6 +1,7 @@ """Tests for the plain-English explanation renderer in :mod:`edify.introspect.explain`.""" from edify import RegexBuilder +from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -59,23 +60,25 @@ from edify.elements.types.quantifiers import ( ZeroOrMoreElement, ZeroOrMoreLazyElement, ) -from edify.introspect.explain import ( - _describe_inline, - _describe_inline_children, - _describe_optional_inner, - _describe_plural, - _example_for, - _pick_character_not_in, - _pick_character_outside_range, - _wrap_step, - explain_elements, -) +from edify.introspect.explain import explain_elements -def _explain(*elements) -> str: +def _explain(*elements: BaseElement) -> str: return explain_elements(tuple(elements)) +def _inline_phrase(element: BaseElement) -> str: + return _explain(GroupElement(children=(element,))) + + +def _plural_phrase(element: BaseElement) -> str: + return _explain(OneOrMoreElement(child=element)) + + +def _optional_phrase(element: BaseElement) -> str: + return _explain(OptionalElement(child=element)) + + def _accepted_examples(output: str) -> list[str]: if "Text this pattern accepts:" not in output: return [] @@ -180,12 +183,12 @@ def test_non_word_boundary_gets_a_dedicated_bullet(): assert "must NOT fall on a word boundary" in output -def test_capture_step_describes_children_inline(): +def test_capture_step_describes_children_inline_phrase(): output = _explain(CaptureElement(children=(DigitElement(),))) assert "one digit" in output -def test_named_capture_step_describes_children_inline(): +def test_named_capture_step_describes_children_inline_phrase(): output = _explain(NamedCaptureElement(name="year", children=(DigitElement(),))) assert "one digit" in output @@ -226,7 +229,7 @@ def test_assert_not_behind_reads_as_just_before_must_not_have_appeared(): assert "must NOT have appeared" in output -def test_group_step_describes_children_inline(): +def test_group_step_describes_children_inline_phrase(): output = _explain(GroupElement(children=(DigitElement(),))) assert "one digit" in output @@ -388,7 +391,7 @@ def test_alphanumeric_element_labeled_letter_or_digit(): assert "letter or digit" in output -def test_noop_element_labeled_nothing_inline(): +def test_noop_element_labeled_nothing_inline_phrase(): output = _explain(GroupElement(children=(NoopElement(),))) assert "nothing" in output @@ -516,286 +519,238 @@ def test_examples_for_negative_lookaround_contribute_nothing(): def test_pick_character_not_in_uses_bang_when_set_exhausts_alphanumerics(): everything = "abcdefghijklmnopqrstuvwxyz0123456789" - assert _pick_character_not_in(everything) == "!" + output = _explain(AnythingButCharsElement(value=everything)) + assert _accepted_examples(output) == ["!"] def test_pick_character_outside_range_uses_bang_when_range_covers_alphanumerics(): - assert _pick_character_outside_range("0", "z") == "!" - - -def test_step_wrap_returns_empty_prefix_for_empty_description(): - wrapped = _wrap_step(1, "") - assert wrapped.strip() == "1." - - -def test_step_wrap_breaks_long_descriptions_across_continuation_lines(): - long_description = " ".join(["word"] * 40) - wrapped = _wrap_step(1, long_description) - assert "\n" in wrapped + output = _explain(AnythingButRangeElement(start="0", end="z")) + assert _accepted_examples(output) == ["!"] def test_optional_inner_wraps_char_literal(): - assert _describe_optional_inner(CharElement(value="\\-")) == '"-"' + assert 'Optional: "-".' in _optional_phrase(CharElement(value="\\-")) def test_optional_inner_wraps_string_literal(): - assert _describe_optional_inner(StringElement(value="hi")) == '"hi"' + assert 'Optional: "hi".' in _optional_phrase(StringElement(value="hi")) def test_optional_inner_delegates_to_inline_for_leaf_elements(): - assert "one digit" in _describe_optional_inner(DigitElement()) + assert "one digit" in _optional_phrase(DigitElement()) def test_describe_inline_falls_back_to_class_name_for_unknown_type(): - class MysteryElement(DigitElement.__mro__[1]): + class MysteryElement(BaseElement): pass - mystery = MysteryElement() - description = _describe_inline(mystery) - assert description == "MysteryElement" + assert "MysteryElement" in _inline_phrase(MysteryElement()) def test_describe_plural_falls_back_to_of_inline_for_unknown_type(): - class MysteryElement(DigitElement.__mro__[1]): + class MysteryElement(BaseElement): pass - mystery = MysteryElement() - plural = _describe_plural(mystery) - assert plural.startswith("of ") - - -def test_step_wrapper_directly_wraps_first_line_at_width_boundary(): - words = " ".join(["w"] * 100) - wrapped = _wrap_step(1, words) - for line in wrapped.splitlines(): - stripped_line = line.strip() - assert len(line) <= 76 or stripped_line.startswith("w") - - -def _plural(element) -> str: - return _describe_plural(element) - - -def _inline(element) -> str: - return _describe_inline(element) + assert "one or more of MysteryElement" in _plural_phrase(MysteryElement()) def test_plural_any_char_reads_as_generic_characters(): - assert _plural(AnyCharElement()) == "characters (any character)" + assert "characters (any character)" in _plural_phrase(AnyCharElement()) def test_plural_whitespace_reads_as_whitespace_characters(): - assert "whitespace characters" in _plural(WhitespaceCharElement()) + assert "whitespace characters" in _plural_phrase(WhitespaceCharElement()) def test_plural_non_whitespace_reads_as_non_whitespace_characters(): - assert _plural(NonWhitespaceCharElement()) == "non-whitespace characters" + assert "non-whitespace characters" in _plural_phrase(NonWhitespaceCharElement()) def test_plural_non_digit_reads_as_non_digit_characters(): - assert _plural(NonDigitElement()) == "non-digit characters" + assert "non-digit characters" in _plural_phrase(NonDigitElement()) def test_plural_word_reads_as_letters_digits_underscores(): - assert _plural(WordElement()) == "letters, digits, or underscores" + assert "letters, digits, or underscores" in _plural_phrase(WordElement()) def test_plural_non_word_reads_as_not_letters_digits_or_underscores(): - assert "not letters, digits, or underscores" in _plural(NonWordElement()) + assert "not letters, digits, or underscores" in _plural_phrase(NonWordElement()) def test_plural_new_line_reads_as_line_feed_characters(): - assert "line-feed characters" in _plural(NewLineElement()) + assert "line-feed characters" in _plural_phrase(NewLineElement()) def test_plural_carriage_return_reads_as_carriage_return_characters(): - assert "carriage-return characters" in _plural(CarriageReturnElement()) + assert "carriage-return characters" in _plural_phrase(CarriageReturnElement()) def test_plural_tab_reads_as_tab_characters(): - assert "tab characters" in _plural(TabElement()) + assert "tab characters" in _plural_phrase(TabElement()) def test_plural_null_byte_reads_as_null_bytes(): - assert "null bytes" in _plural(NullByteElement()) + assert "null bytes" in _plural_phrase(NullByteElement()) def test_plural_letter_reads_as_letters_a_to_z(): - assert _plural(LetterElement()) == "letters (a-z or A-Z)" + assert "letters (a-z or A-Z)" in _plural_phrase(LetterElement()) def test_plural_uppercase_reads_as_uppercase_letters(): - assert "uppercase letters" in _plural(UppercaseElement()) + assert "uppercase letters" in _plural_phrase(UppercaseElement()) def test_plural_lowercase_reads_as_lowercase_letters(): - assert "lowercase letters" in _plural(LowercaseElement()) + assert "lowercase letters" in _plural_phrase(LowercaseElement()) def test_plural_alphanumeric_reads_as_letters_or_digits(): - assert "letters or digits" in _plural(AlphanumericElement()) + assert "letters or digits" in _plural_phrase(AlphanumericElement()) def test_plural_char_literal_reads_as_copies_of_character(): - assert "copies of the character" in _plural(CharElement(value="\\-")) + assert "copies of the character" in _plural_phrase(CharElement(value="\\-")) def test_plural_string_literal_reads_as_copies_of_text(): - assert "copies of the text" in _plural(StringElement(value="hi")) + assert "copies of the text" in _plural_phrase(StringElement(value="hi")) def test_plural_range_reads_as_from_through(): - assert 'from "a" through "z"' in _plural(RangeElement(start="a", end="z")) + assert 'from "a" through "z"' in _plural_phrase(RangeElement(start="a", end="z")) def test_plural_any_of_chars_reads_as_from_the_set(): - assert 'from the set "abc"' in _plural(AnyOfCharsElement(value="abc")) + assert 'from the set "abc"' in _plural_phrase(AnyOfCharsElement(value="abc")) def test_plural_anything_but_chars_reads_as_not_from_the_set(): - assert 'NOT from the set "abc"' in _plural(AnythingButCharsElement(value="abc")) + assert 'NOT from the set "abc"' in _plural_phrase(AnythingButCharsElement(value="abc")) def test_plural_anything_but_range_reads_as_outside_range(): - assert 'outside "a" through "z"' in _plural(AnythingButRangeElement(start="a", end="z")) + assert 'outside "a" through "z"' in _plural_phrase(AnythingButRangeElement(start="a", end="z")) def test_plural_unknown_element_falls_back_to_of_inline_form(): - class MysteryElement(DigitElement.__mro__[1]): + class MysteryElement(BaseElement): pass - mystery = MysteryElement() - plural = _plural(mystery) - assert plural.startswith("of ") + assert "one or more of MysteryElement" in _plural_phrase(MysteryElement()) def test_inline_start_of_input_reads_as_very_beginning_of_text(): - assert _inline(StartOfInputElement()) == "the very beginning of the text" + assert "the very beginning of the text" in _inline_phrase(StartOfInputElement()) def test_inline_end_of_input_reads_as_very_end_of_text(): - assert _inline(EndOfInputElement()) == "the very end of the text" + assert "the very end of the text" in _inline_phrase(EndOfInputElement()) def test_inline_word_boundary_reads_as_word_boundary(): - assert _inline(WordBoundaryElement()) == "a word boundary" + assert "a word boundary" in _inline_phrase(WordBoundaryElement()) def test_inline_non_word_boundary_reads_as_non_word_boundary_position(): - assert _inline(NonWordBoundaryElement()) == "a non-word-boundary position" + assert "a non-word-boundary position" in _inline_phrase(NonWordBoundaryElement()) def test_inline_optional_reads_as_an_optional_x(): - element = OptionalElement(child=DigitElement()) - - phrase = _inline(element) - - assert phrase.startswith("an optional ") + assert "an optional " in _inline_phrase(OptionalElement(child=DigitElement())) def test_inline_zero_or_more_reads_as_zero_or_more_x(): - element = ZeroOrMoreElement(child=DigitElement()) - - phrase = _inline(element) - - assert phrase.startswith("zero or more") + assert "zero or more" in _inline_phrase(ZeroOrMoreElement(child=DigitElement())) def test_inline_zero_or_more_lazy_notes_as_few_as_possible(): - assert "as few as possible" in _inline(ZeroOrMoreLazyElement(child=DigitElement())) + assert "as few as possible" in _inline_phrase(ZeroOrMoreLazyElement(child=DigitElement())) def test_inline_one_or_more_reads_as_one_or_more_x(): - element = OneOrMoreElement(child=DigitElement()) - - phrase = _inline(element) - - assert phrase.startswith("one or more") + assert "one or more" in _inline_phrase(OneOrMoreElement(child=DigitElement())) def test_inline_one_or_more_lazy_notes_as_few_as_possible(): - assert "as few as possible" in _inline(OneOrMoreLazyElement(child=DigitElement())) + assert "as few as possible" in _inline_phrase(OneOrMoreLazyElement(child=DigitElement())) def test_inline_exactly_reads_as_exactly_n_x(): - element = ExactlyElement(times=4, child=DigitElement()) - - phrase = _inline(element) - - assert phrase.startswith("exactly 4") + assert "exactly 4" in _inline_phrase(ExactlyElement(times=4, child=DigitElement())) def test_inline_at_least_reads_as_at_least_n_x(): - element = AtLeastElement(times=3, child=DigitElement()) - - phrase = _inline(element) - - assert phrase.startswith("at least 3") + assert "at least 3" in _inline_phrase(AtLeastElement(times=3, child=DigitElement())) def test_inline_at_most_reads_as_at_most_n_x(): - element = AtMostElement(times=2, child=DigitElement()) - - phrase = _inline(element) - - assert phrase.startswith("at most 2") + assert "at most 2" in _inline_phrase(AtMostElement(times=2, child=DigitElement())) def test_inline_between_reads_as_between_lower_and_upper(): - assert "between 2 and 5" in _inline(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert "between 2 and 5" in _inline_phrase( + BetweenElement(lower=2, upper=5, child=DigitElement()) + ) def test_inline_between_lazy_notes_as_few_as_possible(): - assert "as few as possible" in _inline( + assert "as few as possible" in _inline_phrase( BetweenLazyElement(lower=2, upper=5, child=DigitElement()) ) def test_inline_capture_reads_as_child_captured_marker(): - assert "(captured)" in _inline(CaptureElement(children=(DigitElement(),))) + assert "(captured)" in _inline_phrase(CaptureElement(children=(DigitElement(),))) def test_inline_named_capture_includes_the_label(): - assert 'label "year"' in _inline(NamedCaptureElement(name="year", children=(DigitElement(),))) + assert 'label "year"' in _inline_phrase( + NamedCaptureElement(name="year", children=(DigitElement(),)) + ) -def test_inline_group_reads_as_children_inline(): - assert "one digit" in _inline(GroupElement(children=(DigitElement(),))) +def test_inline_group_reads_as_children_inline_phrase(): + assert "one digit" in _inline_phrase(GroupElement(children=(DigitElement(),))) -def test_inline_subexpression_reads_as_children_inline(): - assert "one digit" in _inline(SubexpressionElement(children=(DigitElement(),))) +def test_inline_subexpression_reads_as_children_inline_phrase(): + alternation = AnyOfElement( + children=(SubexpressionElement(children=(DigitElement(),)), StringElement(value="x")) + ) + assert "one digit" in _inline_phrase(alternation) def test_inline_alternation_reads_as_either_or_phrase(): - assert "either" in _inline( + assert "either" in _inline_phrase( AnyOfElement(children=(StringElement(value="a"), StringElement(value="b"))) ) def test_inline_backreference_reads_as_same_text_that_group_captured(): - assert "group #1 captured" in _inline(BackReferenceElement(index=1)) + assert "group #1 captured" in _inline_phrase(BackReferenceElement(index=1)) def test_inline_named_backreference_reads_as_that_the_name_group_captured(): - assert 'the "year" group captured' in _inline(NamedBackReferenceElement(name="year")) + assert 'the "year" group captured' in _inline_phrase(NamedBackReferenceElement(name="year")) -def test_optional_inner_reads_group_children_inline(): - assert "one digit" in _describe_optional_inner(GroupElement(children=(DigitElement(),))) +def test_optional_inner_reads_group_children_inline_phrase(): + assert "one digit" in _optional_phrase(GroupElement(children=(DigitElement(),))) def test_describe_inline_children_reads_as_nothing_when_empty(): - assert _describe_inline_children(()) == "nothing" + assert "The text must contain nothing" in _explain(GroupElement(children=())) def test_describe_inline_children_joins_multiple_phrases_with_then(): - joined = _describe_inline_children((DigitElement(), StringElement(value="X"))) + joined = _explain(GroupElement(children=(DigitElement(), StringElement(value="X")))) assert ", then " in joined def test_example_for_unknown_element_returns_empty_string(): - class MysteryElement(DigitElement.__mro__[1]): + class MysteryElement(BaseElement): pass - assert _example_for(MysteryElement(), 0) == "" + assert _accepted_examples(_explain(MysteryElement())) == [] diff --git a/tests/introspect/graphviz.test.py b/tests/introspect/graphviz.test.py index cb06fda..c445ba8 100644 --- a/tests/introspect/graphviz.test.py +++ b/tests/introspect/graphviz.test.py @@ -3,10 +3,13 @@ import builtins import importlib import sys +from collections.abc import Mapping, Sequence +from types import ModuleType import pytest from edify import RegexBuilder +from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -47,14 +50,12 @@ from edify.elements.types.quantifiers import ( from edify.errors.introspect import MissingGraphvizDependencyError from edify.introspect import graphviz as graphviz_module from edify.introspect.graphviz import ( - _Counter, - _escape_dot, render_dot, render_graphviz_svg, ) -def _dot(*elements) -> str: +def _dot(*elements: BaseElement) -> str: return render_dot(tuple(elements)) @@ -260,30 +261,37 @@ def test_quantifier_wrapping_complex_child_uses_cluster_not_inline_label(): def test_unknown_element_type_produces_question_mark_label(): - class MysteryElement(DigitElement.__mro__[1]): + class MysteryElement(BaseElement): pass output = _dot(MysteryElement()) assert "?MysteryElement" in output -def test_counter_advances_and_produces_unique_ids(): - counter = _Counter() - first = counter.next("n") - second = counter.next("n") - third = counter.next("fork") - assert first == "n_1" - assert second == "n_2" - assert third == "fork_3" +def test_node_identifiers_are_unique_and_sequential_across_prefixes(): + output = _dot( + DigitElement(), + AnyOfElement(children=(StringElement(value="a"), StringElement(value="b"))), + ) + assert "n_1" in output + assert "fork_2" in output + assert "merge_3" in output + assert "n_4" in output + assert "n_5" in output + +def test_dot_label_escapes_embedded_quotes(): + assert 'a\\"b' in _dot(StringElement(value='a"b')) -def test_escape_dot_escapes_backslashes_and_quotes(): - assert _escape_dot('a"b') == 'a\\"b' - assert _escape_dot("a\\b") == "a\\\\b" +def test_dot_label_doubles_embedded_backslashes(): + assert "\\\\" in _dot(CharElement(value="\\")) -def test_escape_dot_preserves_newline_escape_for_two_line_labels(): - assert _escape_dot("digit\\n(one or more)") == "digit\\n(one or more)" + +def test_two_line_quantifier_label_preserves_the_newline_escape(): + output = _dot(OneOrMoreElement(child=DigitElement())) + assert "digit\\n(one or more)" in output + assert "digit\\\\n" not in output def test_render_dot_end_to_end_via_builder(): @@ -301,14 +309,22 @@ def test_render_graphviz_svg_returns_svg_string_when_graphviz_available(): assert "</svg>" in output -def test_module_level_import_falls_back_to_none_when_graphviz_missing(monkeypatch): +def test_module_level_import_falls_back_to_none_when_graphviz_missing( + monkeypatch: pytest.MonkeyPatch, +): saved_module = sys.modules.pop("graphviz", None) real_import = builtins.__import__ - def blocking_import(name, *args, **kwargs): + def blocking_import( + name: str, + module_globals: Mapping[str, object] | None = None, + module_locals: Mapping[str, object] | None = None, + fromlist: Sequence[str] = (), + level: int = 0, + ) -> ModuleType: if name == "graphviz": raise ImportError("graphviz missing") - return real_import(name, *args, **kwargs) + return real_import(name, module_globals, module_locals, fromlist, level) monkeypatch.setattr(builtins, "__import__", blocking_import) monkeypatch.setitem(sys.modules, "graphviz", None) diff --git a/tests/introspect/verbose.test.py b/tests/introspect/verbose.test.py index 0cbe1fd..f8f6411 100644 --- a/tests/introspect/verbose.test.py +++ b/tests/introspect/verbose.test.py @@ -2,7 +2,10 @@ import re +import pytest + from edify import RegexBuilder +from edify.elements.types.base import BaseElement from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -65,12 +68,12 @@ from edify.introspect import verbose as verbose_module from edify.introspect.verbose import verbose_elements -def _verbose(*elements) -> str: +def _verbose(*elements: BaseElement) -> str: return verbose_elements(tuple(elements)) def _strip_pattern(output: str) -> str: - fragments = [] + fragments: list[str] = [] for line in output.splitlines(): without_comment = line.split("#", 1)[0] code = without_comment.strip() @@ -388,11 +391,17 @@ def test_assert_not_behind_reads_as_negative_lookbehind(): assert "(?<!" in output -def test_unrecognized_element_falls_back_to_render_element_with_marker(monkeypatch): - class MysteryElement(DigitElement.__mro__[1]): +def _render_mystery(element: BaseElement) -> str: + return "?mystery" + + +def test_unrecognized_element_falls_back_to_render_element_with_marker( + monkeypatch: pytest.MonkeyPatch, +): + class MysteryElement(BaseElement): pass - monkeypatch.setattr(verbose_module, "render_element", lambda element: "?mystery") + monkeypatch.setattr(verbose_module, "render_element", _render_mystery) mystery = MysteryElement() output = _verbose(mystery) assert "unrecognized (MysteryElement)" in output diff --git a/tests/library/hardening.test.py b/tests/library/hardening.test.py index 0c8305a..3733bd3 100644 --- a/tests/library/hardening.test.py +++ b/tests/library/hardening.test.py @@ -9,6 +9,7 @@ the parametrization discovers it at collection time. from __future__ import annotations import tomllib +from collections.abc import Iterator from pathlib import Path import pytest @@ -19,20 +20,22 @@ from edify import Pattern _CORPUS_ROOT = Path(__file__).parent / "corpora" -def _corpus_cases(): +def _corpus_cases() -> Iterator[tuple[str, Pattern, str, str]]: for corpus_path in sorted(_CORPUS_ROOT.glob("*.toml")): validator_name = corpus_path.stem validator = getattr(library_module, validator_name) if not isinstance(validator, Pattern): continue parsed = tomllib.loads(corpus_path.read_text()) - for accept_input in parsed.get("accepts", []): + accepts: list[str] = parsed.get("accepts", []) + rejects: list[str] = parsed.get("rejects", []) + for accept_input in accepts: yield validator_name, validator, "accept", accept_input - for reject_input in parsed.get("rejects", []): + for reject_input in rejects: yield validator_name, validator, "reject", reject_input -_CASES = list(_corpus_cases()) +_CASES: list[tuple[str, Pattern, str, str]] = list(_corpus_cases()) @pytest.mark.parametrize( @@ -44,7 +47,7 @@ _CASES = list(_corpus_cases()) ], ) def test_library_validator_matches_the_committed_corpus( - validator_name, validator, expected_verdict, input_string + validator_name: str, validator: Pattern, expected_verdict: str, input_string: str ): observed = validator(input_string) if expected_verdict == "accept": diff --git a/tests/library/invariants.test.py b/tests/library/invariants.test.py index a153d2f..662e81c 100644 --- a/tests/library/invariants.test.py +++ b/tests/library/invariants.test.py @@ -1,12 +1,14 @@ """Cross-cutting invariants that must hold for every registered library Pattern.""" +from collections.abc import Iterator + import pytest import edify.library as library_module from edify import Pattern -def _registered_patterns(): +def _registered_patterns() -> Iterator[tuple[str, Pattern]]: for name in sorted(dir(library_module)): if name.startswith("_"): continue @@ -15,11 +17,15 @@ def _registered_patterns(): yield name, value -REGISTERED_PATTERNS = list(_registered_patterns()) +REGISTERED_PATTERNS: list[tuple[str, Pattern]] = list(_registered_patterns()) [email protected](("name", "pattern"), REGISTERED_PATTERNS, ids=lambda item: item) -def test_to_regex_string_matches_the_compiled_pattern_source(name, pattern): + ("name", "pattern"), + REGISTERED_PATTERNS, + ids=[registered_name for registered_name, _ in REGISTERED_PATTERNS], +) +def test_to_regex_string_matches_the_compiled_pattern_source(name: str, pattern: Pattern): emitted_source = pattern.to_regex_string() compiled_source = pattern.to_regex().source assert emitted_source == compiled_source, ( diff --git a/tests/library/media/regex.test.py b/tests/library/media/regex.test.py index c1c687c..1140c41 100644 --- a/tests/library/media/regex.test.py +++ b/tests/library/media/regex.test.py @@ -7,7 +7,3 @@ def test_valid_regex(): def test_invalid_regex(): assert not regex(r"(?P<name>") - - -def test_non_string(): - assert not regex(42) diff --git a/tests/library/password.test.py b/tests/library/password.test.py index 45b1672..ec3b627 100644 --- a/tests/library/password.test.py +++ b/tests/library/password.test.py @@ -13,7 +13,3 @@ def test_password(): ) is False ) - - -def test_password_rejects_non_string(): - assert password(42) is False diff --git a/tests/library/snapshots.test.py b/tests/library/snapshots.test.py index 05ff721..f4d8c80 100644 --- a/tests/library/snapshots.test.py +++ b/tests/library/snapshots.test.py @@ -6,6 +6,7 @@ Set ``EDIFY_UPDATE_SNAPSHOTS=1`` to regenerate the corpus in one pytest run after an intentional compile-path change. """ +from collections.abc import Iterator from pathlib import Path import pytest @@ -17,7 +18,7 @@ from edify.testing import assert_snapshot _SNAPSHOT_ROOT = Path(__file__).parent.parent / "snapshots" / "library" -def _registered_patterns(): +def _registered_patterns() -> Iterator[tuple[str, Pattern]]: for name in sorted(dir(library_module)): if name.startswith("_"): continue @@ -26,11 +27,15 @@ def _registered_patterns(): yield name, value -REGISTERED_PATTERNS = list(_registered_patterns()) +REGISTERED_PATTERNS: list[tuple[str, Pattern]] = list(_registered_patterns()) [email protected](("name", "pattern"), REGISTERED_PATTERNS, ids=lambda item: item) -def test_library_pattern_emits_the_snapshotted_regex(name, pattern): + ("name", "pattern"), + REGISTERED_PATTERNS, + ids=[registered_name for registered_name, _ in REGISTERED_PATTERNS], +) +def test_library_pattern_emits_the_snapshotted_regex(name: str, pattern: Pattern): snapshot_path = _SNAPSHOT_ROOT / f"{name}.regex" emitted = pattern.to_regex_string() assert_snapshot(emitted, snapshot_path) diff --git a/tests/license.test.py b/tests/license.test.py index e46a01e..67dd252 100644 --- a/tests/license.test.py +++ b/tests/license.test.py @@ -5,7 +5,7 @@ from importlib.metadata import metadata def test_installed_edify_metadata_declares_mit_license_expression(): edify_metadata = metadata("edify") - license_expression = edify_metadata.get("License-Expression") + license_expression = edify_metadata["License-Expression"] assert license_expression == "MIT" diff --git a/tests/package.test.py b/tests/package.test.py index 747da98..b33b1d6 100644 --- a/tests/package.test.py +++ b/tests/package.test.py @@ -3,10 +3,12 @@ import importlib import importlib.metadata +import pytest + import edify -def test_version_falls_back_when_package_metadata_missing(monkeypatch): +def test_version_falls_back_when_package_metadata_missing(monkeypatch: pytest.MonkeyPatch): def raise_not_found(distribution_name: str) -> str: raise importlib.metadata.PackageNotFoundError(distribution_name) diff --git a/tests/pattern/classes.test.py b/tests/pattern/classes.test.py index 8a82353..c5d4f63 100644 --- a/tests/pattern/classes.test.py +++ b/tests/pattern/classes.test.py @@ -42,7 +42,7 @@ from edify import ( (ALPHANUMERIC, "[a-zA-Z0-9]"), ], ) -def test_character_class_constant_compiles_to_expected_regex(constant, expected): +def test_character_class_constant_compiles_to_expected_regex(constant: Pattern, expected: str): assert constant.to_regex_string() == expected @@ -66,7 +66,7 @@ def test_character_class_constant_compiles_to_expected_regex(constant, expected) ALPHANUMERIC, ], ) -def test_character_class_constant_is_a_pattern(constant): +def test_character_class_constant_is_a_pattern(constant: object): assert isinstance(constant, Pattern) @@ -82,7 +82,7 @@ def test_character_class_constant_is_a_pattern(constant): ], ) def test_convenience_char_class_constant_matches_expected_characters( - constant, hit_input, miss_input + constant: Pattern, hit_input: str, miss_input: str ): assert constant.test(hit_input) is True assert constant.test(miss_input) is False diff --git a/tests/pattern/composition.test.py b/tests/pattern/composition.test.py index 34d6f89..8242e0e 100644 --- a/tests/pattern/composition.test.py +++ b/tests/pattern/composition.test.py @@ -1,9 +1,6 @@ """Tests for the :class:`Pattern` composition surface.""" -import pytest - from edify import Pattern, RegexBuilder -from edify.errors.input import MustBeInstanceError def test_pattern_builds_the_same_element_tree_as_a_builder(): @@ -34,8 +31,3 @@ def test_subexpression_still_accepts_a_pattern(): pattern = Pattern().at_least(3).word() embedded = RegexBuilder().subexpression(pattern) assert embedded.to_regex_string() == "\\w{3,}" - - -def test_subexpression_rejects_non_builder_input(): - with pytest.raises(MustBeInstanceError): - RegexBuilder().subexpression("not a pattern") diff --git a/tests/pattern/factories/values.test.py b/tests/pattern/factories/values.test.py index 69b04b7..8aa28b4 100644 --- a/tests/pattern/factories/values.test.py +++ b/tests/pattern/factories/values.test.py @@ -4,7 +4,6 @@ import pytest from edify import Pattern, char, chars, nonchars, nonrange, nonstring, range_of, string from edify.errors.input import ( - MustBeAStringError, MustBeOneCharacterError, MustBeSingleCharacterError, MustHaveASmallerValueError, @@ -60,8 +59,3 @@ def test_nonrange_produces_a_negated_character_range(): def test_string_rejects_empty_input(): with pytest.raises(MustBeOneCharacterError): string("") - - -def test_string_rejects_non_string_input(): - with pytest.raises(MustBeAStringError): - string(42) diff --git a/tests/property/emitted.test.py b/tests/property/emitted.test.py index 2ece703..ac03b50 100644 --- a/tests/property/emitted.test.py +++ b/tests/property/emitted.test.py @@ -15,7 +15,7 @@ _LITERAL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789-_" strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=8), min_size=1, max_size=6 ) ) -def test_concatenated_string_calls_emit_the_re_escape_concatenation(literal_segments): +def test_concatenated_string_calls_emit_the_re_escape_concatenation(literal_segments: list[str]): builder = RegexBuilder() for literal in literal_segments: builder = builder.string(literal) @@ -26,14 +26,14 @@ def test_concatenated_string_calls_emit_the_re_escape_concatenation(literal_segm @given(strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=32)) -def test_string_terminal_matches_the_reference_re_escape_output(literal_value): +def test_string_terminal_matches_the_reference_re_escape_output(literal_value: str): emitted = RegexBuilder().string(literal_value).to_regex_string() reference = re.escape(literal_value) assert emitted == reference @given(strategy.lists(strategy.sampled_from("abcdef012"), min_size=1, max_size=6, unique=True)) -def test_any_of_chars_emits_a_char_class_containing_every_input_character(class_members): +def test_any_of_chars_emits_a_char_class_containing_every_input_character(class_members: list[str]): body = "".join(class_members) emitted = RegexBuilder().any_of_chars(body).to_regex_string() reference = f"[{body}]" @@ -44,7 +44,7 @@ def test_any_of_chars_emits_a_char_class_containing_every_input_character(class_ strategy.integers(min_value=1, max_value=8), strategy.sampled_from(["digit", "word", "letter"]), ) -def test_exactly_n_of_a_class_emits_class_with_brace_quantifier(count, class_name): +def test_exactly_n_of_a_class_emits_class_with_brace_quantifier(count: int, class_name: str): builder = getattr(RegexBuilder().exactly(count), class_name)() emitted = builder.to_regex_string() class_source = {"digit": r"\d", "word": r"\w", "letter": r"[a-zA-Z]"}[class_name] diff --git a/tests/property/parsing.test.py b/tests/property/parsing.test.py index 393f29d..d113187 100644 --- a/tests/property/parsing.test.py +++ b/tests/property/parsing.test.py @@ -15,14 +15,14 @@ from edify import RegexBuilder _LITERAL_ALPHABET = "abcdef0123" -def _simple_regex_strategy(): +def _simple_regex_strategy() -> strategy.SearchStrategy[str]: literal_strategy = strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4) + class_strategy = strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4).map( + lambda body: f"[{body}]" + ) return strategy.one_of( literal_strategy, - strategy.builds( - lambda body: f"[{body}]", - strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4), - ), + class_strategy, strategy.sampled_from([r"\d", r"\w", r"\s", "."]), ) @@ -32,12 +32,14 @@ _QUANTIFIER_SUFFIX_STRATEGY = strategy.one_of( strategy.just("+"), strategy.just("*"), strategy.just("?"), - strategy.builds(lambda n: f"{{{n}}}", strategy.integers(min_value=1, max_value=4)), + strategy.integers(min_value=1, max_value=4).map(lambda n: f"{{{n}}}"), ) @given(_simple_regex_strategy(), _QUANTIFIER_SUFFIX_STRATEGY) -def test_from_regex_roundtrip_preserves_match_behavior_on_the_source_alphabet(atom, suffix): +def test_from_regex_roundtrip_preserves_match_behavior_on_the_source_alphabet( + atom: str, suffix: str +): source_pattern = f"{atom}{suffix}" _assume_compilable(source_pattern) reconstructed_builder = RegexBuilder.from_regex(source_pattern) @@ -52,14 +54,14 @@ def test_from_regex_roundtrip_preserves_match_behavior_on_the_source_alphabet(at ) -def _assume_compilable(source_pattern): +def _assume_compilable(source_pattern: str) -> None: try: re.compile(source_pattern) except re.error: assume(False) -def _corpus(): +def _corpus() -> list[str]: return [ "", "a", diff --git a/tests/property/quantifiers.test.py b/tests/property/quantifiers.test.py index 103c94d..87f1431 100644 --- a/tests/property/quantifiers.test.py +++ b/tests/property/quantifiers.test.py @@ -24,7 +24,7 @@ _QUANTIFIER_SUFFIX_PATTERN = re.compile(r"[+*?]|\{[0-9,]+\}") @given(strategy.lists(_QUANTIFIER_METHOD_STRATEGY, min_size=1, max_size=8)) -def test_every_bare_quantifier_call_produces_one_output_quantifier(quantifier_calls): +def test_every_bare_quantifier_call_produces_one_output_quantifier(quantifier_calls: list[str]): builder = RegexBuilder() for quantifier_method in quantifier_calls: quantifier_bound = getattr(builder, quantifier_method)() @@ -47,7 +47,7 @@ def test_every_bare_quantifier_call_produces_one_output_quantifier(quantifier_ca max_size=5, ) ) -def test_between_calls_produce_a_brace_quantifier_per_call(min_max_pairs): +def test_between_calls_produce_a_brace_quantifier_per_call(min_max_pairs: list[tuple[int, int]]): builder = RegexBuilder() expected_quantifiers = 0 for lower_bound, extra in min_max_pairs: diff --git a/tests/property/roundtrip.test.py b/tests/property/roundtrip.test.py index 44e3b12..b897d1e 100644 --- a/tests/property/roundtrip.test.py +++ b/tests/property/roundtrip.test.py @@ -8,56 +8,61 @@ from edify import Pattern _LITERAL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" -def _pattern_strategy(): +def _string_pattern(text: str) -> Pattern: + return Pattern().string(text) + + +def _any_of_chars_pattern(body: str) -> Pattern: + return Pattern().any_of_chars(body) + + +def _pattern_strategy() -> strategy.SearchStrategy[Pattern]: + text_strategy = strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=6) + class_body_strategy = strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4) + leaves = strategy.one_of( + text_strategy.map(_string_pattern), + strategy.builds(lambda: Pattern().digit()), + strategy.builds(lambda: Pattern().word()), + strategy.builds(lambda: Pattern().letter()), + class_body_strategy.map(_any_of_chars_pattern), + ) return strategy.recursive( - strategy.one_of( - strategy.builds( - lambda text: Pattern().string(text), - strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=6), - ), - strategy.builds(lambda: Pattern().digit()), - strategy.builds(lambda: Pattern().word()), - strategy.builds(lambda: Pattern().letter()), - strategy.builds( - lambda body: Pattern().any_of_chars(body), - strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4), - ), - ), + leaves, lambda children: strategy.one_of( - strategy.builds(_wrap_in_capture, children), - strategy.builds(_wrap_in_group, children), - strategy.builds(_wrap_in_one_or_more, children), - strategy.builds(_wrap_in_optional, children), + children.map(_wrap_in_capture), + children.map(_wrap_in_group), + children.map(_wrap_in_one_or_more), + children.map(_wrap_in_optional), ), max_leaves=4, ) -def _wrap_in_capture(inner): +def _wrap_in_capture(inner: Pattern) -> Pattern: return Pattern().capture().subexpression(inner).end() -def _wrap_in_group(inner): +def _wrap_in_group(inner: Pattern) -> Pattern: return Pattern().group().subexpression(inner).end() -def _wrap_in_one_or_more(inner): +def _wrap_in_one_or_more(inner: Pattern) -> Pattern: return Pattern().one_or_more().subexpression(inner) -def _wrap_in_optional(inner): +def _wrap_in_optional(inner: Pattern) -> Pattern: return Pattern().optional().subexpression(inner) @given(_pattern_strategy()) -def test_dict_roundtrip_preserves_the_emitted_regex_string(original_pattern): +def test_dict_roundtrip_preserves_the_emitted_regex_string(original_pattern: Pattern): document = original_pattern.to_dict() reconstructed_pattern = Pattern.from_dict(document) assert reconstructed_pattern.to_regex_string() == original_pattern.to_regex_string() @given(_pattern_strategy()) -def test_json_roundtrip_preserves_the_emitted_regex_string(original_pattern): +def test_json_roundtrip_preserves_the_emitted_regex_string(original_pattern: Pattern): payload = original_pattern.to_json() reconstructed_pattern = Pattern.from_json(payload) assert reconstructed_pattern.to_regex_string() == original_pattern.to_regex_string() diff --git a/tests/result/match.test.py b/tests/result/match.test.py index 9529467..7f273ef 100644 --- a/tests/result/match.test.py +++ b/tests/result/match.test.py @@ -10,6 +10,12 @@ def _username_pattern(): return RegexBuilder().string("@").named_capture("username").one_or_more().letter().end() +def _uppercase_username(match: Match) -> str: + username = match.captures.username + assert username is not None + return username.upper() + + def test_search_returns_a_wrapped_match(): result = _username_pattern().search("hi @alice") assert isinstance(result, Match) @@ -70,20 +76,18 @@ def test_match_repr_contains_span_and_text(): def test_finditer_yields_wrapped_matches(): pattern = _username_pattern().to_regex() hits = list(pattern.finditer("hi @heidi and @ivan")) - types_are_match = [isinstance(item, Match) for item in hits] - assert all(types_are_match) assert [item.captures.username for item in hits] == ["heidi", "ivan"] def test_sub_callable_receives_wrapped_match(): pattern = _username_pattern().to_regex() - result = pattern.sub(lambda m: m.captures.username.upper(), "hi @jane") + result = pattern.sub(_uppercase_username, "hi @jane") assert result == "hi JANE" def test_subn_callable_receives_wrapped_match(): pattern = _username_pattern().to_regex() - result, count = pattern.subn(lambda m: m.captures.username.upper(), "hi @jane and @jim") + result, count = pattern.subn(_uppercase_username, "hi @jane and @jim") assert result == "hi JANE and JIM" assert count == 2 @@ -102,8 +106,9 @@ def test_named_captures_instance_is_a_namespace(): def test_pattern_match_via_pattern_class_returns_wrapped_match(): pattern = Pattern().named_capture("word").one_or_more().letter().end() - assert pattern.match("hello") is not None - assert pattern.match("hello").captures.word == "hello" + result = pattern.match("hello") + assert result is not None + assert result.captures.word == "hello" def test_fullmatch_returns_wrapped_match_when_full_input_matches(): diff --git a/tests/result/regex.test.py b/tests/result/regex.test.py index b6cefbe..7f0aee8 100644 --- a/tests/result/regex.test.py +++ b/tests/result/regex.test.py @@ -1,6 +1,8 @@ """Tests for the :class:`edify.result.Regex` wrapper class.""" import re +from collections.abc import Mapping +from typing import cast import pytest @@ -12,59 +14,59 @@ def digit_regex() -> Regex: return Regex("\\d+", re.compile("\\d+")) -def test_source_returns_the_pattern_string(digit_regex): +def test_source_returns_the_pattern_string(digit_regex: Regex): assert digit_regex.source == "\\d+" -def test_compiled_returns_the_underlying_re_pattern(digit_regex): +def test_compiled_returns_the_underlying_re_pattern(digit_regex: Regex): assert isinstance(digit_regex.compiled, re.Pattern) -def test_compiled_pattern_matches_the_source(digit_regex): +def test_compiled_pattern_matches_the_source(digit_regex: Regex): assert digit_regex.compiled.pattern == "\\d+" -def test_match_delegates_to_the_compiled_pattern(digit_regex): +def test_match_delegates_to_the_compiled_pattern(digit_regex: Regex): hit = digit_regex.match("123") assert hit is not None assert hit.group() == "123" -def test_search_delegates_to_the_compiled_pattern(digit_regex): +def test_search_delegates_to_the_compiled_pattern(digit_regex: Regex): hit = digit_regex.search("abc 456 def") assert hit is not None assert hit.group() == "456" -def test_fullmatch_delegates_to_the_compiled_pattern(digit_regex): +def test_fullmatch_delegates_to_the_compiled_pattern(digit_regex: Regex): assert digit_regex.fullmatch("789") is not None assert digit_regex.fullmatch("7x9") is None -def test_findall_delegates_to_the_compiled_pattern(digit_regex): +def test_findall_delegates_to_the_compiled_pattern(digit_regex: Regex): assert digit_regex.findall("1 2 3") == ["1", "2", "3"] -def test_finditer_delegates_to_the_compiled_pattern(digit_regex): +def test_finditer_delegates_to_the_compiled_pattern(digit_regex: Regex): hits = list(digit_regex.finditer("42 99")) assert [match.group() for match in hits] == ["42", "99"] -def test_sub_delegates_to_the_compiled_pattern(digit_regex): +def test_sub_delegates_to_the_compiled_pattern(digit_regex: Regex): assert digit_regex.sub("[X]", "hi 12 there 34") == "hi [X] there [X]" -def test_subn_delegates_to_the_compiled_pattern(digit_regex): +def test_subn_delegates_to_the_compiled_pattern(digit_regex: Regex): result, count = digit_regex.subn("[X]", "1 2 3") assert result == "[X] [X] [X]" assert count == 3 -def test_split_delegates_to_the_compiled_pattern(digit_regex): +def test_split_delegates_to_the_compiled_pattern(digit_regex: Regex): assert digit_regex.split("hi1there2end") == ["hi", "there", "end"] -def test_repr_shows_the_source(digit_regex): +def test_repr_shows_the_source(digit_regex: Regex): assert repr(digit_regex) == "<Regex '\\\\d+'>" @@ -97,11 +99,11 @@ def test_equality_with_non_regex_returns_not_implemented(): assert a.__eq__("foo") is NotImplemented -def test_pattern_attribute_delegates_via_getattr(digit_regex): +def test_pattern_attribute_delegates_via_getattr(digit_regex: Regex): assert digit_regex.pattern == "\\d+" -def test_flags_attribute_delegates_via_getattr(digit_regex): +def test_flags_attribute_delegates_via_getattr(digit_regex: Regex): assert isinstance(digit_regex.flags, int) @@ -115,12 +117,10 @@ def test_groupindex_attribute_delegates_via_getattr(): "(?P<num>\\d)(?P<char>\\w)", re.compile("(?P<num>\\d)(?P<char>\\w)"), ) - assert dict(with_named.groupindex) == {"num": 0, "char": 1} or dict(with_named.groupindex) == { - "num": 1, - "char": 2, - } + group_index = cast("Mapping[str, int]", with_named.groupindex) + assert dict(group_index) == {"num": 1, "char": 2} -def test_missing_attribute_raises_attribute_error(digit_regex): +def test_missing_attribute_raises_attribute_error(digit_regex: Regex): with pytest.raises(AttributeError): _ = digit_regex.no_such_attribute diff --git a/tests/serialize/errors.test.py b/tests/serialize/errors.test.py index 1b7dd39..cc5ec30 100644 --- a/tests/serialize/errors.test.py +++ b/tests/serialize/errors.test.py @@ -11,6 +11,7 @@ from edify.errors.serialize import ( NonObjectJSONPayloadError, UnknownElementKindError, ) +from edify.serialize import JSONValue def test_missing_edify_key_raises(): @@ -24,7 +25,7 @@ def test_missing_pattern_key_raises(): def test_missing_kind_on_nested_node_raises(): - document = { + document: dict[str, JSONValue] = { "edify": 0, "pattern": {"kind": "root", "children": [{"value": "x"}]}, } @@ -33,13 +34,13 @@ def test_missing_kind_on_nested_node_raises(): def test_incompatible_schema_version_raises(): - document = {"edify": 999, "pattern": {"kind": "root", "children": []}} + document: dict[str, JSONValue] = {"edify": 999, "pattern": {"kind": "root", "children": []}} with pytest.raises(IncompatibleSchemaVersionError): Pattern.from_dict(document) def test_unknown_kind_raises(): - document = { + document: dict[str, JSONValue] = { "edify": 0, "pattern": {"kind": "root", "children": [{"kind": "mystery"}]}, } @@ -58,12 +59,15 @@ def test_non_object_json_payload_raises_non_object_json_payload_error(): def test_incompatible_schema_version_with_composite_value_stringifies_it(): - document = {"edify": [1, 2, 3], "pattern": {"kind": "root", "children": []}} + document: dict[str, JSONValue] = { + "edify": [1, 2, 3], + "pattern": {"kind": "root", "children": []}, + } with pytest.raises(IncompatibleSchemaVersionError): Pattern.from_dict(document) def test_pattern_key_not_a_root_element_produces_empty_pattern(): - document = {"edify": 0, "pattern": {"kind": "digit"}} + document: dict[str, JSONValue] = {"edify": 0, "pattern": {"kind": "digit"}} restored = Pattern.from_dict(document) assert restored.to_regex_string() == "(?:)" diff --git a/tests/serialize/roundtrip.test.py b/tests/serialize/roundtrip.test.py index a3d7ea0..cc8c762 100644 --- a/tests/serialize/roundtrip.test.py +++ b/tests/serialize/roundtrip.test.py @@ -1,6 +1,7 @@ """Round-trip tests for Pattern.to_dict/from_dict and to_json/from_json.""" from edify import END, START, Pattern +from edify.serialize import JSONValue def _roundtrip_dict(pattern: Pattern) -> Pattern: @@ -28,8 +29,8 @@ def test_anchored_pattern_roundtrips(): original = Pattern().start_of_input().one_or_more().digit().end_of_input() restored = _roundtrip_json(original) assert restored == original - assert restored._state.has_defined_start - assert restored._state.has_defined_end + assert restored.state.has_defined_start + assert restored.state.has_defined_end def test_char_class_pattern_roundtrips(): @@ -55,15 +56,15 @@ def test_anything_but_string_roundtrips(): def test_capture_group_roundtrips(): original = Pattern().capture().digit().end() assert _roundtrip_dict(original) == original - assert _roundtrip_dict(original)._state.total_capture_groups == 1 + assert _roundtrip_dict(original).state.total_capture_groups == 1 def test_named_capture_roundtrips_with_names_and_count(): original = Pattern().named_capture("year").exactly(4).digit().end() restored = _roundtrip_dict(original) assert restored == original - assert restored._state.named_groups == ("year",) - assert restored._state.total_capture_groups == 1 + assert restored.state.named_groups == ("year",) + assert restored.state.total_capture_groups == 1 def test_backreference_roundtrips(): @@ -189,8 +190,8 @@ def test_flags_survive_roundtrip(): original = Pattern().ignore_case().multi_line().digit() restored = _roundtrip_dict(original) assert restored == original - assert restored._state.flags.ignore_case - assert restored._state.flags.multiline + assert restored.state.flags.ignore_case + assert restored.state.flags.multiline def test_string_element_roundtrips(): @@ -230,7 +231,7 @@ def test_flags_key_omitted_when_no_flags_set(): def test_unknown_element_field_is_ignored_for_forward_compatibility(): - document = { + document: dict[str, JSONValue] = { "edify": 0, "pattern": { "kind": "root", diff --git a/tests/testing/snapshots.test.py b/tests/testing/snapshots.test.py index c71b204..828b4de 100644 --- a/tests/testing/snapshots.test.py +++ b/tests/testing/snapshots.test.py @@ -10,14 +10,18 @@ from edify.testing import SnapshotMismatchError, SnapshotMissingError, assert_sn _UPDATE_ENVIRONMENT_VARIABLE = "EDIFY_UPDATE_SNAPSHOTS" -def test_assert_snapshot_passes_when_actual_matches_committed_reference(tmp_path, monkeypatch): +def test_assert_snapshot_passes_when_actual_matches_committed_reference( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) snapshot_path = tmp_path / "identical.snapshot" snapshot_path.write_text("hello\n") assert_snapshot("hello\n", snapshot_path) -def test_assert_snapshot_raises_snapshot_mismatch_when_content_differs(tmp_path, monkeypatch): +def test_assert_snapshot_raises_snapshot_mismatch_when_content_differs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) snapshot_path = tmp_path / "drift.snapshot" snapshot_path.write_text("expected\n") @@ -30,21 +34,27 @@ def test_assert_snapshot_raises_snapshot_mismatch_when_content_differs(tmp_path, assert "EDIFY_UPDATE_SNAPSHOTS=1" in text -def test_assert_snapshot_raises_snapshot_missing_when_reference_absent(tmp_path, monkeypatch): +def test_assert_snapshot_raises_snapshot_missing_when_reference_absent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) absent_snapshot = tmp_path / "never_written.snapshot" with pytest.raises(SnapshotMissingError, match="snapshot file missing"): assert_snapshot("any actual value", absent_snapshot) -def test_update_mode_writes_the_snapshot_when_the_file_is_missing(tmp_path, monkeypatch): +def test_update_mode_writes_the_snapshot_when_the_file_is_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "1") snapshot_path = tmp_path / "created.snapshot" assert_snapshot("fresh content\n", snapshot_path) assert snapshot_path.read_text() == "fresh content\n" -def test_update_mode_overwrites_the_snapshot_on_drift(tmp_path, monkeypatch): +def test_update_mode_overwrites_the_snapshot_on_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "1") snapshot_path = tmp_path / "regenerated.snapshot" snapshot_path.write_text("stale\n") @@ -52,21 +62,27 @@ def test_update_mode_overwrites_the_snapshot_on_drift(tmp_path, monkeypatch): assert snapshot_path.read_text() == "current\n" -def test_update_mode_is_off_when_env_variable_is_set_to_other_value(tmp_path, monkeypatch): +def test_update_mode_is_off_when_env_variable_is_set_to_other_value( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "0") snapshot_path = tmp_path / "no_write.snapshot" with pytest.raises(SnapshotMissingError): assert_snapshot("actual", snapshot_path) -def test_update_mode_creates_missing_parent_directories(tmp_path, monkeypatch): +def test_update_mode_creates_missing_parent_directories( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "1") snapshot_path = tmp_path / "nested" / "dir" / "leaf.snapshot" assert_snapshot("value", snapshot_path) assert snapshot_path.read_text() == "value" -def test_snapshot_mismatch_error_names_the_snapshot_path(tmp_path, monkeypatch): +def test_snapshot_mismatch_error_names_the_snapshot_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) snapshot_path = tmp_path / "named.snapshot" snapshot_path.write_text("was") @@ -83,7 +99,7 @@ def test_snapshot_mismatch_error_is_an_assertion_error_subclass(): assert issubclass(SnapshotMismatchError, AssertionError) -def test_snapshot_helper_is_reachable_via_edify_testing_namespace(tmp_path): +def test_snapshot_helper_is_reachable_via_edify_testing_namespace(tmp_path: Path): snapshot_path: Path = tmp_path / "namespace.snapshot" snapshot_path.write_text("ok\n") testing.assert_snapshot("ok\n", snapshot_path) diff --git a/tests/tools/changes.test.py b/tests/tools/changes.test.py index 7eeb777..98a571a 100644 --- a/tests/tools/changes.test.py +++ b/tests/tools/changes.test.py @@ -1,22 +1,12 @@ """Tests for the ``changes/`` fragment generator in ``tools/changes.py``.""" -import importlib.util from pathlib import Path -_REPO_ROOT = Path(__file__).parent.parent.parent -_GENERATOR_PATH = _REPO_ROOT / "tools" / "changes.py" - +import pytest -def _load_generator(): - spec = importlib.util.spec_from_file_location("edify_changes_tool", _GENERATOR_PATH) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module +from tools import changes - -_GENERATOR = _load_generator() +_REPO_ROOT = Path(__file__).parent.parent.parent def _write_fragment(directory: Path, name: str, body: str) -> Path: @@ -25,92 +15,96 @@ def _write_fragment(directory: Path, name: str, body: str) -> Path: return fragment_path -def test_collect_fragments_returns_files_in_fragment_id_order(tmp_path): +def test_collect_fragments_returns_files_in_fragment_id_order(tmp_path: Path) -> None: _write_fragment(tmp_path, "0020-second.rst", "[change]\nanchor=b\nheading=B\ncontext=b") _write_fragment(tmp_path, "0010-first.rst", "[change]\nanchor=a\nheading=A\ncontext=a") - collected = _GENERATOR.collect_fragments(tmp_path) + collected = changes.collect_fragments(tmp_path) assert [path.name for path in collected] == ["0010-first.rst", "0020-second.rst"] -def test_collect_fragments_skips_the_readme(tmp_path): +def test_collect_fragments_skips_the_readme(tmp_path: Path) -> None: _write_fragment(tmp_path, "README.rst", "not a fragment") _write_fragment(tmp_path, "0010-only.rst", "[change]\nanchor=a\nheading=A\ncontext=a") - collected = _GENERATOR.collect_fragments(tmp_path) + collected = changes.collect_fragments(tmp_path) assert [path.name for path in collected] == ["0010-only.rst"] -def test_render_fragment_emits_anchor_heading_and_context(tmp_path): +def test_render_fragment_emits_anchor_heading_and_context(tmp_path: Path) -> None: fragment_path = _write_fragment( tmp_path, "0010-example.rst", "[change]\nanchor = my-anchor\nheading = My Heading\ncontext = A single sentence.", ) - rendered = _GENERATOR.render_fragment(fragment_path) + rendered = changes.render_fragment(fragment_path) assert ".. _my-anchor:" in rendered assert "My Heading" in rendered assert "-" * len("My Heading") in rendered assert "A single sentence." in rendered -def test_render_fragment_collapses_multiline_context_into_one_paragraph(tmp_path): +def test_render_fragment_collapses_multiline_context_into_one_paragraph(tmp_path: Path) -> None: fragment_path = _write_fragment( tmp_path, "0010-multiline.rst", "[change]\nanchor = a\nheading = H\ncontext = first line\n second line", ) - rendered = _GENERATOR.render_fragment(fragment_path) + rendered = changes.render_fragment(fragment_path) assert "first line second line" in rendered -def test_render_fragment_includes_before_after_block_when_both_present(tmp_path): +def test_render_fragment_includes_before_after_block_when_both_present(tmp_path: Path) -> None: fragment_path = _write_fragment( tmp_path, "0010-beforeafter.rst", "[change]\nanchor = a\nheading = H\nbefore = old\nafter = new\ncontext = changed", ) - rendered = _GENERATOR.render_fragment(fragment_path) + rendered = changes.render_fragment(fragment_path) assert ".. code-block:: text" in rendered assert " old" in rendered assert " new" in rendered -def test_render_fragment_omits_before_after_block_when_absent(tmp_path): +def test_render_fragment_omits_before_after_block_when_absent(tmp_path: Path) -> None: fragment_path = _write_fragment( tmp_path, "0010-nofix.rst", "[change]\nanchor = a\nheading = H\ncontext = no before/after here", ) - rendered = _GENERATOR.render_fragment(fragment_path) + rendered = changes.render_fragment(fragment_path) assert ".. code-block:: text" not in rendered -def test_render_all_concatenates_every_fragment_in_order(tmp_path): +def test_render_all_concatenates_every_fragment_in_order(tmp_path: Path) -> None: _write_fragment(tmp_path, "0020-b.rst", "[change]\nanchor=b\nheading=Bravo\ncontext=b") _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=Alpha\ncontext=a") - rendered = _GENERATOR.render_all(tmp_path) + rendered = changes.render_all(tmp_path) assert rendered.index("Alpha") < rendered.index("Bravo") -def test_main_release_deletes_consumed_fragments(tmp_path, monkeypatch): +def test_main_release_deletes_consumed_fragments( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=A\ncontext=a") - monkeypatch.setattr(_GENERATOR, "_CHANGES_DIR", tmp_path) - exit_code = _GENERATOR.main(["--release"]) + monkeypatch.setattr(changes, "_CHANGES_DIR", tmp_path) + exit_code = changes.main(["--release"]) assert exit_code == 0 - assert _GENERATOR.collect_fragments(tmp_path) == [] + assert changes.collect_fragments(tmp_path) == [] -def test_main_without_release_leaves_fragments_in_place(tmp_path, monkeypatch, capsys): +def test_main_without_release_leaves_fragments_in_place( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=A\ncontext=a") - monkeypatch.setattr(_GENERATOR, "_CHANGES_DIR", tmp_path) - exit_code = _GENERATOR.main([]) + monkeypatch.setattr(changes, "_CHANGES_DIR", tmp_path) + exit_code = changes.main([]) captured = capsys.readouterr() assert exit_code == 0 assert "A" in captured.out - assert len(_GENERATOR.collect_fragments(tmp_path)) == 1 + assert len(changes.collect_fragments(tmp_path)) == 1 -def test_committed_changes_directory_fragments_all_parse(): +def test_committed_changes_directory_fragments_all_parse() -> None: changes_dir = _REPO_ROOT / "changes" - for fragment_path in _GENERATOR.collect_fragments(changes_dir): - rendered = _GENERATOR.render_fragment(fragment_path) + for fragment_path in changes.collect_fragments(changes_dir): + rendered = changes.render_fragment(fragment_path) assert rendered.strip() != "" diff --git a/tests/tools/surface.test.py b/tests/tools/surface.test.py index 0b8daf0..d044982 100644 --- a/tests/tools/surface.test.py +++ b/tests/tools/surface.test.py @@ -1,32 +1,22 @@ """Tests for the public-surface snapshot tool in ``tools/surface.py``.""" -import importlib.util from pathlib import Path -_REPO_ROOT = Path(__file__).parent.parent.parent -_TOOL_PATH = _REPO_ROOT / "tools" / "surface.py" -_SURFACE_PATH = _REPO_ROOT / ".public-surface" - +import pytest -def _load_tool(): - spec = importlib.util.spec_from_file_location("edify_surface_tool", _TOOL_PATH) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module +from tools import surface - -_TOOL = _load_tool() +_REPO_ROOT = Path(__file__).parent.parent.parent +_SURFACE_PATH = _REPO_ROOT / ".public-surface" -def test_committed_surface_file_exists_and_is_non_empty(): +def test_committed_surface_file_exists_and_is_non_empty() -> None: assert _SURFACE_PATH.exists() assert _SURFACE_PATH.read_text(encoding="utf-8").strip() != "" -def test_computed_surface_matches_the_committed_snapshot(): - computed = _TOOL.compute_surface() +def test_computed_surface_matches_the_committed_snapshot() -> None: + computed = surface.compute_surface() committed = _SURFACE_PATH.read_text(encoding="utf-8") assert computed == committed, ( "public surface drift: run `python tools/surface.py --write` and commit " @@ -34,16 +24,16 @@ def test_computed_surface_matches_the_committed_snapshot(): ) -def test_surface_lists_the_core_public_symbols(): - computed = _TOOL.compute_surface() +def test_surface_lists_the_core_public_symbols() -> None: + computed = surface.compute_surface() assert "edify.Pattern" in computed assert "edify.RegexBuilder" in computed assert "edify.Regex" in computed assert "edify.EdifyError" in computed -def test_surface_excludes_private_module_paths(): - computed = _TOOL.compute_surface() +def test_surface_excludes_private_module_paths() -> None: + computed = surface.compute_surface() for line in computed.splitlines(): dotted = line.split("(")[0] parts = dotted.split(".") @@ -52,38 +42,42 @@ def test_surface_excludes_private_module_paths(): ) -def test_surface_is_sorted_and_deduplicated(): - computed = _TOOL.compute_surface() +def test_surface_is_sorted_and_deduplicated() -> None: + computed = surface.compute_surface() lines = computed.splitlines() assert lines == sorted(lines) assert len(lines) == len(set(lines)) -def test_check_returns_zero_when_surface_matches(capsys): - exit_code = _TOOL.main(["--check"]) +def test_check_returns_zero_when_surface_matches() -> None: + exit_code = surface.main(["--check"]) assert exit_code == 0 -def test_check_returns_one_when_surface_drifts(tmp_path, monkeypatch, capsys): +def test_check_returns_one_when_surface_drifts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: drifted_snapshot = tmp_path / ".public-surface" drifted_snapshot.write_text("edify.SomethingRemoved\n", encoding="utf-8") - monkeypatch.setattr(_TOOL, "_SURFACE_PATH", drifted_snapshot) - exit_code = _TOOL.main(["--check"]) + monkeypatch.setattr(surface, "_SURFACE_PATH", drifted_snapshot) + exit_code = surface.main(["--check"]) captured = capsys.readouterr() assert exit_code == 1 assert "public surface drift" in captured.err -def test_write_overwrites_the_snapshot_file(tmp_path, monkeypatch): +def test_write_overwrites_the_snapshot_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: target = tmp_path / ".public-surface" - monkeypatch.setattr(_TOOL, "_SURFACE_PATH", target) - exit_code = _TOOL.main(["--write"]) + monkeypatch.setattr(surface, "_SURFACE_PATH", target) + exit_code = surface.main(["--write"]) assert exit_code == 0 - assert target.read_text(encoding="utf-8") == _TOOL.compute_surface() + assert target.read_text(encoding="utf-8") == surface.compute_surface() -def test_bare_invocation_prints_the_surface_to_stdout(capsys): - exit_code = _TOOL.main([]) +def test_bare_invocation_prints_the_surface_to_stdout(capsys: pytest.CaptureFixture[str]) -> None: + exit_code = surface.main([]) captured = capsys.readouterr() assert exit_code == 0 assert "edify.Pattern" in captured.out diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tools/__init__.py |
