nameparser 1.3.1__tar.gz → 1.4.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 (49) hide show
  1. {nameparser-1.3.1/nameparser.egg-info → nameparser-1.4.0}/PKG-INFO +8 -2
  2. {nameparser-1.3.1 → nameparser-1.4.0}/README.rst +7 -1
  3. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/_version.py +1 -1
  4. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/__init__.py +154 -19
  5. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/regexes.py +4 -0
  6. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/parser.py +69 -10
  7. {nameparser-1.3.1 → nameparser-1.4.0/nameparser.egg-info}/PKG-INFO +8 -2
  8. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser.egg-info/SOURCES.txt +1 -0
  9. {nameparser-1.3.1 → nameparser-1.4.0}/tests/conftest.py +16 -2
  10. nameparser-1.4.0/tests/test_comma_variants.py +53 -0
  11. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_constants.py +310 -32
  12. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_initials.py +12 -6
  13. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_nicknames.py +7 -6
  14. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_output_format.py +9 -4
  15. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_python_api.py +41 -12
  16. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_variations.py +7 -1
  17. {nameparser-1.3.1 → nameparser-1.4.0}/AUTHORS +0 -0
  18. {nameparser-1.3.1 → nameparser-1.4.0}/LICENSE +0 -0
  19. {nameparser-1.3.1 → nameparser-1.4.0}/MANIFEST.in +0 -0
  20. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/__init__.py +0 -0
  21. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/__main__.py +0 -0
  22. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/bound_first_names.py +0 -0
  23. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/capitalization.py +0 -0
  24. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/conjunctions.py +0 -0
  25. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/prefixes.py +0 -0
  26. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/suffixes.py +0 -0
  27. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/config/titles.py +0 -0
  28. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/py.typed +0 -0
  29. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser/util.py +0 -0
  30. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser.egg-info/dependency_links.txt +0 -0
  31. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser.egg-info/requires.txt +0 -0
  32. {nameparser-1.3.1 → nameparser-1.4.0}/nameparser.egg-info/top_level.txt +0 -0
  33. {nameparser-1.3.1 → nameparser-1.4.0}/pyproject.toml +0 -0
  34. {nameparser-1.3.1 → nameparser-1.4.0}/setup.cfg +0 -0
  35. {nameparser-1.3.1 → nameparser-1.4.0}/tests/__init__.py +0 -0
  36. {nameparser-1.3.1 → nameparser-1.4.0}/tests/base.py +0 -0
  37. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_bound_first_names.py +0 -0
  38. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_brute_force.py +0 -0
  39. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_capitalization.py +0 -0
  40. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_config_attribute_docstrings.py +0 -0
  41. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_conjunctions.py +0 -0
  42. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_east_slavic_patronymic_order.py +0 -0
  43. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_first_name.py +0 -0
  44. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_middle_name_as_last.py +0 -0
  45. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_parser_util.py +0 -0
  46. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_prefixes.py +0 -0
  47. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_suffixes.py +0 -0
  48. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_titles.py +0 -0
  49. {nameparser-1.3.1 → nameparser-1.4.0}/tests/test_turkic_patronymic_order.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 1.3.1
3
+ Version: 1.4.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
@@ -31,8 +31,14 @@ Name Parser
31
31
 
32
32
  |Build Status| |PyPI| |PyPI version| |Documentation| |License| |Downloads| |Codecov|
33
33
 
34
+ 📣 **nameparser 2.0 is in design** — a new immutable core API, with full
35
+ compatibility for existing code through 2.x. Read the `design RFC
36
+ <https://github.com/derek73/python-nameparser/pull/285>`__ and share
37
+ feedback on `the discussion issue
38
+ <https://github.com/derek73/python-nameparser/issues/284>`__.
39
+
34
40
  A simple Python (3.10+) module for parsing human names into their
35
- individual components.
41
+ individual components.
36
42
 
37
43
  * hn.title
38
44
  * hn.first
@@ -3,8 +3,14 @@ Name Parser
3
3
 
4
4
  |Build Status| |PyPI| |PyPI version| |Documentation| |License| |Downloads| |Codecov|
5
5
 
