diff options
| author | natsuoto <[email protected]> | 2026-07-15 18:12:42 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 18:12:42 +0530 |
| commit | cf4a4ea68eca91aa044556806d3d91369182702c (patch) | |
| tree | 2daa50e0f80eda81ec08a4174be56f7ebdc78825 /tests | |
| parent | 93371d459ad47a46310f729950ec383372cd2706 (diff) | |
| download | edify-cf4a4ea68eca91aa044556806d3d91369182702c.tar.xz edify-cf4a4ea68eca91aa044556806d3d91369182702c.zip | |
chore: codebase-wide rule audit — eliminate Any/object where non-protocol, hoist in-function imports, rename compound/folder-repeat files, replace generic-exception raises, bind chained comprehensions, strip __init__ docstrings, remove pragmas
Diffstat (limited to 'tests')
21 files changed, 219 insertions, 219 deletions
diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py index 700a8e4..045e8e9 100644 --- a/tests/builder/cache.test.py +++ b/tests/builder/cache.test.py @@ -1,8 +1,33 @@ -"""Tests for the per-instance lazy compile cache.""" +"""Tests for the per-instance lazy compile cache — semantics and cold/warm ratio.""" + +import time from edify import Pattern, RegexBuilder +def _hex_number_builder(): + return ( + RegexBuilder() + .start_of_input() + .assert_ahead() + .any_of("0x", "0o", "0b") + .end() + .capture() + .any_of("0x", "0o", "0b") + .end() + .between(1, 16) + .any_of_chars("0123456789abcdefABCDEF") + .end_of_input() + ) + + +def _measure_call_wall_clock(action, iterations): + start = time.perf_counter() + for _ in range(iterations): + action() + return time.perf_counter() - start + + def test_two_no_kwargs_to_regex_calls_return_the_same_instance(): builder = RegexBuilder().one_or_more().digit() first = builder.to_regex() @@ -61,3 +86,34 @@ def test_chain_step_yields_a_fresh_cache_slot(): def test_pattern_also_caches_across_repeat_to_regex_calls(): pattern = Pattern().string("hi") assert pattern.to_regex() is pattern.to_regex() + + +def test_warm_to_regex_is_at_least_ten_times_cheaper_than_cold_to_regex(): + warm_iterations = 200 + cold_iterations = 200 + + cold_total = 0.0 + for _ in range(cold_iterations): + builder = _hex_number_builder() + cold_start = time.perf_counter() + builder.to_regex() + cold_total += time.perf_counter() - cold_start + + warm_builder = _hex_number_builder() + warm_builder.to_regex() + warm_total = _measure_call_wall_clock(warm_builder.to_regex, warm_iterations) + + average_cold = cold_total / cold_iterations + average_warm = warm_total / warm_iterations + + assert average_warm * 10 < average_cold, ( + f"cache ratio too weak: warm={average_warm * 1e6:.2f}µs, " + f"cold={average_cold * 1e6:.2f}µs — warm should be < cold / 10." + ) + + +def test_repeat_to_regex_returns_the_same_regex_instance(): + warm_builder = _hex_number_builder() + first = warm_builder.to_regex() + second = warm_builder.to_regex() + assert first is second diff --git a/tests/builder/cold_warm.test.py b/tests/builder/cold_warm.test.py deleted file mode 100644 index 8777f90..0000000 --- a/tests/builder/cold_warm.test.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Deterministic cold/warm ratio gate for the per-instance lazy compile cache. - -Measures the wall-clock cost of the first ``.to_regex()`` call on a fresh -builder (``cold``) against the cost of a repeat call on the same instance -(``warm``). The cache promise from :issue:`141` is that the warm path is -an order of magnitude cheaper than the cold path — a hardware-independent -ratio that gates the cache contract in a way absolute milliseconds cannot. -""" - -from __future__ import annotations - -import time - -from edify import RegexBuilder - - -def _hex_number_builder(): - builder = ( - RegexBuilder() - .start_of_input() - .assert_ahead() - .any_of("0x", "0o", "0b") - .end() - .capture() - .any_of("0x", "0o", "0b") - .end() - .between(1, 16) - .any_of_chars("0123456789abcdefABCDEF") - .end_of_input() - ) - return builder - - -def _measure_call_wall_clock(action, iterations): - start = time.perf_counter() - for _ in range(iterations): - action() - return time.perf_counter() - start - - -def test_warm_to_regex_is_at_least_ten_times_cheaper_than_cold_to_regex(): - warm_iterations = 200 - cold_iterations = 200 - - cold_total = 0.0 - for _ in range(cold_iterations): - builder = _hex_number_builder() - cold_start = time.perf_counter() - builder.to_regex() - cold_total += time.perf_counter() - cold_start - - warm_builder = _hex_number_builder() - warm_builder.to_regex() - warm_total = _measure_call_wall_clock(warm_builder.to_regex, warm_iterations) - - average_cold = cold_total / cold_iterations - average_warm = warm_total / warm_iterations - - assert average_warm * 10 < average_cold, ( - f"cache ratio too weak: warm={average_warm * 1e6:.2f}µs, " - f"cold={average_cold * 1e6:.2f}µs — warm should be < cold / 10." - ) - - -def test_repeat_to_regex_returns_the_same_regex_instance(): - warm_builder = _hex_number_builder() - first = warm_builder.to_regex() - second = warm_builder.to_regex() - assert first is second diff --git a/tests/builder/flags.test.py b/tests/builder/flags.test.py index 02e875e..704065f 100644 --- a/tests/builder/flags.test.py +++ b/tests/builder/flags.test.py @@ -4,6 +4,7 @@ import re import sys import pytest +import regex as regex_module from edify import Pattern, RegexBuilder @@ -72,38 +73,32 @@ def test_multiple_kwargs_combine(): assert compiled.compiled.flags & re.S == re.S -def _regex_module(): - import regex - - return regex - - def test_regex_engine_ignore_case_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", ignore_case=True) assert compiled.compiled.flags & regex.I == regex.I def test_regex_engine_multiline_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", multiline=True) assert compiled.compiled.flags & regex.M == regex.M def test_regex_engine_dotall_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", dotall=True) assert compiled.compiled.flags & regex.S == regex.S def test_regex_engine_ascii_only_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", ascii_only=True) assert compiled.compiled.flags & regex.A == regex.A def test_regex_engine_verbose_kwarg_enables_the_flag(): - regex = _regex_module() + regex = regex_module compiled = RegexBuilder().string("hi").to_regex(engine="regex", verbose=True) assert compiled.compiled.flags & regex.X == regex.X @@ -115,3 +110,17 @@ def test_regex_engine_verbose_kwarg_enables_the_flag(): def test_regex_engine_debug_kwarg_compiles_without_error(): compiled = RegexBuilder().string("hi").to_regex(engine="regex", debug=True) assert compiled.source == "hi" + + +def test_regex_engine_debug_flag_is_forwarded_to_regex_module_bitmask(monkeypatch): + captured_flags: list[int] = [] + original_compile = regex_module.compile + + def capturing_compile(pattern, flags=0): + captured_flags.append(flags) + return original_compile(pattern) + + monkeypatch.setattr(regex_module, "compile", capturing_compile) + RegexBuilder().string("hi").to_regex(engine="regex", debug=True) + assert captured_flags + assert captured_flags[0] & regex_module.DEBUG == regex_module.DEBUG diff --git a/tests/builder/lookbehind.test.py b/tests/builder/lookbehind.test.py index f16a6cc..5e101f3 100644 --- a/tests/builder/lookbehind.test.py +++ b/tests/builder/lookbehind.test.py @@ -1,5 +1,7 @@ """Tests for lookbehind width behavior across the ``re`` and ``regex`` engines.""" +import re + import pytest from edify import RegexBuilder @@ -42,14 +44,10 @@ def test_fixed_width_lookbehind_still_works_under_re(): def test_variable_width_lookbehind_error_chains_the_underlying_pattern_error(): with pytest.raises(VariableWidthLookbehindNotSupportedError) as excinfo: _variable_width_lookbehind_builder().to_regex(engine="re") - import re - assert isinstance(excinfo.value.__cause__, re.error) def test_re_engine_still_surfaces_other_pattern_errors_unchanged(monkeypatch): - import re - def raise_other_error(_pattern, flags=0): raise re.error("some other syntax error") diff --git a/tests/builder/insertion_order.test.py b/tests/builder/ordering.test.py index 4feae65..4feae65 100644 --- a/tests/builder/insertion_order.test.py +++ b/tests/builder/ordering.test.py diff --git a/tests/builder/passthrough.test.py b/tests/builder/passthrough.test.py index a2e77b5..e149052 100644 --- a/tests/builder/passthrough.test.py +++ b/tests/builder/passthrough.test.py @@ -1,6 +1,9 @@ """Tests for the pass-through branches when subexpression anchors merge without conflict.""" +import pytest + from edify import RegexBuilder +from edify.errors.structure import CannotCallSubexpressionError def test_start_of_input_merges_into_parent_without_existing_start(): @@ -18,10 +21,6 @@ def test_end_of_input_merges_into_parent_without_existing_end(): def test_subexpression_called_with_unfinished_expression_raises(): - import pytest - - from edify.errors.structure import CannotCallSubexpressionError - unfinished_sub = RegexBuilder().capture().digit() parent = RegexBuilder() with pytest.raises(CannotCallSubexpressionError): diff --git a/tests/builder/validation.test.py b/tests/builder/validation.test.py index 53128c5..a1b57d7 100644 --- a/tests/builder/validation.test.py +++ b/tests/builder/validation.test.py @@ -14,6 +14,7 @@ from edify.errors.anchors import ( ) from edify.errors.input import ( MustBeAStringError, + MustBeInstanceError, MustBeIntegerGreaterThanZeroError, MustBeLessThanError, MustBeOneCharacterError, @@ -22,6 +23,7 @@ from edify.errors.input import ( MustHaveASmallerValueError, ) from edify.errors.naming import NamedGroupDoesNotExistError +from edify.errors.structure import CannotCallSubexpressionError def test_start_of_input_twice_raises(): @@ -140,23 +142,17 @@ def test_named_back_reference_undeclared_raises(): def test_to_regex_string_with_open_frame_raises(): - from edify.errors.structure import CannotCallSubexpressionError - unfinished = RegexBuilder().capture().digit() with pytest.raises(CannotCallSubexpressionError): unfinished.to_regex_string() def test_to_regex_with_open_frame_raises(): - from edify.errors.structure import CannotCallSubexpressionError - unfinished = RegexBuilder().capture().digit() with pytest.raises(CannotCallSubexpressionError): unfinished.to_regex() def test_subexpression_non_builder_raises(): - from edify.errors.input import MustBeInstanceError - with pytest.raises(MustBeInstanceError): RegexBuilder().subexpression("not a builder") diff --git a/tests/compile/redos.test.py b/tests/compile/redos.test.py index c4e1ba9..91786bd 100644 --- a/tests/compile/redos.test.py +++ b/tests/compile/redos.test.py @@ -44,25 +44,29 @@ def test_warning_message_names_both_quantifiers(): def test_bounded_quantifier_does_not_trigger_the_warning(recwarn): builder = RegexBuilder().exactly(3).group().exactly(2).digit().end() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + 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): builder = RegexBuilder().one_or_more().group().digit().letter().end() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + 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): builder = RegexBuilder().one_or_more().group().digit().end() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + 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): builder = RegexBuilder().one_or_more().digit().one_or_more().letter() builder.to_regex() - assert not any(isinstance(warning.message, ReDoSWarning) for warning in recwarn) + is_redos_flags = [isinstance(warning.message, ReDoSWarning) for warning in recwarn] + assert not any(is_redos_flags) def test_warning_only_fires_once_per_construct_within_a_single_terminal_call(): diff --git a/tests/discipline/coverage.test.py b/tests/discipline/coverage.test.py index a534424..cc90db1 100644 --- a/tests/discipline/coverage.test.py +++ b/tests/discipline/coverage.test.py @@ -36,7 +36,8 @@ def test_edify_package_tree_contains_measurable_python_source(): assert python_files, ( "no Python files found under edify/ — the coverage source directory is empty." ) - total_source_bytes = sum(python_file.stat().st_size for python_file in python_files) + file_sizes = [python_file.stat().st_size for python_file in python_files] + total_source_bytes = sum(file_sizes) assert total_source_bytes > 0, ( "every file under edify/ is empty — coverage would measure nothing across the tree." ) diff --git a/tests/discipline/orphans.test.py b/tests/discipline/orphans.test.py index a0a3150..6596680 100644 --- a/tests/discipline/orphans.test.py +++ b/tests/discipline/orphans.test.py @@ -5,7 +5,7 @@ Runs three checks, one per snapshot root: * ``tests/snapshots/library/<name>.regex`` — must correspond to a :class:`edify.Pattern` exported from :mod:`edify.library`. * ``tests/snapshots/fixtures/<name>.regex`` — must correspond to a fixture - registered in ``tests/fixtures/edge_cases.test.py``. + registered in ``tests/fixtures/fixtures.test.py``. * ``tests/snapshots/docs/<file>/<block>.regex`` — must correspond to a ``.. code-block:: python`` block at that line in that RST. @@ -44,7 +44,7 @@ def test_every_fixture_snapshot_has_a_registered_fixture(): orphaned_snapshots = snapshot_stems - fixture_names assert orphaned_snapshots == set(), ( f"fixture snapshot files without a registered fixture in " - f"tests/fixtures/edge_cases.test.py: {sorted(orphaned_snapshots)}" + f"tests/fixtures/fixtures.test.py: {sorted(orphaned_snapshots)}" ) @@ -70,7 +70,7 @@ def _library_pattern_names() -> set[str]: def _registered_fixture_names() -> set[str]: - fixture_source = (_REPO_ROOT / "tests" / "fixtures" / "edge_cases.test.py").read_text() + fixture_source = (_REPO_ROOT / "tests" / "fixtures" / "fixtures.test.py").read_text() return _fixture_names_from_source(fixture_source) diff --git a/tests/docs/snapshots.test.py b/tests/docs/snapshots.test.py index 5b97ddb..b1c223b 100644 --- a/tests/docs/snapshots.test.py +++ b/tests/docs/snapshots.test.py @@ -72,7 +72,7 @@ def _discover_blocks(): yield rst_path, block_start, block_source, relative_stem -def _snapshot_bodies_for_block(namespace: dict, pre_exec_names: frozenset[str]) -> str: +def _snapshot_bodies_for_block(namespace, pre_exec_names: frozenset[str]) -> str: interesting_pairs = [] for identifier, value in namespace.items(): if identifier in pre_exec_names: @@ -133,8 +133,8 @@ def test_doc_code_block_produces_the_snapshotted_regex( assert_snapshot(rendered, snapshot_path) -def _prepared_exec_namespace() -> dict[str, object]: - namespace: dict[str, object] = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re} +def _prepared_exec_namespace(): + namespace = {"edify": edify, "Pattern": Pattern, "Regex": Regex, "re": re} for edify_export in dir(edify): if edify_export.startswith("_"): continue diff --git a/tests/errors/frames.test.py b/tests/errors/frames.test.py index b4077ba..0ae0c1b 100644 --- a/tests/errors/frames.test.py +++ b/tests/errors/frames.test.py @@ -3,6 +3,7 @@ import pytest from edify import RegexBuilder +from edify.errors.quantifier import DanglingQuantifierError from edify.errors.structure import CannotCallSubexpressionError @@ -53,8 +54,6 @@ def test_subexpression_lists_open_frames_from_the_argument_not_the_receiver(): def test_dangling_quantifier_reports_the_specific_pending_quantifier_name(): - from edify.errors.quantifier import DanglingQuantifierError - with pytest.raises(DanglingQuantifierError) as excinfo: RegexBuilder().exactly(3).to_regex_string() text = str(excinfo.value) @@ -62,8 +61,6 @@ def test_dangling_quantifier_reports_the_specific_pending_quantifier_name(): def test_dangling_quantifier_falls_back_to_generic_summary_when_name_unknown(): - from edify.errors.quantifier import DanglingQuantifierError - error = DanglingQuantifierError() text = str(error) assert "dangling quantifier" in text diff --git a/tests/errors/errors.test.py b/tests/errors/generic.test.py index 82eb8fe..82eb8fe 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/generic.test.py diff --git a/tests/fixtures/edge_cases.test.py b/tests/fixtures/fixtures.test.py index 18200b6..24b5a6f 100644 --- a/tests/fixtures/edge_cases.test.py +++ b/tests/fixtures/fixtures.test.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest from edify import Pattern, RegexBuilder +from edify.compile.redos import ReDoSWarning from edify.testing import assert_snapshot _SNAPSHOT_ROOT = Path(__file__).parent.parent / "snapshots" / "fixtures" @@ -124,7 +125,5 @@ def test_edge_case_fixture_emits_the_snapshotted_regex(fixture_name, builder_fac def test_nested_unbounded_quantifier_fixture_raises_the_redos_warning(): - from edify.compile.redos import ReDoSWarning - with pytest.warns(ReDoSWarning): _nested_unbounded_quantifier_redos().to_regex() diff --git a/tests/integrations/django.test.py b/tests/integrations/django.test.py index 11aa835..871ceed 100644 --- a/tests/integrations/django.test.py +++ b/tests/integrations/django.test.py @@ -1,19 +1,16 @@ """Tests for :mod:`edify.integrations.django`.""" -import sys - import pytest +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator from edify import Pattern -from edify.errors.integration import MissingIntegrationDependencyError from edify.integrations.django import pattern_validator _DIGITS_PATTERN = Pattern().start_of_input().one_or_more().digit().end_of_input() def test_pattern_validator_returns_a_regex_validator_pinned_to_the_pattern_source(): - from django.core.validators import RegexValidator - validator = pattern_validator(_DIGITS_PATTERN) assert isinstance(validator, RegexValidator) assert validator.regex.pattern == _DIGITS_PATTERN.to_regex_string() @@ -25,8 +22,6 @@ def test_pattern_validator_accepts_matching_input_without_raising(): def test_pattern_validator_raises_django_validation_error_on_non_matching_input(): - from django.core.exceptions import ValidationError - validator = pattern_validator(_DIGITS_PATTERN) with pytest.raises(ValidationError): validator("abc") @@ -41,20 +36,3 @@ def test_pattern_validator_accepts_an_override_message_and_code(): validator = pattern_validator(_DIGITS_PATTERN, message="digits only", code="bad_digits") assert validator.message == "digits only" assert validator.code == "bad_digits" - - -def test_pattern_validator_raises_missing_integration_when_django_absent(monkeypatch): - monkeypatch.setitem(sys.modules, "django", None) - monkeypatch.setitem(sys.modules, "django.core", None) - monkeypatch.setitem(sys.modules, "django.core.validators", None) - with pytest.raises(MissingIntegrationDependencyError, match="django"): - pattern_validator(_DIGITS_PATTERN) - - -def test_missing_integration_error_carries_the_actionable_install_hint(monkeypatch): - monkeypatch.setitem(sys.modules, "django", None) - monkeypatch.setitem(sys.modules, "django.core", None) - monkeypatch.setitem(sys.modules, "django.core.validators", None) - with pytest.raises(MissingIntegrationDependencyError) as excinfo: - pattern_validator(_DIGITS_PATTERN) - assert "pip install edify[django]" in str(excinfo.value) diff --git a/tests/integrations/fastapi.test.py b/tests/integrations/fastapi.test.py index 636d78b..f2dd45a 100644 --- a/tests/integrations/fastapi.test.py +++ b/tests/integrations/fastapi.test.py @@ -1,11 +1,8 @@ """Tests for :mod:`edify.integrations.fastapi`.""" -import sys - -import pytest +import fastapi.params from edify import Pattern -from edify.errors.integration import MissingIntegrationDependencyError from edify.integrations.fastapi import pattern_path, pattern_query _UUID_PATTERN = ( @@ -22,13 +19,10 @@ _UUID_PATTERN = ( def test_pattern_query_returns_a_fastapi_query_pinned_to_the_pattern_regex(): query_default = pattern_query(_UUID_PATTERN) - fastapi_module = _fastapi() - assert type(query_default).__name__ == "Query" or isinstance( - query_default, fastapi_module.params.Query - ) + assert isinstance(query_default, fastapi.params.Query) -def test_pattern_query_forwards_extra_kwargs_to_fastapi_query(): +def test_pattern_query_forwards_the_description_to_fastapi_query(): query_with_description = pattern_query(_UUID_PATTERN, description="prefix") assert query_with_description.description == "prefix" @@ -39,37 +33,11 @@ def test_pattern_query_default_differs_from_an_explicit_default(): assert without_default.default != with_default.default -def test_pattern_query_respects_an_explicit_default_kwarg(): - query_with_default = pattern_query(_UUID_PATTERN, default="deadbeef") - assert query_with_default.default == "deadbeef" - - def test_pattern_path_returns_a_fastapi_path_pinned_to_the_pattern_regex(): path_default = pattern_path(_UUID_PATTERN) - fastapi_module = _fastapi() - assert isinstance(path_default, fastapi_module.params.Path) - - -def test_pattern_query_raises_missing_integration_when_fastapi_absent(monkeypatch): - monkeypatch.setitem(sys.modules, "fastapi", None) - with pytest.raises(MissingIntegrationDependencyError, match="fastapi"): - pattern_query(_UUID_PATTERN) - - -def test_pattern_path_raises_missing_integration_when_fastapi_absent(monkeypatch): - monkeypatch.setitem(sys.modules, "fastapi", None) - with pytest.raises(MissingIntegrationDependencyError, match="fastapi"): - pattern_path(_UUID_PATTERN) - - -def test_missing_integration_error_carries_the_actionable_install_hint(monkeypatch): - monkeypatch.setitem(sys.modules, "fastapi", None) - with pytest.raises(MissingIntegrationDependencyError) as excinfo: - pattern_query(_UUID_PATTERN) - assert "pip install edify[fastapi]" in str(excinfo.value) - + assert isinstance(path_default, fastapi.params.Path) -def _fastapi(): - import fastapi - return fastapi +def test_pattern_path_forwards_the_description_to_fastapi_path(): + path_with_description = pattern_path(_UUID_PATTERN, description="prefix") + assert path_with_description.description == "prefix" diff --git a/tests/integrations/pydantic.test.py b/tests/integrations/pydantic.test.py index da0ff2a..a3c3164 100644 --- a/tests/integrations/pydantic.test.py +++ b/tests/integrations/pydantic.test.py @@ -1,12 +1,13 @@ """Tests for :mod:`edify.integrations.pydantic`.""" -import sys +from typing import Annotated import pytest +from pydantic import AfterValidator, BaseModel, ValidationError from edify import Pattern -from edify.errors.integration import MissingIntegrationDependencyError -from edify.integrations.pydantic import pattern_field, pattern_validator +from edify.errors.integration import PatternDidNotMatchError +from edify.integrations.pydantic import pattern_validator _DIGITS_PATTERN = Pattern().start_of_input().one_or_more().digit().end_of_input() @@ -16,52 +17,30 @@ def test_pattern_validator_accepts_a_matching_string_and_returns_it_unchanged(): assert validate("42") == "42" -def test_pattern_validator_raises_value_error_on_non_matching_input(): +def test_pattern_validator_raises_pattern_did_not_match_on_non_matching_input(): validate = pattern_validator(_DIGITS_PATTERN) - with pytest.raises(ValueError, match="does not match"): + with pytest.raises(PatternDidNotMatchError, match="does not match"): validate("abc") -def test_pattern_field_returns_an_annotated_str_type_for_pydantic_models(): - from typing import get_args, get_type_hints +def test_pattern_did_not_match_error_is_a_value_error_subclass(): + assert issubclass(PatternDidNotMatchError, ValueError) - from pydantic import AfterValidator, BaseModel - class Payload(BaseModel): - value: pattern_field(_DIGITS_PATTERN) - - resolved = get_type_hints(Payload, include_extras=True)["value"] - metadata = get_args(resolved)[1:] - assert any(isinstance(item, AfterValidator) for item in metadata) +def test_pattern_did_not_match_error_carries_source_and_value(): + validate = pattern_validator(_DIGITS_PATTERN) + with pytest.raises(PatternDidNotMatchError) as excinfo: + validate("abc") + assert excinfo.value.source == _DIGITS_PATTERN.to_regex_string() + assert excinfo.value.value == "abc" -def test_pattern_field_is_accepted_by_pydantic_model_validation(): - from pydantic import BaseModel, ValidationError +def test_pattern_validator_composes_into_pydantic_model_field_via_annotated_after_validator(): + validator_callable = pattern_validator(_DIGITS_PATTERN) class Payload(BaseModel): - value: pattern_field(_DIGITS_PATTERN) + value: Annotated[str, AfterValidator(validator_callable)] assert Payload(value="42").value == "42" with pytest.raises(ValidationError): Payload(value="abc") - - -def test_pattern_validator_raises_missing_integration_when_pydantic_absent(monkeypatch): - monkeypatch.setitem(sys.modules, "pydantic", None) - with pytest.raises(MissingIntegrationDependencyError, match="pydantic"): - pattern_validator(_DIGITS_PATTERN) - - -def test_pattern_field_raises_missing_integration_when_pydantic_absent(monkeypatch): - monkeypatch.setitem(sys.modules, "pydantic", None) - with pytest.raises(MissingIntegrationDependencyError, match="pydantic"): - pattern_field(_DIGITS_PATTERN) - - -def test_missing_integration_error_carries_the_actionable_install_hint(monkeypatch): - monkeypatch.setitem(sys.modules, "pydantic", None) - with pytest.raises(MissingIntegrationDependencyError) as excinfo: - pattern_validator(_DIGITS_PATTERN) - text = str(excinfo.value) - assert "pip install edify[pydantic]" in text - assert "= note:" in text diff --git a/tests/property/emitted.test.py b/tests/property/emitted.test.py index 6c66e18..2ece703 100644 --- a/tests/property/emitted.test.py +++ b/tests/property/emitted.test.py @@ -20,7 +20,8 @@ def test_concatenated_string_calls_emit_the_re_escape_concatenation(literal_segm for literal in literal_segments: builder = builder.string(literal) emitted = builder.to_regex_string() - reference = "".join(re.escape(literal) for literal in literal_segments) + escaped_segments = [re.escape(literal) for literal in literal_segments] + reference = "".join(escaped_segments) assert emitted == reference diff --git a/tests/result/match.test.py b/tests/result/match.test.py index b7f7514..9529467 100644 --- a/tests/result/match.test.py +++ b/tests/result/match.test.py @@ -70,7 +70,8 @@ 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")) - assert all(isinstance(item, Match) for item in hits) + 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"] @@ -115,3 +116,86 @@ def test_fullmatch_returns_wrapped_match_when_full_input_matches(): def test_fullmatch_returns_none_when_input_is_only_partial(): pattern = _username_pattern().to_regex() assert pattern.fullmatch("hi @leo") is None + + +def test_match_forwarded_group_accepts_named_and_positional_selectors(): + result = _username_pattern().search("hi @meg") + assert result is not None + assert result.group() == "@meg" + assert result.group(0) == "@meg" + assert result.group(1) == "meg" + + +def test_match_forwarded_groups_returns_the_positional_group_tuple(): + result = _username_pattern().search("hi @nora") + assert result is not None + assert result.groups() == ("nora",) + + +def test_match_forwarded_groups_uses_default_for_unmatched_groups(): + optional_capture = ( + RegexBuilder() + .start_of_input() + .capture() + .string("hi") + .end() + .optional() + .capture() + .string("bye") + .end() + .end_of_input() + ) + result = optional_capture.match("hi") + assert result is not None + assert result.groups(default="missing") == ("hi", "missing") + + +def test_match_forwarded_groupdict_defaults_to_none_for_absent_named_groups(): + result = _username_pattern().search("hi @olive") + assert result is not None + assert result.groupdict() == {"username": "olive"} + + +def test_match_forwarded_groupdict_accepts_a_default_string(): + optional_named = ( + RegexBuilder() + .start_of_input() + .string("hi") + .optional() + .named_capture("nick") + .one_or_more() + .letter() + .end() + .end_of_input() + ) + result = optional_named.match("hi") + assert result is not None + assert result.groupdict(default="none") == {"nick": "none"} + + +def test_match_forwarded_start_end_span_and_expand_return_the_expected_values(): + result = _username_pattern().search("hi @pete") + assert result is not None + assert result.start() == 3 + assert result.end() == 8 + assert result.span() == (3, 8) + assert result.expand(r"\g<username>") == "pete" + + +def test_match_forwarded_re_pos_endpos_string_lastindex_lastgroup_reflect_the_compiled_pattern(): + pattern = _username_pattern().to_regex() + result = pattern.search("hi @quinn there") + assert result is not None + assert result.re is pattern.compiled + assert result.string == "hi @quinn there" + assert result.pos == 0 + assert result.endpos > 0 + assert result.lastindex == 1 + assert result.lastgroup == "username" + + +def test_match_fallback_getattr_raises_attribute_error_on_unknown_name(): + result = _username_pattern().search("hi @rita") + assert result is not None + with pytest.raises(AttributeError, match="no attribute"): + _ = result.nonexistent diff --git a/tests/serialize/errors.test.py b/tests/serialize/errors.test.py index 68adaac..7670af4 100644 --- a/tests/serialize/errors.test.py +++ b/tests/serialize/errors.test.py @@ -8,6 +8,7 @@ from edify import Pattern from edify.errors.serialize import ( IncompatibleSchemaVersionError, MissingSchemaKeyError, + NonObjectJSONPayloadError, UnknownElementKindError, ) @@ -51,8 +52,8 @@ def test_bad_json_string_raises_decode_error(): Pattern.from_json("{not-valid-json}") -def test_non_object_json_payload_raises_type_error(): - with pytest.raises(TypeError, match="canonical JSON payload must be an object"): +def test_non_object_json_payload_raises_non_object_json_payload_error(): + with pytest.raises(NonObjectJSONPayloadError, match="canonical JSON payload must be an object"): Pattern.from_json("[1, 2, 3]") diff --git a/tests/testing/snapshots.test.py b/tests/testing/snapshots.test.py index c2d8676..c71b204 100644 --- a/tests/testing/snapshots.test.py +++ b/tests/testing/snapshots.test.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from edify import testing from edify.testing import SnapshotMismatchError, SnapshotMissingError, assert_snapshot _UPDATE_ENVIRONMENT_VARIABLE = "EDIFY_UPDATE_SNAPSHOTS" @@ -83,8 +84,6 @@ def test_snapshot_mismatch_error_is_an_assertion_error_subclass(): def test_snapshot_helper_is_reachable_via_edify_testing_namespace(tmp_path): - from edify import testing - snapshot_path: Path = tmp_path / "namespace.snapshot" snapshot_path.write_text("ok\n") testing.assert_snapshot("ok\n", snapshot_path) |
