Skip to content

Commit 2171f90

Browse files
committed
merge: integrate master into pr/166, resolve test_constants conflict
2 parents 0e0d28c + d95b46e commit 2171f90

15 files changed

Lines changed: 498 additions & 50 deletions

AGENTS.md

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,58 @@
22

33
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44

5+
## Workflow
6+
7+
For non-trivial changes, use a feature branch and open a PR.
8+
Branch naming: `fix/issue-NNN-short-description` or `feat/short-description`.
9+
510
## Commands
611

712
```bash
8-
# Install dev dependencies (requires pip >= 24.1)
9-
pip install --group dev
13+
# Preferred: use uv run (works without activating the venv)
14+
# Alternative: .venv/bin/<tool> if the venv is already active
1015

11-
# Run all tests
12-
pytest
16+
# Run all tests (includes --doctest-modules, so doctests in nameparser/ are also run;
17+
# the dual-parametrize fixture doubles the count, so ~370 methods → ~740 results)
18+
uv run pytest # --doctest-modules is set in pyproject.toml, so doctests run automatically
1319

1420
# Run a single test file / class / method
15-
pytest tests/test_python_api.py
16-
pytest tests/test_python_api.py::HumanNamePythonTests::test_utf8
21+
uv run pytest tests/test_python_api.py
22+
uv run pytest tests/test_python_api.py::HumanNamePythonTests::test_utf8
23+
24+
# Type check
25+
uv run mypy nameparser/
26+
27+
# Lint
28+
uv run ruff check nameparser/
1729

1830
# Debug how a specific name string is parsed (prints HumanName repr)
19-
python -m nameparser "Dr. Juan Q. Xavier de la Vega III"
31+
uv run python -m nameparser "Dr. Juan Q. Xavier de la Vega III"
2032

2133
# Build docs
22-
sphinx-build -b html docs dist/docs
23-
24-
# Build package for release
25-
python setup.py sdist bdist_wheel
26-
twine upload dist/*
34+
uv run sphinx-build -b html docs dist/docs
35+
36+
# Maintain docs/release_log.rst as changes land:
37+
# - Keep an "Unreleased" entry at the top: `* X.Y.Z - Unreleased`
38+
# - Add one bullet per notable change; prefix with Add/Fix/Remove/Change
39+
# - Reference the issue or PR in parentheses: (#123) or (#123, #124)
40+
# Use "closes #N" when the change directly resolves the issue
41+
# - Version is decided at release time (patch/minor/major per semver)
42+
# - Format matches existing entries — see 1.3.0 block for a current example
43+
44+
# Release checklist (PyPI publish is triggered automatically by GitHub Actions on release creation)
45+
# 0. Review docs/ for anything stale — especially usage.rst (examples, API surface)
46+
# and any .rst files that reference config constants or HumanName kwargs
47+
# Also review AGENTS.md for stale commands, architecture notes, or gotchas
48+
# 1. Bump VERSION in nameparser/_version.py
49+
# 2. Stamp "Unreleased" → "X.Y.Z - Month DD, YYYY" in docs/release_log.rst
50+
# 3. git commit + git tag -a vX.Y.Z -m "Release X.Y.Z"
51+
# 4. git push origin master && git push origin vX.Y.Z ← tag must be pushed separately before gh release create
52+
# 5. gh release create vX.Y.Z --title "vX.Y.Z" --notes "..."
53+
# 6. Close the vX.Y.Z milestone and create a new "Next Release" one:
54+
# MILESTONE=$(gh api repos/derek73/python-nameparser/milestones --jq '.[] | select(.title=="vX.Y.Z") | .number')
55+
# gh api -X PATCH repos/derek73/python-nameparser/milestones/$MILESTONE -f state=closed
56+
# gh api -X POST repos/derek73/python-nameparser/milestones -f title="Next Release"
2757
```
2858

2959
Enable debug logging to see the parser's internal decisions:
@@ -52,6 +82,8 @@ Each module defines a plain Python set of known name pieces:
5282

