nameparser 2.0.0rc2__tar.gz → 2.1.0__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 (119) hide show
  1. {nameparser-2.0.0rc2/nameparser.egg-info → nameparser-2.1.0}/PKG-INFO +48 -8
  2. {nameparser-2.0.0rc2 → nameparser-2.1.0}/README.rst +45 -7
  3. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/__init__.py +7 -3
  4. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_config_shim.py +30 -12
  5. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_facade.py +18 -0
  6. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_lexicon.py +64 -7
  7. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_parser.py +131 -17
  8. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/__init__.py +4 -3
  9. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_assemble.py +25 -7
  10. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_assign.py +84 -6
  11. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_classify.py +3 -1
  12. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_extract.py +9 -1
  13. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_group.py +72 -5
  14. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_post_rules.py +2 -1
  15. nameparser-2.1.0/nameparser/_pipeline/_script_segment.py +767 -0
  16. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_segment.py +22 -51
  17. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_pipeline/_state.py +24 -10
  18. nameparser-2.1.0/nameparser/_pipeline/_tokenize.py +208 -0
  19. nameparser-2.1.0/nameparser/_pipeline/_vocab.py +356 -0
  20. nameparser-2.1.0/nameparser/_policy.py +916 -0
  21. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_render.py +12 -0
  22. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_types.py +109 -2
  23. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_version.py +2 -2
  24. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/conjunctions.py +5 -1
  25. nameparser-2.1.0/nameparser/config/maiden_markers.py +70 -0
  26. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/suffixes.py +118 -0
  27. nameparser-2.1.0/nameparser/config/surnames.py +49 -0
  28. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/locales/__init__.py +22 -0
  29. nameparser-2.1.0/nameparser/locales/ja.py +228 -0
  30. nameparser-2.1.0/nameparser/locales/zh.py +113 -0
  31. {nameparser-2.0.0rc2 → nameparser-2.1.0/nameparser.egg-info}/PKG-INFO +48 -8
  32. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser.egg-info/SOURCES.txt +7 -0
  33. nameparser-2.1.0/nameparser.egg-info/requires.txt +3 -0
  34. {nameparser-2.0.0rc2 → nameparser-2.1.0}/pyproject.toml +19 -0
  35. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_nicknames.py +39 -0
  36. nameparser-2.1.0/tests/v2/cases.py +1807 -0
  37. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/conftest.py +10 -15
  38. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_assemble.py +7 -4
  39. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_assign.py +68 -1
  40. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_classify.py +21 -1
  41. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_group.py +92 -1
  42. nameparser-2.1.0/tests/v2/pipeline/test_script_segment.py +815 -0
  43. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_segment.py +14 -0
  44. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_state.py +15 -2
  45. nameparser-2.1.0/tests/v2/pipeline/test_tokenize.py +228 -0
  46. nameparser-2.1.0/tests/v2/pipeline/test_vocab.py +373 -0
  47. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_benchmark.py +94 -12
  48. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_cli.py +13 -0
  49. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_config_shim.py +20 -0
  50. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_contracts.py +28 -8
  51. nameparser-2.1.0/tests/v2/test_differential.py +696 -0
  52. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_facade.py +52 -0
  53. nameparser-2.1.0/tests/v2/test_facade_cases.py +153 -0
  54. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_layering.py +18 -8
  55. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_lexicon.py +63 -3
  56. nameparser-2.1.0/tests/v2/test_locales.py +1202 -0
  57. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_parser.py +377 -2
  58. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_policy.py +298 -5
  59. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_properties.py +204 -12
  60. nameparser-2.1.0/tests/v2/test_regex_sync.py +366 -0
  61. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_reprs.py +29 -1
  62. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_types.py +78 -2
  63. nameparser-2.0.0rc2/nameparser/_pipeline/_tokenize.py +0 -125
  64. nameparser-2.0.0rc2/nameparser/_pipeline/_vocab.py +0 -126
  65. nameparser-2.0.0rc2/nameparser/_policy.py +0 -504
  66. nameparser-2.0.0rc2/nameparser/config/maiden_markers.py +0 -42
  67. nameparser-2.0.0rc2/tests/v2/cases.py +0 -469
  68. nameparser-2.0.0rc2/tests/v2/pipeline/test_tokenize.py +0 -91
  69. nameparser-2.0.0rc2/tests/v2/pipeline/test_vocab.py +0 -40
  70. nameparser-2.0.0rc2/tests/v2/test_facade_cases.py +0 -75
  71. nameparser-2.0.0rc2/tests/v2/test_locales.py +0 -550
  72. nameparser-2.0.0rc2/tests/v2/test_regex_sync.py +0 -151
  73. {nameparser-2.0.0rc2 → nameparser-2.1.0}/AUTHORS +0 -0
  74. {nameparser-2.0.0rc2 → nameparser-2.1.0}/LICENSE +0 -0
  75. {nameparser-2.0.0rc2 → nameparser-2.1.0}/MANIFEST.in +0 -0
  76. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/__main__.py +0 -0
  77. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/_locale.py +0 -0
  78. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/__init__.py +0 -0
  79. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/_invariants.py +0 -0
  80. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/bound_first_names.py +0 -0
  81. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/capitalization.py +0 -0
  82. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/prefixes.py +0 -0
  83. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/regexes.py +0 -0
  84. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/config/titles.py +0 -0
  85. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/locales/ru.py +0 -0
  86. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/locales/tr_az.py +0 -0
  87. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/parser.py +0 -0
  88. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/py.typed +0 -0
  89. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser/util.py +0 -0
  90. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser.egg-info/dependency_links.txt +0 -0
  91. {nameparser-2.0.0rc2 → nameparser-2.1.0}/nameparser.egg-info/top_level.txt +0 -0
  92. {nameparser-2.0.0rc2 → nameparser-2.1.0}/setup.cfg +0 -0
  93. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/__init__.py +0 -0
  94. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/base.py +0 -0
  95. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/conftest.py +0 -0
  96. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_bound_first_names.py +0 -0
  97. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_brute_force.py +0 -0
  98. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_capitalization.py +0 -0
  99. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_comma_variants.py +0 -0
  100. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_conjunctions.py +0 -0
  101. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_constants.py +0 -0
  102. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_east_slavic_patronymic_order.py +0 -0
  103. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_first_name.py +0 -0
  104. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_initials.py +0 -0
  105. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_middle_name_as_last.py +0 -0
  106. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_output_format.py +0 -0
  107. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_prefixes.py +0 -0
  108. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_python_api.py +0 -0
  109. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_suffixes.py +0 -0
  110. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_titles.py +0 -0
  111. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_turkic_patronymic_order.py +0 -0
  112. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/test_variations.py +0 -0
  113. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/__init__.py +0 -0
  114. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/__init__.py +0 -0
  115. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_extract.py +0 -0
  116. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/pipeline/test_post_rules.py +0 -0
  117. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_cases.py +0 -0
  118. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_locale.py +0 -0
  119. {nameparser-2.0.0rc2 → nameparser-2.1.0}/tests/v2/test_render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 2.0.0rc2
