Imported from derek73/python-nameparser (
AGENTS.md). Install upstream withnpx skills add derek73/python-nameparser. Copyright stays with the author.
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Workflow
For non-trivial changes, use a feature branch and open a PR. Branch naming: fix/issue-NNN-short-description or feat/short-description.
Before opening the PR, if the change alters parser behavior or internals, read the Architecture, Extension Patterns, and Gotchas sections of this file against the change — don't grep for it: AGENTS.md paraphrases behavior in its own words, so text made stale by a code change rarely matches the code's phrasing (a doc-staleness sweep driven by grep terms from the diff will miss it every time). The same applies when scoping a doc-review pass or a subagent prompt: include AGENTS.md in the list of docs to check, or it won't be checked.
After a review round, review the fix commit too — second-round passes on #398, #400 and #403 each found defects in the first round's fixes.
Check whether the PR description still needs updating before merging. A review that moves behavior or retracts a measurement leaves it stale, and a stale body reads as authoritative.
When a defect is found that an earlier check would have caught, write the check down where the next person will meet it, in the same PR. A review finding, a failing sweep or a regression that arrived because nobody thought to test a shape is a lesson about the repository, not only about the change, and it is lost when the PR merges unless it lands somewhere a later session reads before making the same mistake. Ask, before merging: what should I have checked, and where would I have looked? Then put it there — a missed input shape in the sweep paragraphs under Architecture (comma shapes, then name_order, then spellings, each earned this way); a trap in a module's behavior under Gotchas; a design-review miss in docs/design/AGENTS.md's axes; a measurement or test-design trap in mechanisms.md's Verification shapes or Field notes; a reusable design move in mechanisms.md proper. Name the PR or issue that earned it and the input that showed it, so the rule can be checked against its case. #610 is the shape: its sweep test found that the maiden take missed a shape-only numeral, and the spelling sweep below records the class (VI beside V and III). This is how the file accumulates expertise rather than history — prefer amending an existing paragraph to adding a new one, and delete a lesson the code has since made impossible.
A scripted multi-edit that asserts as it goes discards everything when a late pattern misses. Assert every pattern before applying any, then verify the edit landed — three times in one session a docs batch failed its last assertion, wrote nothing, and reported success, invisible because prose edits fail no test.
Rules documentation (docs/design/)
Three committed contributor docs carry the parser's normative rules and their reasons, enforced by the doc tests in tests/v2/ (rules_doc.py and the test_doc_/test_rules_doc modules). Before proposing a design or a fix that touches parser behavior, read docs/design/mechanisms.md and the relevant docs/design/rules.md sections — the catalog is keyed by problem shape, and the pattern you are about to invent is often already there.
- rules.md — NORMATIVE rules (intended behavior, domain-topic sections, executable examples;
deviates:markers track known parser gaps). Cite asrules.md#P1; code comments quote a verbatim excerpt in double quotes, checked by tests/v2/test_doc_citations.py. - decisions.md — the ADR-style record: dated entries, Declined: (rejected WITH evidence), Excluded: (what must stay out of a wordlist and why), Open: (issue links, never restated; also keyed to a vocabulary set for contested memberships — the right-rule-wrong-set class), 3-0-reevaluations (append whenever a design cites 1.4 parity as load-bearing).
- mechanisms.md — problem-shape catalog with citable contract statements; stage-attribution claims in comments must cite an entry verbatim, never restate it in fresh words.
Same-PR amendment rule. Any PR that changes or clarifies parser behavior — or the boundaries around a documented rule, since a neighboring change can invalidate a rule's stated limits without touching its code — amends rules.md in the same diff (the doc diff is part of the reviewable change, like tests). A change that resolves or reverses a design question adds a decisions.md entry; a new reusable pattern adds a mechanisms.md entry; a fixed deviation removes its deviates: marker in the same PR (the examples test forces this). Issues proposing behavior changes should be drafted in rule shape — rationale, statement, examples with boundaries, accepted consequences, open questions, exclusions — so landing an accepted proposal is a copy, not a rewrite.
Counting claims. A bare count in prose is either an assertion or a liability, keyed by who observes its staleness: asserted counts (a test holds the number) fail CI at change time — the useful kind; dated snapshots ("51 sites at spec time") cannot go stale; standing present-tense prose counts are the forbidden class — promote to an assertion, add a date, or state the invariant and let a test count. After changing how many times something runs, sweep for counts, not for the thing's name.
Release-log claims. Quantified or universal behavior claims in release bullets must come from the snapshot's diff since the last release, be verified against rules.md examples, or -- for a view the snapshot cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. uv run python tools/differential/snapshot.py --since v<last release> prints every corpus name the release moved, grouped by changed field; v2.4.1 is the first tag carrying a snapshot, and for an older release compare.py --baseline <version> prints the same report against the PyPI wheel, closing with the entries compared and how many moved names are Latin-only. Both say how many moved names only corpus_cldr.jsonl holds. The snapshot records the seven role fields, ambiguity kinds and initials() for every name, so an initials count is read off it directly. corpus_cldr.jsonl's fictional renderings are in it: count them out of a release figure (both tools print how many moved names only that file holds), or say the figure includes them. capitalized() and any other render view stay invisible to the snapshot (decisions.md#R4, #R3), and for those the first two sources cannot reach a claim: the snapshot is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare initials() against a folded-first partition, which is what initials() now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. The same standard binds decisions entries and PR bodies: a claim that a change restores the released reading is measured over the whole moved population against the wheel, never spot-checked (#625 claimed its 774 moves landed on the 1.4.0 and 2.3.0 readings from three names; measured, the tree matched 2.3.0 on 0 of 258 moved texts, master on 152), and a population size is re-counted from its recipe, never copied from another entry (the same entry carried #624's 137,810 for a grid of 395,148). Code comments citing rule IDs follow the excerpt discipline, checked by tests/v2/test_doc_citations.py: a rule's statement and its Accepted: clauses are quotable, its example lines are not; rules.md#H names the H section, whose Background is quotable; a decisions.md citation may paraphrase, but what it quotes must be verbatim; and a reference with no quote must still name something that exists. The check's reach is a quote within four words of the ID or chained to one that is: a quote further off reads exactly like a name written in double quotes, so it is NOT checked, and the second review of #632 found 14 such quotes stale by hand. Keep a quote beside its ID. Quotes of AGENTS.md itself are not checked at all.
Writing the user docs (docs/*.rst) has its own AGENTS.md too. docs/AGENTS.md carries the style the docs are written in — tables as indexes, subheadings per task, measured claims, recipe doctests — distilled from PR #588. It loads the same way as the docs/design/ one below; if your tool does not do nested discovery, read it before editing a .rst file under docs/.
Working on docs/design/ has its own AGENTS.md. docs/design/AGENTS.md carries the landing-a-design distillation checklist, the primary-source review rule, the dated-count convention, and the review axes. Claude Code loads it automatically when a session reads or edits anything under docs/design/; if your tool does not do nested discovery, read it yourself before touching those files or reviewing a change to them.
Triaging a design backlog: shape before value. When a pile of open questions has to be ordered, relatedness will not partition it — in a parser nearly everything touches particles or suffixes. The line that does is whether a decision changes WHAT THE MODEL CAN EXPRESS or fills in a value in a shape already fixed. Settle the shape questions first, as a batch: they are few, they are usually independent of each other (so their own ordering does not matter), and each one collapses or re-frames a run of the value questions below it. The rest are leaves needing one measurement and one answer apiece, in any order. Worked example, 2026-08-16 (#386): 30 open issues and ~13 open design questions reduced to three shape decisions, which decided or re-framed fourteen of them — and two of the three turned out smaller than their issues claimed, because the issues had gone stale. Check the age of an issue's premises before believing its cost estimate.
Markdown line width: don't hard-wrap prose. Measured 2026-08-16 — wrapped and unwrapped render identically, docs/design/ is not in the Sphinx build, and unwrapping is byte-identical (newline→space is 1:1), so the width buys nothing and costs diff churn (74% of #386's decisions.md deletions were reflow, not change). AGENTS.md and docs/design/ were unwrapped in one whitespace-only commit on 2026-08-16; keep new prose unwrapped rather than reformatting again. The blame cost was 43 lines of 1717, measured, not the wholesale reset predicted before doing it — git's diff maps most joined lines back to whichever commit wrote their opening text. .git-blame-ignore-revs lists the commit and is worth enabling (git config blame.ignoreRevsFile .git-blame-ignore-revs) for future format-only commits, but it recovered none of those 43: --ignore-revs-file cannot pass blame through a commit that MERGES lines, because the joined line has no counterpart in the parent to re-attribute to. Exceptions, both enforced: rules.md's example and pointer lines are parsed per line, so a wrapped example is a hard error naming its rule; and rules.md's rule BODIES stay wrapped because a rule block is a record format, aligning 4-space statement prose above 6-space examples, not a paragraph.
Guard tests SHOULD carry a recorded negative control — the answer with the guard off, stored as data (the _UNGUARDED_EFFECT shape in tests/v2/pipeline/test_state.py; see mechanisms.md's Verification shapes). Re-take it after the last commit that adds tests, before merge: a review round that adds rows moves the count, and three recorded controls in a row went stale that way (#625 27 → 30, #636 4 → 9, #637 5 → 7 → 11), each caught only by the next review.
Invariant tests over a generated grid share ONE parsed grid, and a grid pairs a text only with configurations that can read it differently. Both rules are in tests/v2/test_properties.py (_connective_findings, _off_switch_findings, _rows) and both were bought at a price: four tests each walking the same 170,100-row grid, and a grid that was a full cross product of texts × lexicons × policies, together took that module from 11s to 115s and CI's build jobs from ~5 minutes to 17–25 (under coverage every parse costs several times more, and every later PR pays it). A new invariant over an existing grid joins that grid's walk instead of opening its own; a new grid states its measured runtime where it is built. Trim a grid by SHAPE — a variant lexicon reaches only the texts holding the word it adds — never by sampling, and prove the dropped rows were duplicates by signature before dropping them. Mutation counts in the tests' docstrings are counts over the grid, so re-measure them whenever the grid moves -- and so do the DESIGN DOCS that quote them. A recorded control reaches docs/design/decisions.md and docs/design/mechanisms.md as well as the docstring, nothing checks those copies, and a grid change silently makes them wrong about a number they present as measured: the #397 second review moved four such figures at once (2026-09-20). Move them in the same commit, and where the older number is worth keeping, label it with the population it was measured over rather than deleting it.
Commands
# Preferred: use uv run (works without activating the venv)
# Alternative: .venv/bin/<tool> if the venv is already active
# Run all tests (includes --doctest-modules, so doctests in nameparser/ are also run;
# the v1 tests' dual-parametrize fixture runs each of them twice)
uv run pytest # --doctest-modules is set in pyproject.toml, so doctests run automatically
# Run a single test file / class / method
uv run pytest tests/test_python_api.py
uv run pytest tests/test_python_api.py::HumanNamePythonTests::test_utf8
# Type check (covers nameparser/ and tests/, per pyproject.toml's [tool.mypy] packages)
uv run mypy
# Lint (the whole repo, tests and tools included -- what CI's "Run linter" step runs)
uv run ruff check
# Debug how a specific name string is parsed: 2.0 core parse() --
# prints the ParsedName repr raw and capitalized, then initials;
# --json emits the component dict
uv run python -m nameparser "Dr. Juan Q. Xavier de la Vega III"
# Build docs
uv run sphinx-build -b html docs dist/docs
# Maintain docs/release_log.rst as changes land:
# - Keep an "Unreleased" entry at the top: `* X.Y.Z - Unreleased`
# - Add one bullet per notable change; prefix with Add/Fix/Remove/Change
# - A bullet is an index, not the record: a bold lead (plain text only --
# RST markup does not nest, so no ``literal`` or role inside ** **), ONE
# before/after example naming the releases it differs from, a limit
# sentence only where a caller would trip on it, then the issue link(s):
# :issue:`123` or :issue:`123`, :issue:`124` (sphinx.ext.extlinks; a PR
# number works too). The mechanism, boundary cases and measurements live
# in the issue and docs/design/decisions.md, never in the bullet --
# 2.4.0's entry was cut from 10,765 words to 1,491 on 2026-10-11
# - Merge bullets a user would see as one change, with several links
# - Version is decided at release time (patch/minor/major per semver)
# - Format matches existing entries — see the 2.4.0 block for a current example
# Release checklist (PyPI publish is triggered automatically by GitHub Actions on release creation)
# 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
# - README.rst's announcement at the top: it is the PyPI project page
# (pyproject's `readme`), so it should lead with the release being
# shipped, drawn from the release log's summary. 2.4.0 nearly shipped
# still announcing 2.0 and 2.1. Check it with `python -m doctest
# README.rst` and `uv run --with readme_renderer python -m
# readme_renderer README.rst -o /tmp/readme.html` (PyPI's renderer)
# - 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
# - docs/design/rules.md, decisions.md and mechanisms.md -- READ, don't
# grep: the excerpt and example tests catch citation and example drift,
# but statement and Background prose can still be wrong about behavior
# that changed
# - 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
# 1. Read what the release moved: `uv run python tools/differential/snapshot.py
# --since v<last release>`. It prints every corpus name that reads
# differently, grouped by changed field. That report is the source for the release notes'
# behavior claims (see Release-log claims above); a move nobody can
# account for is a finding to settle before the log is drafted.
# 2. Clear PRE_RELEASE in nameparser/_version.py — it carries 'dev' through the
# cycle (see step 9), so releasing is setting it to ''. VERSION should already
# be the version you are shipping; bump it here only if step 9 was skipped.
# For an RC, set PRE_RELEASE = 'rc1' (etc.) instead — __version__ joins it
# without a dot ('2.0.0rc1').
# Also set the `version:` field in CITATION.cff to this release. It is the ONE
# version string that does NOT track the tree: it names the last citable
# released artifact, so it lags on purpose for the whole cycle and catches up
# here. Nothing checks this — no test, no CI — so it is on you.
# 3. Stamp "Unreleased" → "X.Y.Z - Month DD, YYYY" in docs/release_log.rst
# 4. git commit + git tag -a vX.Y.Z -m "Release X.Y.Z"
# 5. git push origin master && git push origin vX.Y.Z ← tag must be pushed separately before gh release create
# 6. gh release create vX.Y.Z --title "vX.Y.Z" --notes "..."
# 7. Close the vX.Y.Z milestone and create a new "Next Release" one:
# MILESTONE=$(gh api repos/derek73/python-nameparser/milestones --jq '.[] | select(.title=="vX.Y.Z") | .number')
# gh api -X PATCH repos/derek73/python-nameparser/milestones/$MILESTONE -f state=closed
# gh api -X POST repos/derek73/python-nameparser/milestones -f title="Next Release"
# 8. Set DEFAULT_BASELINE in tools/differential/compare.py to the version
# just released, so a bare compare.py measures against it.
# 9. Open the next cycle's VERSION: bump VERSION in nameparser/_version.py to
# the minor now being worked, and set PRE_RELEASE = 'dev'. The tree then says
# what it is building rather than what it last shipped -- docs/conf.py reads
# __version__ directly, so Read the Docs' "latest" is otherwise stuck on the
# previous release for the whole cycle.
# It also restores compare.py's version tell: with the tree and
# the baseline wheel both reporting the same release, only the __file__ path
# distinguishes them, which is the weak state test_tell_rejects_a_module_
# loaded_from_the_checkout documents. A dev tree differs on both halves.
# 'dev' normalizes to X.Y.0.dev0 -- above the last release, below the one it
# precedes -- so an install from master can never masquerade as the release.
# Leave CITATION.cff alone: it names the last RELEASED version and is meant
# to lag until step 2 of the next release.
Enable debug logging to see the parser's internal decisions:
import logging
logging.getLogger('HumanName').setLevel(logging.DEBUG)
Architecture
The library has two layers: nameparser/config/ (data) and nameparser/parser.py (logic).
Design philosophy — positional and language-agnostic. The parser assigns parts by position plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in Constants config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. de Mesnil (want last name) vs Van Johnson (want first name) are the same [prefix][word] shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention. The never-detect-language rule above is about Latin transliteration, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Five defaults fall out of it, plus a sixth that applies the same not-a-guess standard to specific WORDS rather than to a script (#308's honorific peel, below). Wholly-Han, wholly-Hangul and kana-licensed names read family-first (Policy.script_orders) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (config/surnames.py ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana — full-width or halfwidth, the halfwidth block U+FF65–FF9F being katakana in the script table since #594 — is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order, and a Japanese reading written in katakana (legacy halfwidth data spells Japanese names that way too) cannot be told from one, so the script settles nothing and a caller who knows opts in through script_orders. And the 间隔号 U+00B7 (#298) is the transcription marker for scripts that HAVE no transcription script: a name it divides (威廉·莎士比亚 — flanked by classified characters on both sides, so Catalan's Gal·la is untouched) keeps the declared order and never segments — the orthography names the convention, with the divider carrying the signal that stands the script's family-first reading down, so the default reads it in its written order and a declared FAMILY_FIRST reverses it like any other name; pure katakana reaches the same reading by the opposite route, its script settling nothing so the declared order stands. And a listed CJK honorific glued to the END of a name token is split off it (#308) — 田中さん is 田中 plus さん — on the same orthography-settles-it test, narrowed for the glued position: an entry peels only where it can never end a name, so 씨/님/さん/様/先生 peel while 양/군/氏/博士/殿 stay spaced-only (김지양 and 田中博士 are names, and ~90 Japanese surnames end in 殿) and 君 is in NEITHER set (王君 is a complete Chinese name), though its kana spelling くん peels. Like the nakaguro's tokenize-level separation described next, it is reached by neither policy opt-out — but for its own reason: the vocabulary carries the license itself rather than borrowing the script's, so segment_scripts has nothing to say about it. Since #312 it also crosses the 间隔号, which still stops the surname split standing right beside it: it answers where a name DIVIDES into surname and given, and the peel never asks that question. Whether it also crosses the FAMILY comma is tolerated rather than settled: the 2026-09-01 demotion (rules.md#W3) narrowed that half from contract to best-effort, since no CJK writing system's own convention puts a comma between family and given at all — so 김, 민준씨 reads today exactly as the spaced 김 민준씨 does (family 김, given 민준, suffix 씨) while the split stands down as before, but that reading is watched in the snapshot rather than pinned as contract. Its site is accordingly the name-bearing segment runs — segments[:2] under a family comma, and segments[0] as before otherwise, the family comma being the one structure that splits the name itself across two runs, with the honorific as often glued to the given side as to the family. That is the whole reach and nothing past it (김, 민준 지훈씨 peels; 김, 민준, 지훈씨 and 김,, 민준씨 do not, both landing in a third run), and whether segments[1] is name text at all is now ASKED rather than inferred from the structure — segment does not guarantee it, since a one-word part before the comma reads as FAMILY_COMMA even when the part after it is entirely suffix-shaped, and the peel walking into such a run took V. for its site, found no listed tail and abandoned (#319). The question is asked in two parts. First the comma's own decision: _comma.decide on a classified copy of the name as written, since #613's /simplify -- a hand copy of it here drifted from decide four times in that PR's review (the ambiguous class's count, the numeral refusal, a suffix before the comma, the split Ph. D.), each drift dividing a name decide read as a family comma or leaving whole one it read as a suffix comma, and asking the real decision is what ended it. Where that leaves the family comma, C1's suffix-word predicate, lifted into _vocab.is_wholly_suffix so the stages cannot drift: a wholly suffix-shaped second run is declined and the scan stays in segments[0], so 田中さん, V. and 田中さん, Ph. D. give さん up as 田中さん, PhD always did. The test is necessary but NOT sufficient, and the second condition is not decoration: every honorific tail is also a suffix word, so a glued honorific is itself part of what makes its run read as suffix-shaped, and declining a run that holds the ONLY site loses the peel outright. segments[0] must therefore offer a peel site of its own before the second run is declined — 이, J.씨 and 선생님, J.씨 pass the suffix test and are scanned anyway, keeping the pre-#319 reading, while 김민준씨, J.씨 has a site on both sides and peels the person's own 씨 rather than the junk one behind the comma. Uniform in the PEEL, that is — where the credential itself lands is assign's question and still differs by spelling (V. → given, PhD and Ph. D. → suffix). Not _is_post_nominal pluralized: the run predicate says yes both to what the token predicate vetoes (V., V, I — the class the defect was reported as) and to what the token predicate never sees at all, since period_joined_vocab and the delimiter routes are the run predicate's alone (Msc.Ed. and J.씨 reach it that way, and 田中さん, Msc.Ed. moves with the rest). Policy(lenient_comma_suffixes=False) drops this call to the strict token test too — so those three read as name text again and keep the pre-#319 answer, while Ph. D. peels under the knob regardless, its merged phd passing the strict test. 田中さん, 太郎 is unchanged, and not because of its comma — the honorific there is not at the end of the name, 太郎 is. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (script_orders={}, segment_scripts=()) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard _tokenize_region gives it (#298). Han segmentation stays OPT-IN (locales.ZH for Chinese, locales.JA for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so locales.JA activates the stage and a pluggable Parser(segmenter=...) does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by name_order and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes.
A constant's membership is a question you may reopen. Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. SUFFIX_ACRONYMS arrived in af5bdab as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave family empty in "John <word>" against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is ba; 568 of 575 against seven, measured 2026-09-25 after #540), and sa, se and om are borne as surnames (measured 2026-08-23), as were rai, cha, ba and mc before decisions.md#suffix-acronym-collisions decided all four — rai and cha removed, ba marked ambiguous, mc left alone as no borne name at all. The same entry marked meng and lac ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: decisions.md#vocabulary-collisions, with #360's positional qualifier.
Sweep the forms a change can reach: comma shapes, then name_order. No comma, a FULL name before the comma, a ONE-WORD name before the comma and NOTHING before the comma are four paths, and the last two are the misses — #429, #430 and #432 are all the one-word path disagreeing with the full-name path on inputs the full-name path reads correctly, and #613's PR review found , PhD and , Jr. (an empty surname field, as CSV data writes it) read as given and title by a decision that returned early on an empty head, after two review rounds whose every grid had a name before the comma. Put an empty head and a nickname-only head ((Bob), PhD) in every comma grid. Count the one-word path the way C1 counts, which is neither tokens nor pieces: a particle run and the one word it attaches to are one word (#575), a connective join and a bound given-name pair count as their words (#613, so Ortega y Gasset, Dr. is a whole name), and a suffix beside the surname counts as no name word at all. #603's review found the suffix half (Vega y Lopez Jr., PhD Jones lost its family where Vega y Lopez, PhD Jones kept it); #613 then reversed the connective half, and both now read given 'Vega', middle 'y', family 'Lopez' — so test a particle surname, a connective surname and a surname with a suffix, each before a comma. For orders the sweep already exists (tests/v2/test_cases.py runs every row under all three) but carries one assertion, R2's family partition, so it checks nothing a new change moves; coverage there has been vacuous before (PR #394's review found the suite passed with name_order discarded from grouping). Parse your change's names in each comma shape and each order, and read the ones you did not predict. And read a generated grid's moves by SHAPE, not by corpus membership: #614's first design passed the suite, the five gates and a fingerprint of every corpus name, because its grid moves were sorted into 'corpus name moved' and 'garbage', and the review found the moves that mattered among the 'garbage' -- a bare salutation (Mr. and Mrs. lost its connective to the family name) and a name with one trailing word (Freiherr von Berg X.Y.Z.). Pull out the head-plus-one-word slice of the grid and a salutation with nothing after it, and read every move there. Two more were the second review's (#614): no grid put TWO words that are both a title and a particle at the head (Freiherr St van Berg MA, where an index into the pieces was applied to a merged view of them -- TITLES ∩ particles_ambiguous is {freiherr, st}, so put both in every head list, each in its bare and its period-marked spelling -- #637's grid had St and Freiherr alone and missed that St. and Freiherr. are declined by the bound join and then attached by P6), and the grids compared ambiguity KINDS, so a report telling the caller the wrong field (Freiherr von Berg Ma, 'read as a given name' of a word in the family) passed every pin. Compare each report's detail against the field its tokens land in -- since #626 test_a_report_names_the_field_its_word_lands_in (tests/v2/test_cases.py) does it over every non-locale case row under all three orders (a row carrying its own policy only as declared), for the phrasings its _FIELD_CLAIMS lists -- so a row with the shape is the test only where the report's wording is listed: a new emitter's wording goes in that list, and segment's report on a part past the second comma names no field (#629). #626's own first grid then missed an emitter for want of one comma shape (, Ma Dr.: an empty head, an ambiguous word, a trailing title, which H1 moves to the family): a trailing title belongs in every empty-head comma grid too. And #620's: an exclusion list in code being replaced is a set of rules, not noise -- #614's merged copy excluded every word the trailing read weighs from its units, the first #620 prototype dropped the list as a symptom of the copy, and its review found the list had been keeping positional counting's rule that a member in front counts as a word (Freiherr von Berg MA X.Y.Z. lost its suffix). Before deleting one, mutate each entry out and read what moves.
Then sweep the spellings: a class reached by vocabulary is also reached by SHAPE. A test that uses only the listed spelling of a word class walks only the vocabulary path, and the shape path is a separate branch that can be missing while every test passes. Roman numerals are the sharpest case: ii, iii and iv are suffix vocabulary; i and v are vocabulary too but initial-shaped, so is_suffix_piece vetoes them and only the numeral fork reads them; and vi through x are in no list at all, read by the fork's shape test alone (checked 2026-10-04 against Lexicon.default()) -- which stops at x: _vocab._ROMAN matches I to X, so John Smith XI reads XI as a name word (re-checked 2026-10-07, #614's /simplify, which found a docstring claiming "any length"). So a change that handles V and III can still miss VI — #610 found exactly that, the maiden take's candidate loop admitting a shape-only numeral in one position where the peel reads it in two (Jane Doe nee Smith VI Prof. kept maiden Smith VI while V and III worked). The same split runs through the credentials (MA listed, X.Y.Z. by the dotted shape, XYZ by the caps shape, and Ph. D. merged by group into one flagged piece the peel's walk never holds -- #603 found #602's run-start predicate refusing it too, so Smith, John Ph. D. Jones kept middle 'Jones' where Smith, John PhD Jones read suffix 'PhD Jones') and the titles (Prof. period-marked and read at the end of a name, Prof bare and a name word there). When a change touches a word class, test one spelling from each path. The same holds for a separator's SPACING: a comma (or any punctuation) can stand alone, glue to the word before it or glue to the word after, and each is a different token stream. #653's first fix swept (née, Jones) and missed (née , Jones), where the kept comma became a word after the marker and M3 read the clause as maiden ', Jones'; its review found it. Put all three spacings in any grid around a separator.
And the encoding: a letter class written [^\W\d_] or tested with isalpha() is blind to a combining mark. Python's \w and isalpha() do not match Mn, so a letter written as base plus accent (NFD) ends a word early; the vocabulary path is immune because _normalize NFC-folds, the shape path is not. #652: Dr. inž. Janez Novak read inž. as the given name when the ž was decomposed, and Janez Novak, ŽŠČ reversed, in two families that share the blind spot — the letter-shape regexes (period_abbreviation, _DOTTED, _CHUNKED, _PAIRED_INITIALS), now spelled with _vocab._LETTER (a letter plus the general combining blocks; test_regex_sync compares the two _MARKS strings and pins period_abbreviation against config's copy), and the caps predicates, which NFC-compose a non-ASCII word before isalpha() (is_caps_word, behind C-level isalpha()/is_normalized tests, so already-composed text pays no frame — measured 2026-10-12, Smith, José was 193 frames before and after; the first version called it for every non-ASCII word and cost one frame each, and no guard saw it because the reference name is ASCII). A width test must count letters, not code points (composed_len): admitting a decomposed word to the caps set put John Smith, MÁ on the pair exception's wrong side, and the review's grid found it where the rows (all three letters long) did not — put a two-letter word in every caps grid. The initial shape (_INITIAL, \w\., v1-verbatim and public) is not widened but asked of the COMPOSED word, in is_initial_shaped and in _render's two copies; and the single-letter connective tests count composed letters, since a decomposed й is two code points (#665, where É. y É. Smith and John й Smith read differently decomposed). Compose the WHOLE predicate, not one half: is_initial is a shape ANDed with the #320 script veto, and composing the shape alone made a decomposed 씨. an initial, since decomposed hangul is conjoining jamo outside every script range (#665's first version; in_initialless_script composes too now). Not covered, deliberately: the marks only some scripts write (Indic vowel signs, Arabic and Hebrew points; rules.md#H2's Accepted clause). The guard is test_a_decomposed_name_renders_as_its_composed_twin over the CLDR corpus plus test_a_decomposed_abbreviation_reads_as_its_composed_twin (rows × three orders × EVERYWHERE); the corpus guard reaches a shape only if some corpus name does, which is how the caps shape was missed until review, so when adding a shape rule add a row there. Four of #652's rows and two of #665's pass with every fix reverted (measured) and are parity pins, and the test compares ambiguity kinds and initials() too, since #665's John й Smith differed in its report; _DOTTED has no observable input and is pinned by a direct call.
Configuration layer (nameparser/config/)
Most modules define a frozenset of known name pieces; capitalization.py and regexes.py define dicts. The SETS are frozen since 2.2 (#293): there is no .add()/.remove() on any of them, so a default word list is changed by configuring an object — a private Constants for HumanName, a Lexicon for the 2.0 API — never by editing the constant. A union of a frozenset with a set literal is still a frozenset (TITLES, PARTICLES), so the derived sets are frozen too. CONSTANTS/Constants still hand out mutable SetManagers; the freeze is on the module set constants they copy from. Neither dict was frozen, and CAPITALIZATION_EXCEPTIONS is not covered by anything else either. REGEXES is a compiled-pattern table rather than vocabulary and was never in #293's scope; CAPITALIZATION_EXCEPTIONS is vocabulary-shaped and is a decided, in-scope exemption. So the split-default hazard the freeze closes is still live for it, measured on 2.2 and re-measured 2026-09-23 with the key below once 'PhD' became the shipped value: CAPITALIZATION_EXCEPTIONS['dphil'] = 'DPhil' reaches a freshly built Constants and neither the cached Lexicon.default() nor the shared CONSTANTS. Same advice — configure the object (constants.capitalization_exceptions[...], or dataclasses.replace(lexicon, capitalization_exceptions=...)). tests/v2/test_contracts.py::test_every_vocabulary_constant_is_frozen names both dicts as explicit exemptions rather than letting its isinstance filter drop them.
titles.py—TITLES(prenominals) andGIVEN_NAME_TITLES(e.g. "Sir", which treat the following name as given, not family)suffixes.py—SUFFIX_ACRONYMS(with periods, e.g. "M.D.") andSUFFIX_WORDS(e.g. "Jr."), plusGLUED_HONORIFICS(#308), the subset ofSUFFIX_WORDSthe peel may split off the END of a name token — a separate, harsher set, since the glued position has no writer-drawn boundary to lean onparticles.py—PARTICLES(family-name particles, e.g. "de", "van") andNON_GIVEN_NAME_PARTICLES, the curated subset that is never a standalone given name. What the subset licenses is rules.md#P1's fold inpost_rules(the "rule 1b" of older issue comments — decisions.md maps the old numbers), and the fold has ONE site: the piece that OPENS the name. A lone never-given particle there, with at least one more name word behind it, starts the family. Titles do not move the opening position (rules.md#P4), soSir de Mesnilreads familyde Mesnilunder everyname_order, asde Mesnildoes. How far the fold reaches depends on the order the name was read under: by default it takes the rest of the name (de Mesnil Jean→ familyde Mesnil Jean); under either family-first order it takes the particle run plus ONE name unit and lays the rest out by the order (de Mesnil Jean→ familyde Mesnil, givenJean); after a family comma it takes the rest of that part whatever the order (Smith, de Mesnil Jean→ familySmith de Mesnil Jean). The leading particle stays a lone piece for the fold to find —groupdoes not chain it forward, and the fold walks the particles after it itself — sode la Vegareads familyde la Vegaunder all three orders. The GIVEN position is not a site. It was a second one until #467, which removed it because under a family-first order that slot holds what the caller DECLARED to be the given name:Mesnil deunderFAMILY_FIRSTorFAMILY_FIRST_GIVEN_LASTreads givende, familyMesnil. Nor is a particle inside the name:Juan de la Vegaunder a family-first order reads familyJuan, givende la Vega, with no ambiguity recorded — what the vocabulary forbids is the bare particle as a given name, not a name part that begins with one. A baredehas nothing to fold into and is placed by position like any one-word name: givendeby default, familydeunder both family-first orders. A leading particle OUTSIDE the set is left to position —van Goghis givenvan, familyGoghby default and familyvan, givenGoghunder both family-first orders — and records aPARTICLE_OR_GIVENambiguity under all three; a never-given particle opening the name records none (de Mesnil), and neither does an ambiguous one inside it (Vincent van Gogh).Lexicon.particles_ambiguousis its complement withinPARTICLES, so the two mark OPPOSITE sets — see the flip warning indocs/migrate.rstbefore translating eitherbound_given_names.py—BOUND_GIVEN_NAMES(bound given-name prefixes, e.g. "abdul", "abu"); a group-stage rule joins the first non-title piece to its following piece before roles are assigned, reserving a family word unless a family comma has already fixed it or a given-name title stands ahead (#369, rules.md#P5) (v1's_join_bound_first_name, ported into_pipeline/_group.pyand gone from the tree — the v1 descriptions further down are history, not current code)conjunctions.py—CONJUNCTIONS(e.g. "and", "of") used to chain multi-word titles and to join name parts (rules.md#P3), plusCONJUNCTIONS_AMBIGUOUS(#383/#479), the subset of single cased letters that read as an INITIAL in a name written wholly in one case — a marker set with no v1Constantsattribute of its own, so the only v1 knob that reaches it is deleting the conjunctionmaiden_markers.py—MAIDEN_MARKERS(e.g. "née", "geb.") routing the following name tomaidensurnames.py—KOREAN_SURNAMES, the census list the 2.0 API splits unspaced hangul on (#271). Withmaiden_markers.pyit is one of the two data modulesConstantshas no attribute for: both reach the parse only throughConstants._snapshot()→Lexicon, so the v1 surface stays frozen and there is no v1 knob to turn either off (the opt-out is the 2.0Policy)capitalization.py—CAPITALIZATION_EXCEPTIONSmapping of case MASKS (e.g.{'phd': 'PhD'}): each value is its key's own letters and digits recased, laid over the word as written, soph.d.repairs toPh.D.and repair never writes the value's own punctuation into the word (#459);LexiconraisesValueErroron a value that spells anything elseregexes.py— dict of compiled regular expressions (wrapped inRegexTupleManagerbyConstants)
config/__init__.py wraps everything into SetManager and TupleManager instances inside a Constants class. A module-level singleton CONSTANTS is shared across all HumanName instances by default.
Two-tier config pattern: CONSTANTS is global; passing None as the second arg to HumanName creates a fresh per-instance Constants(). After modifying per-instance config you must call hn.parse_full_name() again. SetManager normalizes elements (lowercase, leading/trailing periods stripped) at every entry point — constructor, add()/remove(), set operators, and in membership checks (__contains__, #244) — and raises TypeError for bare strings/bytes and non-str elements, so callers don't need to worry about case. TupleManager/RegexTupleManager apply the equivalent bare-string/bytes guard to their own constructor, plus a check that no element of an iterable-of-pairs argument is itself a str/bytes — a 2-character string is a valid dict-update-sequence element, so without the check it silently shreds into a key/value pair (#242).
_SetManagerAttribute/_CachedUnionMember descriptors: all nine SetManager-backed Constants attributes (prefixes, suffix_acronyms, suffix_not_acronyms, titles, first_name_titles, conjunctions, bound_first_names, non_first_name_prefixes, suffix_acronyms_ambiguous) are managed by a descriptor so a non-SetManager assignment (e.g. c.conjunctions = 'and') raises TypeError immediately instead of silently degrading later in checks into substring tests (#241). The four PST-contributing attrs (prefixes, suffix_acronyms, suffix_not_acronyms, titles) additionally use _CachedUnionMember, a subclass that also wires the _pst cache-invalidation callback on assignment; the other five use the plain _SetManagerAttribute base with no cache involved. Both store their values under the private name (_prefixes, _conjunctions, etc.) in the instance __dict__ so the descriptor's __set__ owns every assignment. Any code that inspects __dict__ directly (e.g. __getstate__) must map _xxx → xxx for descriptor-managed attrs rather than filtering on not k.startswith('_').
_EmptyAttributeDefaultAttribute descriptor backs empty_attribute_default (1.4, #255): unlike _SetManagerAttribute, __get__(obj=None, ...) returns '', not self — deliberate, so Constants.__repr__'s getattr(type(self), name) default comparison still works and the pre-existing str-only mypy inference on the attribute (never widened to str | None, matching the descriptor-free days) is preserved. Because of that, __getstate__/__setstate__ use inspect.getattr_static(type(self), name), not plain getattr, to detect this descriptor (or the property used by the legacy-pickle migration shim below) without triggering __get__ — a plain getattr there would silently miss both. __setstate__ also bypasses the descriptor's own __set__ (writes _empty_attribute_default directly) so restoring pickled/copied state doesn't itself emit the deprecation warning meant for user assignment.
Parser (nameparser/parser.py)
HumanName is the single public class. Assigning to full_name (or instantiating with a string) triggers parse_full_name().
Parse flow:
pre_process()— strips nicknames/maiden names (parenthesis/quotes, routed tonickname_list/maiden_listperConstants.nickname_delimiters/maiden_delimiters) and emoji, fixes "Ph.D." variant spellings- Split on commas → 1 part (no comma), 2 parts (suffix-comma or lastname-comma), 3+ parts
parse_pieces()— splits on spaces, detects dotted abbreviations like "Lt.Gov." and registers them as per-parse derived titles/suffixes (the_derived_*sets — never the config; see Gotchas)join_on_conjunctions()— merges pieces adjacent to conjunctions into single tokens (e.g.['Secretary', 'of', 'State']→['Secretary of State']); also joins prefix particles to the following lastname token — a piece merged with a title or prefix neighbor is re-registered into the per-parse derived title/prefix set so later steps still recognize it, e.g.von+und+zu→ registered as a derived prefix so the whole phrase joins to the last name (German "von und zu"; PR #191) 4a._join_bound_first_name()— called immediately after step 4 in all three paths that build a first-name-bearing token sequence: no-comma, lastname-comma (post-comma pieces), and suffix-comma (parts[0]); merges bound given-name prefixes (e.g. "abdul") with the next piece before the assignment loop runs; suffixes are still inpiecesat this point, so thereserve_lastguard must count non-suffix pieces only. Not called for the lastname portion itself (lastname_piecesin the lastname-comma path) — that token sequence is a surname, not first-name text, so the join must not apply there. The join lives at each of these three call sites individually rather than insideparse_pieces()itself. That's becauseparse_pieces()is also called for the lastname portion with the sameadditional_parts_countvalue used for the (joinable) post-comma given-names portion, soadditional_parts_countalone can't disambiguate which callers want the join.- Iterates pieces, assigning to
title_list,first_list,middle_list,last_list,suffix_list post_process()—handle_firstnames()swaps first/last when only a title + one name; then, gated onpatronymic_name_order,handle_east_slavic_patronymic_name_order()andhandle_turkic_patronymic_name_order()reorder Russian-formal-order and reversed Turkic patronymics; then, gated onmiddle_name_as_last,handle_middle_name_as_last()foldsmiddle_listintolast_list; finallyhandle_capitalization()applies optional auto-cap. Any newself._attrused bypost_process()helpers must be initialized in__init__(with its default value) — the direct-kwargs path bypassesparse_full_name(), so the attribute won't exist otherwise.
Each named attribute (title, first, etc.) is a @property that joins its corresponding _list. Setters call _set_list() which runs the value through parse_pieces(), so assigning hn.last = "de la Vega" correctly re-parses prefix tokens.
2.0 API modules (nameparser/_*.py, tests/v2/)
The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These conventions apply to all new-API code and are stricter than the v1 sections above. The full design record (rationale, settled-decision logs, dated amendments) lives in untracked docs/superpowers/specs/; this section is the enforceable subset. A commit that establishes or amends one of these conventions must update this section in the same commit — grep-driven staleness sweeps miss paraphrased prose (see Workflow above), so write-time maintenance is the mechanism, audits are the backstop.
- Module layout: every new module is underscore-private (
_types.py,_lexicon.py,_policy.py,_locale.py,_render.py,_pipeline/,_parser.py, plus the facade layer:_facade.py,_config_shim.py). The public import surface is exactlynameparserandnameparser.locales;nameparser/__init__.pyholds re-exports and__all__only — no logic. Since the M11 swap, the old paths are import-path-preserving re-exports:nameparser.parserre-exports the_facadeHumanName,nameparser.configre-exports the_config_shimnames (Constants,CONSTANTS,SetManager,TupleManager,RegexTupleManager); theconfig/DATA modules stay the vocabulary source through 2.x. The whole facade layer is deleted in 3.0. - Facade layer (
_facade.py,_config_shim.py): the v1-compatHumanName/Constantsover the core. Key mechanisms:Constants._generationdirty-tracking (every mutation bumps; facades resolve theirParserlazily via_cached_parser(lexicon, policy));Constants._snapshot()mirrors_lexicon._default_lexicon()(equality-pinned); the facade pickles v1-SHAPED state (component lists, one__setstate__path for 1.4 and 2.x blobs; components rebuild viareplace(), never a re-parse);_V1_HOOKSoverrides warn once per subclass (#280). The compat contract is the migration spec's promise: warning-free 1.4 code behaves identically except release-log-classified fixes — 2.0 was verified against 1.4-on-PyPI by a classified differential gate, retired in #664;tools/differential/compare.py --baseline 1.4.0still prints every corpus name that reads differently from 1.4.0, unclassified, when the question comes up.tools/differential/README.mdowns the corpus roster and what each file is blind to; don't restate it here.parser.py:NNNNcitations throughout the 2.0 code refer to the PRE-swap v1 file, deleted at the M11 swap; resolve them withgit show 2d5d8c2:nameparser/parser.py. - Layering is enforced by
tests/v2/test_layering.py(exact-module matching;if TYPE_CHECKING:imports don't count):_typesimports nothing internal at module level — its rendering delegates import_renderat call time;_lexiconand_policysit above_typesindependently (_lexiconmay importnameparser.config.*DATA modules only — vocabulary is single-sourced from the v1 data modules through 2.x, e.g.config/maiden_markers.py);_localesits on_lexicon+_policy(plus_typesfor the shared pickle mixin);_renderimports_typesand_lexicon(forLexicon.default()and_normalize);_pipeline/*imports_types+_lexicon+_policyplus in-package_pipelinehelpers;_parsersits on everything except_render; the facade layer (_facade,_config_shim,parser,config/__init__,__main__) may import anything public plus_render; locale pack modules (locales/*.py) import_locale/_lexicon/_policy/_typesonly (_typesjoined the list in #272 —locales/ja.py's segmenter factory constructs aSegmentation), and thelocales/__init__additionally lazy-imports its packs (PEP 562). Extend the test'sALLOWEDtable when adding a module. - Canonical field order — the seven roles in
Roleenum declaration order, defined once and derived everywhere (properties,as_dict, reprs,comparison_key). Never restate the order literally.Roleis aStrEnum: members compare as their field-name strings, andtokens_for()coerces strings via_coerce_enum. - Method organization, fixed section order in every class: fields +
__post_init__validation → alternative constructors → dunders (construction/equality → protocol → operators) → properties → public methods by concern (access → editing → comparison → rendering delegates) → private helpers last, except a helper serving exactly one section may sit at that section's head. Sanctioned deviation, facade layer only:HumanNameand the shimConstantsorganize by v1 concern groups (# -- render defaults --,# -- config / parsing --,# -- fields --, ..., dunders and pickle last) — the classes mirror v1's own surface and die in 3.0; the canonical order still binds every core type. - Validation is eager and fail-loud: every
raisestates the offending value, the expected form, and the fix. Exception taxonomy: wrong type — including wrong element type inside a collection, barestrwhere an iterable of strings is expected, or aMappingwhere a plain iterable is expected — raisesTypeError; well-typed but unacceptable values raiseValueError; failed enum lookups stayValueErrorfor any input (stdlibEnumTypeprecedent). When the message hands the reader code to paste, that code has to survive a type checker, and then RUN — nameparser shipspy.typed, and a placeholder can type-check and still raise: the 2.4 release sweep's first fix to thecapitalization_exceptionshint offered('key', 'Value'), which passes mypy and fails #459's mask check, sotest_add_capitalization_exceptions_raises_pointing_at_replacenow executes the offered code. #337's segmenterless warning offeredPolicy(segment_scripts=()), anarg-typeerror, because these fields are annotated with what they STORE rather than everything the constructor accepts. Prefer thefrozenset()/()spellings in messages and docstrings, and pin the offered spelling in a test — the warning tests matched onja_segmenterand never checked the actionable half of the message. A warning emitted inParser.__post_init__needsparser_forto re-emit it from its own frame (thecatch_warnings(record=True)block at its return):__post_init__'sstacklevelis sized for directParser(...)construction, and throughparser_for's extra frame the default one-line rendering attributes the warning to the library's ownreturn Parser(...)— the exact call the message tells the user to change becomes invisible. No single stacklevel serves both entry points; a new construction warning gets the re-emission for free, but a new CONSTRUCTION SITE forParserinside this package needs its own re-emission or its callers get library-attributed warnings (#337 review). - Guard, hint, and emit for the WHOLE family, and parametrize the test over it: a check added to one member of a set belongs on all of it, and the test must sweep the family, not one example. This session shipped
_reject_str_and_mappingonPolicybut notPolicyPatch, the bytes decode hint on three of five config entry points, and a regex-sync roster missing four of its copies — each a separate follow-up bug that a{class} × {field} × {bad-value}parametrization would have caught and a per-example test hid. When
Truncated - read the full file at https://github.com/derek73/python-nameparser/blob/9e7b72800c335ac327c5d10c2d2af7f35715e693/AGENTS.md.
