From c6a66b6a8226a075efaf6434075cdde9ff989412 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 19:49:14 -0700 Subject: [PATCH 01/10] docs: add design spec for suffix_delimiter feature (issue #156) Co-Authored-By: Claude Sonnet 4.6 --- .../2026-06-28-suffix-delimiter-design.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md diff --git a/docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md b/docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md new file mode 100644 index 0000000..04897f4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md @@ -0,0 +1,88 @@ +# suffix_delimiter — Design Spec + +**Issue:** #156 +**Branch:** fix/issue-156-suffix-delimiter +**Date:** 2026-06-28 + +## Problem + +Names like `"Steven Hardman, RN - CRNA"` parse incorrectly because ` - ` is not +a recognized suffix separator. The parser splits on commas first, leaving +`"RN - CRNA"` as a single part. Splitting that on spaces yields +`["RN", "-", "CRNA"]`, and `"-"` is not a suffix, so `are_suffixes()` returns +False and the parser falls through to a wrong code path. + +Both `RN` and `CRNA` are in `SUFFIX_ACRONYMS` — the issue is solely the +delimiter between them. + +## Approach + +Post-comma-split expansion. After splitting the full name on commas, re-split +`parts[1:]` on `suffix_delimiter` and flatten. This scopes the feature to the +region where suffixes live, leaving `parts[0]` (the name portion) untouched. + +## Changes + +### `nameparser/config/__init__.py` + +Add class-level attribute to `Constants`: + +```python +suffix_delimiter = None +""" +If set, an additional delimiter used to split suffix groups after +comma-splitting. For example, setting suffix_delimiter=" - " allows +"RN - CRNA" to be parsed as two separate suffixes. Default is None +(no additional splitting beyond the standard comma split). + +Note: setting this to ", " is a no-op — comma-splitting already occurs +unconditionally before this step. +""" +``` + +### `nameparser/parser.py` + +**Constructor signature:** +```python +suffix_delimiter: str | None = None, +``` + +**Constructor body** (mirrors `initials_separator` pattern): +```python +self.suffix_delimiter = suffix_delimiter if suffix_delimiter is not None else self.C.suffix_delimiter +``` + +**Docstring entry:** +``` +:param str suffix_delimiter: additional delimiter to split suffix groups + after comma-splitting, e.g. " - " for "RN - CRNA" +``` + +**`parse()` method** — insert immediately after the comma split: +```python +parts = [x.strip() for x in self._full_name.split(",")] + +if self.suffix_delimiter and len(parts) > 1: + expanded = [parts[0]] + for part in parts[1:]: + expanded.extend([p.strip() for p in part.split(self.suffix_delimiter)]) + parts = expanded +``` + +### `tests/test_suffixes.py` + +1. `HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ")` → + `first="Steven"`, `last="Hardman"`, `suffix="RN, CRNA"` +2. `HumanName("John Doe, MD - PhD - FACS", suffix_delimiter=" - ")` → + `suffix="MD, PhD, FACS"` +3. `CONSTANTS.suffix_delimiter = " - "` applies to new instances without + passing the kwarg explicitly +4. `HumanName("Steven Hardman, RN - CRNA")` without `suffix_delimiter` — + documents existing (broken) behavior as a known limitation, not a regression + +## Non-goals + +- No change to `are_suffixes()`, `is_suffix()`, or the downstream parse paths — + expanding `parts` before those checks is sufficient. +- No handling of ` - ` in name portions (e.g. hyphenated last names) — the + expansion only touches `parts[1:]`. From b0b4193bcd9dfab82ac20323d1e1675825f0ec89 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 19:54:51 -0700 Subject: [PATCH 02/10] docs: add implementation plan for suffix_delimiter feature Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-06-28-suffix-delimiter.md | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-suffix-delimiter.md diff --git a/docs/superpowers/plans/2026-06-28-suffix-delimiter.md b/docs/superpowers/plans/2026-06-28-suffix-delimiter.md new file mode 100644 index 0000000..c1dba5c --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-suffix-delimiter.md @@ -0,0 +1,263 @@ +# suffix_delimiter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a user-configurable `suffix_delimiter` that allows suffixes separated by arbitrary strings (e.g. ` - `) to be parsed correctly. + +**Architecture:** After splitting the full name on commas, re-split `parts[1:]` on `suffix_delimiter` and flatten. This leaves the name portion (`parts[0]`) untouched and feeds the existing downstream suffix logic exactly the shape of input it already handles. `suffix_delimiter` is exposed as a class attribute on `Constants` (global default) and as a `HumanName.__init__` kwarg (per-instance override), mirroring the `initials_separator` pattern. + +**Tech Stack:** Python 3, pytest, nameparser internal APIs only. + +--- + +### Task 1: Add `suffix_delimiter` to `Constants` + +**Files:** +- Modify: `nameparser/config/__init__.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: + +```python +def test_suffix_delimiter_default_on_constants(self) -> None: + from nameparser.config import CONSTANTS + self.assertIsNone(CONSTANTS.suffix_delimiter) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_default_on_constants -v +``` + +Expected: `FAIL` — `AttributeError: type object 'Constants' has no attribute 'suffix_delimiter'` + +- [ ] **Step 3: Add the attribute to `Constants`** + +In `nameparser/config/__init__.py`, locate the block of scalar class attributes (near line 202 where `string_format`, `initials_delimiter`, `initials_separator` are defined). Add after `initials_separator`: + +```python +suffix_delimiter = None +""" +If set, an additional delimiter used to split suffix groups after +comma-splitting. For example, setting ``suffix_delimiter=" - "`` allows +``"RN - CRNA"`` to be parsed as two separate suffixes. Default is +``None`` (no additional splitting beyond the standard comma split). + +Note: setting this to ``", "`` is a no-op — comma-splitting already +occurs unconditionally before this step. +""" +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_default_on_constants -v +``` + +Expected: `PASS` + +- [ ] **Step 5: Commit** + +```bash +git add nameparser/config/__init__.py tests/test_suffixes.py +git commit -m "feat: add suffix_delimiter to Constants (default None)" +``` + +--- + +### Task 2: Wire `suffix_delimiter` into `HumanName` + +**Files:** +- Modify: `nameparser/parser.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: + +```python +def test_suffix_delimiter_kwarg_accepted(self) -> None: + hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") + self.assertEqual(hn.suffix_delimiter, " - ") +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_kwarg_accepted -v +``` + +Expected: `FAIL` — `TypeError: __init__() got an unexpected keyword argument 'suffix_delimiter'` + +- [ ] **Step 3: Add kwarg to `HumanName.__init__`** + +In `nameparser/parser.py`, add `suffix_delimiter` to the class docstring param list (near line 55): + +```python +:param str suffix_delimiter: additional delimiter to split suffix groups + after comma-splitting, e.g. ``" - "`` for ``"RN - CRNA"`` +``` + +Add to the `__init__` signature (after `initials_separator`, around line 97): + +```python +suffix_delimiter: str | None = None, +``` + +Add to the `__init__` body (after the `initials_separator` assignment, around line 113): + +```python +self.suffix_delimiter = suffix_delimiter if suffix_delimiter is not None else self.C.suffix_delimiter +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_kwarg_accepted -v +``` + +Expected: `PASS` + +- [ ] **Step 5: Commit** + +```bash +git add nameparser/parser.py tests/test_suffixes.py +git commit -m "feat: wire suffix_delimiter kwarg into HumanName.__init__" +``` + +--- + +### Task 3: Implement post-comma expansion in `parse()` + +**Files:** +- Modify: `nameparser/parser.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: + +```python +def test_suffix_delimiter_basic(self) -> None: + hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") + self.m(hn.first, "Steven", hn) + self.m(hn.last, "Hardman", hn) + self.m(hn.suffix, "RN, CRNA", hn) + +def test_suffix_delimiter_multiple(self) -> None: + hn = HumanName("John Doe, MD - PhD - FACS", suffix_delimiter=" - ") + self.m(hn.first, "John", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "MD, PhD, FACS", hn) + +def test_suffix_delimiter_no_effect_without_comma(self) -> None: + # suffix_delimiter only applies after the comma split; space-separated + # suffixes already work via the no-comma parse path + hn = HumanName("John Doe MD PhD", suffix_delimiter=" - ") + self.m(hn.first, "John", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "MD, PhD", hn) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_basic tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_multiple tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_no_effect_without_comma -v +``` + +Expected: first two `FAIL` (suffix parses incorrectly without expansion), third `PASS` (no-comma path already works). + +- [ ] **Step 3: Add expansion step in `parse()`** + +In `nameparser/parser.py`, locate `parse()`. Find the line: + +```python +parts = [x.strip() for x in self._full_name.split(",")] +``` + +Insert immediately after it: + +```python +if self.suffix_delimiter and len(parts) > 1: + expanded = [parts[0]] + for part in parts[1:]: + expanded.extend([p.strip() for p in part.split(self.suffix_delimiter)]) + parts = expanded +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_basic tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_multiple tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_no_effect_without_comma -v +``` + +Expected: all three `PASS` + +- [ ] **Step 5: Run the full test suite to check for regressions** + +```bash +pytest --tb=short +``` + +Expected: all existing tests `PASS` + +- [ ] **Step 6: Commit** + +```bash +git add nameparser/parser.py tests/test_suffixes.py +git commit -m "feat: expand parts on suffix_delimiter after comma split in parse()" +``` + +--- + +### Task 4: Test `CONSTANTS`-level setting and document known limitation + +**Files:** +- Modify: `tests/test_suffixes.py` + +- [ ] **Step 1: Write the tests** + +Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: + +```python +def test_suffix_delimiter_constants_level(self) -> None: + from nameparser.config import CONSTANTS + _orig = CONSTANTS.suffix_delimiter + try: + CONSTANTS.suffix_delimiter = " - " + hn = HumanName("Steven Hardman, RN - CRNA") + self.m(hn.first, "Steven", hn) + self.m(hn.last, "Hardman", hn) + self.m(hn.suffix, "RN, CRNA", hn) + finally: + CONSTANTS.suffix_delimiter = _orig + +def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: + # Without suffix_delimiter set, " - " between suffixes breaks parsing. + # This test documents the known limitation — do not "fix" it. + hn = HumanName("Steven Hardman, RN - CRNA") + self.assertNotEqual(hn.first, "Steven") +``` + +- [ ] **Step 2: Run tests to verify they pass** + +```bash +pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_constants_level tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_none_by_default_known_limitation -v +``` + +Expected: both `PASS` + +- [ ] **Step 3: Run the full test suite one final time** + +```bash +pytest --tb=short +``` + +Expected: all tests `PASS` + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_suffixes.py +git commit -m "test: add CONSTANTS-level and known-limitation tests for suffix_delimiter" +``` From 4ca5a4949778481eead6d5977bebf87bca2ed46b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 19:58:14 -0700 Subject: [PATCH 03/10] feat: add suffix_delimiter to Constants (default None) Add the suffix_delimiter class attribute to the Constants class with a default value of None. This attribute will be used by HumanName to split suffix groups after comma-splitting. The attribute follows the existing pattern of scalar class attributes (string_format, initials_delimiter, initials_separator). Co-Authored-By: Claude Sonnet 4.6 --- nameparser/config/__init__.py | 11 +++++++++++ tests/test_suffixes.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 6dd591c..2537257 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -228,6 +228,17 @@ class Constants: spacing from the template is still applied. """ + suffix_delimiter = None + """ + If set, an additional delimiter used to split suffix groups after + comma-splitting. For example, setting ``suffix_delimiter=" - "`` allows + ``"RN - CRNA"`` to be parsed as two separate suffixes. Default is + ``None`` (no additional splitting beyond the standard comma split). + + Note: setting this to ``", "`` is a no-op — comma-splitting already + occurs unconditionally before this step. + """ + empty_attribute_default = '' """ Default return value for empty attributes. diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index b22945b..3b4fd88 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -136,3 +136,7 @@ def test_suffix_with_periods_with_lastname_comma(self) -> None: self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Msc.Ed.", hn) + + def test_suffix_delimiter_default_on_constants(self) -> None: + from nameparser.config import CONSTANTS + self.assertIs(CONSTANTS.suffix_delimiter, None) From 70a14a63db834ce16770ed73d384a8dcfb5da92d Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 20:01:44 -0700 Subject: [PATCH 04/10] feat: wire suffix_delimiter kwarg into HumanName.__init__ --- nameparser/parser.py | 5 +++++ tests/test_suffixes.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/nameparser/parser.py b/nameparser/parser.py index 4e9d188..fa6a8f8 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -53,6 +53,9 @@ class HumanName: :param str string_format: python string formatting :param str initials_format: python initials string formatting :param str initials_delimter: string delimiter for initials + :param str initials_separator: string separator between consecutive initials + :param str suffix_delimiter: additional delimiter to split suffix groups + after comma-splitting, e.g. ``" - "`` for ``"RN - CRNA"`` :param str first: first name :param str middle: middle name :param str last: last name @@ -95,6 +98,7 @@ def __init__( initials_format: str | None = None, initials_delimiter: str | None = None, initials_separator: str | None = None, + suffix_delimiter: str | None = None, first: str | list[str] | None = None, middle: str | list[str] | None = None, last: str | list[str] | None = None, @@ -111,6 +115,7 @@ def __init__( self.initials_format = initials_format if initials_format is not None else self.C.initials_format self.initials_delimiter = initials_delimiter if initials_delimiter is not None else self.C.initials_delimiter self.initials_separator = initials_separator if initials_separator is not None else self.C.initials_separator + self.suffix_delimiter = suffix_delimiter if suffix_delimiter is not None else self.C.suffix_delimiter if (first or middle or last or title or suffix or nickname): self.first = first self.middle = middle diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 3b4fd88..a9fae84 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -140,3 +140,7 @@ def test_suffix_with_periods_with_lastname_comma(self) -> None: def test_suffix_delimiter_default_on_constants(self) -> None: from nameparser.config import CONSTANTS self.assertIs(CONSTANTS.suffix_delimiter, None) + + def test_suffix_delimiter_kwarg_accepted(self) -> None: + hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") + self.assertEqual(hn.suffix_delimiter, " - ") From 55288135e0bf10829b5285a760a0e6532633d572 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 20:06:39 -0700 Subject: [PATCH 05/10] feat: expand parts on suffix_delimiter after comma split in parse() When parsing names with suffixes separated by a custom delimiter (e.g. "Steven Hardman, RN - CRNA" with suffix_delimiter=" - "), the parser now re-splits post-comma parts on the suffix_delimiter and flattens them before processing. This transforms ["Steven Hardman", "RN - CRNA"] into ["Steven Hardman", "RN", "CRNA"], making downstream suffix detection work correctly instead of treating the delimiter as an invalid suffix. Co-Authored-By: Claude Sonnet 4.6 --- nameparser/parser.py | 6 ++++++ tests/test_suffixes.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/nameparser/parser.py b/nameparser/parser.py index fa6a8f8..9768d12 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -628,6 +628,12 @@ def parse_full_name(self) -> None: # break up full_name by commas parts = [x.strip() for x in self._full_name.split(",")] + if self.suffix_delimiter and len(parts) > 1: + expanded = [parts[0]] + for part in parts[1:]: + expanded.extend([p.strip() for p in part.split(self.suffix_delimiter)]) + parts = expanded + log.debug("full_name: %s", self._full_name) log.debug("parts: %s", parts) diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index a9fae84..242b8b8 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -144,3 +144,23 @@ def test_suffix_delimiter_default_on_constants(self) -> None: def test_suffix_delimiter_kwarg_accepted(self) -> None: hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") self.assertEqual(hn.suffix_delimiter, " - ") + + def test_suffix_delimiter_basic(self) -> None: + hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") + self.m(hn.first, "Steven", hn) + self.m(hn.last, "Hardman", hn) + self.m(hn.suffix, "RN, CRNA", hn) + + def test_suffix_delimiter_multiple(self) -> None: + hn = HumanName("John Doe, MD - PhD - FACS", suffix_delimiter=" - ") + self.m(hn.first, "John", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "MD, PhD, FACS", hn) + + def test_suffix_delimiter_no_effect_without_comma(self) -> None: + # suffix_delimiter only applies after the comma split; space-separated + # suffixes already work via the no-comma parse path + hn = HumanName("John Doe MD PhD", suffix_delimiter=" - ") + self.m(hn.first, "John", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "MD, PhD", hn) From 10e58368e5701cbadf969577182550865bbbdabf Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 20:10:20 -0700 Subject: [PATCH 06/10] test: add CONSTANTS-level and known-limitation tests for suffix_delimiter --- tests/test_suffixes.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 242b8b8..6199271 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -164,3 +164,21 @@ def test_suffix_delimiter_no_effect_without_comma(self) -> None: self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "MD, PhD", hn) + + def test_suffix_delimiter_constants_level(self) -> None: + from nameparser.config import CONSTANTS + _orig = CONSTANTS.suffix_delimiter + try: + CONSTANTS.suffix_delimiter = " - " + hn = HumanName("Steven Hardman, RN - CRNA") + self.m(hn.first, "Steven", hn) + self.m(hn.last, "Hardman", hn) + self.m(hn.suffix, "RN, CRNA", hn) + finally: + CONSTANTS.suffix_delimiter = _orig + + def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: + # Without suffix_delimiter set, " - " between suffixes breaks parsing. + # This test documents the known limitation — do not "fix" it. + hn = HumanName("Steven Hardman, RN - CRNA") + self.assertNotEqual(hn.first, "Steven") From f5f5d78ca490e92c08a12b7af0335ca3037f556b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 20:14:27 -0700 Subject: [PATCH 07/10] docs: note inverted-format limitation in suffix_delimiter docstring; clarify known-limitation test Co-Authored-By: Claude Sonnet 4.6 --- nameparser/config/__init__.py | 7 +++++++ tests/test_suffixes.py | 1 + 2 files changed, 8 insertions(+) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 2537257..f4e67ac 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -237,6 +237,13 @@ class Constants: Note: setting this to ``", "`` is a no-op — comma-splitting already occurs unconditionally before this step. + + Known limitation: the expansion is applied to all post-comma parts, not + just suffix groups. In inverted format (``"Last, First, suffix"``), the + first-name part is also split on the delimiter. In practice this is + harmless since first names rarely contain the delimiter string, but a + name like ``"Doe, Mary - Kate, RN"`` with ``suffix_delimiter=" - "`` + would misparse. """ empty_attribute_default = '' diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 6199271..532a76e 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -180,5 +180,6 @@ def test_suffix_delimiter_constants_level(self) -> None: def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: # Without suffix_delimiter set, " - " between suffixes breaks parsing. # This test documents the known limitation — do not "fix" it. + # (Passes when first is "RN" or empty — any incorrect parse is acceptable.) hn = HumanName("Steven Hardman, RN - CRNA") self.assertNotEqual(hn.first, "Steven") From 49090a9bc6cffc770e9584abba8f0748ab89c9f1 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 21:38:44 -0700 Subject: [PATCH 08/10] chore: remove planning docs for suffix_delimiter feature Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-06-28-suffix-delimiter.md | 263 ------------------ .../2026-06-28-suffix-delimiter-design.md | 88 ------ 2 files changed, 351 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-28-suffix-delimiter.md delete mode 100644 docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md diff --git a/docs/superpowers/plans/2026-06-28-suffix-delimiter.md b/docs/superpowers/plans/2026-06-28-suffix-delimiter.md deleted file mode 100644 index c1dba5c..0000000 --- a/docs/superpowers/plans/2026-06-28-suffix-delimiter.md +++ /dev/null @@ -1,263 +0,0 @@ -# suffix_delimiter Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a user-configurable `suffix_delimiter` that allows suffixes separated by arbitrary strings (e.g. ` - `) to be parsed correctly. - -**Architecture:** After splitting the full name on commas, re-split `parts[1:]` on `suffix_delimiter` and flatten. This leaves the name portion (`parts[0]`) untouched and feeds the existing downstream suffix logic exactly the shape of input it already handles. `suffix_delimiter` is exposed as a class attribute on `Constants` (global default) and as a `HumanName.__init__` kwarg (per-instance override), mirroring the `initials_separator` pattern. - -**Tech Stack:** Python 3, pytest, nameparser internal APIs only. - ---- - -### Task 1: Add `suffix_delimiter` to `Constants` - -**Files:** -- Modify: `nameparser/config/__init__.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: - -```python -def test_suffix_delimiter_default_on_constants(self) -> None: - from nameparser.config import CONSTANTS - self.assertIsNone(CONSTANTS.suffix_delimiter) -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_default_on_constants -v -``` - -Expected: `FAIL` — `AttributeError: type object 'Constants' has no attribute 'suffix_delimiter'` - -- [ ] **Step 3: Add the attribute to `Constants`** - -In `nameparser/config/__init__.py`, locate the block of scalar class attributes (near line 202 where `string_format`, `initials_delimiter`, `initials_separator` are defined). Add after `initials_separator`: - -```python -suffix_delimiter = None -""" -If set, an additional delimiter used to split suffix groups after -comma-splitting. For example, setting ``suffix_delimiter=" - "`` allows -``"RN - CRNA"`` to be parsed as two separate suffixes. Default is -``None`` (no additional splitting beyond the standard comma split). - -Note: setting this to ``", "`` is a no-op — comma-splitting already -occurs unconditionally before this step. -""" -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_default_on_constants -v -``` - -Expected: `PASS` - -- [ ] **Step 5: Commit** - -```bash -git add nameparser/config/__init__.py tests/test_suffixes.py -git commit -m "feat: add suffix_delimiter to Constants (default None)" -``` - ---- - -### Task 2: Wire `suffix_delimiter` into `HumanName` - -**Files:** -- Modify: `nameparser/parser.py` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: - -```python -def test_suffix_delimiter_kwarg_accepted(self) -> None: - hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") - self.assertEqual(hn.suffix_delimiter, " - ") -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_kwarg_accepted -v -``` - -Expected: `FAIL` — `TypeError: __init__() got an unexpected keyword argument 'suffix_delimiter'` - -- [ ] **Step 3: Add kwarg to `HumanName.__init__`** - -In `nameparser/parser.py`, add `suffix_delimiter` to the class docstring param list (near line 55): - -```python -:param str suffix_delimiter: additional delimiter to split suffix groups - after comma-splitting, e.g. ``" - "`` for ``"RN - CRNA"`` -``` - -Add to the `__init__` signature (after `initials_separator`, around line 97): - -```python -suffix_delimiter: str | None = None, -``` - -Add to the `__init__` body (after the `initials_separator` assignment, around line 113): - -```python -self.suffix_delimiter = suffix_delimiter if suffix_delimiter is not None else self.C.suffix_delimiter -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_kwarg_accepted -v -``` - -Expected: `PASS` - -- [ ] **Step 5: Commit** - -```bash -git add nameparser/parser.py tests/test_suffixes.py -git commit -m "feat: wire suffix_delimiter kwarg into HumanName.__init__" -``` - ---- - -### Task 3: Implement post-comma expansion in `parse()` - -**Files:** -- Modify: `nameparser/parser.py` - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: - -```python -def test_suffix_delimiter_basic(self) -> None: - hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") - self.m(hn.first, "Steven", hn) - self.m(hn.last, "Hardman", hn) - self.m(hn.suffix, "RN, CRNA", hn) - -def test_suffix_delimiter_multiple(self) -> None: - hn = HumanName("John Doe, MD - PhD - FACS", suffix_delimiter=" - ") - self.m(hn.first, "John", hn) - self.m(hn.last, "Doe", hn) - self.m(hn.suffix, "MD, PhD, FACS", hn) - -def test_suffix_delimiter_no_effect_without_comma(self) -> None: - # suffix_delimiter only applies after the comma split; space-separated - # suffixes already work via the no-comma parse path - hn = HumanName("John Doe MD PhD", suffix_delimiter=" - ") - self.m(hn.first, "John", hn) - self.m(hn.last, "Doe", hn) - self.m(hn.suffix, "MD, PhD", hn) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_basic tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_multiple tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_no_effect_without_comma -v -``` - -Expected: first two `FAIL` (suffix parses incorrectly without expansion), third `PASS` (no-comma path already works). - -- [ ] **Step 3: Add expansion step in `parse()`** - -In `nameparser/parser.py`, locate `parse()`. Find the line: - -```python -parts = [x.strip() for x in self._full_name.split(",")] -``` - -Insert immediately after it: - -```python -if self.suffix_delimiter and len(parts) > 1: - expanded = [parts[0]] - for part in parts[1:]: - expanded.extend([p.strip() for p in part.split(self.suffix_delimiter)]) - parts = expanded -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_basic tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_multiple tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_no_effect_without_comma -v -``` - -Expected: all three `PASS` - -- [ ] **Step 5: Run the full test suite to check for regressions** - -```bash -pytest --tb=short -``` - -Expected: all existing tests `PASS` - -- [ ] **Step 6: Commit** - -```bash -git add nameparser/parser.py tests/test_suffixes.py -git commit -m "feat: expand parts on suffix_delimiter after comma split in parse()" -``` - ---- - -### Task 4: Test `CONSTANTS`-level setting and document known limitation - -**Files:** -- Modify: `tests/test_suffixes.py` - -- [ ] **Step 1: Write the tests** - -Add to `tests/test_suffixes.py` inside `SuffixesTestCase`: - -```python -def test_suffix_delimiter_constants_level(self) -> None: - from nameparser.config import CONSTANTS - _orig = CONSTANTS.suffix_delimiter - try: - CONSTANTS.suffix_delimiter = " - " - hn = HumanName("Steven Hardman, RN - CRNA") - self.m(hn.first, "Steven", hn) - self.m(hn.last, "Hardman", hn) - self.m(hn.suffix, "RN, CRNA", hn) - finally: - CONSTANTS.suffix_delimiter = _orig - -def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: - # Without suffix_delimiter set, " - " between suffixes breaks parsing. - # This test documents the known limitation — do not "fix" it. - hn = HumanName("Steven Hardman, RN - CRNA") - self.assertNotEqual(hn.first, "Steven") -``` - -- [ ] **Step 2: Run tests to verify they pass** - -```bash -pytest tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_constants_level tests/test_suffixes.py::SuffixesTestCase::test_suffix_delimiter_none_by_default_known_limitation -v -``` - -Expected: both `PASS` - -- [ ] **Step 3: Run the full test suite one final time** - -```bash -pytest --tb=short -``` - -Expected: all tests `PASS` - -- [ ] **Step 4: Commit** - -```bash -git add tests/test_suffixes.py -git commit -m "test: add CONSTANTS-level and known-limitation tests for suffix_delimiter" -``` diff --git a/docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md b/docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md deleted file mode 100644 index 04897f4..0000000 --- a/docs/superpowers/specs/2026-06-28-suffix-delimiter-design.md +++ /dev/null @@ -1,88 +0,0 @@ -# suffix_delimiter — Design Spec - -**Issue:** #156 -**Branch:** fix/issue-156-suffix-delimiter -**Date:** 2026-06-28 - -## Problem - -Names like `"Steven Hardman, RN - CRNA"` parse incorrectly because ` - ` is not -a recognized suffix separator. The parser splits on commas first, leaving -`"RN - CRNA"` as a single part. Splitting that on spaces yields -`["RN", "-", "CRNA"]`, and `"-"` is not a suffix, so `are_suffixes()` returns -False and the parser falls through to a wrong code path. - -Both `RN` and `CRNA` are in `SUFFIX_ACRONYMS` — the issue is solely the -delimiter between them. - -## Approach - -Post-comma-split expansion. After splitting the full name on commas, re-split -`parts[1:]` on `suffix_delimiter` and flatten. This scopes the feature to the -region where suffixes live, leaving `parts[0]` (the name portion) untouched. - -## Changes - -### `nameparser/config/__init__.py` - -Add class-level attribute to `Constants`: - -```python -suffix_delimiter = None -""" -If set, an additional delimiter used to split suffix groups after -comma-splitting. For example, setting suffix_delimiter=" - " allows -"RN - CRNA" to be parsed as two separate suffixes. Default is None -(no additional splitting beyond the standard comma split). - -Note: setting this to ", " is a no-op — comma-splitting already occurs -unconditionally before this step. -""" -``` - -### `nameparser/parser.py` - -**Constructor signature:** -```python -suffix_delimiter: str | None = None, -``` - -**Constructor body** (mirrors `initials_separator` pattern): -```python -self.suffix_delimiter = suffix_delimiter if suffix_delimiter is not None else self.C.suffix_delimiter -``` - -**Docstring entry:** -``` -:param str suffix_delimiter: additional delimiter to split suffix groups - after comma-splitting, e.g. " - " for "RN - CRNA" -``` - -**`parse()` method** — insert immediately after the comma split: -```python -parts = [x.strip() for x in self._full_name.split(",")] - -if self.suffix_delimiter and len(parts) > 1: - expanded = [parts[0]] - for part in parts[1:]: - expanded.extend([p.strip() for p in part.split(self.suffix_delimiter)]) - parts = expanded -``` - -### `tests/test_suffixes.py` - -1. `HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ")` → - `first="Steven"`, `last="Hardman"`, `suffix="RN, CRNA"` -2. `HumanName("John Doe, MD - PhD - FACS", suffix_delimiter=" - ")` → - `suffix="MD, PhD, FACS"` -3. `CONSTANTS.suffix_delimiter = " - "` applies to new instances without - passing the kwarg explicitly -4. `HumanName("Steven Hardman, RN - CRNA")` without `suffix_delimiter` — - documents existing (broken) behavior as a known limitation, not a regression - -## Non-goals - -- No change to `are_suffixes()`, `is_suffix()`, or the downstream parse paths — - expanding `parts` before those checks is sufficient. -- No handling of ` - ` in name portions (e.g. hyphenated last names) — the - expansion only touches `parts[1:]`. From b2d9a9f35b0c9a82f1f997e295fe698c9fbc9854 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 21:41:03 -0700 Subject: [PATCH 09/10] docs: add suffix_delimiter to customize.rst and release_log.rst Co-Authored-By: Claude Sonnet 4.6 --- docs/customize.rst | 1 + docs/release_log.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/customize.rst b/docs/customize.rst index 1e4f38d..876580a 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -59,6 +59,7 @@ Other editable attributes * :py:obj:`~nameparser.config.Constants.empty_attribute_default` - value returned by empty attributes, defaults to empty string * :py:obj:`~nameparser.config.Constants.capitalize_name` - If set, applies :py:meth:`~nameparser.parser.HumanName.capitalize` to :py:class:`~nameparser.parser.HumanName` instance. * :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. +* :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). diff --git a/docs/release_log.rst b/docs/release_log.rst index 266d0dd..6db50df 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -1,5 +1,7 @@ Release Log =========== +* 1.2.2 - June 28, 2026 + - Add ``suffix_delimiter`` to ``Constants`` and ``HumanName`` for parsing suffixes separated by arbitrary delimiters, e.g. ``"RN - CRNA"`` (#156) * 1.2.1 - June 19, 2026 - 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`` - Add ``python -m nameparser "Name String"`` command-line helper that prints a parsed name From 89a88a61e998fa3f4a79946db503c84bdea9754b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 28 Jun 2026 22:08:23 -0700 Subject: [PATCH 10/10] fix: address PR review feedback on suffix_delimiter - Filter empty tokens from trailing/leading delimiters to prevent silent parse corruption (e.g. "MD-PhD-" with suffix_delimiter="-") - Clarify no-op note in Constants docstring: both the comma split and subsequent strip() make ", " a no-op, not just the comma split - Correct param docstring: expansion applies to all post-comma parts, not just identified suffix groups - Tighten known-limitation test with concrete field assertions instead of a fragile assertNotEqual - Add tests: trailing delimiter, comma-space no-op, inverted-format limitation Co-Authored-By: Claude Sonnet 4.6 --- nameparser/config/__init__.py | 5 +++-- nameparser/parser.py | 6 +++--- tests/test_suffixes.py | 24 ++++++++++++++++++++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index f4e67ac..d2f32e0 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -235,8 +235,9 @@ class Constants: ``"RN - CRNA"`` to be parsed as two separate suffixes. Default is ``None`` (no additional splitting beyond the standard comma split). - Note: setting this to ``", "`` is a no-op — comma-splitting already - occurs unconditionally before this step. + Note: setting this to ``","`` or ``", "`` has no additional effect — + the full name is already split on bare commas first, and each resulting + part is stripped of surrounding whitespace before this step runs. Known limitation: the expansion is applied to all post-comma parts, not just suffix groups. In inverted format (``"Last, First, suffix"``), the diff --git a/nameparser/parser.py b/nameparser/parser.py index 9768d12..f5cde59 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -54,8 +54,8 @@ class HumanName: :param str initials_format: python initials string formatting :param str initials_delimter: string delimiter for initials :param str initials_separator: string separator between consecutive initials - :param str suffix_delimiter: additional delimiter to split suffix groups - after comma-splitting, e.g. ``" - "`` for ``"RN - CRNA"`` + :param str suffix_delimiter: additional delimiter to split post-comma parts + before suffix detection, e.g. ``" - "`` for ``"RN - CRNA"`` :param str first: first name :param str middle: middle name :param str last: last name @@ -631,7 +631,7 @@ def parse_full_name(self) -> None: if self.suffix_delimiter and len(parts) > 1: expanded = [parts[0]] for part in parts[1:]: - expanded.extend([p.strip() for p in part.split(self.suffix_delimiter)]) + expanded.extend([p for p in (p.strip() for p in part.split(self.suffix_delimiter)) if p]) parts = expanded log.debug("full_name: %s", self._full_name) diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 532a76e..115543d 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -180,6 +180,26 @@ def test_suffix_delimiter_constants_level(self) -> None: def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: # Without suffix_delimiter set, " - " between suffixes breaks parsing. # This test documents the known limitation — do not "fix" it. - # (Passes when first is "RN" or empty — any incorrect parse is acceptable.) hn = HumanName("Steven Hardman, RN - CRNA") - self.assertNotEqual(hn.first, "Steven") + self.m(hn.first, "RN", hn) + self.m(hn.last, "Steven Hardman", hn) + self.m(hn.suffix, "CRNA", hn) + + def test_suffix_delimiter_trailing_delimiter_ignored(self) -> None: + # Trailing delimiter produces an empty token that must be filtered out. + # Using a non-whitespace-terminated delimiter so stripping doesn't consume it. + hn = HumanName("John Doe, MD-PhD-", suffix_delimiter="-") + self.m(hn.first, "John", hn) + self.m(hn.last, "Doe", hn) + self.m(hn.suffix, "MD, PhD", hn) + + def test_suffix_delimiter_comma_space_is_noop(self) -> None: + hn = HumanName("John Doe, MD, PhD", suffix_delimiter=", ") + self.m(hn.suffix, "MD, PhD", hn) + + def test_suffix_delimiter_inverted_format_known_limitation(self) -> None: + # In inverted format, the first-name part is also split on the delimiter. + # "Mary - Kate" becomes two separate parts, causing a wrong parse. + # This is a documented limitation — do not "fix" it without a broader solution. + hn = HumanName("Doe, Mary - Kate, RN", suffix_delimiter=" - ") + self.assertNotEqual(hn.first, "Mary - Kate")