-
Notifications
You must be signed in to change notification settings - Fork 112
Expand file tree
/
Copy pathtest_regex_sync.py
More file actions
366 lines (317 loc) · 17 KB
/
Copy pathtest_regex_sync.py
File metadata and controls
366 lines (317 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
"""Pins the pipeline's hand-duplicated regex/table copies to their
nameparser.config.regexes source of truth.
The 2.0 layering rule forbids nameparser._pipeline/_render importing
nameparser.config directly, so several patterns and tables are copied
by hand into the modules that need them, each with a "keep in sync by
hand" comment. Nothing previously enforced that promise: if
config/regexes.py changed, the copies would silently diverge with no
CI signal. Tests may legally import both sides (test_layering.py's own
convention), so this module is where the promise gets checked.
Layering is the usual reason for a copy but not the only one, so this
module's scope is the PROMISE rather than that one pair of packages:
the comma-set pin below reads _pipeline._state instead of config, and
the last three tests reach outside the package altogether -- two to a
TOML file that could not import a Python constant if it wanted to,
one to a generated corpus whose generator can, and must stay run.
"""
import importlib.util
import json
import re
import tomllib
from pathlib import Path
import pytest
from nameparser.config import regexes as _config
from nameparser._pipeline import _assign, _post_rules, _tokenize, _vocab
from nameparser import _policy
from nameparser._policy import Script
from nameparser import _render
def test_emoji_ranges_match_config() -> None:
assert _tokenize._EMOJI_RANGES == _config._EMOJI_RANGES
def test_bidi_pattern_matches_config() -> None:
assert _tokenize._BIDI.pattern == _config.re_bidi.pattern
assert _tokenize._BIDI.flags == _config.re_bidi.flags
def test_period_not_at_end_matches_config() -> None:
source = _config.REGEXES["period_not_at_end"]
assert _vocab._PERIOD_NOT_AT_END.pattern == source.pattern
assert _vocab._PERIOD_NOT_AT_END.flags == source.flags
def test_period_abbreviation_matches_config() -> None:
source = _config.REGEXES["period_abbreviation"]
assert _assign._PERIOD_ABBREV.pattern == source.pattern
assert _assign._PERIOD_ABBREV.flags == source.flags
def test_roman_numeral_matches_config() -> None:
source = _config.REGEXES["roman_numeral"]
assert _assign._ROMAN.pattern == source.pattern
assert _assign._ROMAN.flags == source.flags
def test_patronymic_patterns_match_config() -> None:
pairs = (
(_post_rules._EAST_SLAVIC, "east_slavic_patronymic"),
(_post_rules._EAST_SLAVIC_CYR, "east_slavic_patronymic_cyrillic"),
(_post_rules._TURKIC, "turkic_patronymic_marker"),
(_post_rules._TURKIC_CYR, "turkic_patronymic_marker_cyrillic"),
)
for copy, key in pairs:
source = _config.REGEXES[key]
assert copy.pattern == source.pattern, key
assert copy.flags == source.flags, key
def test_initial_copies_agree_with_each_other_and_config() -> None:
# _vocab._INITIAL and _render._INITIAL are both v1's "initial"
# pattern minus its trailing "?" (documented at _render.py's
# _INITIAL definition: the two call sites always fullmatch a
# non-empty token, so the empty-string alternative is dropped on
# purpose). Assert both the internal-copy agreement and the exact,
# documented relationship to the config source, so a future edit to
# either side that breaks the relationship fails loudly here
# instead of silently drifting.
assert _vocab._INITIAL.pattern == _render._INITIAL.pattern
source = _config.REGEXES["initial"]
# config's pattern is the pipeline copy with an extra "?" spliced in
# just before the trailing "$", making the whole group optional.
trimmed = _vocab._INITIAL.pattern
reconstructed = trimmed[:-1] + "?" + trimmed[-1:]
assert source.pattern == reconstructed
assert source.flags == _vocab._INITIAL.flags
# The roster above grew one test at a time, and four hand-copies were
# never added to it -- _render's _SPACES, _SPACE_BEFORE_COMMA, _MAC and
# _WORD. They had not diverged, but nothing would have said so, which
# is the exact promise this module exists to keep. All four mirror a
# config key whose only other reader was v1's parser.py, deleted at the
# M11 swap, so nothing else touches them either.
#
# Declared as a roster rather than one test per copy, with a
# completeness check below: adding a pattern without declaring its
# source now fails here instead of being silently unpinned.
_SOURCES: dict[tuple[str, str], str | None] = {
("_assign", "_PERIOD_ABBREV"): "period_abbreviation",
("_assign", "_ROMAN"): "roman_numeral",
("_post_rules", "_EAST_SLAVIC"): "east_slavic_patronymic",
("_post_rules", "_EAST_SLAVIC_CYR"): "east_slavic_patronymic_cyrillic",
("_post_rules", "_TURKIC"): "turkic_patronymic_marker",
("_post_rules", "_TURKIC_CYR"): "turkic_patronymic_marker_cyrillic",
("_render", "_SPACES"): "spaces",
("_render", "_SPACE_BEFORE_COMMA"): "space_before_comma",
("_render", "_MAC"): "mac",
("_render", "_WORD"): "word",
("_vocab", "_PERIOD_NOT_AT_END"): "period_not_at_end",
# Deliberately NOT a straight copy -- pinned by the dedicated tests
# above, which assert the documented RELATIONSHIP instead:
("_render", "_INITIAL"): None, # config's pattern minus one "?"
("_vocab", "_INITIAL"): None, # same
("_tokenize", "_BIDI"): None, # re_bidi, not a REGEXES key
# Mirrors _pipeline._state.COMMA_CHARS, not nameparser.config
("_render", "_COMMA_CHAR"): None,
}
_MODULES = {"_assign": _assign, "_post_rules": _post_rules,
"_render": _render, "_tokenize": _tokenize, "_vocab": _vocab}
@pytest.mark.parametrize(
"where,key", [(w, k) for w, k in _SOURCES.items() if k is not None],
ids=lambda v: v if isinstance(v, str) else f"{v[0]}.{v[1]}")
def test_declared_copy_matches_its_config_source(
where: tuple[str, str], key: str) -> None:
copy = getattr(_MODULES[where[0]], where[1])
source = _config.REGEXES[key]
assert copy.pattern == source.pattern, f"{where[1]} vs REGEXES[{key!r}]"
assert copy.flags == source.flags, f"{where[1]} vs REGEXES[{key!r}]"
def test_every_hand_copied_pattern_is_declared() -> None:
"""The roster must cover every compiled pattern in these modules.
Without this, the roster is just another list that a new constant
can be left out of -- which is how the four above went unpinned.
"""
undeclared = [
(name, attr)
for name, mod in _MODULES.items()
for attr, value in vars(mod).items()
if attr.startswith("_") and not attr.startswith("__")
and isinstance(value, re.Pattern)
and (name, attr) not in _SOURCES
]
assert not undeclared, (
f"compiled patterns missing from _SOURCES: {undeclared}. Add each "
f"with its nameparser.config.regexes key, or None if it has no "
f"config counterpart.")
def test_comma_char_matches_the_pipeline_comma_set() -> None:
# _render splits on the same comma characters segment does; the set
# lives in _state, so this one is pinned against that, not config.
from nameparser._pipeline._state import COMMA_CHARS
assert set(_render._COMMA_CHAR.pattern.strip("[]")) == set(COMMA_CHARS)
# The one sanctioned divergence between the differential rules'
# character classes and _SCRIPT_RANGES: the halfwidth middle dot
# separates tokens without being classified (halfwidth kana stays out
# of the table on purpose). U+00B7 is deliberately NOT here -- its
# flank guard means every name it can change matches through a
# classified flanking character already. Single-sourced: both span
# pins below read this set.
_SANCTIONED_EXTRAS = frozenset({(0xFF65, 0xFF65)})
def _declared_spans(name_regex: str) -> set[tuple[int, int]]:
"""The \\uXXXX-\\uXXXX span pairs a rule's character class declares."""
return {
(int(lo, 16), int(hi, 16))
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
name_regex)}
def _expected_bmp_spans() -> set[tuple[int, int]]:
"""What a full CJK character class in the toml must declare: the
table's BMP spans plus the sanctioned extras."""
return {span
for spans in _policy._SCRIPT_RANGES.values()
for span in spans
if span[1] <= 0xFFFF} | set(_SANCTIONED_EXTRAS)
def test_differential_cjk_rule_matches_the_script_ranges() -> None:
"""The CJK rule in tools/differential/expected_changes.toml hand-
copies the script spans from _policy._SCRIPT_RANGES into a character
class. A TOML file cannot import the constant, so this is the one
copy with no possible alternative -- and the one whose divergence
is quietest, because the harness is run by hand rather than in CI.
Both failure directions matter, which is why this compares sets
rather than checking coverage. A span MISSING from the class turns
an intended #271/#272 change into an UNEXPLAINED diff (a release
blocker for the wrong reason); a span that should not be there
silently classifies a real regression as intended, which is the
failure the whole harness exists to prevent.
Every table entry is in scope. The rule covered HAN and HANGUL
alone while the kana members existed only for classification, but
#272 gave HIRAGANA a default order entry and made the kana blocks
part of the same first/middle/last diff shape the rule explains,
so scoping it by issue no longer draws a real line. Comparing
against the whole table is also the stronger promise: a script
added to _SCRIPT_RANGES for ANY reason fails here until someone
decides, in writing, whether the rule should cover it.
Han's astral block is the single exception, out of scope on both
sides. The rule omits it deliberately -- no corpus name reaches
it, see the comment there -- so the comparison runs over the BMP
spans only.
The rule is also WIDER than the table by exactly one span, which
the equality has to know about or it would just fail forever. The
halfwidth middle dot U+FF65 changes parses without being
classified as anything: tokenize separates on it, so a halfwidth
transcription splits where 1.4 kept one token, while halfwidth
kana stays out of _SCRIPT_RANGES on purpose. U+00B7 -- the
context-sensitive 间隔号 (#298) -- also changes parses yet is
deliberately NOT an extra: its flank guard means every name it
can change matches the class through a flanking character
already, and a B7 span's only actual effect would be letting the
rule claim diffs on punt-volat Latin names (Gal·la), pre-excusing
a regression on exactly the guarded class. Naming the sanctioned
span here rather than relaxing the comparison to a subset check
is what keeps the pin honest in both directions: an unsanctioned
source of divergence still fails, and each sanctioned difference
has to be written down to exist.
"""
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
/ "expected_changes.toml")
rules = tomllib.loads(toml_path.read_text())["change"]
matched = [r for r in rules
if "#271" in r["issue"] or "#272" in r["issue"]]
assert len(matched) == 1, (
f"expected exactly one CJK rule in {toml_path.name}, "
f"found {len(matched)}")
# A new table entry must force an explicit decision rather than
# quietly widening (or failing to widen) the rule above.
assert set(_policy._SCRIPT_RANGES) == {
Script.HAN, Script.HANGUL, Script.HIRAGANA, Script.KATAKANA}, (
"a Script joined _SCRIPT_RANGES: decide whether the "
f"differential rule in {toml_path.name} should cover it, then "
"update this assertion")
declared = _declared_spans(matched[0]["name_regex"])
# the extras must stay UNclassified, or they belong in the table
for xlo, xhi in _SANCTIONED_EXTRAS:
assert not any(lo <= xhi and xlo <= hi
for spans in _policy._SCRIPT_RANGES.values()
for lo, hi in spans), (
f"U+{xlo:04X}-U+{xhi:04X} is classified now; drop it "
"from _SANCTIONED_EXTRAS")
expected = _expected_bmp_spans()
assert declared == expected, (
f"{toml_path.name}'s CJK name_regex declares {sorted(declared)}; "
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
def test_every_span_bearing_rule_matches_the_script_ranges() -> None:
"""Auto-discovered pin for every FURTHER hand copy of the script
spans in the toml: any rule whose character class declares spans
intersecting _SCRIPT_RANGES must declare the whole expected class
(table BMP spans + sanctioned extras). The compound rules'
require-a-classified-codepoint lookaheads exist so their trigger
sets alone (delimiters; a comma) cannot claim a Latin name's
regression -- and each such lookahead is a copy nothing else
checks. Discovery replaces a hand-maintained slug roster: a new
compound rule's copy is pinned by existing here, not by an author
remembering to enroll it. Rules whose spans touch OTHER scripts
(Cyrillic, say) are out of scope and skipped by the intersection
test.
Selection note for future rule authors: the canonical-rule pin
above selects by the literal '#271'/'#272' substrings and asserts
uniqueness -- compound slugs must avoid them.
"""
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
/ "expected_changes.toml")
rules = tomllib.loads(toml_path.read_text())["change"]
table_spans = _expected_bmp_spans()
checked = []
for rule in rules:
regex = rule.get("name_regex")
if not isinstance(regex, str):
continue
declared = _declared_spans(regex)
if not declared & table_spans:
continue
checked.append(rule["issue"])
assert declared == table_spans, (
f"{rule['issue']!r} declares {sorted(declared)}; expected "
f"{sorted(table_spans)}")
# the canonical rule plus both compound rules, today -- if this
# count drops, a hand copy fell out of discovery's reach
assert len(checked) >= 3, checked
# the delimiter compound's trigger set is its own decision surface
nickname = [r for r in rules
if "cjk-delimited-nickname" in r["issue"]]
assert len(nickname) == 1
assert "[\u300C\u300D\u300E\u300F\u30FB\uFF65]" in nickname[0][
"name_regex"] or "[「」『』・・]" in nickname[0]["name_regex"], (
"the compound rule's delimiter set changed; decide deliberately")
def test_cjk_corpus_matches_the_case_table() -> None:
"""corpus_cjk.jsonl is GENERATED, not curated (#295): every
distinct case-table text bearing a codepoint the script table
classifies, sorted -- see build_cjk_corpus.py for why the other
two corpora cannot carry these names. The checked-in file must
equal what the generator would write, so a CJK case row added
without regenerating fails HERE instead of silently narrowing
the differential gate back toward the blind spot #295 closed.
Same promise as the toml pin above, aimed at a generated artifact
instead of a hand copy.
"""
tools = Path(__file__).parents[2] / "tools" / "differential"
spec = importlib.util.spec_from_file_location(
"build_cjk_corpus", tools / "build_cjk_corpus.py")
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
checked_in = [json.loads(line) for line in
(tools / "corpus_cjk.jsonl")
.read_text(encoding="utf-8").splitlines()]
assert checked_in == module.selected_names(), (
"corpus_cjk.jsonl is stale: regenerate with "
"`uv run python tools/differential/build_cjk_corpus.py`")
def test_differential_honorific_rule_matches_the_suffix_vocabulary() -> None:
"""The fix(cjk-honorific-suffix) rule's alternation is a hand copy
of SUFFIX_NOT_ACRONYMS' CJK entries (#307) -- the toml cannot
import them. The expected set is DERIVED from the config by script
membership (a classified codepoint anywhere in the entry), so
adding a CJK honorific without widening the rule -- or widening
the rule with something the vocabulary does not ship -- fails
here. The span-bearing pins above skip this rule on purpose: its
trigger is the alternation, not a character class.
"""
from nameparser.config.suffixes import SUFFIX_NOT_ACRONYMS
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
/ "expected_changes.toml")
rules = tomllib.loads(toml_path.read_text())["change"]
matched = [r for r in rules if "cjk-honorific-suffix" in r["issue"]]
assert len(matched) == 1
regex = matched[0]["name_regex"]
prefix = "(?:^| )(?:"
assert regex.startswith(prefix) and regex.endswith(")$"), (
"the honorific rule's shape changed; update this parser")
declared = set(regex[len(prefix):-2].split("|"))
has_classified = _policy._script_matcher(*_policy._SCRIPT_RANGES)
expected = {entry for entry in SUFFIX_NOT_ACRONYMS
if has_classified(entry)}
assert declared == expected, (
f"rule declares {sorted(declared)}; the config's CJK suffix "
f"entries are {sorted(expected)}")