Skip to content

Commit 0cdd787

Browse files
derek73claude
andcommitted
Add facade capitalize/matches/comparison_key and v1-compatible pickling
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bd2abc7 commit 0cdd787

3 files changed

Lines changed: 140 additions & 4 deletions

File tree

nameparser/_facade.py

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import re
1111
import warnings
1212
from collections.abc import Iterator
13+
from typing import Any
1314

1415
from nameparser._config_shim import CONSTANTS, Constants, _cached_parser
1516
from nameparser._lexicon import _normalize
@@ -229,11 +230,16 @@ def _apply_full_name(self, value: str) -> None:
229230
if self._C.capitalize_name:
230231
self.capitalize() # v1 parser.py:1653 parity
231232

232-
def capitalize(self) -> None:
233-
"""Minimal M6 body (M10 ports the full force-semantics rules):
234-
re-capitalize the current parse against the bound lexicon."""
233+
def capitalize(self, force: bool | None = None) -> None:
234+
"""Re-capitalize the current parse against the bound lexicon.
235+
force=None reads the bound Constants' render default
236+
(force_mixed_case_capitalization); the core's capitalized()
237+
implements the single-case gate (v1 parity) -- not
238+
re-implemented here."""
235239
self._resolve()
236-
self._parsed = self._parsed.capitalized(self._lexicon, force=True)
240+
if force is None:
241+
force = self._C.force_mixed_case_capitalization
242+
self._parsed = self._parsed.capitalized(self._lexicon, force=force)
237243

238244
@property
239245
def full_name(self) -> str:
@@ -485,6 +491,17 @@ def group(items: list[str]) -> str:
485491
first=group(first), middle=group(middle), last=group(last))
486492
return self.collapse_whitespace(_s)
487493

494+
# -- comparison -----------------------------------------------------------
495+
496+
def matches(self, other: str | HumanName) -> bool:
497+
"""Component-wise case-insensitive comparison (v1 parity); a
498+
str argument is parsed with this instance's resolved parser."""
499+
target = other._parsed if isinstance(other, HumanName) else other
500+
return self._parsed.matches(target, parser=self._resolve())
501+
502+
def comparison_key(self) -> tuple[str, ...]:
503+
return self._parsed.comparison_key()
504+
488505
# -- dunders ------------------------------------------------------------
489506

490507
def collapse_whitespace(self, string: str) -> str:
@@ -535,3 +552,55 @@ def as_dict(self, include_empty: bool = True) -> dict[str, str]:
535552
if include_empty:
536553
return d
537554
return {k: v for k, v in d.items() if v}
555+
556+
# -- pickle (v1-shaped state; one path for 1.4 and 2.x blobs) -----------
557+
558+
def __getstate__(self) -> dict[str, Any]:
559+
# The emitted key set matches v1.4's pickle shape (minus
560+
# encoding/_had_comma/_derived_*, which are v1-internal and
561+
# ignored on read), so one __setstate__ path serves both eras.
562+
state: dict[str, Any] = {
563+
"_full_name": self._full_name,
564+
"original": self.original,
565+
"C": None if self._C is CONSTANTS else self._C,
566+
"string_format": self.string_format,
567+
"initials_format": self.initials_format,
568+
"initials_delimiter": self.initials_delimiter,
569+
"initials_separator": self.initials_separator,
570+
"suffix_delimiter": self.suffix_delimiter,
571+
}
572+
for member in _MEMBERS:
573+
state[f"{member}_list"] = getattr(self, f"{member}_list")
574+
return state
575+
576+
def __setstate__(self, state: dict[str, Any]) -> None:
577+
c = state.get("C")
578+
self._C = CONSTANTS if c is None else c
579+
self._snapshot_gen = -1
580+
defaults = self._C._snapshot()[2]
581+
self._string_format = state.get("string_format",
582+
defaults.string_format)
583+
self._initials_format = state.get("initials_format",
584+
defaults.initials_format)
585+
self._initials_delimiter = state.get("initials_delimiter",
586+
defaults.initials_delimiter)
587+
self._initials_separator = state.get("initials_separator",
588+
defaults.initials_separator)
589+
self._suffix_delimiter = state.get("suffix_delimiter",
590+
defaults.suffix_delimiter)
591+
self._full_name = state.get("_full_name", "")
592+
# components come back exactly as pickled (spec §2): synthetic
593+
# tokens via replace(), never a re-parse. Known edge: replace()
594+
# re-splits on whitespace without the "joined" tag, so joined-tag
595+
# healing is lost for multi-word list elements -- v1's fix_phd
596+
# suffix pickles as ["Ph. D."] but round-trips to ["Ph.", "D."],
597+
# rendering the suffix as "Ph., D.". Classify in the differential
598+
# harness / M12 if it surfaces.
599+
parsed = ParsedName(original=str(state.get("original", "")),
600+
tokens=(), ambiguities=())
601+
fields = {}
602+
for member in _MEMBERS:
603+
values = state.get(f"{member}_list") or []
604+
fields[_V2_FIELD.get(member, member)] = " ".join(values)
605+
self._parsed = parsed.replace(
606+
**{k: v for k, v in fields.items() if v})

