nameparser 2.4.1__tar.gz → 2.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. {nameparser-2.4.1/nameparser.egg-info → nameparser-2.4.2}/PKG-INFO +1 -1
  2. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_classify.py +13 -6
  3. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_pieces.py +5 -0
  4. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_vocab.py +21 -2
  5. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_version.py +1 -1
  6. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/titles.py +0 -1
  7. {nameparser-2.4.1 → nameparser-2.4.2/nameparser.egg-info}/PKG-INFO +1 -1
  8. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_cldr_agreement.py +70 -1
  9. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_parser.py +70 -3
  10. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_rules_doc.py +16 -6
  11. {nameparser-2.4.1 → nameparser-2.4.2}/AUTHORS +0 -0
  12. {nameparser-2.4.1 → nameparser-2.4.2}/LICENSE +0 -0
  13. {nameparser-2.4.1 → nameparser-2.4.2}/MANIFEST.in +0 -0
  14. {nameparser-2.4.1 → nameparser-2.4.2}/README.rst +0 -0
  15. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/__init__.py +0 -0
  16. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/__main__.py +0 -0
  17. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_config_shim.py +0 -0
  18. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_facade.py +0 -0
  19. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_lexicon.py +0 -0
  20. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_locale.py +0 -0
  21. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_parser.py +0 -0
  22. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/__init__.py +0 -0
  23. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_assemble.py +0 -0
  24. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_assign.py +0 -0
  25. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_comma.py +0 -0
  26. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_extract.py +0 -0
  27. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_group.py +0 -0
  28. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_post_rules.py +0 -0
  29. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_script_segment.py +0 -0
  30. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_segment.py +0 -0
  31. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_state.py +0 -0
  32. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_pipeline/_tokenize.py +0 -0
  33. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_policy.py +0 -0
  34. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_render.py +0 -0
  35. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/_types.py +0 -0
  36. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/__init__.py +0 -0
  37. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/_deprecated.py +0 -0
  38. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/_invariants.py +0 -0
  39. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/bound_first_names.py +0 -0
  40. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/bound_given_names.py +0 -0
  41. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/capitalization.py +0 -0
  42. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/conjunctions.py +0 -0
  43. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/maiden_markers.py +0 -0
  44. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/particles.py +0 -0
  45. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/prefixes.py +0 -0
  46. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/regexes.py +0 -0
  47. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/suffixes.py +0 -0
  48. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/config/surnames.py +0 -0
  49. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/locales/__init__.py +0 -0
  50. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/locales/ja.py +0 -0
  51. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/locales/ru.py +0 -0
  52. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/locales/tr_az.py +0 -0
  53. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/locales/zh.py +0 -0
  54. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/parser.py +0 -0
  55. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/py.typed +0 -0
  56. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser/util.py +0 -0
  57. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser.egg-info/SOURCES.txt +0 -0
  58. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser.egg-info/dependency_links.txt +0 -0
  59. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser.egg-info/requires.txt +0 -0
  60. {nameparser-2.4.1 → nameparser-2.4.2}/nameparser.egg-info/top_level.txt +0 -0
  61. {nameparser-2.4.1 → nameparser-2.4.2}/pyproject.toml +0 -0
  62. {nameparser-2.4.1 → nameparser-2.4.2}/setup.cfg +0 -0
  63. {nameparser-2.4.1 → nameparser-2.4.2}/tests/__init__.py +0 -0
  64. {nameparser-2.4.1 → nameparser-2.4.2}/tests/base.py +0 -0
  65. {nameparser-2.4.1 → nameparser-2.4.2}/tests/conftest.py +0 -0
  66. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_bound_given_names.py +0 -0
  67. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_brute_force.py +0 -0
  68. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_capitalization.py +0 -0
  69. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_comma_variants.py +0 -0
  70. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_conjunctions.py +0 -0
  71. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_constants.py +0 -0
  72. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_east_slavic_patronymic_order.py +0 -0
  73. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_first_name.py +0 -0
  74. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_initials.py +0 -0
  75. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_middle_name_as_last.py +0 -0
  76. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_nicknames.py +0 -0
  77. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_output_format.py +0 -0
  78. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_particles.py +0 -0
  79. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_python_api.py +0 -0
  80. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_suffixes.py +0 -0
  81. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_titles.py +0 -0
  82. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_turkic_patronymic_order.py +0 -0
  83. {nameparser-2.4.1 → nameparser-2.4.2}/tests/test_variations.py +0 -0
  84. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/__init__.py +0 -0
  85. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/_differential_fixtures.py +0 -0
  86. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/cases.py +0 -0
  87. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/conftest.py +0 -0
  88. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/__init__.py +0 -0
  89. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_assemble.py +0 -0
  90. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_assign.py +0 -0
  91. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_classify.py +0 -0
  92. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_comma.py +0 -0
  93. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_extract.py +0 -0
  94. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_group.py +0 -0
  95. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_pieces.py +0 -0
  96. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_post_rules.py +0 -0
  97. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_script_segment.py +0 -0
  98. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_segment.py +0 -0
  99. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_state.py +0 -0
  100. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_tokenize.py +0 -0
  101. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/pipeline/test_vocab.py +0 -0
  102. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/rules_doc.py +0 -0
  103. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_benchmark.py +0 -0
  104. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_cases.py +0 -0
  105. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_cldr_corpus.py +0 -0
  106. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_cli.py +0 -0
  107. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_config_aliases.py +0 -0
  108. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_config_shim.py +0 -0
  109. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_contracts.py +0 -0
  110. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_corpora.py +0 -0
  111. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_differential.py +0 -0
  112. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_doc_citations.py +0 -0
  113. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_doc_spellings.py +0 -0
  114. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_facade.py +0 -0
  115. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_facade_cases.py +0 -0
  116. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_layering.py +0 -0
  117. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_lexicon.py +0 -0
  118. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_locale.py +0 -0
  119. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_locales.py +0 -0
  120. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_name_usage_tool.py +0 -0
  121. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_order_correspondence.py +0 -0
  122. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_policy.py +0 -0
  123. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_properties.py +0 -0
  124. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_regex_sync.py +0 -0
  125. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_render.py +0 -0
  126. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_reprs.py +0 -0
  127. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_rules_doc_grammar.py +0 -0
  128. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_snapshot.py +0 -0
  129. {nameparser-2.4.1 → nameparser-2.4.2}/tests/v2/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 2.4.1