3
+ Version: 2.1.0
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
@@ -22,6 +22,8 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/x-rst
23
23
  License-File: LICENSE
24
24
  License-File: AUTHORS
25
+ Provides-Extra: ja
26
+ Requires-Dist: namedivider-python>=0.4; extra == "ja"
25
27
  Dynamic: license-file
26
28
 
27
29
  Name Parser
@@ -33,6 +35,19 @@ nameparser parses human names into seven fields — title, given, middle,
33
35
  family, suffix, nickname, maiden. Results are immutable, configuration is
34
36
  composable, and locale packs are opt-in.
35
37
 
38
+ 📣 **nameparser 2.0 is out.** Existing ``HumanName`` code keeps working
39
+ through 2.x, and most 1.x code needs no changes. The `migration guide
40
+ <https://nameparser.readthedocs.io/en/latest/migrate.html>`__ has the
41
+ field-by-field map. Please `open an issue
42
+ <https://github.com/derek73/python-nameparser/issues>`__ for anything that
43
+ parses wrong.
44
+
45
+ **2.1 adds East Asian name support.** Chinese, Japanese and Korean names
46
+ written in their own scripts are read family-first, unspaced Korean names
47
+ are split against the census surname list, and CJK honorifics are
48
+ recognized. See `East Asian names
49
+ <https://nameparser.readthedocs.io/en/latest/usage.html#east-asian-names>`__.
50
+
36
51
  Installation
37
52
  ------------
38
53
 
@@ -48,16 +63,41 @@ Quick Start Example
48
63
  .. code-block:: python
49
64
 
50
65
  >>> from nameparser import parse
51
- >>> name = parse("Dr. Juan Q. Xavier de la Vega III")
52
- >>> name.given, name.family
53
- ('Juan', 'de la Vega')
66
+ >>> name = parse("Dr. Juan Q. Xavier de la Vega III (Doc Vega)")
67
+ >>> name
68
+ <ParsedName: [
69
+ title: 'Dr.'
70
+ given: 'Juan'
71
+ middle: 'Q. Xavier'
72
+ family: 'de la Vega'
73
+ suffix: 'III'
74
+ nickname: 'Doc Vega'
75
+ ]>
76
+ >>> name.family_base, name.family_particles
77
+ ('Vega', 'de la')
54
78
  >>> name.render("{family}, {given}")
55
79
  'de la Vega, Juan'
56
80
 
57
- Those seven fields are ``title``, ``given``, ``middle``, ``family``,
58
- ``suffix``, ``nickname``, and ``maiden`` — plus aggregate views like
59
- ``given_names``, ``surnames``, ``family_base``, and ``family_particles``
60
- for combining or splitting them further.
81
+ >>> parse("김민준").family # Korean: unspaced, split on the census list
82
+ '김'
83
+ >>> parse("高橋 みなみ").family # Japanese: kanji with kana, family first
84
+ '高橋'
85
+ >>> parse("김민준씨").suffix # an honorific written against the name
86
+ '씨'
87
+ >>> parse("г-н Иван Петров").title # Cyrillic title
88
+ 'г-н'
89
+ >>> parse("محمد بن سلمان").family # Arabic: بن chains onto the family name
90
+ 'بن سلمان'
91
+
92
+ >>> from nameparser import locales, parser_for
93
+ >>> chinese = parser_for(locales.ZH) # Han text does not say which language
94
+ >>> chinese.parse("毛泽东").family # so splitting it is opt-in
95
+ '毛'
96
+ >>> russian = parser_for(locales.RU)
97
+ >>> russian.parse("Сидоров Иван Петрович").family
98
+ 'Сидоров'
99
+ >>> locales.available()
100
+ ('ja', 'ru', 'tr_az', 'zh')
61
101
 
