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 /tests | |
| 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
Diffstat (limited to 'tests')
46 files changed, 481 insertions, 548 deletions
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 |
