Skip to content

Commit cf46e3c

Browse files
derek73claude
andcommitted
test: simplify locale test scaffolding (/simplify review)
Drop ~13 function-scope re-imports that restated module-scope names (hoisting Policy to the top); share one default-parser baseline (functools.cache) and module-level packed parsers across the gate and rotator tests, so the suite scales corpus + packs instead of corpus x packs; factor the per-pack layering ALLOWED tuples into a shared _LOCALE_PACK_ALLOWED constant mirroring _PIPELINE_STAGE_ALLOWED; and replace the rotator lists' per-row branch comments as the only coverage guarantee with a mechanical test that every alternation branch of every marker regex is exercised by some rotator token. Document why the corpus loader stays a local copy of compare.py's convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d6071c commit cf46e3c

2 files changed

Lines changed: 82 additions & 52 deletions

File tree

tests/v2/test_layering.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
"nameparser._pipeline.",
2929
)
3030

31+
# a locale pack: pure data over the three base types, no pipeline or
32+
# config access (locales spec §2) -- one contract shared by every pack,
33+
# like _PIPELINE_STAGE_ALLOWED above, so tightening it is a one-line edit
34+
_LOCALE_PACK_ALLOWED = (
35+
"nameparser._lexicon", "nameparser._locale", "nameparser._policy",
36+
)
37+
3138
# module -> prefixes it may import from within nameparser
3239
ALLOWED = {
3340
# call-time imports only (inside the rendering-delegate method
@@ -82,14 +89,8 @@
8289
# "nameparser.locales." prefix documents that reach for humans even
8390
# though the walker never exercises it.
8491
"locales/__init__.py": ("nameparser._locale", "nameparser.locales."),
85-
# a locale pack: pure data over the three base types, no pipeline
86-
# or config access (locales spec §2)
87-
"locales/ru.py": ("nameparser._lexicon", "nameparser._locale",
88-
"nameparser._policy"),
89-
# a locale pack: pure data over the three base types, no pipeline
90-
# or config access (locales spec §2)
91-
"locales/tr_az.py": ("nameparser._lexicon", "nameparser._locale",
92-
"nameparser._policy"),
92+
"locales/ru.py": _LOCALE_PACK_ALLOWED,
93+
"locales/tr_az.py": _LOCALE_PACK_ALLOWED,
9394
# v1 import-path preservation: thin re-exports of the facade/shim
9495
"parser.py": ("nameparser._facade",),
9596
"config/__init__.py": ("nameparser._config_shim",),

tests/v2/test_locales.py

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,60 @@
11
"""The locale pack layer (locales spec §2-3): lazy access, the two
22
2.0.0 packs, composition, and the non-interference gate."""
3+
import functools
34
import json
5+
import re
46
from collections.abc import Callable, Iterable
57
from pathlib import Path
68

79
import pytest
810

911
from nameparser import Locale, Parser, locales, parse, parser_for
1012
from nameparser._lexicon import Lexicon
11-
from nameparser._policy import PatronymicRule
13+
from nameparser._policy import PatronymicRule, Policy
1214
from nameparser.locales import ru as _ru
1315
from nameparser.locales import tr_az as _tr_az
1416

17+
# one JSON-encoded name string per line, blank lines skipped -- the same
18+
# convention tools/differential/compare.py::main() reads (kept as two
19+
# small copies on purpose: tools/ is a standalone uv-run script tree,
20+
# not an importable package, and making it one costs more than 3 lines)
1521
_CORPUS = [
1622
json.loads(line)
1723
for line in (Path(__file__).parents[2] / "tools" / "differential"
1824
/ "corpus.jsonl").read_text().splitlines()
1925
if line.strip()
2026
]
2127

28+
# shared across the gate and rotator tests: the default-parser baseline
29+
# is identical everywhere (only the packed side varies per test), so
30+
# parse each name once for the whole module instead of once per test
31+
_DEFAULT_PARSER = Parser()
32+
_RU_PACKED = parser_for(locales.RU)
33+
_TR_AZ_PACKED = parser_for(locales.TR_AZ)
34+
_COMBINED_PACKED = parser_for(locales.RU, locales.TR_AZ)
35+
36+
37+
@functools.cache
38+
def _default_parse(name: str) -> dict:
39+
"""Default-parser components for `name` (treat as read-only -- the
40+
dict is cached and shared across callers)."""
41+
return _DEFAULT_PARSER.parse(name).as_dict()
42+
2243

2344
def test_locales_module_attribute_access() -> None:
24-
from nameparser import locales
2545
assert isinstance(locales.RU, Locale)
2646
assert locales.RU.code == "ru"
2747
assert locales.RU is locales.RU # cached, not rebuilt
2848

2949

3050
def test_locales_get_and_available() -> None:
31-
from nameparser import locales
3251
assert locales.get("ru") is locales.RU
3352
assert set(locales.available()) == {"ru", "tr_az"}
3453
with pytest.raises(KeyError, match="ru, tr_az"):
3554
locales.get("xx")
3655

3756

3857
def test_tr_az_pack_contents() -> None:
39-
from nameparser import locales
40-
4158
assert locales.TR_AZ.code == "tr_az"
4259
assert locales.TR_AZ.policy.patronymic_rules == frozenset(
4360
{PatronymicRule.TURKIC})
@@ -51,13 +68,12 @@ def test_pack_marker_regexes_stay_in_sync_with_post_rules() -> None:
5168
# equality mechanically so drift fails here, not at the L6
5269
# non-interference gate.
5370
from nameparser._pipeline import _post_rules
54-
from nameparser.locales import ru, tr_az
5571

5672
for pack_regex, rule_regex in (
57-
(ru._EAST_SLAVIC, _post_rules._EAST_SLAVIC),
58-
(ru._EAST_SLAVIC_CYR, _post_rules._EAST_SLAVIC_CYR),
59-
(tr_az._TURKIC, _post_rules._TURKIC),
60-
(tr_az._TURKIC_CYR, _post_rules._TURKIC_CYR),
73+
(_ru._EAST_SLAVIC, _post_rules._EAST_SLAVIC),
74+
(_ru._EAST_SLAVIC_CYR, _post_rules._EAST_SLAVIC_CYR),
75+
(_tr_az._TURKIC, _post_rules._TURKIC),
76+
(_tr_az._TURKIC_CYR, _post_rules._TURKIC_CYR),
6177
):
6278
assert pack_regex.pattern == rule_regex.pattern
6379
assert pack_regex.flags == rule_regex.flags
@@ -69,44 +85,36 @@ def test_ru_deviates_scans_per_token() -> None:
6985
# suffix -- under-declaration, the unsafe direction for the
7086
# non-interference gate. The pack rotates this name; DEVIATES must
7187
# say so.
72-
from nameparser.locales import ru
73-
74-
assert ru.DEVIATES("Ivan Petr Sidorovich Jr.")
88+
assert _ru.DEVIATES("Ivan Petr Sidorovich Jr.")
7589
# Over-declaration is the accepted safe direction: the ending
7690
# matches a token although the rule's 1 given + 1 middle +
7791
# 1 family shape never fires on a two-token name.
78-
assert ru.DEVIATES("Sidorovich Anna")
79-
assert not ru.DEVIATES("John Smith")
92+
assert _ru.DEVIATES("Sidorovich Anna")
93+
assert not _ru.DEVIATES("John Smith")
8094

8195

8296
def test_tr_az_deviates_scans_per_token() -> None:
8397
# Mirror of the RU regression above: the Turkic marker regexes are
8498
# fullmatch-shaped (^...$), so anything but a per-token scan would
8599
# miss a marker followed by a suffix (under-declaration) -- and a
86100
# whole-string match would never fire at all on multi-token names.
87-
from nameparser.locales import tr_az
88-
89-
assert tr_az.DEVIATES("Mammadova Aygun Ali kizi Jr.")
101+
assert _tr_az.DEVIATES("Mammadova Aygun Ali kizi Jr.")
90102
# over-declaration stays the safe direction: a bare marker-shaped
91103
# token declares even where the rule won't rewrite anything
92-
assert tr_az.DEVIATES("Kizi Anna")
93-
assert not tr_az.DEVIATES("John Smith")
104+
assert _tr_az.DEVIATES("Kizi Anna")
105+
assert not _tr_az.DEVIATES("John Smith")
94106
# markers are whole-token: a name merely CONTAINING one must not
95107
# declare ("Ogluev" is not a marker)
96-
assert not tr_az.DEVIATES("Ogluev Ivan")
108+
assert not _tr_az.DEVIATES("Ogluev Ivan")
97109

98110

99111
def test_locales_unknown_attribute() -> None:
100-
from nameparser import locales
101112
with pytest.raises(AttributeError, match="XX"):
102113
locales.XX
103114

104115

105116
def test_ru_plus_tr_az_unions_patronymic_rules() -> None:
106-
from nameparser import locales, parser_for
107-
from nameparser._policy import PatronymicRule
108-
109-
p = parser_for(locales.RU, locales.TR_AZ)
117+
p = _COMBINED_PACKED
110118
assert p.policy.patronymic_rules == frozenset(
111119
{PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC})
112120
# both rules live: one name from each pack's case segment
@@ -118,8 +126,6 @@ def test_ru_plus_tr_az_unions_patronymic_rules() -> None:
118126

119127

120128
def test_pack_over_custom_base() -> None:
121-
from nameparser import Lexicon, Parser, locales, parser_for
122-
123129
base = Parser(lexicon=Lexicon.default().add(titles={"zqxcustom"}))
124130
p = parser_for(locales.RU, base=base)
125131
n = p.parse("Zqxcustom Иван Петрович")
@@ -130,9 +136,6 @@ def test_pack_preserves_custom_base_policy() -> None:
130136
# the lexicon-survival twin above has a policy counterpart: a
131137
# policy-only pack must ADD its patronymic rule without resetting
132138
# unrelated policy fields the base customized
133-
from nameparser import Parser, locales, parser_for
134-
from nameparser._policy import Policy
135-
136139
base = Parser(policy=Policy(strip_emoji=False))
137140
p = parser_for(locales.RU, base=base)
138141
assert p.policy.strip_emoji is False
@@ -143,9 +146,7 @@ def test_pack_preserves_custom_base_policy() -> None:
143146
def test_parser_for_results_chain_as_bases() -> None:
144147
# parser_for's result is itself a valid base= -- packs applied in
145148
# two steps accumulate exactly like one call with both packs
146-
from nameparser import locales, parser_for
147-
148-
chained = parser_for(locales.TR_AZ, base=parser_for(locales.RU))
149+
chained = parser_for(locales.TR_AZ, base=_RU_PACKED)
149150
assert chained.policy.patronymic_rules == frozenset(
150151
{PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC})
151152
assert chained.parse("Сидоров Иван Петрович").given == "Иван"
@@ -179,10 +180,9 @@ def _assert_non_interference(
179180
) -> int:
180181
"""Return the number of DECLARED deviations seen; fail on any
181182
undeclared one (spec §5.2 = the pack-acceptance rejection rule)."""
182-
default = Parser()
183183
declared = 0
184184
for name in corpus:
185-
base = default.parse(name).as_dict()
185+
base = _default_parse(name)
186186
got = packed.parse(name).as_dict()
187187
if got != base:
188188
assert deviates(name), (
@@ -257,27 +257,56 @@ def _default_corpus() -> list[str]:
257257
]
258258

259259

260+
def _alternation_branches(pattern: str) -> list[str]:
261+
"""Split a marker regex of the shape '(a|b|...)$' / '^(a|b|...)$'
262+
into its alternatives. The packs' character classes contain no '|',
263+
so a flat split is safe (and the shape assert guards the day one
264+
stops being true)."""
265+
inner = pattern.removeprefix("^").removesuffix("$")
266+
assert inner.startswith("(") and inner.endswith(")") and "|" not in (
267+
pattern.replace(inner, ""))
268+
return inner[1:-1].split("|")
269+
270+
271+
@pytest.mark.parametrize("regex, rotators, anchored", [
272+
(_ru._EAST_SLAVIC, _RU_ROTATORS, False),
273+
(_ru._EAST_SLAVIC_CYR, _RU_ROTATORS, False),
274+
(_tr_az._TURKIC, _TR_AZ_ROTATORS, True),
275+
(_tr_az._TURKIC_CYR, _TR_AZ_ROTATORS, True),
276+
], ids=["east-slavic", "east-slavic-cyr", "turkic", "turkic-cyr"])
277+
def test_rotators_cover_every_marker_branch(
278+
regex: re.Pattern, rotators: list[str], anchored: bool) -> None:
279+
# The rotator lists' per-row branch comments are a human convention;
280+
# this enforces them mechanically: every alternation branch of every
281+
# marker regex must be hit by some rotator token, so a regex edit
282+
# that adds a branch (kept in sync with _post_rules by the pattern-
283+
# equality test above) fails HERE until a rotator name covers it.
284+
tokens = [tok for name in rotators for tok in name.split()]
285+
for branch in _alternation_branches(regex.pattern):
286+
shape = f"^({branch})$" if anchored else f"({branch})$"
287+
branch_re = re.compile(shape, re.I)
288+
assert any(branch_re.search(tok) for tok in tokens), (
289+
f"no rotator name exercises marker branch {branch!r}")
290+
291+
260292
@pytest.mark.parametrize("name", _RU_ROTATORS)
261293
def test_ru_rotator_deviates_and_declares(name: str) -> None:
262294
# every branch of the pack's marker regexes both FIRES (packed parse
263295
# differs from default) and is DECLARED (DEVIATES says so) -- the
264296
# per-name proof behind the gate's declared-count floor below
265-
packed = parser_for(locales.RU)
266-
assert packed.parse(name).as_dict() != Parser().parse(name).as_dict()
297+
assert _RU_PACKED.parse(name).as_dict() != _default_parse(name)
267298
assert _ru.DEVIATES(name)
268299

269300

270301
@pytest.mark.parametrize("name", _TR_AZ_ROTATORS)
271302
def test_tr_az_rotator_deviates_and_declares(name: str) -> None:
272-
packed = parser_for(locales.TR_AZ)
273-
assert packed.parse(name).as_dict() != Parser().parse(name).as_dict()
303+
assert _TR_AZ_PACKED.parse(name).as_dict() != _default_parse(name)
274304
assert _tr_az.DEVIATES(name)
275305

276306

277307
def test_non_interference_ru() -> None:
278308
corpus = _default_corpus() + _RU_ROTATORS
279-
declared = _assert_non_interference(
280-
parser_for(locales.RU), _ru.DEVIATES, corpus)
309+
declared = _assert_non_interference(_RU_PACKED, _ru.DEVIATES, corpus)
281310
# the positive side: every synthetic rotator (plus the corpus's own
282311
# east-slavic names) must actually flow through the declared branch
283312
assert declared >= len(_RU_ROTATORS)
@@ -286,14 +315,14 @@ def test_non_interference_ru() -> None:
286315
def test_non_interference_tr_az() -> None:
287316
corpus = _default_corpus() + _TR_AZ_ROTATORS
288317
declared = _assert_non_interference(
289-
parser_for(locales.TR_AZ), _tr_az.DEVIATES, corpus)
318+
_TR_AZ_PACKED, _tr_az.DEVIATES, corpus)
290319
assert declared >= len(_TR_AZ_ROTATORS)
291320

292321

293322
def test_non_interference_combined() -> None:
294323
corpus = _default_corpus() + _RU_ROTATORS + _TR_AZ_ROTATORS
295324
declared = _assert_non_interference(
296-
parser_for(locales.RU, locales.TR_AZ),
325+
_COMBINED_PACKED,
297326
lambda n: _ru.DEVIATES(n) or _tr_az.DEVIATES(n),
298327
corpus)
299328
assert declared >= len(_RU_ROTATORS) + len(_TR_AZ_ROTATORS)

0 commit comments

Comments
 (0)