62
102
  Learn more
63
103
  ----------
@@ -7,6 +7,19 @@ nameparser parses human names into seven fields — title, given, middle,
7
7
  family, suffix, nickname, maiden. Results are immutable, configuration is
8
8
  composable, and locale packs are opt-in.
9
9
 
10
+ 📣 **nameparser 2.0 is out.** Existing ``HumanName`` code keeps working
11
+ through 2.x, and most 1.x code needs no changes. The `migration guide
12
+ <https://nameparser.readthedocs.io/en/latest/migrate.html>`__ has the
13
+ field-by-field map. Please `open an issue
14
+ <https://github.com/derek73/python-nameparser/issues>`__ for anything that
15
+ parses wrong.
16
+
17
+ **2.1 adds East Asian name support.** Chinese, Japanese and Korean names
18
+ written in their own scripts are read family-first, unspaced Korean names
19
+ are split against the census surname list, and CJK honorifics are
20
+ recognized. See `East Asian names
21
+ <https://nameparser.readthedocs.io/en/latest/usage.html#east-asian-names>`__.
22
+
10
23
  Installation
11
24
  ------------
12
25
 
@@ -22,16 +35,41 @@ Quick Start Example
22
35
  .. code-block:: python
23
36
 
24
37
  >>> from nameparser import parse
25
- >>> name = parse("Dr. Juan Q. Xavier de la Vega III")
26
- >>> name.given, name.family
27
- ('Juan', 'de la Vega')
38
+ >>> name = parse("Dr. Juan Q. Xavier de la Vega III (Doc Vega)")
39
+ >>> name
40
+ <ParsedName: [
41
+ title: 'Dr.'
42
+ given: 'Juan'
43
+ middle: 'Q. Xavier'
44
+ family: 'de la Vega'
45
+ suffix: 'III'
46
+ nickname: 'Doc Vega'
47
+ ]>
48
+ >>> name.family_base, name.family_particles
49
+ ('Vega', 'de la')
28
50
  >>> name.render("{family}, {given}")
29
51
  'de la Vega, Juan'
30
52
 
31
- Those seven fields are ``title``, ``given``, ``middle``, ``family``,
32
- ``suffix``, ``nickname``, and ``maiden`` — plus aggregate views like
33
- ``given_names``, ``surnames``, ``family_base``, and ``family_particles``
34
- for combining or splitting them further.
53
+ >>> parse("김민준").family # Korean: unspaced, split on the census list
54
+ '김'
55
+ >>> parse("高橋 みなみ").family # Japanese: kanji with kana, family first
56
+ '高橋'
57
+ >>> parse("김민준씨").suffix # an honorific written against the name
58
+ '씨'
59
+ >>> parse("г-н Иван Петров").title # Cyrillic title
60
+ 'г-н'
61
+ >>> parse("محمد بن سلمان").family # Arabic: بن chains onto the family name
62
+ 'بن سلمان'
63
+
64
+ >>> from nameparser import locales, parser_for
65
+ >>> chinese = parser_for(locales.ZH) # Han text does not say which language
66
+ >>> chinese.parse("毛泽东").family # so splitting it is opt-in
67
+ '毛'
68
+ >>> russian = parser_for(locales.RU)
69
+ >>> russian.parse("Сидоров Иван Петрович").family
70
+ 'Сидоров'
71
+ >>> locales.available()
72
+ ('ja', 'ru', 'tr_az', 'zh')
35
73
 
36
74
  Learn more
37
75
  ----------
@@ -11,6 +11,7 @@ from nameparser._locale import Locale
11
11
  from nameparser._parser import Parser, parse, parser_for