6
+ 📣 **nameparser 2.0 is in design** — a new immutable core API, with full
7
+ compatibility for existing code through 2.x. Read the `design RFC
8
+ <https://github.com/derek73/python-nameparser/pull/285>`__ and share
9
+ feedback on `the discussion issue
10
+ <https://github.com/derek73/python-nameparser/issues/284>`__.
11
+
6
12
  A simple Python (3.10+) module for parsing human names into their
7
- individual components.
13
+ individual components.
8
14
 
9
15
  * hn.title
10
16
  * hn.first
@@ -1,2 +1,2 @@
1
- VERSION = (1, 3, 1)
1
+ VERSION = (1, 4, 0)
2
2
  __version__ = '.'.join(map(str, VERSION))
@@ -1,31 +1,46 @@
1
1
  """
2
2
  The :py:mod:`nameparser.config` module manages the configuration of the
3
- nameparser.
3
+ nameparser.
4
4
 
5
- A module-level instance of :py:class:`~nameparser.config.Constants` is created
6
- and used by default for all HumanName instances. You can adjust the entire module's
7
- configuration by importing this instance and changing it.
5
+ :py:class:`~nameparser.config.Constants` is for application-level
6
+ configuration, set once at startup. ``CONSTANTS``, the module-level instance
7
+ used by every ``HumanName`` created without its own config, is the only
8
+ channel that reaches parses happening in code you don't own (helpers,
9
+ pipelines, a third-party library using nameparser internally) -- the same
10
+ role ``logging`` and ``locale`` play elsewhere. Import it and change it
11
+ directly:
8
12
 
9
13
  ::
10
14
 
11
15
  >>> from nameparser.config import CONSTANTS
12
16
  >>> CONSTANTS.titles.remove('hon').add('chemistry','dean') # doctest: +SKIP
13
17
 
14
- You can also adjust the configuration of individual instances by passing
15
- ``None`` as the second argument upon instantiation.
18
+ For anything scoped -- one dataset, one library, one test -- pass your own
19
+ :py:class:`Constants` instance as the second argument upon instantiation
20
+ instead: ``Constants()`` for fresh library defaults, or ``CONSTANTS.copy()``
21
+ for a private snapshot of the current module config.
16
22
 
17
23
  ::
18
24
 
19
25
  >>> from nameparser import HumanName
20
- >>> hn = HumanName("Dean Robert Johns", None)
26
+ >>> from nameparser.config import Constants
27
+ >>> hn = HumanName("Dean Robert Johns", Constants())
21
28
  >>> hn.C.titles.add('dean') # doctest: +SKIP
22
29
  >>> hn.parse_full_name() # need to run this again after config changes
23
30
 
24
- **Potential Gotcha**: If you do not pass ``None`` (or your own
25
- :py:class:`Constants` instance) as the second argument, ``hn.C`` will be a
26
- reference to the module config, possibly yielding unexpected results. See
27
- `Customizing the Parser <customize.html>`_.
31
+ Mixing the two up is where the surprises come from, not the API itself: if
32
+ you do not pass your own :py:class:`Constants` instance as the second
33
+ argument, ``hn.C`` will be a reference to the module config, and a change
34
+ there reaches every other instance sharing it. See `Customizing the Parser
35
+ <customize.html>`_.
36
+
37
+ .. deprecated:: 1.4.0
38
+ Passing ``None`` as the second argument also builds a fresh
39
+ ``Constants()``, but is deprecated in favor of the explicit spellings
40
+ above; it will raise ``TypeError`` in 2.0 (issue #260).
28
41
  """
42
+ import copy
43
+ import inspect
29
44
  import re
30
45
  import sys
31
46
  import warnings
@@ -242,7 +257,18 @@ class SetManager(Set):
242
257
  .. deprecated:: 1.3.0
243
258
  ``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue
244
259
  #245); decode before adding.
