Skip to content

Commit 182249a

Browse files
derek73claude
andcommitted
Enforce the bound-given/never-given-particle invariant on Lexicon
nameparser/config/prefixes.py asserts two invariants on its own data: NON_FIRST_NAME_PREFIXES stays a subset of PREFIXES, and stays disjoint from BOUND_FIRST_NAMES. Only the first had a 2.0 counterpart -- particles_ambiguous <= particles is checked on every Lexicon, while the disjointness rule guarded nameparser's shipped vocabulary and nothing a caller supplied. In 2.0's complement model the second reads as bound_given_names & particles <= particles_ambiguous "a bound given name that is also a particle must be one that may start a given name". A particle declared never-to-start-a-given-name cannot also bind one; before this, the contradiction resolved by discarding the never-given declaration at leading position, with no signal. Note the import-time assert only protects the shipped data, and vanishes under python -O. This check runs on every Lexicon. v1 compatibility, as with given_name_titles: v1 has no runtime guard, so a caller can add a never-given prefix to bound_first_names, and 1.4 then lets the bound rule win -- "dos Santos Silva" parses first="dos Santos". The shim reproduces that by promoting such a word to may-be-given rather than raising on a config v1 accepted. Verified byte-identical to a live 1.4.0 for both the leading and non-leading positions, and pinned by test_snapshot_keeps_a_bound_never_given_prefix_parseable -- the hole had no coverage, since no existing test built that combination. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 418a210 commit 182249a

4 files changed

Lines changed: 73 additions & 6 deletions

File tree

nameparser/_config_shim.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,8 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
934934
from nameparser.config.maiden_markers import MAIDEN_MARKERS
935935
acronyms = frozenset(self.suffix_acronyms)
936936
titles = frozenset(self.titles)
937+
particles = frozenset(self.prefixes)
938+
bound = frozenset(self.bound_first_names)
937939
# keep in sync with _lexicon._default_lexicon() (pinned by the
938940
# default-Constants equality test in tests/v2/test_config_shim.py)
939941
lexicon = Lexicon(
@@ -953,13 +955,20 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]:
953955
# ambiguous entry lingers (the entry simply stops mattering)
954956
suffix_acronyms_ambiguous=frozenset(
955957
self.suffix_acronyms_ambiguous) & acronyms,
956-
particles=frozenset(self.prefixes),
958+
particles=particles,
957959
# complement translation: v1 marks the never-given subset;
958-
# v2 marks the may-be-given subset
959-
particles_ambiguous=frozenset(self.prefixes)
960-
- frozenset(self.non_first_name_prefixes),
960+
# v2 marks the may-be-given subset. The trailing union keeps
961+
# a config v1 accepted: prefixes.py asserts its own data has
962+
# no word in both non_first_name_prefixes and
963+
# bound_first_names, but nothing stops a caller adding one at
964+
# runtime, and v1 then lets the bound rule win (leading "dos
965+
# Santos Silva" parses first="dos Santos"). Treating such a
966+
# word as may-be-given reproduces that instead of raising.
967+
particles_ambiguous=(
968+
particles - frozenset(self.non_first_name_prefixes))
969+
| (bound & particles),
961970
conjunctions=frozenset(self.conjunctions),
962-
bound_given_names=frozenset(self.bound_first_names),
971+
bound_given_names=bound,
963972
# v1 Constants has no manager for these (#274 is 2.0
964973
# behavior); the data module is the only source
965974
maiden_markers=frozenset(MAIDEN_MARKERS),

nameparser/_lexicon.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,19 @@ def __post_init__(self) -> None:
236236
f"Add them to {base} as well — "
237237
f"add({base}={{...}}, {marker}={{...}})"
238238
)
239+
# The v2 form of prefixes.py's NON_FIRST_NAME_PREFIXES-disjoint-
240+
# from-BOUND_FIRST_NAMES assertion. That module guards its own
241+
# data at import; this guards vocabulary a caller supplies.
242+
contradictory = (
243+
self.bound_given_names & self.particles) - self.particles_ambiguous
244+
if contradictory:
245+
raise ValueError(
246+
f"bound_given_names entries that are also particles must be "
247+
f"in particles_ambiguous; not in particles_ambiguous: "
248+
f"{', '.join(sorted(contradictory))}. A particle that never "
249+
f"starts a given name cannot also bind one — add them to "
250+
f"particles_ambiguous, or drop them from bound_given_names"
251+
)
239252

