11"""The locale pack layer (locales spec §2-3): lazy access, the two
222.0.0 packs, composition, and the non-interference gate."""
3+ import functools
34import json
5+ import re
46from collections .abc import Callable , Iterable
57from pathlib import Path
68
79import pytest
810
911from nameparser import Locale , Parser , locales , parse , parser_for
1012from nameparser ._lexicon import Lexicon
11- from nameparser ._policy import PatronymicRule
13+ from nameparser ._policy import PatronymicRule , Policy
1214from nameparser .locales import ru as _ru
1315from 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
2344def 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
3050def 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
3857def 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
8296def 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
99111def test_locales_unknown_attribute () -> None :
100- from nameparser import locales
101112 with pytest .raises (AttributeError , match = "XX" ):
102113 locales .XX
103114
104115
105116def 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
120128def 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:
143146def 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 )
261293def 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 )
271302def 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
277307def 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:
286315def 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
293322def 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