From 7f62a704cea9a2b996cd9dd602325318d8e95b19 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 6 Aug 2026 23:28:50 -0700 Subject: [PATCH 1/2] Sweep the non-.rst prose for staleness Four sites, two kinds of drift. #334 moved the guide to the Policy spellings that type-check, but only the hand-written .rst. Four _policy.py docstrings teach the old ones and autodoc renders them into modules.html, so the API reference and the guide disagreed on the same page. Worse, #337's new runtime warning hands the user Policy(segment_scripts=()) -- the library telling you, in a package that ships py.typed, to write an arg-type error. A test now pins the offered spelling; the warning tests matched on 'ja_segmenter' and never checked the actionable half of the message. Two stage headers understated their inputs. _extract declared only the two delimiter policy fields while reading three Lexicon suffix fields through _suffix_shaped, which is not a detail: that is the mechanism letting a clause's content overrule the delimiter, and #335 would extend it. _post_rules omitted Policy.middle_as_family. Checked mechanically rather than by eye: every stage's declared Reads against the policy and lexicon attributes it actually touches. _script_segment, _tokenize, _assign, _group and _classify were already accurate. _vocab has no Reads line by design, being a helper whose predicates take vocabulary explicitly. Its 'no state' claim still holds. --- nameparser/_parser.py | 2 +- nameparser/_pipeline/_extract.py | 7 ++++++- nameparser/_pipeline/_post_rules.py | 3 ++- nameparser/_policy.py | 8 ++++---- tests/v2/test_parser.py | 14 ++++++++++++++ 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/nameparser/_parser.py b/nameparser/_parser.py index 5c24712e..a13806c8 100644 --- a/nameparser/_parser.py +++ b/nameparser/_parser.py @@ -120,7 +120,7 @@ def __post_init__(self) -> None: f"written in {'it' if one else 'them'} will never " f"divide. Supply covering surnames, pass a " f"segmenter, or deactivate with " - f"Policy(segment_scripts=()).{ja_hint}", + f"Policy(segment_scripts=frozenset()).{ja_hint}", UserWarning, stacklevel=3) def __repr__(self) -> str: diff --git a/nameparser/_pipeline/_extract.py b/nameparser/_pipeline/_extract.py index bf6b82f3..5a5d9dec 100644 --- a/nameparser/_pipeline/_extract.py +++ b/nameparser/_pipeline/_extract.py @@ -7,7 +7,12 @@ A Role.MAIDEN region is the WHOLE inner span, marker word included -- nothing here strips one. classify tags a marker inside it like any other token, and group drops it from a multi-token clause (#329). -Reads: Policy.nickname_delimiters, Policy.maiden_delimiters. +Reads: Policy.nickname_delimiters, Policy.maiden_delimiters, and +Lexicon.suffix_words / suffix_acronyms / suffix_acronyms_ambiguous +through _suffix_shaped, which lets a clause's CONTENT overrule the +delimiter's verdict: 'Andrew Perkins (MBA)' is not a nickname, so +only the two delimiter spans are masked and the content rejoins the +token stream. Matching rules (the #273 mechanism): one left-to-right scan over the original text, no nesting. At each position the LEFTMOST boundary-valid diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index fc96fb12..40c0ac54 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -2,7 +2,8 @@ Consumes: tokens (roles assigned). Produces: tokens with roles adjusted by the post rules. -Reads: Policy.patronymic_rules; Lexicon.given_name_titles. +Reads: Policy.patronymic_rules, Policy.middle_as_family; +Lexicon.given_name_titles. Rules (each a small pure function over the role-bearing tokens): 1. v1 handle_firstnames: when the parse is exactly a title plus ONE diff --git a/nameparser/_policy.py b/nameparser/_policy.py index 833433c5..657717d7 100644 --- a/nameparser/_policy.py +++ b/nameparser/_policy.py @@ -563,7 +563,7 @@ class Policy: """The behavior switches a parser runs with: name order, patronymic rules, delimiter routing, input scrubbing. Immutable and hashable; every field has a safe default, so construct with - only what you change -- ``Policy(maiden_delimiters={("(", ")")})`` + only what you change -- ``Policy(maiden_delimiters=frozenset({("(", ")")}))`` -- and pass the result to ``Parser(policy=...)``.""" #: How positional (no-comma) input maps onto given/middle/family. @@ -584,7 +584,7 @@ class Policy: #: stored as sorted pairs). The default reads wholly-Han/Hangul #: names, and kana-licensed Japanese names, family-first -- see #: :data:`~nameparser.DEFAULT_SCRIPT_ORDERS`. Opt out with - #: ``script_orders={}``. Latin-script and mixed-script input is + #: ``script_orders=()``. Latin-script and mixed-script input is #: never affected. Like name_order, ignored where a comma already #: decides the family name. script_orders: tuple[tuple[Script, tuple[Role, Role, Role]], ...] = ( @@ -602,7 +602,7 @@ class Policy: #: locales.ZH for Chinese and locales.JA -- which activates #: Script.HIRAGANA alongside it, the kana license's carrier key -- #: for Japanese. - #: Opt out with ``segment_scripts=()``; note a PolicyPatch unions + #: Opt out with ``segment_scripts=frozenset()``; note a PolicyPatch unions #: rather than replaces, so a pack can only add scripts, never #: disable one. segment_scripts: frozenset[Script] = frozenset({Script.HANGUL}) @@ -622,7 +622,7 @@ class Policy: #: (open, close) pairs whose enclosed content becomes the maiden #: field instead; a pair listed here is dropped from the effective #: nickname set (maiden wins, see __post_init__), so - #: maiden_delimiters={("(", ")")} is the whole recipe (#274). + #: maiden_delimiters=frozenset({("(", ")")}) is the whole recipe (#274). #: A maiden_markers word opening the enclosed content is dropped #: from the value, but only where that content holds more than one #: token: a lone "(Nee)" is a maiden NAME, not a marker (#329). diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py index afe43fe2..7bfd5e50 100644 --- a/tests/v2/test_parser.py +++ b/tests/v2/test_parser.py @@ -772,6 +772,20 @@ def test_segmenterless_activation_without_vocabulary_warns() -> None: parser_for(locales.JA) +def test_the_activation_warning_offers_a_spelling_that_type_checks() -> None: + # The message's actionable half is the deactivation it offers, and + # a user pastes it verbatim. nameparser ships py.typed, so that + # paste has to survive a type checker: Policy(segment_scripts=()) + # is an arg-type error, since the field is annotated with what it + # STORES (a frozenset) rather than everything the constructor + # accepts. Nothing pinned this half of the message before. + with pytest.warns(UserWarning) as caught: + parser_for(locales.JA) + message = str(caught[0].message) + assert "Policy(segment_scripts=frozenset())" in message + assert "Policy(segment_scripts=())" not in message + + def test_a_segmenter_silences_the_activation_warning() -> None: # any segmenter counts: the gap is "nothing can divide these", # not "you did not use namedivider" From b6d71b26c4d8e9736c0541ff9a728883b65db99c Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 6 Aug 2026 23:34:41 -0700 Subject: [PATCH 2/2] Name the sweep sites in the release checklist Step 0 said 'review docs/ for anything stale' and named only .rst files and AGENTS.md. That misses the three sites this sweep actually found drift in, and it does not say the method: grepping for the changed SYMBOL finds almost none of this, because prose describes behavior in words rather than identifiers. The sites are listed now, each one having gone stale at least once, with the autodoc case called out for why an .rst-only sweep cannot catch it. The stage-header check is given as a command rather than an instruction to read carefully, since it is mechanical. --- AGENTS.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8d3dd7a7..1f257729 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,9 +46,22 @@ uv run sphinx-build -b html docs dist/docs # - Format matches existing entries — see 1.3.0 block for a current example # Release checklist (PyPI publish is triggered automatically by GitHub Actions on release creation) -# 0. Review docs/ for anything stale — especially usage.rst (examples, API surface) -# and any .rst files that reference config constants or HumanName kwargs -# Also review AGENTS.md for stale commands, architecture notes, or gotchas +# 0. Sweep for stale prose. Grepping for the changed SYMBOL finds almost none of +# it: prose describes behavior in words, not identifiers. Walk these sites +# explicitly — every one of them has gone stale at least once. +# - docs/*.rst: usage.rst examples and API surface, plus any file listing +# config constants or HumanName kwargs +# - docstrings that autodoc renders (nameparser/_policy.py, _types.py, ...). +# These ARE the API reference in modules.html, so a docs/*.rst-only sweep +# structurally misses half the rendered page — that is how the guide and +# the reference ended up teaching different Policy spellings in 2.1 +# - user-facing MESSAGES: warnings and raise text often hand the reader code +# to paste, and it has to be code that still works (and type-checks) +# - pipeline stage header blocks: each _pipeline/*.py docstring declares +# "Reads:". That is checkable, so check it rather than reading it: +# compare it against grep -oE '\b(policy|lexicon)\.[a-z_]+' on the module +# - tests/v2/cases.py notes, which explain why a row lands where it does +# - AGENTS.md itself, for stale commands, architecture notes, or gotchas # And check for open Dependabot PRs on uv.lock (namedivider-python) and merge them # first — pyproject floats >=0.4 so fresh installs get the newest namedivider, but # CI's ja-extra job installs from uv.lock and only tests what the lock pins