5383
**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.add()`/`remove()` normalizes inputs to lowercase with no periods, so callers don't need to worry about case.
5484

85+
**`_CachedUnionMember` descriptor**: The four PST-contributing attrs (`prefixes`, `suffix_acronyms`, `suffix_not_acronyms`, `titles`) are managed by this descriptor, which stores their values under the *private* name (`_prefixes`, `_titles`, etc.) in the instance `__dict__` so that the descriptor's `__set__` owns every assignment and can wire the cache-invalidation callback. Any code that inspects `__dict__` directly (e.g. `__getstate__`) must map `_xxx``xxx` for descriptor-managed attrs rather than filtering on `not k.startswith('_')`.
86+
5587
### Parser (`nameparser/parser.py`)
5688

5789
`HumanName` is the single public class. Assigning to `full_name` (or instantiating with a string) triggers `parse_full_name()`.
@@ -66,6 +98,28 @@ Parse flow:
6698

6799
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.
68100

101+
## Extension Patterns
102+
103+
**Adding a scalar `Constants` attribute + `HumanName` kwarg** (e.g. `initials_separator`, `suffix_delimiter`):
104+
1. Add class attr to `Constants` in `config/__init__.py` with docstring
105+
2. Add `x: str | None = None` to `HumanName.__init__` signature after related kwargs
106+
3. Add `self.x = x if x is not None else self.C.x` in body — use `is not None`, not `or`, to allow falsy values like `""`
107+
4. conftest auto-restores scalar CONSTANTS between tests, but tests that *set* CONSTANTS mid-run still need their own try/finally
108+
109+
## Gotchas
110+
111+
**`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. Two separate code paths need context-aware workarounds: (1) suffix-comma detection uses `are_suffixes_after_comma()` which bypasses `is_suffix()` for `suffix_not_acronyms` members; (2) lastname-comma post-comma parsing uses `is_suffix_at_lastname_comma_end()` which only fires when `nxt is None` and `len(parts)==2` (no `parts[2]` suffix segment). See issues #136, #144.
112+
113+
**Expected-failure tests use `@pytest.mark.xfail`** — the conftest parametrized fixture breaks `@unittest.expectedFailure`; always use `@pytest.mark.xfail` instead.
114+
115+
**`lc()` strips only trailing periods**`'M.D.'``'m.d'`, not `'md'`. Exception keys in `capitalization_exceptions` are dot-free, so lookups must also try `.replace('.', '')`.
116+
117+
**`docs/usage.rst` contains live doctests** — edits can break `uv run pytest` (run via `--doctest-modules`). Verify new examples with `python3 -c "..."` before committing.
118+
119+
**`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}"`.
120+
121+
**`pr/NNN` local branches** track upstream PRs — don't commit to them by accident. Check `git branch --show-current` before starting work.
122+
69123
### Tests (`tests/`)
70124

71-
Tests run under **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`.
125+
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`.

docs/customize.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Other editable attributes
5959
* :py:obj:`~nameparser.config.Constants.empty_attribute_default` - value returned by empty attributes, defaults to empty string
6060
* :py:obj:`~nameparser.config.Constants.capitalize_name` - If set, applies :py:meth:`~nameparser.parser.HumanName.capitalize` to :py:class:`~nameparser.parser.HumanName` instance.
6161
* :py:obj:`~nameparser.config.Constants.force_mixed_case_capitalization` - If set, forces the capitalization of mixed case strings when :py:meth:`~nameparser.parser.HumanName.capitalize` is called.
62+
* :py:obj:`~nameparser.config.Constants.suffix_delimiter` - additional delimiter used to split suffix groups after comma-splitting, e.g. ``" - "`` for names like ``"Jane Smith, RN - CRNA"``. Defaults to ``None`` (disabled).
63+
* :py:obj:`~nameparser.config.Constants.initials_separator` - string placed between consecutive initials within the same name group (after the delimiter). Defaults to ``" "``, so ``"A. K."``; set to ``""`` for compact ``"A.K."``.
6264

6365

6466

