Skip to content

Commit 1eca44b

Browse files
derek73claude
andcommitted
Apply the /simplify findings: snapshot caching, shared collapse, one PH/D
- Constants._snapshot() is generation-cached: rebuilding the Lexicon re-normalized ~1400 vocabulary entries (~185us) TWICE per HumanName() construction -- the legacy path ran ~428us/name against the core's ~35us. With the cache (plus _resolve() storing the resolved Parser instead of re-hashing the lru key per call) the facade is ~66us/name, a ~5us wrapper over the core. A facade-path benchmark test now exists so this regression class stays visible. - HumanName.__str__ delegates its cleanup chain to _render._collapse (the #254 collapse has ONE owner; the facade layering row always allowed the import); collapse_whitespace keeps v1's narrower two-step contract over _render's regexes; the facade's three duplicate regex constants are gone. - _MEMBERS derives from Role in the v1 spellings (declaration order is canonical, never restated). - the fix_phd credential-pair regexes live once in _vocab, imported by segment and group (both are pipeline-internal; the keep-in-sync comment pair is gone). - the inert per-token entry_open rewrite in group's tail loop is hoisted out (piece-level state, now visibly so). - a cold-import regression test pins BOTH import orders around the _facade cycle-breaker (the load-bearing line an import-sort pass could otherwise silently reorder). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 43969ad commit 1eca44b

7 files changed

Lines changed: 77 additions & 23 deletions

File tree

nameparser/_config_shim.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,19 @@ def __repr__(self) -> str: # #221
894894
# -- snapshot -----------------------------------------------------------
895895