240253
# -- constructors ----------------------------------------------------
241254

tests/v2/test_config_shim.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from nameparser import GIVEN_FIRST, Lexicon, PatronymicRule, Policy
9+
from nameparser import GIVEN_FIRST, HumanName, Lexicon, PatronymicRule, Policy
1010
from nameparser._config_shim import (
1111
CONSTANTS, Constants, SetManager, TupleManager, _DelimiterManager,
1212
_RegexesProxy, _cached_parser,
@@ -335,6 +335,22 @@ def test_v14_constants_blob_unpickles_into_shim() -> None:
335335
assert loaded.string_format == "{title} {first} {middle} {last} {suffix} ({nickname})"
336336

337337

338+
def test_snapshot_keeps_a_bound_never_given_prefix_parseable() -> None:
339+
# prefixes.py asserts its own data keeps non_first_name_prefixes
340+
# disjoint from bound_first_names, but nothing stops a v1 caller
341+
# adding one at runtime, and 1.4 accepts it -- letting the bound
342+
# rule win, so "dos Santos Silva" parses first="dos Santos".
343+
# Lexicon rejects that combination, so the shim promotes such a word
344+
# to may-be-given rather than raising on config v1 allowed.
345+
c = Constants()
346+
assert "dos" in c.non_first_name_prefixes # baseline: never-given
347+
c.bound_first_names.add("dos")
348+
lexicon, _, _ = c._snapshot() # must not raise
349+
assert "dos" in lexicon.particles_ambiguous
350+
name = HumanName("dos Santos Silva", constants=c)
351+
assert (name.first, name.last) == ("dos Santos", "Silva")
352+
353+
338354
def test_snapshot_field_translation() -> None:
339355
c = Constants()
340356
lexicon, policy, defaults = c._snapshot()

tests/v2/test_lexicon.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,35 @@ def test_subset_error_names_the_fix(
254254
make(frozenset({"zzqnovel"}))
255255

256256

257+
def test_bound_given_name_that_is_a_particle_must_be_ambiguous() -> None:
258+
# nameparser/config/prefixes.py asserts this on its own data:
259+
# NON_FIRST_NAME_PREFIXES stays disjoint from BOUND_FIRST_NAMES. In
260+
# 2.0's complement model that reads as bound_given_names & particles
261+
# <= particles_ambiguous. A particle declared never-to-start-a-given-
262+
# name cannot simultaneously bind one; without the check, one of the
263+
# two contradictory declarations is silently discarded.
264+
with pytest.raises(ValueError, match="particles_ambiguous"):
265+
Lexicon(particles=frozenset({"dos"}),
266+
particles_ambiguous=frozenset(),
267+
bound_given_names=frozenset({"dos"}))
268+
269+
270+
def test_bound_given_name_may_overlap_an_ambiguous_particle() -> None:
271+
# the shipped case: "abu" is both, and legitimately so
272+
lex = Lexicon(particles=frozenset({"abu"}),
273+
particles_ambiguous=frozenset({"abu"}),
274+
bound_given_names=frozenset({"abu"}))
275+
assert "abu" in lex.bound_given_names
276+
# and a bound name that is not a particle at all is unconstrained
277+
assert "abdul" in Lexicon(bound_given_names=frozenset({"abdul"})
278+
).bound_given_names
279+
280+
281+
def test_default_lexicon_satisfies_the_bound_particle_invariant() -> None:
282+
d = Lexicon.default()
283+
assert not (d.bound_given_names & d.particles) - d.particles_ambiguous
284+
285+
257286
def test_remove_orphaning_given_name_titles_raises() -> None:
258287
lex = Lexicon(titles=frozenset({"sheikh"}),
259288
given_name_titles=frozenset({"sheikh"}))

0 commit comments

Comments
 (0)