tests/v2/data/humanname_v14.pickle

590 Bytes
Binary file not shown.

tests/v2/test_facade.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""The 2.0 HumanName facade (migration spec §2)."""
2+
import pickle
23
import warnings
4+
from pathlib import Path
35

46
import pytest
57

68
from nameparser._config_shim import CONSTANTS, Constants
79
from nameparser._facade import HumanName
810

11+
_DATA_DIR = Path(__file__).parent / "data"
12+
913

1014
def test_basic_parse_and_v1_spellings() -> None:
1115
n = HumanName("Dr. Juan de la Vega III")
@@ -258,3 +262,66 @@ def test_render_default_setters_validate() -> None:
258262
n.suffix_delimiter = 5 # type: ignore[assignment]
259263
n.string_format = None # None allowed for these two
260264
n.suffix_delimiter = None
265+
266+
267+
def test_capitalize_gate_and_force() -> None:
268+
n = HumanName("bob v. de la macdole-eisenhower phd")
269+
n.capitalize()
270+
assert str(n) == "Bob V. de la MacDole-Eisenhower Ph.D."
271+
m = HumanName("Shirley Maclaine") # mixed case: untouched
272+
m.capitalize()
273+
assert str(m) == "Shirley Maclaine"
274+
m.capitalize(force=True)
275+
assert str(m) == "Shirley MacLaine"
276+
277+
278+
def test_force_mixed_case_flag_feeds_default() -> None:
279+
c = Constants()
280+
c.force_mixed_case_capitalization = True
281+
n = HumanName("Shirley Maclaine", constants=c)
282+
n.capitalize() # force=None -> flag -> True
283+
assert str(n) == "Shirley MacLaine"
284+
285+
286+
def test_matches_and_comparison_key() -> None:
287+
# NB: the task spec's example compared "Dr. John A. Smith" against
288+
# "John Smith" -- but matches()/comparison_key() are component-wise
289+
# over all seven fields (v1 parity, verified against live 1.4), so a
290+
# title/middle-initial mismatch always fails the match; swapped in
291+
# case/order variations that actually exercise "case-insensitive,
292+
# component-wise" without dropping fields.
293+
n = HumanName("Dr. John A. Smith")
294+
assert n.matches("dr. john a. smith")
295+
assert n.matches(HumanName("smith, dr. john a."))
296+
assert not n.matches("Jane Smith")
297+
assert n.comparison_key() == HumanName("DR. JOHN A. SMITH").comparison_key()
298+
299+
300+
def test_pickle_round_trip_preserves_components() -> None:
301+
n = HumanName("Dr. Juan de la Vega III")
302+
n.first = "Johan" # mutated state must survive
303+
loaded = pickle.loads(pickle.dumps(n))
304+
assert loaded.first == "Johan"
305+
assert loaded.last == "de la Vega"
306+
assert loaded.original == "Dr. Juan de la Vega III"
307+
assert loaded.C is CONSTANTS # shared sentinel restored
308+
309+
310+
@pytest.mark.xfail(
311+
reason="resolves to the v1 class until the M11 swap", strict=True)
312+
def test_v14_humanname_blob_unpickles() -> None:
313+
# tests/v2/data/humanname_v14.pickle was produced by a real 1.4.0
314+
# install (`uv run --no-project --with "nameparser==1.4.*" ...`) on
315+
# HumanName("Dr. Juan de la Vega III"); components come back exactly
316+
# as pickled, NOT re-parsed. Until the M11 swap replaces
317+
# nameparser.parser.HumanName with this facade, the blob's pickled
318+
# class reference still resolves to the live v1 class, so this
319+
# assertion fails today by construction (mirrors
320+
# test_v14_constants_blob_unpickles_into_shim in test_config_shim.py).
321+
# pickle.load is safe here: humanname_v14.pickle is a repo-controlled
322+
# fixture generated by this task from a real 1.4.0 install (Step 2
323+
# above), not data from an untrusted source.
324+
with open(_DATA_DIR / "humanname_v14.pickle", "rb") as f:
325+
loaded = pickle.load(f)
326+
assert isinstance(loaded, HumanName)
327+
assert loaded.first == "Juan" and loaded.last == "de la Vega"

0 commit comments

Comments
 (0)