docs/release_log.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
Release Log
22
===========
3+
* 1.3.0 - Unreleased
4+
- Add ``suffix_delimiter`` to ``Constants`` and ``HumanName`` for parsing suffixes separated by arbitrary delimiters, e.g. ``"RN - CRNA"`` (#156)
5+
- Add ``initials_separator`` to ``Constants`` and ``HumanName`` to control spacing between consecutive initials within a name group (#171)
6+
- Fix ``Constants`` customizations, singleton identity, and ``TupleManager`` subclass being lost across ``pickle``/``deepcopy`` round-trips (#167, #168, #169)
7+
- Fix capitalization of suffix acronyms written with dots, e.g. ``"M.D."`` (closes #141)
8+
- Fix recognition of single-letter roman numeral suffixes (e.g. ``"I"``, ``"V"``) in suffix-comma format (closes #136)
9+
- Fix recognition of trailing ``suffix_not_acronyms`` (e.g. ``"Jr."``) in lastname-comma format (closes #144)
10+
- Fix single-character symbol conjunctions (e.g. ``"&"``, ``"/"``) being ignored in short names (#173)
11+
- Fix spurious leading space in surnames and empty token in suffix list after ``capitalize()`` with an empty middle or suffix (#164)
12+
- Fix extra whitespace before punctuation in ``str()`` output when a ``string_format`` field is empty (closes #139)
13+
- Fix ``'apn aprn'`` split into separate ``suffix_acronyms`` entries so each is recognized independently (closes #155)
314
* 1.2.1 - June 19, 2026
415
- Fix ``initials()`` interpolating the literal ``None`` for empty name parts when ``empty_attribute_default = None`` (e.g. ``"J. None D."``); empty parts now render as an empty string and a fully-empty result returns ``empty_attribute_default``
516
- Add ``python -m nameparser "Name String"`` command-line helper that prints a parsed name

docs/usage.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,26 @@ Furthermore, the delimiter for the string output can be set through:
203203

204204
>>> HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter=";").initials()
205205
'J; A; K; D;'
206-
>>> HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first}{middle}{last}", initials_delimiter=".").initials()
207-
'J.A. K.D.'
206+
207+
The separator between consecutive initials *within* a name group (e.g. two middle
208+
names) is controlled by :py:attr:`~nameparser.config.Constants.initials_separator`,
209+
which defaults to ``" "``. Setting it to ``""`` removes that space within a group;
210+
spacing *between* groups is still governed by ``initials_format``.
211+
212+
``initials_delimiter``, ``initials_separator``, and ``initials_format`` work together:
213+
214+
- ``initials_delimiter`` — appended *after* each individual initial (default ``"."``)
215+
- ``initials_separator`` — placed *after* the delimiter between consecutive initials in the same group (default ``" "``), so with ``delimiter="."`` and ``separator=" "`` you get ``A. K.``
216+
- ``initials_format`` — controls how the first, middle, and last groups are arranged
217+
218+
For example, to produce compact period-separated initials with no spaces:
219+
220+
.. doctest:: initials separator
221+
222+
>>> HumanName("Doe, John A. Kenneth, Jr.", initials_separator="", initials_format="{first}{middle}{last}").initials()
223+
'J.A.K.D.'
224+
>>> HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter="", initials_separator="", initials_format="{first}{middle}{last}").initials()
225+
'JAKD'
208226

209227
To get a list representation of the initials, use :py:meth:`~nameparser.HumanName.initials_list`.
210228
This function is unaffected by :py:attr:`~nameparser.config.Constants.initials_format`

nameparser/config/__init__.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
::
1010
1111
>>> from nameparser.config import CONSTANTS
12-
>>> CONSTANTS.titles.remove('hon').add('chemistry','dean') # doctest: +ELLIPSIS
13-
SetManager({'msgt', ..., 'adjutant'})
12+
>>> CONSTANTS.titles.remove('hon').add('chemistry','dean') # doctest: +SKIP
1413
1514
You can also adjust the configuration of individual instances by passing
1615
``None`` as the second argument upon instantiation.
@@ -19,8 +18,7 @@
1918
2019
>>> from nameparser import HumanName
2120
>>> hn = HumanName("Dean Robert Johns", None)
22-
>>> hn.C.titles.add('dean') # doctest: +ELLIPSIS
23-
SetManager({'msgt', ..., 'adjutant'})
21+
>>> hn.C.titles.add('dean') # doctest: +SKIP
2422
>>> hn.parse_full_name() # need to run this again after config changes
2523
2624
**Potential Gotcha**: If you do not pass ``None`` as the second argument,
@@ -265,6 +263,38 @@ class Constants:
265263
Will be used to add a delimiter between each initial.
266264
"""
267265

266+
initials_separator = " "
267+
"""
268+
The default separator placed between consecutive initials within a name
269+
group (first, middle, or last). Distinct from ``initials_delimiter``,
270+
which is the trailing character after each individual initial.
271+
272+
With defaults ``initials_delimiter="."`` and ``initials_separator=" "``,
273+
``initials()`` produces ``"J. A. D."``. Setting ``initials_separator=""``
274+
with ``initials_delimiter="."`` and ``initials_format="{first}{middle}{last}"``
275+
produces ``"J.A.D."``. With the default ``initials_format``, group-level
276+
spacing from the template is still applied.
277+
"""
278+
279+
suffix_delimiter = None
280+
"""
281+
If set, an additional delimiter used to split suffix groups after
282+
comma-splitting. For example, setting ``suffix_delimiter=" - "`` allows
283+
``"RN - CRNA"`` to be parsed as two separate suffixes. Default is
284+
``None`` (no additional splitting beyond the standard comma split).
285+
286+
Note: setting this to ``","`` or ``", "`` has no additional effect —
287+
the full name is already split on bare commas first, and each resulting
288+
part is stripped of surrounding whitespace before this step runs.
289+
290+
Known limitation: the expansion is applied to all post-comma parts, not
291+
just suffix groups. In inverted format (``"Last, First, suffix"``), the
292+
first-name part is also split on the delimiter. In practice this is
293+
harmless since first names rarely contain the delimiter string, but a
294+
name like ``"Doe, Mary - Kate, RN"`` with ``suffix_delimiter=" - "``
295+
would misparse.
296+
"""
297+
268298
empty_attribute_default = ''
269299
"""
270300
Default return value for empty attributes.

nameparser/config/regexes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
("period_not_at_end",re.compile(r'.*\..+$', re.I | re.U)),
2323
("emoji",re_emoji),
2424
("phd", re.compile(r'\s(ph\.?\s+d\.?)', re.I | re.U)),
25+
("space_before_comma", re.compile(r'\s+,', re.U)),
2526
])
2627
"""
2728
All regular expressions used by the parser are precompiled and stored in the config.

nameparser/config/suffixes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@
5555
'amieee',
5656
'ams',
5757
'aphr',
58-
'apn aprn',
58+
'apn',
59+
'aprn',
5960
'apr',
6061
'apss',
6162
'aqp',

0 commit comments

Comments
 (0)