896896
def _snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
897+
# generation-keyed cache: rebuilding the Lexicon re-normalizes
898+
# ~1400 vocabulary entries (~185us) -- the same dirty-tracking
899+
# the facade uses, applied one level up. A pure read either way
900+
# (no bump, no warning).
901+
cached = getattr(self, "_snapshot_cache", None)
902+
if cached is not None and cached[0] == self._generation:
903+
return cached[1] # type: ignore[no-any-return]
904+
snapshot = self._build_snapshot()
905+
object.__setattr__(
906+
self, "_snapshot_cache", (self._generation, snapshot))
907+
return snapshot
908+
909+
def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
897910
"""Resolve this v1-shaped, mutable Constants into the frozen
898911
2.0 value objects it corresponds to (spec §3). A pure read: no
899912
generation bump, no deprecation warning even on the shared

nameparser/_facade.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from __future__ import annotations
88

99
import dataclasses
10-
import re
1110
import warnings
1211
from collections.abc import Iterator
1312
from typing import Any
@@ -28,19 +27,19 @@
2827
# still-executing __init__.
2928
import nameparser.config # noqa: F401
3029

30+
import nameparser._render as _render
3131
from nameparser._config_shim import CONSTANTS, Constants, _cached_parser
3232
from nameparser._lexicon import _normalize
3333
from nameparser._parser import Parser
3434
from nameparser._types import FOLDED_TAG, ParsedName, Role
3535

3636
_V2_FIELD = {"first": "given", "last": "family"} # v1 name -> v2 name
37-
_MEMBERS = ("title", "first", "middle", "last", "suffix", "nickname",
38-
"maiden")
37+
_V1_SPELLING = {v2: v1 for v1, v2 in _V2_FIELD.items()}
38+
# derived from Role: declaration order IS the canonical field order
39+
# (never restated), rendered in the v1 spellings
40+
_MEMBERS = tuple(_V1_SPELLING.get(r.value, r.value) for r in Role)
41+
3942

40-
_SPACES = re.compile(r"\s+")
41-
_SPACE_BEFORE_COMMA = re.compile(r"\s+,")
42-
# the three comma characters, same set as the pipeline's COMMA_CHARS
43-
_TRAILING_COMMA = re.compile(r"[,،,]$")
4443

4544
#: v1 parsing hooks the facade never calls (spec §2 exception 2 / #280).
4645
_V1_HOOKS = (
@@ -230,8 +229,11 @@ def _resolve(self) -> Parser:
230229
extra_suffix_delimiters=frozenset(
231230
{self.suffix_delimiter}))
232231
self._lexicon, self._policy = lexicon, policy
232+
self._parser = _cached_parser(lexicon, policy)
233233
self._snapshot_gen = gen
234-
return _cached_parser(self._lexicon, self._policy)
234+
# the fast path is a plain attribute return: hashing the two
235+
# value objects for the lru lookup is the whole fast-path cost
236+
return self._parser
235237

236238
def parse_full_name(self) -> None:
237239
"""Re-parse the stored ``full_name`` (v1's documented re-parse
@@ -572,19 +574,21 @@ def comparison_key(self) -> tuple[str, ...]:
572574
# -- dunders ------------------------------------------------------------
573575

574576
def collapse_whitespace(self, string: str) -> str:
575-
# v1 parser.py:976 verbatim (regexes.spaces / regexes.commas)
576-
string = _SPACES.sub(" ", string.strip())
577-
if string and _TRAILING_COMMA.search(string):
577+
# v1 parser.py:976 verbatim, over _render's regexes (the #254
578+
# collapse owns them; this public method keeps v1's narrower
579+
# two-step contract for initials() and direct callers)
580+
string = _render._SPACES.sub(" ", string.strip())
581+
if string and _render._COMMA_CHAR.fullmatch(string[-1]):
578582
string = string[:-1]
579583
return string
580584

581585
def __str__(self) -> str:
582586
if self.string_format is not None:
583-
_s = self.string_format.format(
587+
rendered = self.string_format.format(
584588
**{k: v or "" for k, v in self.as_dict().items()})
585-
_s = _s.replace(" ()", "").replace(" ''", "").replace(' ""', "")
586-
_s = _SPACE_BEFORE_COMMA.sub(",", _s)
587-
return self.collapse_whitespace(_s).strip(", ")
589+
# the full #254 collapse is _render._collapse -- one owner
590+
# for the cleanup chain the v1 __str__ spelled inline
591+
return _render._collapse(rendered)
588592
return " ".join(self)
589593

590594
def __repr__(self) -> str:

nameparser/_pipeline/_group.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@
2020
from __future__ import annotations
2121

2222
import dataclasses
23-
import re
2423
from collections.abc import Sequence, Set
2524
from enum import IntEnum
2625

2726
from nameparser._pipeline._state import ParseState, Structure, WorkToken
27+
from nameparser._pipeline._vocab import D as _D
28+
from nameparser._pipeline._vocab import PH as _PH
2829
from nameparser._pipeline._vocab import delimiter_cores
2930
from nameparser._types import Role
3031

31-
_PH = re.compile(r"^ph\.?$", re.IGNORECASE)
32-
_D = re.compile(r"^d\.?$", re.IGNORECASE)
32+
# the credential-pair regexes live in _vocab (shared with segment)
3333

3434
Piece = list[int]
3535

@@ -234,7 +234,8 @@ def group(state: ParseState) -> ParseState:
234234
if entry_open or pos > 0:
235235
tokens[i] = dataclasses.replace(
236236
tokens[i], tags=tokens[i].tags | {"joined"})
237-
entry_open = True
237+
# piece-level state: the NEXT piece continues this entry
238+
entry_open = True
238239
if len(kept) != len(pieces):
239240
pieces = [pieces[k] for k in kept]
240241
ptags = [ptags[k] for k in kept]

nameparser/_pipeline/_segment.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,18 @@
2121

2222
import bisect
2323
import dataclasses
24-
import re
2524

2625
from nameparser._pipeline._state import ParseState, PendingAmbiguity, Structure
2726
from nameparser._pipeline._vocab import (
27+
D as _D,
28+
PH as _PH,
2829
delimiter_cores, is_suffix_lenient, is_suffix_strict,
2930
period_joined_vocab, splits_into_suffixes,
3031
)
3132
from nameparser._types import AmbiguityKind
3233

3334

34-
# keep in sync with _group.py's merge pair (the fix_phd port)
35-
_PH = re.compile(r"^ph\.?$", re.IGNORECASE)
36-
_D = re.compile(r"^d\.?$", re.IGNORECASE)
35+
3736

3837

3938
def segment(state: ParseState) -> ParseState:

nameparser/_pipeline/_vocab.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,10 @@ def period_joined_vocab(text: str, lexicon: Lexicon) -> str | None:
117117
for c in chunks):
118118
return "suffix"
119119
return None
120+
121+
122+
# The fix_phd credential pair ('Ph.' + 'D.' as adjacent tokens), shared
123+
# by segment's suffix-comma detection and group's merge (v1 extracted
124+
# the credential pre-parse; the two stages must agree on the pattern).
125+
PH = re.compile(r"^ph\.?$", re.IGNORECASE)
126+
D = re.compile(r"^d\.?$", re.IGNORECASE)

tests/v2/test_benchmark.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,16 @@ def test_parse_thousand_names_under_a_second() -> None:
1313
parse(f"Dr. Juan{i} de la Vega III")
1414
elapsed = time.perf_counter() - start
1515
assert elapsed < 1.0, f"1000 parses took {elapsed:.2f}s"
16+
17+
18+
def test_facade_thousand_names_under_a_second() -> None:
19+
# the legacy-API path (what all existing users call): snapshot
20+
# resolution must stay generation-cached, not rebuilt per instance
21+
from nameparser import HumanName
22+
23+
HumanName("warm up the caches")
24+
start = time.perf_counter()
25+
for i in range(1000):
26+
HumanName(f"Dr. Juan{i} de la Vega III")
27+
elapsed = time.perf_counter() - start
28+
assert elapsed < 1.0, f"1000 facade parses took {elapsed:.2f}s"

tests/v2/test_facade.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,20 @@ def test_middle_as_family_list_view_matches_string_view() -> None:
431431
n = HumanName("Hassan, Mohamad Ahmad Ali", constants=c)
432432
assert n.last == "Ahmad Ali Hassan"
433433
assert n.last_list == ["Ahmad", "Ali", "Hassan"]
434+
435+
436+
def test_cold_import_order_config_first() -> None:
437+
# the _facade cycle-breaker (import nameparser.config first) is
438+
# order-dependent; this pins BOTH cold-start directions in fresh
439+
# interpreters so an import-sorting pass cannot silently break one
440+
import subprocess
441+
import sys
442+
443+
for first in ("nameparser.config", "nameparser._facade"):
444+
proc = subprocess.run(
445+
[sys.executable, "-c",
446+
f"import {first}; import nameparser; "
447+
f"print(nameparser.HumanName('John Smith').last)"],
448+
capture_output=True, text=True)
449+
assert proc.returncode == 0, (first, proc.stderr)
450+
assert proc.stdout.strip() == "Smith"

0 commit comments

Comments
 (0)