260
+
261
+ .. deprecated:: 1.4.0
262
+ The method itself is removed in 2.0 (see issue #245); use
263
+ :py:func:`add` instead, decoding bytes first.
245
264
  """
265
+ warnings.warn(
266
+ "SetManager.add_with_encoding() is deprecated and will be "
267
+ "removed in 2.0; use add() instead (decode bytes first). See "
268
+ "https://github.com/derek73/python-nameparser/issues/245",
269
+ DeprecationWarning,
270
+ stacklevel=2,
271
+ )
246
272
  self._add_normalized(s, encoding, stacklevel=3)
247
273
 
248
274
  def add(self, *strings: str) -> Self:
@@ -327,7 +353,7 @@ def _is_dunder(attr: str) -> bool:
327
353
  # validate and normalize each one exactly once at import. Constants()
328
354
  # copies these via _normalized_elements' SetManager fast path instead of
329
355
  # re-checking ~1,400 elements per construction — a cost that otherwise
330
- # repeats on the per-instance-config path, HumanName(constants=None).
356
+ # repeats on the per-instance-config path, HumanName(constants=Constants()).
331
357
  #
332
358
  # This snapshot is taken once, at import time: mutating a raw constant
333
359
  # (e.g. `TITLES.add('x')`) after import is *not* picked up by Constants()
@@ -383,10 +409,28 @@ class TupleManager(dict[str, T]):
383
409
  arg = checked
384
410
  super().__init__(arg, **kwargs)
385
411
 
412
+ def _warn_unknown_key(self, attr: str) -> None:
413
+ # Deprecated 1.4.0, raises AttributeError in 2.0 (#256): a misspelled
414
+ # key otherwise degrades silently with no traceback pointing at the
415
+ # typo.
416
+ warnings.warn(
417
+ f"{attr!r} is not a known key ({', '.join(sorted(self))}); "
418
+ "unknown-key attribute access is deprecated and will raise "
419
+ "AttributeError in 2.0. Use .get() for intentional soft access. "
420
+ "See https://github.com/derek73/python-nameparser/issues/256",
421
+ DeprecationWarning,
422
+ stacklevel=3,
423
+ )
424
+
386
425
  def __getattr__(self, attr: str) -> T | None:
387
426
  # Otherwise the dict default (None) is mistaken for a real protocol hook.
388
427
  if _is_dunder(attr):
389
428
  raise AttributeError(attr)
429
+ # Single-underscore introspection probes (IPython/Jupyter's
430
+ # _repr_html_, _ipython_canary_method_should_not_exist_, etc.) are
431
+ # never config keys either -- no real config key starts with '_'.
432
+ if attr not in self and not attr.startswith('_'):
433
+ self._warn_unknown_key(attr)
390
434
  return self.get(attr)
391
435
 
392
436
  def __setattr__(self, attr: str, value: T) -> None:
@@ -430,6 +474,8 @@ class RegexTupleManager(TupleManager[re.Pattern[str]]):
430
474
  # then tries to call the returned re.Pattern and raises TypeError.
431
475
  if _is_dunder(attr):
432
476
  raise AttributeError(attr)
477
+ if attr not in self and not attr.startswith('_'):
478
+ self._warn_unknown_key(attr)
433
479
  return self.get(attr, EMPTY_REGEX)
434
480
 
435
481
 
@@ -495,6 +541,48 @@ class _CachedUnionMember(_SetManagerAttribute):
495
541
  setattr(obj, self._attr, value)
496
542
 
497
543
 
544
+ class _EmptyAttributeDefaultAttribute:
545
+ """Descriptor backing ``Constants.empty_attribute_default``.
546
+
547
+ .. deprecated:: 1.4.0
548
+ Assignment is deprecated (see issue #255): the only legal value
549
+ left once ``None`` support goes in 2.0 is the default ``''``, so a
550
+ dial with one position isn't configuration.
551
+ """
552
+
553
+ _attr = '_empty_attribute_default'
554
+
555
+ def __get__(self, obj: 'Constants | None', objtype: type | None = None) -> str:
556
+ # Annotated `str`, not `str | None`, to match the pre-descriptor
557
+ # plain-attribute inference: None is documented/supported (see the
558
+ # class docstring), but typing it honestly cascades `| None`
559
+ # through every public str-typed name accessor (title, first, ...).
560
+ # Returning '' rather than `self` on class access (unlike
561
+ # _SetManagerAttribute, which returns `self`) is also load-bearing
562
+ # for Constants.__repr__'s `getattr(type(self), name)` default
563
+ # comparison in _repr_scalar_attrs -- returning `self` there would
564
+ # make every Constants() show this attribute as "customized".
565
+ if obj is None:
566
+ return ''
567
+ return getattr(obj, self._attr, '')
568
+
569
+ def __set__(self, obj: 'Constants', value: str | None) -> None:
570
+ if value is not None and not isinstance(value, str):
571
+ raise TypeError(
572
+ f"empty_attribute_default must be a str or None, got "
573
+ f"{type(value).__name__!r}"
574
+ )
575
+ warnings.warn(
576
+ "Assigning Constants.empty_attribute_default is deprecated and "
577
+ "will raise TypeError in 2.0; empty attributes will always "
578
+ "return ''. See "
579
+ "https://github.com/derek73/python-nameparser/issues/255",
580
+ DeprecationWarning,
581
+ stacklevel=2,
582
+ )
583
+ setattr(obj, self._attr, value)
584
+
585
+
498
586
  class Constants:
499
587
  """
500
588
  An instance of this class hold all of the configuration constants for the parser.
@@ -590,8 +678,9 @@ class Constants:
590
678
  ``None`` (no additional splitting beyond the standard comma split).
591
679
 
592
680
  Note: setting this to ``","`` or ``", "`` has no additional effect —
593
- the full name is already split on bare commas first, and each resulting
594
- part is stripped of surrounding whitespace before this step runs.
681
+ the full name is already split on comma characters first (including the
682
+ Arabic ``،`` and fullwidth ``,`` variants), and each resulting part is
683
+ stripped of surrounding whitespace before this step runs.
595
684
 
596
685
  The delimiter is only applied to parts once they've been identified as
597
686
  a suffix group, so it never leaks into a first- or middle-name part. For
@@ -600,20 +689,29 @@ class Constants:
600
689
  does not get mistaken for a suffix split.
601
690
  """
602
691
 
603
- empty_attribute_default = ''
692
+ empty_attribute_default = _EmptyAttributeDefaultAttribute()
604
693
  """
605
694
  Default return value for empty attributes.
606
695
 
696
+ .. deprecated:: 1.4.0
697
+ Assignment emits ``DeprecationWarning``; the option is removed in
698
+ 2.0 (see issue #255) and empty attributes will always return ``''``.
699
+
607
700
  .. doctest::
608
701
 
702
+ >>> import warnings
609
703
  >>> from nameparser.config import CONSTANTS
610
- >>> CONSTANTS.empty_attribute_default = None
704
+ >>> with warnings.catch_warnings():
705
+ ... warnings.simplefilter('ignore', DeprecationWarning)
706
+ ... CONSTANTS.empty_attribute_default = None
611
707
  >>> name = HumanName("John Doe")
612
708
  >>> print(name.title)
613
709
  None
614
710
  >>> name.first
615
711
  'John'
616
- >>> CONSTANTS.empty_attribute_default = ''
712
+ >>> with warnings.catch_warnings():
713
+ ... warnings.simplefilter('ignore', DeprecationWarning)
714
+ ... CONSTANTS.empty_attribute_default = ''
617
715
 
618
716
  """
619
717
 
@@ -805,13 +903,29 @@ class Constants:
805
903
  ]
806
904
  return "<Constants : [\n" + "\n".join(lines) + "\n]>"
807
905
 
906
+ def copy(self) -> 'Constants':
907
+ """
908
+ Return a detached deep copy of this ``Constants`` instance, preserving
909
+ its current customizations -- unlike :py:class:`Constants`'s own
910
+ constructor, which always starts from library defaults. Useful for
911
+ snapshotting the shared module-level ``CONSTANTS`` (including
912
+ whatever it's been customized with) into a private instance, e.g.
913
+ ``CONSTANTS.copy()``. Relies on the same ``__getstate__``/``__setstate__``
914
+ pair pickling uses, so it's as cheap and correct as pickle round-tripping.
915
+ """
916
+ return copy.deepcopy(self)
917
+
808
918
  def __setstate__(self, state: Mapping[str, Any]) -> None:
809
919
  # Restore each saved attribute directly. The previous implementation
810
920
  # passed the whole state dict to __init__ as the ``prefixes`` argument,
811
921
  # which silently reverted every collection to its module default on
812
922
  # unpickling.
813
923
  self._pst = None
924
+ legacy_format = False
814
925
  for name, value in state.items():
926
+ # inspect.getattr_static, not getattr, so descriptors are
927
+ # inspected directly rather than triggering their __get__.
928
+ descriptor = inspect.getattr_static(type(self), name, None)
815
929
  # Migration shim: pickles written before this fix (1.3.0 and earlier,
816
930
  # including 1.2.1) used a dir() sweep for __getstate__, so their state
817
931
  # carries the read-only ``suffixes_prefixes_titles`` property. Skip any
@@ -820,9 +934,29 @@ class Constants:
820
934
  # don't promise to read pre-fix blobs forever — this only smooths
821
935
  # migration for anyone persisting them, and can be dropped a release
822
936
  # or two after 1.3.0 once they've re-pickled.
823
- if isinstance(getattr(type(self), name, None), property):
937
+ if isinstance(descriptor, property):
938
+ legacy_format = True
939
+ continue
940
+ if isinstance(descriptor, _EmptyAttributeDefaultAttribute):
941
+ # Bypass the descriptor's setter: restoring saved state isn't
942
+ # a user assignment, so it shouldn't emit #255's deprecation
943
+ # warning on every unpickle/copy() of a customized instance.
944
+ setattr(self, descriptor._attr, value)
824
945
  continue
825
946
  setattr(self, name, value)
947
+ if legacy_format:
948
+ # Once per __setstate__ call, not once per skipped key (see
949
+ # issue #279): the 2.0 removal turns this into a ValueError
950
+ # naming the same fix.
951
+ warnings.warn(
952
+ "Loading a legacy-format Constants pickle (written by "
953
+ "nameparser <= 1.2.x, before the 1.3.0 pickle fix) is "
954
+ "deprecated and will raise ValueError in 2.0; re-pickle "
955
+ "under 1.3/1.4 to migrate. See "
956
+ "https://github.com/derek73/python-nameparser/issues/279",
957
+ DeprecationWarning,
958
+ stacklevel=2,
959
+ )
826
960
  # Verify each descriptor-backed attr was restored. Without this, a missing
827
961
  # key surfaces later as AttributeError: 'Constants' object has no attribute
828
962
  # '_prefixes' — the private mangled name, not the public one, making it
@@ -847,7 +981,8 @@ class Constants:
847
981
  for name, val in self.__dict__.items():
848
982
  if name.startswith('_'):
849
983
  public = name[1:]
850
- if isinstance(getattr(type(self), public, None), _SetManagerAttribute):
984
+ descriptor = inspect.getattr_static(type(self), public, None)
985
+ if isinstance(descriptor, (_SetManagerAttribute, _EmptyAttributeDefaultAttribute)):
851
986
  state[public] = val
852
987
  else:
853
988
  state[name] = val
@@ -29,6 +29,10 @@ REGEXES = {
29
29
  "bidi": re_bidi,
30
30
  "phd": re.compile(r'\s(ph\.?\s+d\.?)', re.I),
31
31
  "space_before_comma": re.compile(r'\s+,'),
32
+ # ASCII comma plus its Arabic (U+060C) and fullwidth CJK (U+FF0C)
33
+ # counterparts, used to split "Last, First" format and to strip a
34
+ # trailing comma before parsing (#265).
35
+ "commas": re.compile(r'[,،,]'),
32
36
  "east_slavic_patronymic": re.compile(
33
37
  r'(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich)$',
34
38
  re.I,
@@ -51,8 +51,14 @@ class HumanName:
51
51
  :param str full_name: The name string to be parsed.
52
52
  :param constants:
53
53
  a :py:class:`~nameparser.config.Constants` instance (subclasses are
54
- honored). Pass ``None`` for `per-instance config <customize.html>`_.
55
- Anything else raises ``TypeError``.
54
+ honored). Defaults to the shared module-level ``CONSTANTS``. For
55
+ `per-instance config <customize.html>`_, pass ``Constants()`` for
56
+ fresh library defaults, or ``CONSTANTS.copy()`` for a private
57
+ snapshot of the current shared config. Passing ``None`` also builds
58
+ a fresh ``Constants()``, but is deprecated (warns; raises
59
+ ``TypeError`` in 2.0, see issue #260) since it silently discards any
60
+ customizations the caller may have expected to carry over. Anything
61
+ else raises ``TypeError``.
56
62
  :param str encoding: string representing the encoding of your input
57
63
  (deprecated with ``bytes`` input, removal in 2.0 — decode before
58
64
  passing; see issue #245)
@@ -106,7 +112,10 @@ class HumanName:
106
112
  nickname: str | list[str] | None = None,
107
113
  maiden: str | list[str] | None = None,
108
114
  ) -> None:
109
- self.C = constants
115
+ # calls _validate_constants directly (not through the C setter) so
116
+ # the deprecation warning below attributes to this constructor's
117
+ # caller rather than to the setter, mirroring _apply_full_name below
118
+ self._C = self._validate_constants(constants, stacklevel=3)
110
119
 
111
120
  # Lookup entries derived while parsing this instance (period-joined
112
121
  # titles/suffixes like "Lt.Gov.", conjunction-joined pieces like
@@ -143,11 +152,27 @@ class HumanName:
143
152
  self._apply_full_name(full_name, stacklevel=3)
144
153
 
145
154
  @staticmethod
146
- def _validate_constants(constants: 'Constants | None') -> 'Constants':
155
+ def _validate_constants(constants: 'Constants | None', *, stacklevel: int) -> 'Constants':
147
156
  # Shared by the constructor and the C setter so both assignment paths
148
157
  # give the same immediate TypeError instead of one bypassing the
149
158
  # other and failing far from the cause (#239).
150
159
  if constants is None:
160
+ # deprecated 1.4.0, raises TypeError in 2.0 (#260, removal #261):
161
+ # None means "build a fresh private Constants()", the opposite of
162
+ # what None conventionally means (the default is the *shared*
163
+ # CONSTANTS) -- an easy trap since customizing CONSTANTS then
164
+ # passing None elsewhere silently drops those customizations with
165
+ # no error. CONSTANTS.copy() is the explicit spelling for the
166
+ # other reading: a private snapshot of the current shared config.
167
+ warnings.warn(
168
+ "Passing constants=None is deprecated and will raise "
169
+ "TypeError in 2.0; use constants=Constants() for fresh "
170
+ "library defaults, or constants=CONSTANTS.copy() to snapshot "
171
+ "the current shared config. See "
172
+ "https://github.com/derek73/python-nameparser/issues/260",
173
+ DeprecationWarning,
174
+ stacklevel=stacklevel,
175
+ )
151
176
  return Constants()
152
177
  if not isinstance(constants, Constants):
153
178
  # passing the class itself is the likeliest mistake, and
@@ -169,14 +194,16 @@ class HumanName:
169
194
  <customize.html>`_.
170
195
 
171
196
  Assigning a non-``Constants`` value (besides ``None``, which builds a
172
- fresh private ``Constants()``) raises the same ``TypeError`` as passing
173
- an invalid ``constants`` argument to the constructor (#239).
197
+ fresh private ``Constants()`` and emits a ``DeprecationWarning`` --
198
+ see :py:meth:`~nameparser.parser.HumanName.__init__`) raises the same
199
+ ``TypeError`` as passing an invalid ``constants`` argument to the
200
+ constructor (#239).
174
201
  """
175
202
  return self._C
176
203
 
177
204
  @C.setter
178
205
  def C(self, constants: 'Constants | None') -> None:
179
- self._C = self._validate_constants(constants)
206
+ self._C = self._validate_constants(constants, stacklevel=3)
180
207
 
181
208
  def __getstate__(self) -> dict:
182
209
  state = self.__dict__.copy()
@@ -227,12 +254,38 @@ class HumanName:
227
254
  @overload
228
255
  def __getitem__(self, key: str) -> str: ...
229
256
  def __getitem__(self, key: slice | str) -> str | list[str]:
257
+ """
258
+ .. deprecated:: 1.4.0
259
+ Slice access (``name[1:-3]``) is removed in 2.0 (see issue
260
+ #258); field access by position has no real use case.
261
+ String-key access (``name['first']``) is unaffected.
262
+ """
230
263
  if isinstance(key, slice):
264
+ warnings.warn(
265
+ "Slicing a HumanName by position is deprecated and will be "
266
+ "removed in 2.0; access the named attributes instead. See "
267
+ "https://github.com/derek73/python-nameparser/issues/258",
268
+ DeprecationWarning,
269
+ stacklevel=2,
270
+ )
231
271
  return [getattr(self, x) for x in self._members[key]]
232
272
  else:
233
273
  return getattr(self, key)
234
274
 
235
275
  def __setitem__(self, key: str, value: str | list[str] | None) -> None:
276
+ """
277
+ .. deprecated:: 1.4.0
278
+ Removed in 2.0 (see issue #258); it duplicates plain attribute
279
+ assignment. Use ``name.first = value`` instead.
280
+ """
281
+ warnings.warn(
282
+ "HumanName item assignment is deprecated and will be removed "
283
+ "in 2.0; it duplicates plain attribute assignment, use "
284
+ "name.first = value instead. See "
285
+ "https://github.com/derek73/python-nameparser/issues/258",
286
+ DeprecationWarning,
287
+ stacklevel=2,
288
+ )
236
289
  if key in self._members:
237
290
  self._set_list(key, value)
238
291
  else:
@@ -923,7 +976,7 @@ class HumanName:
923
976
  def collapse_whitespace(self, string: str) -> str:
924
977
  # collapse multiple spaces into single space
925
978
  string = self.C.regexes.spaces.sub(" ", string.strip())
926
- if string.endswith(","):
979
+ if string and self.C.regexes.commas.fullmatch(string[-1]):
927
980
  string = string[:-1]
928
981
  return string
929
982
 
@@ -1195,8 +1248,14 @@ class HumanName:
1195
1248
 
1196
1249
  self._full_name = self.collapse_whitespace(self._full_name)
1197
1250
 
1198
- # break up full_name by commas
1199
- parts = [x.strip() for x in self._full_name.split(",")]
1251
+ # break up full_name by commas. A missing "commas" key in a custom
1252
+ # regexes dict falls back to RegexTupleManager's EMPTY_REGEX, whose
1253
+ # .split() matches between every character rather than not
1254
+ # splitting at all -- guard against that so a custom regexes dict
1255
+ # that omits "commas" disables the comma split instead of shattering
1256
+ # the name into single characters.
1257
+ commas = self.C.regexes.commas
1258
+ parts = [x.strip() for x in (commas.split(self._full_name) if commas.pattern else [self._full_name])]
1200
1259
  self._had_comma = len(parts) > 1
1201
1260
 
1202
1261
  log.debug("full_name: %s", self._full_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 1.3.1
3
+ Version: 1.4.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
@@ -31,8 +31,14 @@ Name Parser
31
31
 
32
32
  |Build Status| |PyPI| |PyPI version| |Documentation| |License| |Downloads| |Codecov|
33
33
 
34
+ 📣 **nameparser 2.0 is in design** — a new immutable core API, with full
35
+ compatibility for existing code through 2.x. Read the `design RFC
36
+ <https://github.com/derek73/python-nameparser/pull/285>`__ and share
37
+ feedback on `the discussion issue
38
+ <https://github.com/derek73/python-nameparser/issues/284>`__.
39
+
34
40
  A simple Python (3.10+) module for parsing human names into their
35
- individual components.
41
+ individual components.
36
42
 
37
43
  * hn.title
38
44
  * hn.first
@@ -28,6 +28,7 @@ tests/conftest.py
28
28
  tests/test_bound_first_names.py
29
29
  tests/test_brute_force.py
30
30
  tests/test_capitalization.py
31
+ tests/test_comma_variants.py
31
32
  tests/test_config_attribute_docstrings.py
32
33
  tests/test_conjunctions.py
33
34
  tests/test_constants.py
@@ -1,4 +1,5 @@
1
1
  import copy
2
+ import warnings
2
3
  from collections.abc import Iterator
3
4
 
4
5
  import pytest
@@ -60,9 +61,22 @@ def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | No
60
61
  attr: copy.deepcopy(getattr(CONSTANTS, attr))
61
62
  for attr in _COLLECTION_CONFIG_ATTRS
62
63
  }
63
- CONSTANTS.empty_attribute_default = request.param
64
+ # empty_attribute_default assignment is deprecated (#255); this fixture's
65
+ # own systematic exercise of both settings across the whole suite is
66
+ # infrastructure, not a test of the deprecation itself, so it's silenced
67
+ # here (narrowly, around just the assignment) rather than at every one of
68
+ # its ~1500 call sites. Must not wrap `yield` -- that would suppress
69
+ # DeprecationWarning for the test body itself, hiding real ones.
70
+ with warnings.catch_warnings():
71
+ warnings.simplefilter("ignore", DeprecationWarning)
72
+ CONSTANTS.empty_attribute_default = request.param
64
73
  yield request.param
65
74
  for attr, value in scalar_snapshot.items():
66
- setattr(CONSTANTS, attr, value)
75
+ if attr == "empty_attribute_default":
76
+ with warnings.catch_warnings():
77
+ warnings.simplefilter("ignore", DeprecationWarning)
78
+ setattr(CONSTANTS, attr, value)
79
+ else:
80
+ setattr(CONSTANTS, attr, value)
67
81
  for attr, value in collection_snapshot.items():
68
82
  setattr(CONSTANTS, attr, value)
@@ -0,0 +1,53 @@
1
+ import pytest
2
+
3
+ from nameparser import HumanName
4
+ from nameparser.config import Constants
5
+ from nameparser.config.regexes import REGEXES
6
+
7
+ from tests.base import HumanNameTestBase
8
+
9
+
10
+ class HumanNameCommaVariantsTests(HumanNameTestBase):
11
+ """Non-ASCII comma characters should split "Last, First" the same as ',' (#265)."""
12
+
13
+ def test_arabic_comma_splits_lastname_format(self) -> None:
14
+ hn = HumanName("سلمان، محمد")
15
+ self.m(hn.first, "محمد", hn)
16
+ self.m(hn.last, "سلمان", hn)
17
+
18
+ def test_fullwidth_comma_splits_lastname_format(self) -> None:
19
+ hn = HumanName("Smith,John")
20
+ self.m(hn.first, "John", hn)
21
+ self.m(hn.last, "Smith", hn)
22
+
23
+ def test_arabic_comma_does_not_pollute_output(self) -> None:
24
+ hn = HumanName("سلمان، محمد")
25
+ self.assertNotIn("،", hn.last)
26
+ self.assertNotIn("،", str(hn))
27
+
28
+ def test_trailing_arabic_comma_stripped(self) -> None:
29
+ # matches ASCII behavior: a single word with a trailing comma has
30
+ # nothing after the comma, so it's a bare name, not "Last,"
31
+ hn = HumanName("سلمان،")
32
+ self.m(hn.first, "سلمان", hn)
33
+
34
+ def test_custom_regexes_without_commas_key_does_not_shatter_name(self) -> None:
35
+ # A custom regexes dict that omits "commas" entirely must not fall
36
+ # back to RegexTupleManager's EMPTY_REGEX default for splitting --
37
+ # re.compile('').split(...) matches between every character, which
38
+ # explodes any name into single-char pieces instead of leaving it
39
+ # unsplit (the EMPTY_REGEX convention elsewhere in this codebase
40
+ # means "feature disabled", not "split on every character").
41
+ # With comma splitting disabled, "Smith, John" is tokenized like any
42
+ # other no-comma input (word tokenizing drops the punctuation),
43
+ # yielding a plain first/last pair -- not the inverted "Last, First"
44
+ # reading, and definitely not single-character pieces.
45
+ # Unknown-key access (here, the deliberately-omitted 'commas') is
46
+ # deprecated for removal in 2.0 (#256); this fallback pattern will
47
+ # AttributeError-crash the parser instead of degrading silently.
48
+ custom = {k: v for k, v in REGEXES.items() if k != 'commas'}
49
+ c = Constants(regexes=custom)
50
+ with pytest.deprecated_call():
51
+ hn = HumanName("Smith, John", constants=c)
52
+ self.m(hn.first, "Smith", hn)
53
+ self.m(hn.last, "John", hn)