12
12
  from nameparser._policy import (
13
13
  DEFAULT_NICKNAME_DELIMITERS,
14
+ DEFAULT_SCRIPT_ORDERS,
14
15
  FAMILY_FIRST,
15
16
  FAMILY_FIRST_GIVEN_LAST,
16
17
  GIVEN_FIRST,
@@ -18,6 +19,7 @@ from nameparser._policy import (
18
19
  PatronymicRule,
19
20
  Policy,
20
21
  PolicyPatch,
22
+ Script,
21
23
  )
22
24
  from nameparser._types import (
23
25
  STABLE_TAGS,
@@ -25,6 +27,8 @@ from nameparser._types import (
25
27
  AmbiguityKind,
26
28
  ParsedName,
27
29
  Role,
30
+ Segmentation,
31
+ Segmenter,
28
32
  Span,
29
33
  Token,
30
34
  )
@@ -34,9 +38,9 @@ __all__ = [
34
38
  "HumanName",
35
39
  # v2 core
36
40
  "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName",
37
- "STABLE_TAGS",
38
- "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "UNSET",
41
+ "STABLE_TAGS", "Segmentation", "Segmenter",
42
+ "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "Script", "UNSET",
39
43
  "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST",
40
- "DEFAULT_NICKNAME_DELIMITERS", "Locale",
44
+ "DEFAULT_NICKNAME_DELIMITERS", "DEFAULT_SCRIPT_ORDERS", "Locale",
41
45
  "Parser", "parse", "parser_for",
42
46
  ]
@@ -986,10 +986,23 @@ class Constants:
986
986
  removal path.
987
987
  """
988
988
  from nameparser.config.maiden_markers import MAIDEN_MARKERS
989
+ from nameparser.config.suffixes import GLUED_HONORIFICS
990
+ from nameparser.config.surnames import KOREAN_SURNAMES
989
991
  acronyms = frozenset(self.suffix_acronyms)
990
992
  particles = frozenset(self.prefixes)
991
993
  bound = frozenset(self.bound_first_names)
992
994
  ambiguous_acronyms = frozenset(self.suffix_acronyms_ambiguous) & acronyms
995
+ # Drop any ambiguous acronym from the word set rather than the
996
+ # other way round. Lexicon forbids the overlap because the word
997
+ # branch bypasses the period gate, and adding an ambiguous
998
+ # acronym to suffix_not_acronyms is INERT in v1 anyway:
999
+ # is_suffix already accepts it via the acronym branch, and
1000
+ # reserve_last keeps it as the surname. So ignoring the
1001
+ # addition reproduces v1 ("Jack Ma" keeps last='Ma'), where
1002
+ # dropping it from the AMBIGUOUS set instead ungated the word
1003
+ # and lost the family name -- a silent misparse worse than the
1004
+ # raise it avoided.
1005
+ suffix_words = frozenset(self.suffix_not_acronyms) - ambiguous_acronyms
993
1006
  # keep in sync with _lexicon._default_lexicon() (pinned by
994
1007
  # tests/v2/test_config_shim.py::test_snapshot_field_translation)
995
1008
  lexicon = Lexicon(
@@ -1016,18 +1029,7 @@ class Constants:
1016
1029
  if e == " ".join(e.split())
1017
1030
  ) if t),
1018
1031
  suffix_acronyms=acronyms,
1019
- # Drop any ambiguous acronym from the word set rather than
1020
- # the other way round. Lexicon forbids the overlap because
1021
- # the word branch bypasses the period gate, and adding an
1022
- # ambiguous acronym to suffix_not_acronyms is INERT in v1
1023
- # anyway: is_suffix already accepts it via the acronym
1024
- # branch, and reserve_last keeps it as the surname. So
1025
- # ignoring the addition reproduces v1 ("Jack Ma" keeps
1026
- # last='Ma'), where dropping it from the AMBIGUOUS set
1027
- # instead ungated the word and lost the family name --
1028
- # a silent misparse worse than the raise it avoided.
1029
- suffix_words=frozenset(
1030
- self.suffix_not_acronyms) - ambiguous_acronyms,
1032
+ suffix_words=suffix_words,
1031
1033
  # Intersect with acronyms: Lexicon enforces ambiguous <=
1032
1034
  # acronyms; v1 behaves the same when an acronym is deleted
1033
1035
  # but its ambiguous entry lingers (the entry stops
@@ -1061,6 +1063,22 @@ class Constants:
1061
1063
  # v1 Constants has no manager for these (#274 is 2.0
1062
1064
  # behavior); the data module is the only source
1063
1065
  maiden_markers=frozenset(MAIDEN_MARKERS),
1066
+ # likewise no v1 manager: the unspaced-name segmentation
1067
+ # vocabulary is 2.0 behavior (#271), so it rides in the
1068
+ # snapshot only -- v1's Constants surface stays frozen.
1069
+ # Unwrapped where maiden_markers above is wrapped: this
1070
+ # module is born frozen (#293), so no wrap
1071
+ surnames=KOREAN_SURNAMES,
1072
+ # likewise no v1 manager: the glued-honorific tail set is
1073
+ # 2.1 behavior (#308), so it rides in the snapshot only.
1074
+ # Wrapped, unlike surnames above: suffixes.py is still a
1075
+ # mutable v1 module, not born-frozen like surnames.py
1076
+ # (#293). Intersect with the word set: Lexicon enforces
1077
+ # tails <= suffix_words, and v1 semantics are that deleting
1078
+ # a suffix word turns the behavior off -- a lingering tail
1079
+ # simply stops mattering, the same rule ambiguous_acronyms
1080
+ # gets against suffix_acronyms above.
1081
+ honorific_tails=frozenset(GLUED_HONORIFICS) & suffix_words,
1064
1082
  # TupleManager is dict[str, object] (v1 parity: values were
1065
1083
  # never statically str-typed); every real entry is a str,
1066
1084
  # same assumption _DelimiterManager's sentinel lookup makes
@@ -623,6 +623,24 @@ class HumanName:
623
623
  # spelling the facade actually exposes as attributes.
624
624
  return getattr(self, _V1_SPELLING.get(key, key))
625
625
 
626
+ def __setattr__(self, name: str, value: object) -> None:
627
+ # "given"/"family" are the 2.0 spellings of first/last; the
628
+ # facade has no such attributes, so plain assignment creates a
629
+ # stray instance attribute while the parse (and .first/.last)
630
+ # keeps the old value -- a silently forked name. Warn but
631
+ # still set: ad-hoc attribute stashing is a legal v1 pattern,
632
+ # so any code that worked keeps working. Only these two names
633
+ # warn -- the other five 2.0 field names are real properties
634
+ # whose setters work, and Role members reach here as their
635
+ # string values (StrEnum).
636
+ if name in _V1_SPELLING:
637
+ warnings.warn(
638
+ f"assigning HumanName.{name} creates an inert attribute; "
639
+ f"the parse is unchanged -- use .{_V1_SPELLING[name]} "
640
+ f"(the v1 spelling) to update the name",
641
+ UserWarning, stacklevel=2)
642
+ super().__setattr__(name, value)
643
+
626
644
  def as_dict(self, include_empty: bool = True) -> dict[str, str]:
627
645
  """The seven v1-named components as a dict; include_empty=False
628
646
  drops empty fields."""
@@ -23,20 +23,44 @@ from typing import cast
23
23
  _VOCAB_FIELDS = (
24
24
  "titles", "given_name_titles", "suffix_acronyms", "suffix_words",
25
25
  "suffix_acronyms_ambiguous", "particles", "particles_ambiguous",
26
- "conjunctions", "bound_given_names", "maiden_markers",
26
+ "conjunctions", "bound_given_names", "maiden_markers", "surnames",
27
+ "honorific_tails",
27
28
  )
28
29
 
29
- #: (marker, base, why) triples. Each marker narrows how entries of its
30
- #: base vocabulary are read and carries no vocabulary of its own, so an
31
- #: entry outside the base is a configuration mistake -- but the mistake
32
- #: differs per pair, and the reason is recorded here rather than
33
- #: generalized, because an orphan is NOT simply inert:
30
+ #: (marker, base, why) triples. Each marker QUALIFIES how entries of
31
+ #: its base vocabulary are read and carries no vocabulary of its own,
32
+ #: so an entry outside the base is a configuration mistake -- but the
33
+ #: mistake differs per pair, and the reason is recorded here rather
34
+ #: than generalized, because an orphan is NOT simply inert. Nor is the
35
+ #: qualification one-directional: the first two NARROW their base (an
36
+ #: entry is read as vocabulary in fewer places), while honorific_tails
37
+ #: WIDENS it, granting a suffix word the glued position on top of the
38
+ #: whole-token match every suffix word already gets.
34
39
  #:
35
40
  #: * particles_ambiguous: _assign keys on the tag alone, so an orphan
36
41
  #: makes the parse emit a spurious particle-or-given ambiguity.
37
42
  #: * suffix_acronyms_ambiguous: _vocab returns True on the ambiguous
38
43
  #: set before testing suffix_acronyms, so an orphan silently turns a
39
44
  #: word into a period-gated suffix.
45
+ #: * honorific_tails: script_segment peels the tail into its own token
46
+ #: before classify ever runs, so an orphan splits the name and leaves
47
+ #: the fragment stranded inside it -- worse than not peeling at all.
48
+ #: Its base is deliberately NARROWER than what actually claims the
49
+ #: peeled piece: suffix_as_written ORs suffix_words with the
50
+ #: non-ambiguous suffix_acronyms, so a tail listed only as an acronym
51
+ #: would classify fine yet is rejected here. Accepted, and a decision
52
+ #: rather than an oversight -- the three-term predicate is easy to
53
+ #: get wrong in the dangerous direction (an ambiguous acronym admitted
54
+ #: as a tail would peel a period-gated word off a real name), and
55
+ #: nothing needs the acronym half: the shipped tails are CJK
56
+ #: honorifics, which are words.
57
+ #: The same relation is asserted a second time in config/suffixes.py,
58
+ #: over the raw GLUED_HONORIFICS/SUFFIX_NOT_ACRONYMS constants at
59
+ #: import. The two are not redundant in the way they look: that one
60
+ #: is an `assert`, stripped under `python -O`, while the check here
61
+ #: raises unconditionally -- so under -O this is what still holds the
62
+ #: SHIPPED vocabulary to the invariant, as it is the only thing that
63
+ #: ever held a caller's own.
40
64
  #:
41
65
  #: given_name_titles is deliberately NOT here and has no check of its
42
66
  #: own -- see the note in __post_init__ for why every attempt at one
@@ -46,6 +70,8 @@ _SUBSET_FIELDS = (
46
70
  "an orphan emits a spurious particle-or-given ambiguity"),
47
71
  ("suffix_acronyms_ambiguous", "suffix_acronyms",
48
72
  "an orphan silently becomes a period-gated suffix"),
73
+ ("honorific_tails", "suffix_words",
74
+ "an orphan splits the name and leaves the tail inside it"),
49
75
  )
50
76
 
51
77
 
@@ -327,6 +353,31 @@ class Lexicon:
327
353
  #: field ("née", "geb.", "roz.", ...). Full default list:
328
354
  #: :data:`~nameparser.config.maiden_markers.MAIDEN_MARKERS`.
329
355
  maiden_markers: frozenset[str] = frozenset()
356
+ #: Family names for the unspaced-name segmentation stage (#271),
357
+ #: matched longest-first against the start of the FIRST token
358
+ #: written wholly in a script :attr:`Policy.segment_scripts
359
+ #: <nameparser.Policy.segment_scripts>` activates. The default
360
+ #: carries the Korean census list
361
+ #: (:data:`~nameparser.config.surnames.KOREAN_SURNAMES`); Chinese
362
+ #: surnames ship in locales.ZH because Han segmentation is opt-in.
363
+ surnames: frozenset[str] = frozenset()
364
+ #: Honorifics that may be peeled off the END of a name token
365
+ #: (#308), matched longest-first: 田中さん splits into 田中 and さん
366
+ #: before the tokens are classified. Every entry must also be a
367
+ #: :attr:`suffix_words` entry -- the peeled tail is claimed by
368
+ #: suffix classification like any other post-nominal. Deliberately
369
+ #: NOT gated on :attr:`Policy.segment_scripts
370
+ #: <nameparser.Policy.segment_scripts>` (unlike :attr:`surnames`
371
+ #: above): 田中さん peels under the default policy, where HAN is in
372
+ #: no activation set, because a tail entry carries its own license
373
+ #: to fire. Entries are matched against the RAW token text, and
374
+ #: only within a name containing a non-ASCII character, so an ASCII
375
+ #: or mixed-case entry is at best conditionally active -- a ``"Jr"``
376
+ #: entry is stored ``"jr"`` and matches only lowercase text. The
377
+ #: field is effectively CJK-scoped in 2.1, which is what the shipped
378
+ #: vocabulary is. Full default list:
379
+ #: :data:`~nameparser.config.suffixes.GLUED_HONORIFICS`.
380
+ honorific_tails: frozenset[str] = frozenset()
330
381
  #: Lowercase word -> exact-cased replacement used by capitalized()
331
382
  #: ("phd" -> "Ph.D."). Pair-valued: change it with
332
383
  #: dataclasses.replace(), not add()/remove(); read it as a mapping
@@ -569,8 +620,10 @@ def _default_lexicon() -> Lexicon:
569
620
  from nameparser.config.maiden_markers import MAIDEN_MARKERS
570
621
  from nameparser.config.prefixes import NON_FIRST_NAME_PREFIXES, PREFIXES
571
622
  from nameparser.config.suffixes import (
572
- SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_NOT_ACRONYMS,
623
+ GLUED_HONORIFICS, SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS,
624
+ SUFFIX_NOT_ACRONYMS,
573
625
  )
626
+ from nameparser.config.surnames import KOREAN_SURNAMES
574
627
  from nameparser.config.titles import FIRST_NAME_TITLES, TITLES
575
628
 
576
629
  # v1 data modules export plain `set[str]`; wrap each at this call site
@@ -590,6 +643,10 @@ def _default_lexicon() -> Lexicon:
590
643
  conjunctions=frozenset(CONJUNCTIONS),
591
644
  bound_given_names=frozenset(BOUND_FIRST_NAMES),
592
645
  maiden_markers=frozenset(MAIDEN_MARKERS),
646
+ # surnames.py is born frozen (#293) -- no call-site wrap needed,
647
+ # unlike the v1 modules above (their wraps drop when #293 lands)
648
+ surnames=KOREAN_SURNAMES,
649
+ honorific_tails=frozenset(GLUED_HONORIFICS),
593
650
  # pass canonical pair-tuples so this strictly-typed call site never
594
651
  # feeds a Mapping to the tuple-annotated field; __post_init__
595
652
  # still tolerates a Mapping at runtime for interactive use
@@ -11,17 +11,18 @@ from __future__ import annotations
11
11
  import dataclasses
12
12
  import functools
13
13
  import warnings
14
- from dataclasses import dataclass
14
+ from dataclasses import dataclass, field
15
15
 
16
16
  from nameparser._lexicon import Lexicon
17
17
  from nameparser._locale import Locale
18
18
  from nameparser._pipeline import run
19
19
  from nameparser._pipeline._assemble import assemble
20
20
  from nameparser._pipeline._state import ParseState
21
- from nameparser._policy import UNSET, Policy, PolicyPatch, apply_patch
21
+ from nameparser._pipeline._vocab import _SCRIPT_MATCHERS
22
+ from nameparser._policy import UNSET, Policy, PolicyPatch, _Unset, apply_patch
22
23
  from nameparser._types import (
23
- FOLDED_TAG, ParsedName, Token, _guarded_getstate, _guarded_setstate,
24
- _validated_field_strings,
24
+ FOLDED_TAG, ParsedName, Segmenter, Token, _guarded_getstate,
25
+ _guarded_setstate, _validated_field_strings,
25
26
  )
26
27
 
27
28
 
@@ -30,18 +31,37 @@ class Parser:
30
31
  """A configured name parser: a :class:`Lexicon` (vocabulary) plus
31
32
  a :class:`Policy` (behavior), both defaulted when omitted. Build
32
33
  one when you need non-default configuration, build it once, and
33
- call :meth:`parse` many times -- it is immutable, thread-safe, and
34
- picklable by construction: all validity checking happens at
34
+ call :meth:`parse` many times -- it is immutable and thread-safe.
35
+
36
+ An optional keyword-only ``segmenter`` (a :data:`~nameparser.Segmenter`)
37
+ plugs in outside knowledge of where an unspaced CJK token divides --
38
+ Japanese kanji names, which no bundled list can settle. It is
39
+ consulted only for a token the segmentation stage gates in and the
40
+ vocabulary DECLINES, so a locale pack's surnames always win where
41
+ they match; returning None declines in turn and the token stays
42
+ whole. Two promises narrow when one is supplied (locales spec §4):
43
+ parse-totality gains its one exception -- an exception raised by
44
+ the segmenter propagates, because a user-supplied callable's own
45
+ error is a user-code error, not a content error -- and this Parser
46
+ pickles only if its segmenter does (a module-level function
47
+ pickles; a lambda or closure does not). With no segmenter, both
48
+ promises hold unconditionally: all validity checking happens at
35
49
  construction, so a Parser that constructs successfully cannot fail
36
50
  at parse time on any str content.
37
51
 
38
52
  (The None field defaults resolve in __post_init__; after
39
- construction both fields are always non-None -- the annotations
40
- state the steady-state truth, hence the assignment ignores on the
41
- defaults.)"""
53
+ construction lexicon and policy are always non-None -- the
54
+ annotations state the steady-state truth, hence the assignment
55
+ ignores on the defaults.)"""
42
56
 
43
57
  lexicon: Lexicon = None # type: ignore[assignment] # None -> default()
44
58
  policy: Policy = None # type: ignore[assignment] # None -> Policy()
59
+ #: An optional hook supplying outside knowledge of where an unspaced
60
+ #: token divides -- see the class docstring; None leaves such tokens
61
+ #: whole. Keyword-only, so the reserved growth stays additive
62
+ #: (locales spec §4): positional construction keeps its two-argument
63
+ #: shape.
64
+ segmenter: Segmenter | None = field(default=None, kw_only=True)
45
65
 
46
66
  # in the class body so @dataclass(slots=True) keeps them
47
67
  __getstate__ = _guarded_getstate
@@ -58,16 +78,73 @@ class Parser:
58
78
  elif not isinstance(self.policy, Policy):
59
79
  raise TypeError(
60
80
  f"policy must be a Policy or None, got {self.policy!r}")
81
+ if self.segmenter is not None and not callable(self.segmenter):
82
+ raise TypeError(
83
+ f"segmenter must be callable or None, got {self.segmenter!r}")
84
+ # A configuration gap that used to be silent (#272's API, made
85
+ # loud before 2.1.0): segment_scripts can activate a script
86
+ # that neither the vocabulary nor a segmenter can ever divide
87
+ # -- the JA pack's whole shape, when its segmenter is
88
+ # forgotten. The parser then behaves identically to a working
89
+ # one minus the feature, which reads as "not working" with no
90
+ # signal why. Statically decidable here, so say it here; a
91
+ # warning rather than an error because the inert pack is a
92
+ # pinned, deliberate property (a JA registration must be safe
93
+ # without the extra), and warnings are filterable by the rare
94
+ # caller who wants exactly that.
95
+ if self.segmenter is None:
96
+ uncovered = sorted(
97
+ script.value
98
+ for script in self.policy.segment_scripts
99
+ if not any(_SCRIPT_MATCHERS[script](entry)
100
+ for entry in self.lexicon.surnames))
101
+ if uncovered:
102
+ names = ", ".join(uncovered)
103
+ one = len(uncovered) == 1
104
+ # the ja hint only where a Japanese script is among the
105
+ # dead ones -- a hangul-only gap (a from-scratch
106
+ # lexicon under the default policy) has different
107
+ # remedies, and pointing it at ja_segmenter would be a
108
+ # non sequitur
109
+ ja_hint = (
110
+ " For Japanese, pass "
111
+ "segmenter=locales.ja_segmenter() (install with: "
112
+ "pip install 'nameparser[ja]')."
113
+ if {"han", "hiragana", "katakana"} & set(uncovered)
114
+ else "")
115
+ warnings.warn(
116
+ f"Policy.segment_scripts activates {names} but the "
117
+ f"vocabulary has no surnames in "
118
+ f"{'that script' if one else 'those scripts'} "
119
+ f"and no segmenter is configured: unspaced names "
120
+ f"written in {'it' if one else 'them'} will never "
121
+ f"divide. Supply covering surnames, pass a "
122
+ f"segmenter, or deactivate with "
123
+ f"Policy(segment_scripts=frozenset()).{ja_hint}",
124
+ UserWarning, stacklevel=3)
61
125
 
62
126
  def __repr__(self) -> str:
63
- # composes the two bounded component reprs (spec §2 reprs)
64
- return f"Parser({self.lexicon!r}, {self.policy!r})"
127
+ # composes the two bounded component reprs (spec §2 reprs); the
128
+ # segmenter shows by name, and only when one is set, so the
129
+ # default Parser's repr is unchanged
130
+ seg = ""
131
+ if self.segmenter is not None:
132
+ # never repr() the callable itself: a partial reprs its
133
+ # bound arguments and a callable instance its address, both
134
+ # unbounded -- the class name is the bounded fallback
135
+ name = (getattr(self.segmenter, "__qualname__", None)
136
+ or type(self.segmenter).__name__)
137
+ seg = f", segmenter={name}"
138
+ return f"Parser({self.lexicon!r}, {self.policy!r}{seg})"
65
139
 
66
140
  def parse(self, text: str) -> ParsedName:
67
141
  """Parse one name string into a :class:`ParsedName`. Never
68
142
  raises on string content (unparseable input yields empty
69
143
  fields plus ambiguities); non-str raises TypeError eagerly,
70
- with a decode hint for bytes (bytes support ended with 1.x)."""
144
+ with a decode hint for bytes (bytes support ended with 1.x).
145
+ The one exception to that totality is a configured
146
+ ``segmenter``, whose own exceptions propagate (see the class
147
+ docstring)."""
71
148
  if isinstance(text, bytes):
72
149
  raise TypeError(
73
150
  "parse() takes str, not bytes -- decode first, e.g. "
@@ -75,7 +152,7 @@ class Parser:
75
152
  if not isinstance(text, str):
76
153
  raise TypeError(f"parse() takes str, got {text!r}")
77
154
  state = ParseState(original=text, lexicon=self.lexicon,
78
- policy=self.policy)
155
+ policy=self.policy, segmenter=self.segmenter)
79
156
  return assemble(run(state))
80
157
 
81
158
  # -- editing ----------------------------------------------------------
@@ -92,7 +169,11 @@ class Parser:
92
169
  role choices and ambiguities are discarded -- every harvested
93
170
  token takes the named field's role -- and its structural
94
171
  behavior applies: delimiter characters do not become tokens,
95
- and a mid-value maiden marker is consumed as in parsing.
172
+ and a maiden marker is consumed as in parsing -- mid-value
173
+ always, and leading a DELIMITED value under a policy routing
174
+ that pair to maiden, where "(née Jones)" revises to "Jones"
175
+ while the bare "née Jones" keeps its marker, a leading marker
176
+ in an undelimited value being no marker at all (#329).
96
177
  Tokens are synthetic (span=None); original is unchanged; a
97
178
  value with no name content (empty, whitespace, or punctuation
98
179
  only) clears the field; ambiguities referencing replaced
@@ -151,14 +232,27 @@ def parse(text: str) -> ParsedName:
151
232
  return _default_parser().parse(text)
152
233
 
153
234
 
154
- def parser_for(*locales: Locale, base: Parser | None = None) -> Parser:
235
+ def parser_for(*locales: Locale, base: Parser | None = None,
236
+ segmenter: Segmenter | None | _Unset = UNSET) -> Parser:
155
237
  """Lexicon fragments unioned left-to-right onto base's; policy
156
238
  patches applied left-to-right (later wins; set-valued fields union
157
239
  per the patch metadata). Validation errors raised while applying a
158
240
  pack are wrapped with that pack's identity (spec §4 amendment) --
159
241
  PolicyPatch validates lazily, so with stacked packs the raw error
160
242
  would otherwise point at nothing. Two packs setting the same SCALAR
161
- field is a declared conflict: UserWarning, later wins."""
243
+ field is a declared conflict: UserWarning, later wins.
244
+
245
+ A ``segmenter`` is passed straight through to the built Parser --
246
+ ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` is how
247
+ a pack and a segmenter combine, since packs are pure data and
248
+ cannot supply one. The argument has THREE states, the same
249
+ :data:`~nameparser.UNSET` spelling a PolicyPatch field uses, because
250
+ None is a meaningful value here and not an absence: omitted (UNSET)
251
+ carries base's segmenter through unchanged; a callable OVERRIDES
252
+ base's (later wins, the rule scalar policy fields follow); and an
253
+ explicit ``None`` CLEARS base's, which is how you derive an
254
+ unsegmented parser from a segmented one without rebuilding its
255
+ lexicon and policy by hand."""
162
256
  if base is not None and not isinstance(base, Parser):
163
257
  raise TypeError(f"base must be a Parser or None, got {base!r}")
164
258
  for loc in locales:
@@ -166,6 +260,15 @@ def parser_for(*locales: Locale, base: Parser | None = None) -> Parser:
166
260
  raise TypeError(f"parser_for() takes Locale packs, got {loc!r}")
167
261
  lexicon = base.lexicon if base is not None else Lexicon.default()
168
262
  policy = base.policy if base is not None else Policy()
263
+ # Resolved here rather than at the return because the return builds
264
+ # a FRESH Parser: any field not listed there silently takes its
265
+ # default, and a dropped segmenter would be invisible. UNSET, not
266
+ # None, is what "not given" means -- None is the CLEAR request, and
267
+ # collapsing the two would make an explicit
268
+ # parser_for(..., segmenter=None) silently inherit the very
269
+ # segmenter it was asked to drop.
270
+ if segmenter is UNSET:
271
+ segmenter = base.segmenter if base is not None else None
169
272
  scalar_setters: dict[str, str] = {}
170
273
  for loc in locales:
171
274
  for f in dataclasses.fields(PolicyPatch):
@@ -189,4 +292,15 @@ def parser_for(*locales: Locale, base: Parser | None = None) -> Parser:
189
292
  # a subclass with extra mandatory args would break this rewrap
190
293
  raise type(exc)(
191
294
  f"while applying locale {loc.code!r}: {exc}") from exc
192
- return Parser(lexicon=lexicon, policy=policy)
295
+ # Construction warnings (the segmenterless-activation check in
296
+ # Parser.__post_init__) re-emit from THIS frame: its stacklevel is
297
+ # sized for direct Parser(...) construction, and through this
298
+ # function's extra frame the default single-line rendering would
299
+ # point into the library instead of at the caller -- the exact
300
+ # call the message tells them to change.
301
+ with warnings.catch_warnings(record=True) as caught:
302
+ warnings.simplefilter("always")
303
+ built = Parser(lexicon=lexicon, policy=policy, segmenter=segmenter)
304
+ for w in caught:
305
+ warnings.warn(w.message, stacklevel=2)
306
+ return built
@@ -16,14 +16,15 @@ from nameparser._pipeline._classify import classify
16
16
  from nameparser._pipeline._extract import extract_delimited
17
17
  from nameparser._pipeline._group import group
18
18
  from nameparser._pipeline._post_rules import post_rules
19
+ from nameparser._pipeline._script_segment import script_segment
19
20
  from nameparser._pipeline._segment import segment
20
21
  from nameparser._pipeline._state import ParseState
21
22
  from nameparser._pipeline._tokenize import tokenize
22
23
 
23
- #: The full seven-stage fold.
24
+ #: The full eight-stage fold.
24
25
  STAGES: tuple[Callable[[ParseState], ParseState], ...] = (
25
- extract_delimited, tokenize, segment, classify, group, assign,
26
- post_rules,
26
+ extract_delimited, tokenize, segment, script_segment, classify,
27
+ group, assign, post_rules,
27
28
  )
28
29
 
29
30