This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
# 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 dual-parametrize fixture doubles the count, so ~630 methods → ~1250 results)
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
uv run ruff check nameparser/
# 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
# - Reference the issue or PR in parentheses: (#123) or (#123, #124)
# Use "closes #N" when the change directly resolves the issue
# - Version is decided at release time (patch/minor/major per semver)
# - 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
# 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. Bump VERSION in nameparser/_version.py (and the `version:` field in CITATION.cff to match)
# For a pre-release, set PRE_RELEASE = 'rc1' (etc.) — __version__ joins it without a dot ('2.0.0rc1'); '' means final
# 2. Stamp "Unreleased" → "X.Y.Z - Month DD, YYYY" in docs/release_log.rst
# 3. git commit + git tag -a vX.Y.Z -m "Release X.Y.Z"
# 4. git push origin master && git push origin vX.Y.Z ← tag must be pushed separately before gh release create
# 5. gh release create vX.Y.Z --title "vX.Y.Z" --notes "..."
# 6. 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"Enable debug logging to see the parser's internal decisions:
import logging
logging.getLogger('HumanName').setLevel(logging.DEBUG)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 is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order. 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 its source order and never segments — the orthography names the convention, exactly as pure katakana does, with the divider carrying the signal instead of the script. 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. 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.
Most modules define a plain Python set of known name pieces; capitalization.py and regexes.py define dicts:
titles.py—TITLES(prenominals) andFIRST_NAME_TITLES(e.g. "Sir", which treat the following name as first, not last)suffixes.py—SUFFIX_ACRONYMS(with periods, e.g. "M.D.") andSUFFIX_NOT_ACRONYMS(e.g. "Jr."), plusGLUED_HONORIFICS(#308), the subset ofSUFFIX_NOT_ACRONYMSthe 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 onprefixes.py—PREFIXES(lastname particles, e.g. "de", "van")bound_first_names.py—BOUND_FIRST_NAMES(bound given-name prefixes, e.g. "abdul", "abu");_join_bound_first_namejoins the first non-title piece to its following piece before the main assignment loopconjunctions.py—CONJUNCTIONS(e.g. "and", "of") used to chain multi-word titlesmaiden_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 (e.g.{'phd': 'Ph.D.'})regexes.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.
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.
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 —tools/differential/(dev-only, not shipped) verifies this against 1.4-on-PyPI over a checked-in corpus of ~650 names (no exact count here:corpus_issues.jsonlgrows whenever it is regenerated, and the run prints its own per-file totals) (two files:corpus.jsonlfrom the v1 test banks at a pinned ref,corpus_issues.jsonlharvested from the issue tracker;compare.pyglobscorpus*.jsonland fails loudly if none match).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). - 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 you find you're guarding member N, grep for the other members first. - Ambiguities are emitted at the DECISION site: an
Ambiguityrecords a fork the parse had to call, not a token that sits in an ambiguous vocabulary. Emit where the branch is taken — the trailing-suffix peel in_assign, the delimiter escape's follow-up inclassify— never by scanning for avocab:*-ambiguoustag. The same tagged token is a genuine fork in one position and unremarkable in another (domid-name in "Joao da Silva do Amaral de Souza" chooses nothing). A branch that runs but changes nothing is not a decision either -- the prefix chain'smerge(k, j)executes even whenj == k + 1, folding a piece into itself, and keying on "the code got here" reported a fork for all 39 ambiguous particles on "Dr. Van Jr.", where the particle stayed the GIVEN name and_assignreported the same token again. Check that the branch actually claimed something (j > k + 1) before recording. Structure also structure often settles the question before it arises, which is whyPARTICLE_OR_GIVENis deliberately not emitted on theFAMILY_COMMApath andSUFFIX_OR_NAMEis not emitted for "Ma, Jack". The decision site also has the token index and the detail text in hand, which the tag scan would have to reconstruct. If a fork's two branches are taken in DIFFERENT stages, every one of them needs the emitter --PARTICLE_OR_GIVENis decided in_assignwhen the ambiguous particle stays a lone leading piece and in_groupwhen a title shifts it off index 0 and the prefix chain claims it, so both report; for two years only the first did. The stage-ownership map intests/v2/pipeline/test_state.pymust listambiguitiesfor each such stage, and it passes vacuously until a case row exercises the path, so add the row too. Report BOTH directions of a two-way fork — "John Smith MA" (read as a suffix) and "Jack MA" (read as the family name) are equally guesses. Every kind needs a trigger intests/v2/test_contracts.py::_AMBIGUITY_TRIGGERS(an explicitNone, strict-xfail, while reserved), and case-table rows pin expected kinds exactly, so a new emitter shows up in both immediately. Pin the decision, not the vocabulary: the only titled-particle test used an UNAMBIGUOUS particle, so it walked the right code path and proved nothing about the branch under test -- two criticals passed 1539 tests. A row contrasting the two readings ("John Smith V" against "John Smith B") is what makes an emitter's absence meaningful. - A kind is worth adding only if a reader would hesitate too: the test is not "does the code take a branch" but whether a person reading that input would genuinely be unsure. "Smith, John V" reads as a middle initial to anyone -- the comma settles it -- so reporting it would be noise that teaches callers to ignore the field, which costs more than the missing report. Reachability of the second branch is necessary, not sufficient. Prefer leaving a fork silent and documenting the omission (see the comma paths in concepts.rst) over emitting on input nobody finds ambiguous.
- Parser owns config-dependent conveniences:
Parser.matches/Parser.capitalized/Parser.reviseexist because theParsedNameequivalents fall back to DEFAULT config for str/omitted arguments (documented loudly in both docstrings).reviseharvests tokens from a full sub-parse of each replacement value (tags kept minusFOLDED_TAG, roles forced, ambiguities discarded); the merge tail is shared withreplace()viaParsedName._with_field_tokens.Parser.capitalizeddelegates throughname.capitalized(self.lexicon)specifically so_parsernever imports_render— keep it that way. - Per-word vocabulary fields warn on multi-word entries (
_normset/_normpairsvia_warn_dead_entry, UserWarning, never a raise — see the given_name_titles Gotcha for why raising is wrong).given_name_titlesis the one multi-word-matched field and is exempt;_editpasseswarn=False(add() warns once via the new instance's__post_init__; remove() stores nothing). The default vocabulary and every locale pack must stay warning-free (test_default_lexicon_builds_warning_free,test_pack_vocabulary_entries_are_single_words). - Invariants guard harm, not no-ops: add a constructor check when violating it produces a wrong parse, not when it produces nothing. A false positive costs a working configuration; a true positive on an inert condition costs the user nothing, so that trade is never worth taking.
suffix_acronyms_ambiguous ∩ suffix_wordsis guarded because the overlap loses a family name;given_name_titlesis not, because an unreachable entry is simply never consulted (see Gotchas). Before adding one, construct the config it forbids and check what actually breaks. - The shim TRANSLATES; it never raises on a config v1 accepted, and never silently changes the parse:
Constants._snapshot()is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. Six exist today —first_name_titlesre-folded per word (v1 joins-then-lc, v2 normalizes-then-joins),suffix_acronyms_ambiguous ∩ acronyms(a provable no-op),suffix_words − ambiguous(v1 already accepts the word via the acronym branch, so the addition is inert there),particles_ambiguous ∪ (bound ∩ particles)(a pinned deviation,test_bound_never_given_prefix_deviates_on_two_pieces),honorific_tails = GLUED_HONORIFICS ∩ suffix_words(#308 behavior with no v1 manager of its own, so the one v1 knob that reaches it is deleting the suffix word — which turns the peel off,test_snapshot_removing_a_honorific_word_turns_the_peel_off), andmaiden_delimiters − nickname_delimiterson the POLICY half of the same method (v1 precedence: a pair in both v1 buckets parses as a nickname, whilePolicyresolves the overlap the other way, so the subtraction is what keeps the facade at v1 behavior,test_snapshot_overlap_keeps_v1_nickname_precedence). Note that last one is on thePolicy, not theLexicon— the roster is per-_snapshot(), not per-vocabulary-field, so a sweep that only reads theLexicon(...)call misses it. When a v1 config cannot satisfy a v2 invariant, work out what v1 actually does with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. Test the case the translation decides, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing. - Reprs are bounded: render which fields deviate from a named baseline and by how much, never contents (
Lexicon(default + titles: +2)).PolicyPatch's repr shows only set (non-UNSET) fields;_order_reprmust never raise even on an unvalidated patch's garbagename_order(PolicyPatch defers validation to apply time); the sweep test intests/v2/test_reprs.pypins that no config repr leaks the UNSET sentinel. - Typing/docs:
from __future__ import annotations;frozen=True, slots=Trueon every public dataclass; strict-profile mypy flags via per-module overrides in pyproject (strict = trueitself is not valid per-module). Docstrings state contracts in prose with no doctest blocks —--doctest-modulesmakes every example a test; behavior examples go to unit tests per the lean-docs rule. Document the positive direction of a partial property: "a non-emptyambiguitiesis a signal to act on" is checkable, while "an empty one means no fork occurred" is a universal negative needing exhaustive verification -- that claim was written twice and falsified twice, at sites the author had not audited. - The segmenter contract: the optional
Parser(segmenter=...)hook is parse-totality's ONE exception (locales spec section 4). Everything inside that exception is a bug in USER CODE, never a fact about the name, so it is surfaced rather than absorbed: the segmenter's own exceptions propagate, and the two protocol violations the stage can detect for itself — an answer of the wrong type, and one cutting at or past the end of the token it was handed — raiseTypeError/ValueErrorfrom_script_segmentfor the same reason. The line to hold when adding a check there: a protocol violation by the segmenter's AUTHOR raises, while an adapter's defense against its own third-party library (locales/ja.py's repertoire, length, reconstruction and score guards) declines withNone, because what those catch is a fact about the content. - Pickling: v2 types must round-trip (
Parseris picklable by construction, and it holds aLexicon; the one qualifier is that aParserpickles iff its segmenter does — see the segmenter bullet above). Every frozen type assigns_guarded_getstate/_guarded_setstate(_types.py) in its class body (@dataclass(slots=True)would override inherited pickle methods) — unpickling fails at the LOAD site on field-layout skew, and values are deliberately NOT re-validated (pickle is not a security boundary; canonical state comes from a validated instance).Lexiconkeeps its own copy of the guard (layering) plus themappingproxyslot rebuild; a new unpicklable slot type needs the same treatment plus a round-trip test. - One sanctioned global: the (future) cached default
Parser. Lazily cached FROZEN singletons (Lexicon.default()'sfunctools.cache, the future default parser) are constants, not state; any second piece of module-level MUTABLE state requires amending the conventions doc, on purpose, in review. Sanctioned exceptions, facade layer only:_config_shim.CONSTANTS(the v1 shared singleton, mutable by design) and_facade._WARNED_SUBCLASSES(the once-per-subclass hook-warning dedup set) — both deleted with the layer in 3.0. - Tests: all v2 tests live in the
tests/v2/package (itsconftest.pyoverrides the v1 dual-run fixture — v2 code never reads sharedCONSTANTS), one test module per source module plus the cross-cutting ones (test_reprs.py,test_layering.py,test_contracts.py,test_properties.py,test_benchmark.py, thecases.py/test_cases.pytable, andtest_regex_sync.py, which pins every hand-copied pattern or codepoint table against its source wherever the copy lives — including copies outside the package), names stating behavior. Never assertLexicon.default()contents; the narrow sourcing spot-checks intest_default_sources_v1_vocabularythat pin the v1→v2 migration contract (e.g. the flippedparticles_ambiguousmodel) are the sanctioned exception.
Adding a scalar Constants attribute + HumanName kwarg (e.g. initials_separator, suffix_delimiter):
- Add class attr to
Constantsinconfig/__init__.pywith docstring - Add
x: str | None = NonetoHumanName.__init__signature after related kwargs - Add
self.x = x if x is not None else self.C.xin body — useis not None, notor, to allow falsy values like"" - conftest auto-restores scalar CONSTANTS between tests, but tests that set CONSTANTS mid-run still need their own try/finally
- Update
docs/customize.rst's constants list (anddocs/usage.rstif it affects a documented example) — don't wait for the release checklist, these.rstdocs aren't covered by CI so a stale one can ship silently. CheckAGENTS.mditself too (Extension Patterns, Gotchas, the Architecture section) for now-stale attribute/test names or descriptions — it has the same blind spot as the.rstdocs and the release checklist only catches it at release time.
Adding a new mutable/collection Constants attribute (a SetManager/TupleManager-backed group, e.g. nickname_delimiters/maiden_delimiters): add it to _COLLECTION_CONFIG_ATTRS in tests/conftest.py, or tests that mutate the global CONSTANTS copy will leak state into later tests. Contents must be deep-copyable (the snapshot uses copy.deepcopy) — already true for the existing manager types. (The parse-no-mutation tests in tests/test_constants.py need no such registration — they discover collections structurally via Constants.__getstate__(); conftest's list is the only manual one.)
Add a dedicated copy.deepcopy() round-trip test for it too (see test_regexes_deepcopy_roundtrip/test_nickname_delimiters_deepcopy_roundtrip in tests/test_constants.py), not just reliance on conftest's autouse snapshot/restore exercising it incidentally. TupleManager/RegexTupleManager.__getattr__ answer any unknown attribute lookup — dunder probes like __deepcopy__ raise AttributeError (never answered as config keys), everything else still returns self.get(attr)/EMPTY_REGEX — so a new manager subtype or a __getattr__ tweak can silently break copy.deepcopy (this bit RegexTupleManager before the dunder-lookup guard was added). A direct test on the new attribute's own manager instance catches that where the conftest fixture, which never asserts on the copy, would not. As with the scalar-attribute pattern above, also check AGENTS.md itself for now-stale references when you touch this.
Unknown-key attribute access on TupleManager/RegexTupleManager warns (1.4, #256) — 2.0 note: the warning became a hard AttributeError naming the known keys (shim TupleManager); the paragraph below describes the deleted v1 machinery and applies only when reading v1 history. — a key not currently in the dict emits DeprecationWarning naming the miss and the known keys (_warn_unknown_key), before falling back to the same None/EMPTY_REGEX default as before; .get() stays silent. Dunder probes (__deepcopy__) still raise AttributeError outright, and single-underscore probes (_repr_html_, IPython's _ipython_canary_method_should_not_exist_, etc.) are excluded from the warning too — no real config key starts with _, so both guards just keep protocol/introspection probes from misfiring as "typo" warnings. This means internal parser code that reads self.C.regexes.<key> unconditionally (e.g. squash_bidi's bidi) now warns if a caller's custom regexes dict omits that key — a previously-silent partial-override pattern is on the same deprecation path as an actual typo.
Adding a word to a config set — first check the other sets for the same word (grep nameparser/config/ or intersect the sets in a python3 -c). Real overlaps exist: do/st/mc ∈ PREFIXES ∩ TITLES/SUFFIX_ACRONYMS; abd = "ABD" ∈ SUFFIX_ACRONYMS; abu ∈ PREFIXES ∩ bound_first_names (position-dependent: leading token → first-name join, mid-name → last-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the last_base all-particles guard; dropping abd from bound_first_names).
Before adding a short/common word to PREFIXES globally, test it mid-string against realistic 3-token names, not just check for English-word collisions: Korean/Vietnamese given names put a short syllable in the middle slot (Park In Hwan, Nguyen To Nga), and Western names put a bare initial there (John V. Smith). A word that looks safe ("nobody is named 'to'") can still swallow a real middle name/initial into the last name once it's a global prefix — confirmed regressions for to/in/an/ten/then and bare v this way (PR #191).
Adding a curated sub-set of an existing config set (must stay ⊆ its parent, e.g. FIRST_NAME_TITLES ⊂ TITLES) — define the parent as a static union in the config module: TITLES = FIRST_NAME_TITLES | set([...]). The sub-set is a _SetManagerAttribute on Constants (like first_name_titles), not a _CachedUnionMember — only prefixes/suffix_acronyms/suffix_not_acronyms/titles feed the _pst hot-path cache, so a sub-set costs nothing at runtime and stays out of is_rootname. The union is import-time only: a runtime .add() to the sub-set does not propagate to the parent's SetManager (same as first_name_titles→titles), so a caller adding a brand-new word adds it to both. Pin the relationships with import-time asserts in the config module itself (see the bottom of prefixes.py): subset ⊆ parent, and ∩ == ∅ with any set it logically can't overlap (a sub-set member that's also in TITLES is silently inert — title handling consumes it first). A violated assert fails at import — before any test runs — so don't also duplicate them as tests. Do not assert titles ∩ prefixes == ∅ — that overlap is intentional (st, do).
Adding a flag-gated post-parse transform (reorder/adjust) — add a Constants boolean (default False), implement a handle_*() method, and call it in post_process() after handle_firstnames() and before handle_capitalization(), gated on the flag. Default-off keeps existing parses byte-for-byte unchanged. Two shipped examples: patronymic_name_order gates both handle_east_slavic_patronymic_name_order() (#85) and handle_turkic_patronymic_name_order() (#185) — one flag driving two independent handlers, added in the same post_process() slot; middle_name_as_last gates handle_middle_name_as_last() (#133), which folds middle_list into last_list.
Validating a new parsing rule — before implementing, simulate it in a throwaway script against TEST_NAMES (plus a few target-language examples) to catch regressions/false-positives early. E.g. this surfaced the last_base do/st/mc empties and the patronymic "David Michael Abramovich" false-reorder.
Parsing must never write into Constants — parse-time derived recognition (dotted abbreviations like "Lt.Gov.", conjunction-joined titles/prefixes like "Mr. and Mrs." / "von und zu") lives in per-instance HumanName._derived_titles / _derived_suffixes / _derived_conjunctions / _derived_prefixes sets, consulted by is_title/is_suffix/is_conjunction/is_prefix/is_rootname, reset at the start of parse_full_name(), and backfilled in __setstate__ for pre-existing pickles. Never self.C.<set>.add() during parsing — self.C is usually the shared CONSTANTS singleton, so a write makes parse results order-dependent and thread-unsafe (this was a real bug through 1.2.x). ParsingDoesNotMutateConfigTests (tests/test_constants.py) enforces the invariant by snapshotting the whole config around a parse; it discovers collections structurally via Constants.__getstate__(), so new Constants collections are watched automatically — nothing to register. If a new derived category is ever needed: add a _derived_* set in __init__, reset it in parse_full_name(), backfill it in __setstate__, consult it in the matching is_* predicate (store lc()-normalized values, mirroring SetManager), and add a leak test with a name that triggers it.
HumanName.C is a property backed by _C, but pickles under the public key 'C' — __init__/direct assignment route through the C setter, which calls the shared _validate_constants staticmethod (also used by __init__) so an invalid value raises TypeError immediately instead of surfacing later as an unrelated AttributeError deep in parsing (#239). __getstate__/__setstate__ deliberately translate self._C ↔ a 'C' key in the pickled dict (with the usual CONSTANTS-singleton-becomes-None sentinel) rather than pickling _C directly, so the on-disk pickle format hasn't changed across this fix — don't "simplify" that translation away or old pickles/tests that hand-build a state dict with a 'C' key will break.
Titles permanently shadow first names — be conservative — any word in TITLES is always consumed as a title and can never be parsed as a first name. "Dean" is the canonical example: it's a common academic title and a common given name, so it is intentionally absent from the default titles (see docs/customize.rst — users who need it add it via opt-in Constants). Before adding a word to TITLES, ask: "Could this plausibly be someone's given name in any culture?" If yes, don't add it globally; it belongs in caller-supplied Constants instead. This same caution applies to international honorifics — Prince, Sheikh, Frau are all first names in some contexts. It also applies to any prefix sub-set gated on "never a first name": obscure-looking foreign particles are surprisingly often real given names — Von (Von Miller), Vander (Brazilian, also the Arcane character). When unsure, exclude — a missing member just means that name isn't auto-handled, whereas a wrong member misparses a real person.
is_leading_title() infers titles beyond the TITLES set, but only in leading position — an unrecognized multi-letter word ending in a single trailing period (matched via the period_abbreviation regex, {2,} letters) is treated as a title when it appears before the first name is set, e.g. "Major. Dona Smith" → title='Major.'. It's distinct from is_title() and does not mutate C.titles, so the periodless form ("Major") is unaffected elsewhere. The {2,} length requirement — not a separate initials check — is what excludes single-letter initials like "J." from being swallowed as titles; the same word after the first name is left as a middle name. (#109; see docs/usage.rst "Leading Period-Abbreviation Titles")
Cyrillic suffix regexes need re.I even when the pattern is suffix-only — a Latin title-cased word (Ivanovich) keeps its suffix lowercase, so re.I seemed skippable; but an irregular Cyrillic suffix can be nearly the whole word (ильич), so title-casing capitalizes into the suffix itself (Ильич). east_slavic_patronymic_cyrillic shipped without re.I on the Latin reasoning and silently failed on capitalized irregular forms — don't assume Latin's title-case safety transfers to Cyrillic. (#185)
suffix_not_acronyms vs is_an_initial tension — single-letter roman numeral suffixes (i, v) are in suffix_not_acronyms but also match the is_an_initial regex (single uppercase letter), so is_suffix() rejects them. The lenient test lives in is_suffix_lenient(), which accepts suffix_not_acronyms members unconditionally and is only safe in unambiguous positions: (1) suffix-comma detection uses it via are_suffixes_after_comma(); (2) lastname-comma post-comma parsing uses it inline, only when nxt is None and len(parts)==2 (no parts[2] suffix segment). See issues #136, #144.
Comparing against v1 needs PYTHONSAFEPATH=1 AND a directory outside the worktree — uv run --isolated --no-project --with 'nameparser==1.4.0' still puts the checkout's nameparser/ ahead of the pinned wheel on sys.path, so the "v1" side silently imports the branch and every comparison reports parity. Run it as cd <scratch dir> && PYTHONSAFEPATH=1 uv run --isolated --no-project --with 'nameparser==1.4.0' python -c "...". This produces false confidence rather than an error, so it invalidates results without ever looking wrong.
esq is in both SUFFIX_ACRONYMS and SUFFIX_NOT_ACRONYMS on purpose — do not "deduplicate" it — the two branches normalize differently (see the asymmetry gotcha above): the word test strips only edge periods, the acronym test strips all of them. Esq matches only through the word set, E.S.Q. only through the acronym set. Removing either membership drops a spelling and, for the acronym one, loses the family name ("John Smith E.S.Q." → family='E.S.Q.'). Pinned by the suffix_acronym_multidot_spelling case-table row. The disjointness that IS asserted is SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_NOT_ACRONYMS, which is a different claim: suffix_as_written ORs the branches, so a word membership bypasses the period gate the ambiguous set exists to impose.
Lexicon.given_name_titles is deliberately unvalidated against titles — do not add a check — the lookup key is the space-joined run of Role.TITLE tokens, built by the parse, and a conjunction inside a run is itself tagged Role.TITLE, so "sir and dame" is a matchable key whose middle word lives in conjunctions. A whole-entry check rejected multi-word entries; a per-word check rejected that one. No static relation over the vocabulary sets decides reachability. An unreachable entry is inert — nothing consults it and nothing misparses — so the condition being "guarded" costs the user nothing while each guard cost a working configuration. If a diagnostic is wanted, it must be non-blocking — the multi-word UserWarning in _normset is the shipped example; a raise remains wrong. Note the SHIPPED data cannot carry a multi-word given-name title without a spurious warning: FIRST_NAME_TITLES ⊆ TITLES puts the entry in titles too, which is per-word warned; user-supplied v2 Lexicons are unaffected (add(given_name_titles=...) alone is silent).
_normalize must reach a fixed point — storage and match-time share the one fold, and Lexicon.__setstate__ re-validates, so a value that changes on re-normalization changes under its owner. strip().strip(".") alone is not idempotent ('. a .' → ' a ' → 'a'). The loop is the fix; keep any new stripping inside it. Anything built on _normalize must converge too — _title_key joins per-word _normalize and DROPS words that fold away; keeping the empty slot stored 'lt .' as 'lt ', a key match-time can never rebuild (so the entry is silently inert) and __setstate__ rejects on the next round-trip as "not written by this version".
Perf regressions are caught by the scaling test, not the absolute-time ones — tests/v2/test_benchmark.py::test_parse_cost_grows_no_worse_than_linearly times a repeated unit at n vs 4n over ten shapes (one per pipeline inner loop) and bounds the ratio; the _thousand_names tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. Two rules when touching it: calibrate _MAX_RATIO against the WEAKEST quadratic's signal (a mixed quadratic surfaces far below the textbook 16×, so the operating point _BASE matters more than the bound), and confirm a planted regression fails it across REPEATED runs — one failure is a coin-flip on a timing test. The ten shapes cover different dimensions (segment count only via commas, intra-piece accumulation only via particles/conjunctions, non-ASCII input only via honorifics — the other nine are pure ASCII, so script_segment returns at its bail and the CJK stages go unmeasured); measure before pruning one.
Expected-failure tests use @pytest.mark.xfail — the conftest parametrized fixture breaks @unittest.expectedFailure; always use @pytest.mark.xfail instead.
lc() strips leading and trailing periods — 'M.D.' → 'm.d', not 'md' (interior periods are preserved). Exception keys in capitalization_exceptions are dot-free, so lookups must also try .replace('.', '').
is_suffix()'s period-stripping is asymmetric — it does lc(piece).replace('.', '') (strips all periods) before checking suffix_acronyms, but only lc(piece) (leading/trailing only) before checking suffix_not_acronyms. Code that reimplements this check instead of calling is_suffix() must mirror both branches or it will misclassify acronym suffixes with internal-only periods (e.g. "M.D" with no trailing dot) — bit parse_nicknames()'s handle_match() in PR #189.
nickname_delimiters/maiden_delimiters built-ins are string sentinels, not compiled patterns — the three default keys (quoted_word, double_quotes, parenthesis) store the name of a Constants.regexes entry (a plain str), resolved via getattr(self.C.regexes, name) at parse time in parse_nicknames() — not the compiled re.Pattern itself. This is what lets CONSTANTS.regexes.parenthesis = ... keep affecting nickname/maiden parsing after construction, same as before this mechanism existed. Routing a built-in between buckets must be a pop() + assign (maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')) to carry that string sentinel over — copying CONSTANTS.regexes['parenthesis'] directly into the new bucket instead would freeze it as a snapshot and silently stop tracking further regexes overrides. A key added by a caller for a custom delimiter is a real compiled pattern, distinguished at parse time via isinstance(raw_pattern, re.Pattern). (#22)
TupleManager.__setattr__/__delattr__ guard dunder names too, not just __getattr__ — constructing a subscripted generic, e.g. TupleManager[re.Pattern[str] | str]({...}) (needed so mypy sees the right value type instead of inferring one from the dict literal), makes typing's GenericAlias.__call__ set __orig_class__ on the new instance right after __init__ returns. Before this guard existed, __setattr__ was a bare dict.__setitem__ alias, so that assignment silently inserted a bogus '__orig_class__' entry into the dict itself, corrupting .values()/iteration for every TupleManager/RegexTupleManager instance, not just the one being constructed — this bit nickname_delimiters's construction (#22) before the guard was added. Same fix shape as the __getattr__ dunder guard above: fall back to object.__setattr__/object.__delattr__ for dunder names, dict-backed storage for everything else.
_join_bound_first_name guard must exclude trailing suffixes — suffix tokens are still in pieces when the helper runs (suffix detection happens in the assignment loop, later). The reserve_last guard must count if not self.is_suffix(p) to avoid treating a trailing suffix like "Jr." as a last-name slot; otherwise "abdul salam jr" → last='jr'.
Doctests — docstring examples in nameparser/*.py run under uv run pytest (--doctest-modules; testpaths is tests + nameparser only). The .rst doctests in docs/ (usage.rst, customize.rst) and README.rst are also run in CI: the python-package.yml workflow runs sphinx-build -b doctest docs dist/doctest (covers every .rst under docs/) and python -m doctest README.rst as separate steps after the existing -b html build. Both should pass with zero failures — expect a clean run, not baked-in noise.
Every .. doctest:: block in one .rst file shares a single namespace — the per-document default group — so both local variables and global state carry forward, in document order. Two consequences, and the first is the one that bites. A variable bound in one block clobbers that name in every later block, so inserting a section that binds something common breaks examples further down the file that were passing and that you never touched: writing the #271 usage.rst section with name = parse("김민준") in it made name.initials() six blocks later return '민. 김.' instead of 'J. Q. X. V.'. Pick a distinctive variable name (minjun), or reuse whatever the surrounding blocks already bind. Second, examples that mutate the shared CONSTANTS singleton (e.g. capitalize_name, titles.clear()) must reset it at the end of the same block, because nothing else will — an unreset mutation leaks into every later example in the same build (this caused several real failures before it was fixed). If an example isn't actually demonstrating shared-singleton behavior, prefer a local Constants() instance instead of CONSTANTS — no reset needed, and no ordering dependency on other examples. SetManager.__repr__ sorts its elements for this reason too: plain set() iteration order depends on per-process string hash randomization, so an unsorted repr would make ellipsis-style doc examples (SetManager({'10th', ..., 'zoologist'})) unreproducible across runs.
Don't use the bare python3 -m doctest <file>.rst CLI (no optionflags) to check files under docs/ — it ignores each block's :options: directive (e.g. +NORMALIZE_WHITESPACE, +ELLIPSIS) and reports false-positive whitespace failures that look like real regressions. uv run sphinx-build -b doctest docs <tmpdir> is the faithful check for those. README.rst has no :options: directives (plain :: blocks, not .. doctest::), so the bare python -m doctest README.rst CLI is fine there — that's what CI actually runs.
Constants class attributes (e.g. patronymic_name_order, middle_name_as_last) document behavior with a bare string literal placed right after the assignment — Sphinx's attribute-docstring convention. That string never becomes a real __doc__, so --doctest-modules (which walks __doc__ attributes) never sees any .. doctest:: examples inside it — this let a stale example slip through CI once (middle_name_as_last, #133). tests/test_config_attribute_docstrings.py (#195) closes that gap: it parses nameparser/config/__init__.py with ast to recover those literals and runs any doctest examples through doctest.DocTestParser/DocTestRunner explicitly, so pytest -q now exercises them too. When adding or editing a .. doctest:: example in a Constants attribute's bare-string docstring, this is the mechanism that actually runs it — don't assume --doctest-modules covers it.
uv run mypy covers tests/ too (pyproject.toml's [tool.mypy] packages = ["nameparser", "tests"], PR #250) — a test that deliberately passes an off-contract value to assert a TypeError/ValueError, or exercises a documented falsy-disables-the-feature toggle (e.g. regexes.emoji = False), needs a # type: ignore[code] comment on that line rather than a signature change. Before reaching for an ignore, check whether the "error" is really a source annotation that hasn't caught up with already-supported runtime behavior (e.g. is_prefix/is_conjunction/is_suffix accepting list[str], add_with_encoding accepting bytes) — widen the annotation instead in that case.
initials_separator is intra-group only — it controls the joiner between consecutive initials within a name group (e.g. two middle names in middle_list). Spaces between groups come from initials_format. To fully concatenate initials you need both initials_separator="" and initials_format="{first}{middle}{last}".
pr/NNN local branches track upstream PRs — don't commit to them by accident. Check git branch --show-current before starting work.
Prefix-join uses value-based list.index() in join_on_conjunctions — fragile when a token value repeats (e.g. a trailing title that's also a suffix acronym, or two vans); constrain such lookups to start at i + 1. See #100.
Title vs suffix is purely positional — a word matching TITLES at the front of a name becomes title; the same word matching SUFFIX_ACRONYMS/SUFFIX_NOT_ACRONYMS at the end becomes suffix (never both, regardless of the word's real-world meaning). External test sources (old issue gists, etc.) sometimes assert suffix for a leading professional abbreviation like RA/PD/Dipl.-Ing. — that's the source data being wrong, not a parser bug. Verify position before "fixing" it.
Prefer behavior tests over constant-content tests — don't assert on the literal contents/structure of config constants (e.g. SUFFIX_ACRONYMS == SET_A | SET_B, 'x' in SOME_SET). Test observable parsing behavior instead (HumanName(...) output). Constant-content assertions just create a second place to update whenever the lists change.
A parse-behavior test can still be a disguised constant-content test if it only exercises one or two hardcoded entries from a large list (e.g. guarding a specific missing-comma typo in SUFFIX_ACRONYMS) — it documents that one instance without catching the same bug class elsewhere in the list. Prefer a test on the structural property itself, or skip a dedicated test for narrow, unrepeatable typo fixes.
When adding a new aggregate property (like given_names or surnames), always include a test for the empty path — a name that produces no value for that property — so the or self.C.empty_attribute_default guard is covered. The conftest dual-fixture then automatically exercises both "" and None variants. Example: HumanName("Williams") for a given-names property (last-name-only string has no first or middle).
python_classes = ["*Tests", "*TestCase"] in pyproject.toml — suffix style (FooTests), NOT prefix (TestFoo); wrong style silently skips discovery.
Tests run under pytest (via uv run pytest) and are split one file per concern (tests/test_titles.py, tests/test_suffixes.py, etc.). tests/base.py holds HumanNameTestBase — a plain (non-unittest) base whose m() helper is a custom assert that prints the original name string on failure (plus thin assert* shims so the moved test bodies are unchanged). tests/conftest.py defines an autouse fixture that runs every test twice — once with empty_attribute_default = '' and once with None — so reported counts are doubled (e.g. 11 methods → 22 results); it also snapshots/restores the scalar CONSTANTS config around each test to keep tests order-independent. TEST_NAMES (in tests/test_variations.py) is a list of name strings permuted into comma-separated variants as a regression check. Tests that should fail use @pytest.mark.xfail. When adding a parsing case, add it to the relevant tests/test_*.py file and consider adding the base form to TEST_NAMES.
Test classes that parse with a dedicated flagged Constants (e.g. patronymic_name_order=True) subclass FlaggedConstantsTestBase (tests/base.py) and set constants_kwargs = {...} — don't hand-write per-class setup_method/hn() fixture pairs.