3
+ Version: 2.4.2
4
4
  Summary: A simple Python module for parsing human names into their individual components.
5
5
  Author-email: Derek Gulbranson <derek73@gmail.com>
6
6
  License: LGPL
@@ -62,8 +62,8 @@ from nameparser._pipeline._state import (
62
62
  )
63
63
  from nameparser._types import AmbiguityKind, Role
64
64
  from nameparser._pipeline._vocab import (
65
- caps_shape_candidate, is_initial, is_one_case, period_joined_vocab,
66
- suffix_as_written, tag_marker_runs,
65
+ caps_shape_candidate, composed_len, is_initial, is_one_case,
66
+ period_joined_vocab, suffix_as_written, tag_marker_runs,
67
67
  )
68
68
  from nameparser._pipeline._pieces import own_words
69
69
 
@@ -134,9 +134,14 @@ def _tags_for(token: WorkToken, n: str, state: ParseState,
134
134
  # written wholly in one case, where nothing says so — where the
135
135
  # letter is one the vocabulary marks as reading both ways"
136
136
  # (#383/#479; history: decisions.md#P3)
137
- single_letter_connective = (len(token.text) == 1
137
+ # one letter as the writer sees it: a decomposed 'й' is two code
138
+ # points (#665), so non-ASCII text is counted composed, after the
139
+ # C-level tests have declined everything else
140
+ single_letter_connective = (n in lex.conjunctions
138
141
  and token.text.upper() != token.text.lower()
139
- and n in lex.conjunctions)
142
+ and (len(token.text) == 1
143
+ or not token.text.isascii()
144
+ and composed_len(token.text) == 1))
140
145
  if single_letter_connective and one_case_own:
141
146
  # No case evidence, so the vocabulary decides. No namespaced
142
147
  # tag beside it: the emitted ambiguity IS the record of the
@@ -334,10 +339,12 @@ def classify(state: ParseState) -> ParseState:
334
339
  # site (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE), per
335
340
  # token -- 'e and e' reports twice.
336
341
  if (one_case and "initial" in token.tags
337
- and len(token.text) == 1
338
342
  and folded[i] in state.lexicon.conjunctions_ambiguous
339
343
  and folded[i] in state.lexicon.conjunctions
340
- and i < clause_at and token.role is None):
344
+ and i < clause_at and token.role is None
345
+ and (len(token.text) == 1
346
+ or not token.text.isascii()
347
+ and composed_len(token.text) == 1)):
341
348
  ambiguities.append(PendingAmbiguity(
342
349
  AmbiguityKind.CONJUNCTION_OR_INITIAL,
343
350
  f"{token.text!r} is both a connective and an initial; "
@@ -52,6 +52,7 @@ helper.
52
52
  """
53
53
  from __future__ import annotations
54
54
 
55
+ import unicodedata
55
56
  from collections.abc import (
56
57
  Callable, Container, Iterable, Mapping, Sequence, Set)
57
58
  from typing import NamedTuple
@@ -314,6 +315,10 @@ def join_connectives(pieces: list[list[int]], ptags: list[set[str]],
314
315
  continue
315
316
  if letter_stays and len(pieces[k]) == 1:
316
317
  text = tokens[pieces[k][0]].text
318
+ if not text.isascii():
319
+ # a decomposed 'й' is one letter in two code points
320
+ # (#665); compose it, as the lookup does
321
+ text = unicodedata.normalize("NFC", text)
317
322
  if len(text) == 1 and text.isalpha():
318
323
  k += 1
319
324
  continue
@@ -143,7 +143,18 @@ def _drop_kana_voicing_marks(text: str) -> str:
143
143
  # vetoes the whole word -- 'Kim김.' is refused as a title along with
144
144
  # '田中.' -- and that is deliberate: a word carrying a script with no
145
145
  # abbreviations is not wearing an abbreviation's period.
146
- in_initialless_script = _script_matcher(*_NO_INITIALS, whole=False)
146
+ _has_initialless_char = _script_matcher(*_NO_INITIALS, whole=False)
147
+
148
+
149
+ def in_initialless_script(text: str) -> bool:
150
+ """Whether TEXT holds a character of a script that writes no
151
+ initials (#320), asked of the composed spelling: a decomposed
152
+ hangul syllable is conjoining jamo, which no _NO_INITIALS range
153
+ holds, so '씨.' typed decomposed escaped the veto every shape test
154
+ beside it reads composed (#665). ASCII answers without the
155
+ matcher's frame, as the callers' own `isascii()` gates do."""
156
+ return (not text.isascii()
157
+ and _has_initialless_char(unicodedata.normalize("NFC", text)))
147
158
 
148
159
 
149
160
  # H2's own shape, text-level: an unlisted period-marked abbreviation,
@@ -224,7 +235,15 @@ def is_initial_shaped(text: str) -> bool:
224
235
  name want is_initial (#320). The two answers differ only inside
225
236
  _NO_INITIALS scripts, where '씨.' is initial-SHAPED but is not an
226
237
  initial -- see assign's roman-numeral fork, the shape caller, for
227
- what picking the wrong one costs."""
238
+ what picking the wrong one costs.
239
+
240
+ Asked of the composed spelling, as `_render` asks it (#542): `\\w`
241
+ does not match a combining mark, so a decomposed 'É.' is three
242
+ characters to the pattern and no initial (#665). Composing rather
243
+ than widening keeps the pattern v1-verbatim, and both calls are
244
+ C-level, so the predicate costs no frame it did not before."""
245
+ if not text.isascii():
246
+ text = unicodedata.normalize("NFC", text)
228
247
  return bool(_INITIAL.fullmatch(text))
229
248
 
230
249
 
@@ -7,7 +7,7 @@
7
7
  #: cannot carry the dev marker, so `VERSION >= (2, 5, 0)` is already
8
8
  #: true here while 2.5.0 is unreleased. Compare `__version__` instead
9
9
  #: where that distinction matters.
10
- VERSION = (2, 4, 1)
10
+ VERSION = (2, 4, 2)
11
11
  #: PEP 440 pre-release/dev segment appended to the numeric version, or
12
12
  #: "" for a final release. Joined WITHOUT a dot ("2.0.0rc1", not
13
13
  #: "2.0.0.rc1"); setuptools reads __version__ as the package version.
@@ -672,7 +672,6 @@ TITLES = GIVEN_NAME_TITLES | {
672
672
  'sgt',
673
673
  'sgtmaj',
674
674
  'sgtmajmc',
675
- 'shehu',
676
675
  'sheikh',
677
676
  'sheriff',
678
677
  'siddha',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 2.4.1
3
+ Version: 2.4.2
4
4
  Summary: A simple Python module for parsing human names into their individual components.
5
5
  Author-email: Derek Gulbranson <derek73@gmail.com>
6
6
  License: LGPL
@@ -18,7 +18,7 @@ from types import ModuleType
18
18
 
19
19
  import pytest
20
20
 
21
- from nameparser import parse
21
+ from nameparser import locales, parse
22
22
 
23
23
  _TOOLS = Path(__file__).parents[2] / "tools" / "differential"
24
24
 
@@ -206,3 +206,72 @@ def test_an_unspaced_role_is_expected_in_text_order() -> None:
206
206
  {"given": "セシリア", "given2": "ローズ", "surname": "ブラウン"},
207
207
  "ブラウン・ローズ・セシリア")
208
208
  assert agreement.same(roles["given_names"], "ローズ セシリア", "ブラウン・ローズ・セシリア")
209
+
210
+
211
+ # -- the locale view (#672) --------------------------------------------------
212
+
213
+ def test_every_locale_pack_is_measured_or_listed_as_having_no_cldr_file() -> None:
214
+ # A new pack must add its CLDR files to LOCALE_FILES or say it has
215
+ # none; otherwise it goes unmeasured by omission.
216
+ mapped = set(agreement.LOCALE_FILES.values())
217
+ assert not mapped & agreement.NO_CLDR
218
+ assert mapped | agreement.NO_CLDR == {code.upper() for code in locales.available()}
219
+
220
+
221
+ def test_every_mapped_cldr_file_has_renderings() -> None:
222
+ # A misspelled file in the table would measure nothing and pass.
223
+ files = {row["file"] for row in agreement.load_rows()}
224
+ assert set(agreement.LOCALE_FILES) <= files
225
+
226
+
227
+ def test_the_locale_view_parses_with_the_pack() -> None:
228
+ # A view that fell back to the default parser would record the
229
+ # default's disagreements and still pass its own ratchet. 王小明 is
230
+ # one unspaced Han token: only ZH segments it.
231
+ row = {"file": "zh", "label": "nativeG", "order": "surnameFirst",
232
+ "text": "王小明", "shape": 4, "fields": {"surname": "王", "given": "小明"}}
233
+ assert agreement.disagreements([row], locale=True) == {}
234
+ assert agreement.disagreements([row]) != {}
235
+
236
+
237
+ def test_a_pack_parser_keeps_the_family_first_order_of_its_shape() -> None:
238
+ # Latin text: no script decides the order, so only the FAMILY_FIRST
239
+ # base the pack is applied over can read Ivanov as the family.
240
+ name = agreement.parse_rendering("Ivanov Ivan", 4, "RU")
241
+ assert (name.family, name.given) == ("Ivanov", "Ivan")
242
+
243
+
244
+ def test_a_recorded_entry_whose_file_left_the_table_is_reported_gone() -> None:
245
+ # No pack's check reads such an entry, so without this it would sit
246
+ # in the record unguarded -- and survive every re-record.
247
+ [message] = agreement.unmapped({"zh/nativeG/givenFirst": {}, "xx/nativeG/sorting": {}})
248
+ assert message.startswith("gone: xx/nativeG/sorting")
249
+ assert agreement.unmapped(agreement.read_record(agreement.LOCALE_RECORD)) == []
250
+
251
+
252
+ def test_a_re_record_drops_entries_whose_file_left_the_table(
253
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
254
+ # The carry-over that keeps an unmeasured pack's entries must not
255
+ # keep an unmapped file's too.
256
+ record = tmp_path / "locales.json"
257
+ agreement.write_record({"xx/nativeG/sorting": {"text": "X"}}, record)
258
+ monkeypatch.setattr(agreement, "LOCALE_RECORD", record)
259
+ monkeypatch.setattr(agreement, "RECORD", tmp_path / "default.json")
260
+ assert agreement.main(["--record"]) == 0
261
+ assert "xx/nativeG/sorting" not in agreement.read_record(record)
262
+
263
+
264
+ @pytest.mark.parametrize("pack", [
265
+ pytest.param(pack, marks=pytest.mark.skipif(
266
+ not agreement.pack_available(pack),
267
+ reason=f"needs {agreement._EXTRAS.get(pack)}; CI's ja-extra job runs it"))
268
+ for pack in sorted(set(agreement.LOCALE_FILES.values()))])
269
+ def test_each_pack_still_agrees_with_cldr_where_it_did(pack: str) -> None:
270
+ problems = agreement.locale_changes(
271
+ agreement.load_rows(), pack, agreement.read_record(agreement.LOCALE_RECORD))
272
+ assert not problems, "\n".join([
273
+ *problems[:40],
274
+ f"{len(problems)} change(s) against "
275
+ f"tools/differential/cldr_agreement_locales.json for {pack}.",
276
+ f"If every one is intended, re-record with `{agreement.RECORD_COMMAND}` "
277
+ "and say why in the commit."])
@@ -1962,6 +1962,34 @@ _NFD_ROWS = [
1962
1962
  "Janez Novak, Ž.Š.Č.",
1963
1963
  "Janez Novak, Žž.Šš.",
1964
1964
  "Smith, Ž.Š. Jones",
1965
+ # #665: a period-marked initial (`\w\.`) beside a connective, in
1966
+ # each slot and comma shape -- a lone one read alike before the fix
1967
+ "É. y É. Smith",
1968
+ "Й. John Smith",
1969
+ "Ana É. y Luis",
1970
+ "Smith, É. y É.",
1971
+ ", É. y É.",
1972
+ "(Bob), É. y É.",
1973
+ # ... and the one-letter connective written in two code points:
1974
+ # three words (P3's carve-out keeps it a name word), four words
1975
+ # (it joins), one case, and after a comma
1976
+ "John й Smith",
1977
+ "JOHN Й SMITH",
1978
+ "john й smith",
1979
+ "Ivan й Petrov Sidorov",
1980
+ "Smith, John й Jones",
1981
+ # ... and the veto on scripts that write no initials (#320), asked
1982
+ # of the composed spelling too: decomposed hangul is conjoining
1983
+ # jamo, outside every _NO_INITIALS range, so composing the initial
1984
+ # shape alone made a decomposed '씨.' an initial (a title or a
1985
+ # given name). Spaced or after a comma, since an UNSPACED
1986
+ # decomposed hangul name is not split (docs/usage.rst) and so has
1987
+ # no composed twin to match
1988
+ "김민준, 씨.",
1989
+ "김 민준 씨.",
1990
+ "김 민준, 씨.",
1991
+ ", 씨.",
1992
+ "김. 민준",
1965
1993
  ]
1966
1994
  _NFD_POLICIES = [
1967
1995
  Policy(),
@@ -1976,6 +2004,15 @@ def _composed_roles(parser: Parser, text: str) -> dict[str, str]:
1976
2004
  for k, v in parser.parse(text).as_dict().items()}
1977
2005
 
1978
2006
 
2007
+ def _composed_reading(parser: Parser, text: str) -> tuple[object, ...]:
2008
+ """The roles, the reports' kinds and initials(), composed: #665's
2009
+ `John й Smith` differed in its report as well as its fields."""
2010
+ name = parser.parse(text)
2011
+ return (_composed_roles(parser, text),
2012
+ [a.kind for a in name.ambiguities],
2013
+ unicodedata.normalize("NFC", name.initials()))
2014
+
2015
+
1979
2016
  @pytest.mark.parametrize("policy", _NFD_POLICIES,
1980
2017
  ids=["default", "family-first", "family-first-given-last",
1981
2018
  "caps-everywhere"])
@@ -1996,13 +2033,43 @@ def test_a_decomposed_abbreviation_reads_as_its_composed_twin(
1996
2033
  two-letter rows pass there; the two-letter rows exist for the caps
1997
2034
  fix's own length count, which counted code points the first time
1998
2035
  (`John Smith, MÁ` read as a pair composed and a credential
1999
- decomposed, found in the review of 08c0dd60)."""
2036
+ decomposed, found in the review of 08c0dd60).
2037
+
2038
+ #665 added sixteen rows and compares kinds and initials() too.
2039
+ Recorded control, measured 2026-10-13 against f9b2b81c's
2040
+ nameparser/: 38 rows x 4 policies = 152 twin cases plus the four
2041
+ direct tests, of which 41 fail -- the six initial rows under all
2042
+ four policies (24; the same 24 with only `is_initial_shaped`'s
2043
+ compose reverted), the three-word `й` rows (12; `_pieces`'
2044
+ carve-out alone), `김. 민준` (4), and the one-case report test
2045
+ below (1; either `_classify` width site alone). The other four
2046
+ hangul rows pass there: they guard the fix itself, failing (with
2047
+ `김. 민준`, 20 in all) when `in_initialless_script`'s compose
2048
+ alone is reverted, since the composed initial shape without the
2049
+ composed veto made a decomposed '씨.' an initial. `Ivan й Petrov
2050
+ Sidorov` and `Smith, John й Jones` pass everywhere and pin parity
2051
+ only."""
2000
2052
  composed = unicodedata.normalize("NFC", text)
2001
2053
  decomposed = unicodedata.normalize("NFD", text)
2002
2054
  assert decomposed != composed
2003
2055
  parser = Parser(policy=policy)
2004
- assert (_composed_roles(parser, decomposed)
2005
- == _composed_roles(parser, composed))
2056
+ assert (_composed_reading(parser, decomposed)
2057
+ == _composed_reading(parser, composed))
2058
+
2059
+
2060
+ def test_a_decomposed_conjunction_reports_as_its_composed_twin() -> None:
2061
+ """#665: the one-case report on a letter that is both a connective
2062
+ and an initial (rules.md#P3) counted code points, so it is reached
2063
+ by `й` only once a caller marks it ambiguous -- no default entry is
2064
+ decomposable."""
2065
+ parser = Parser(lexicon=Lexicon.default().add(
2066
+ conjunctions_ambiguous={"й"}))
2067
+ text = "ivan й petrov"
2068
+ composed = parser.parse(text)
2069
+ assert [a.kind for a in composed.ambiguities] == [
2070
+ AmbiguityKind.CONJUNCTION_OR_INITIAL]
2071
+ assert (_composed_reading(parser, unicodedata.normalize("NFD", text))
2072
+ == _composed_reading(parser, text))
2006
2073
 
2007
2074
 
2008
2075
  def test_a_decomposed_caps_credential_is_still_a_suffix() -> None:
@@ -145,12 +145,22 @@ def test_example(rule: Rule, example: Example) -> None:
145
145
  assert actual == example.value
146
146
 
147
147
 
148
- def test_the_h2_nfd_example_is_still_decomposed() -> None:
149
- """#652: rules.md#H2's NFD example is the only executable witness
150
- of the encoding clause, and an editor or formatter that
151
- NFC-normalizes the file turns it into the composed case, which
152
- always worked and still passes. Its bytes are z + U+030C."""
148
+ @pytest.mark.parametrize("decomposed", [
149
+ "inž.", # H2's abbreviation shape (#652): z + U+030C
150
+ "John й Smith", # P3's one-letter connective (#665)
151
+ "É. y É. Smith", # P3's initial beside a connective (#665)
152
+ "김. 민준", # H2's script veto on hangul typed as jamo (#665)
153
+ ])
154
+ def test_the_nfd_examples_are_still_decomposed(decomposed: str) -> None:
155
+ """The decomposed example lines in rules.md are the only executable
156
+ witnesses of its encoding clauses (H2, P3), and an editor or
157
+ formatter that NFC-normalizes the file turns each into the
158
+ composed case, which always worked and still passes. Each entry
159
+ here is typed decomposed, which the first assert checks so that
160
+ normalizing this file cannot hollow the test out either."""
161
+ import unicodedata
153
162
  from pathlib import Path
163
+ assert not unicodedata.is_normalized("NFC", decomposed)
154
164
  text = (Path(__file__).parents[2] / "docs" / "design" / "rules.md"
155
165
  ).read_text(encoding="utf-8")
156
- assert "inž." in text, "H2's NFD example was recomposed"
166
+ assert decomposed in text, f"the NFD example {decomposed!r} was recomposed"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes