Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ def __init__(
# full_name setter triggers the parse
self.full_name = full_name

def __getstate__(self) -> dict:
state = self.__dict__.copy()
if state.get('C') is CONSTANTS:
state['C'] = None # sentinel: restore shared singleton on load
return state

def __setstate__(self, state: dict) -> None:
if state.get('C') is None:
state['C'] = CONSTANTS
self.__dict__.update(state)

def __iter__(self) -> Iterator[str]:
return self

Expand Down
6 changes: 6 additions & 0 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ def assertIn(self, member: object, container: object, msg: object = None) -> Non

def assertNotIn(self, member: object, container: object, msg: object = None) -> None:
assert member not in container, msg # type: ignore[operator]

def assertIs(self, first: object, second: object, msg: object = None) -> None:
assert first is second, msg or f"{first!r} is not {second!r}"

def assertIsNot(self, first: object, second: object, msg: object = None) -> None:
assert first is not second, msg or f"{first!r} is {second!r}"
64 changes: 60 additions & 4 deletions tests/test_python_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
dill = False # type: ignore[assignment]

from nameparser import HumanName
from nameparser.config import Constants, TupleManager
from nameparser.config import CONSTANTS, Constants, TupleManager

from tests.base import HumanNameTestBase

Expand Down Expand Up @@ -92,11 +92,67 @@ def test_name_instance_deepcopy_isolates_instance_config(self) -> None:
self.assertIn('chancellor', dup.C.titles)
self.assertNotIn('marker', hn.C.titles)

def test_pickle_default_name_preserves_singleton_identity(self) -> None:
"""A default HumanName must re-attach to CONSTANTS after a pickle round-trip.

Without __getstate__/__setstate__, pickle serializes .C by value, so the
restored name gets a detached copy — has_own_config flips to True and
every pickled default name carries a full Constants copy.
"""
hn = HumanName("John Doe")
self.assertFalse(hn.has_own_config)
self.assertIs(hn.C, CONSTANTS)

# Safe: round-tripping an object we just built, not untrusted data.
restored = pickle.loads(pickle.dumps(hn))

self.assertIs(restored.C, CONSTANTS)
self.assertFalse(restored.has_own_config)
self.assertEqual(str(restored), str(hn))
self.assertEqual(restored.first, hn.first)
self.assertEqual(restored.last, hn.last)

def test_pickle_instance_config_name_preserves_own_config(self) -> None:
"""A HumanName with its own Constants must not be collapsed onto CONSTANTS after pickle."""
hn = HumanName("Smith, Dr. John", None)
hn.C.titles.add('chancellor')
hn.parse_full_name()
self.assertTrue(hn.has_own_config)
self.assertIsNot(hn.C, CONSTANTS)

# Safe: round-tripping a HumanName the test just built, not untrusted data.
restored = pickle.loads(pickle.dumps(hn))

self.assertTrue(restored.has_own_config)
self.assertIsNot(restored.C, CONSTANTS)
self.assertIn('chancellor', restored.C.titles)

def test_shallow_copy_default_name_preserves_singleton_identity(self) -> None:
"""copy.copy of a default HumanName shares the CONSTANTS reference without hooks."""
hn = HumanName("John Doe")

sc = copy.copy(hn)

self.assertIs(sc.C, CONSTANTS)
self.assertFalse(sc.has_own_config)

def test_deepcopy_default_name_preserves_singleton_identity(self) -> None:
"""copy.deepcopy of a default HumanName must re-attach to CONSTANTS."""
hn = HumanName("John Doe")

dup = copy.deepcopy(hn)

self.assertIs(dup.C, CONSTANTS)
self.assertFalse(dup.has_own_config)
self.assertEqual(str(dup), str(hn))
self.assertEqual(dup.first, hn.first)
self.assertEqual(dup.last, hn.last)

def test_comparison(self) -> None:
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")
self.assertTrue(hn1 == hn2)
self.assertTrue(hn1 is not hn2)
self.assertIsNot(hn1, hn2)
self.assertTrue(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC")
hn1 = HumanName("Doe, Dr. John P., CLU, CFP, LUTC")
hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC")
Expand Down Expand Up @@ -156,7 +212,7 @@ def test_comparison_case_insensitive(self) -> None:
hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC")
hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC")
self.assertTrue(hn1 == hn2)
self.assertTrue(hn1 is not hn2)
self.assertIsNot(hn1, hn2)
self.assertTrue(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC")

def test_slice(self) -> None:
Expand Down Expand Up @@ -222,7 +278,7 @@ def test_is_conjunction_with_list(self) -> None:
def test_override_constants(self) -> None:
C = Constants()
hn = HumanName(constants=C)
self.assertTrue(hn.C is C)
self.assertIs(hn.C, C)

def test_override_regex(self) -> None:
var = TupleManager([("spaces", re.compile(r"\s+", re.U)),])
Expand Down