Skip to content

Commit ba164e1

Browse files
derek73claude
andauthored
fix: add initials_separator; fix or-defaulting for format/delimiter kwargs (#171)
* fix: add initials_separator; fix or-defaulting for format/delimiter kwargs (closes #152) - Add initials_separator = " " to Constants: controls the joiner between consecutive initials within a name group, distinct from initials_delimiter which is the trailing character after each individual initial - Add initials_separator kwarg to HumanName.__init__ - Fix or-defaulting to is-not-None for string_format, initials_format, and initials_delimiter kwargs so empty string '' is accepted as a valid value - Use initials_separator in __process_initial__ and initials() in place of hardcoded " " - Document initials_separator in usage.rst with examples * fix: honor initials_separator kwarg in __process_initial__; fix string_format='' in __str__ - `__process_initial__` was reading `self.C.initials_separator` (Constants) instead of `self.initials_separator` (per-instance), so the constructor kwarg was silently ignored for multi-word name parts - `__str__` used `if self.string_format:` (truthiness) so `string_format=""` fell through to the default format despite the assignment fix in the PR - Add regression tests for both kwarg paths through the affected code - Fix `test_initials_separator_multiword_name_part` to set instance attr instead of Constants attr, so it tests the now-correct code path - Correct `Constants.initials_separator` docstring example (requires initials_format="{first}{middle}{last}" to produce "J.A.D.") - Clarify usage.rst: initials_separator only removes intra-group spaces; inter-group spacing is still governed by initials_format * test: strengthen test suite after PR review - Add assertNotEqual shim to HumanNameTestBase - Tighten test_initials_format_empty_string_kwarg: use assertNotEqual + assertFalse instead of vacuous or-equality check - Add test_initials_separator_custom_value: non-empty separator on a multi-word token via __process_initial__ - Add test_str_default_behavior_unchanged: regression guard for the or→is-not-None __str__ fix - Remove duplicate test_initials_separator_multiword_name_part - Wrap CONSTANTS mutation tests in try/finally to ensure state is restored even when an assertion fails --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7686567 commit ba164e1

5 files changed

Lines changed: 150 additions & 24 deletions

File tree

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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,19 @@ class Constants:
215215
Will be used to add a delimiter between each initial.
216216
"""
217217

218+
initials_separator = " "
219+
"""
220+
The default separator placed between consecutive initials within a name
221+
group (first, middle, or last). Distinct from ``initials_delimiter``,
222+
which is the trailing character after each individual initial.
223+
224+
With defaults ``initials_delimiter="."`` and ``initials_separator=" "``,
225+
``initials()`` produces ``"J. A. D."``. Setting ``initials_separator=""``
226+
with ``initials_delimiter="."`` and ``initials_format="{first}{middle}{last}"``
227+
produces ``"J.A.D."``. With the default ``initials_format``, group-level
228+
spacing from the template is still applied.
229+
"""
230+
218231
empty_attribute_default = ''
219232
"""
220233
Default return value for empty attributes.

nameparser/parser.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def __init__(
9494
string_format: str | None = None,
9595
initials_format: str | None = None,
9696
initials_delimiter: str | None = None,
97+
initials_separator: str | None = None,
9798
first: str | list[str] | None = None,
9899
middle: str | list[str] | None = None,
99100
last: str | list[str] | None = None,
@@ -106,9 +107,10 @@ def __init__(
106107
self.C = Constants()
107108

108109
self.encoding = encoding
109-
self.string_format = string_format or self.C.string_format
110-
self.initials_format = initials_format or self.C.initials_format
111-
self.initials_delimiter = initials_delimiter or self.C.initials_delimiter
110+
self.string_format = string_format if string_format is not None else self.C.string_format
111+
self.initials_format = initials_format if initials_format is not None else self.C.initials_format
112+
self.initials_delimiter = initials_delimiter if initials_delimiter is not None else self.C.initials_delimiter
113+
self.initials_separator = initials_separator if initials_separator is not None else self.C.initials_separator
112114
if (first or middle or last or title or suffix or nickname):
113115
self.first = first
114116
self.middle = middle
@@ -177,7 +179,7 @@ def __next__(self) -> str:
177179
return getattr(self, self._members[c]) or next(self)
178180

179181
def __str__(self) -> str:
180-
if self.string_format:
182+
if self.string_format is not None:
181183
# string_format = "{title} {first} {middle} {last} {suffix} ({nickname})"
182184
_s = self.string_format.format(**self.as_dict())
183185
# remove trailing punctuation from missing nicknames
@@ -241,7 +243,7 @@ def __process_initial__(self, name_part: str, firstname: bool = False) -> str:
241243
if not (self.is_prefix(part) or self.is_conjunction(part)) or firstname:
242244
initials.append(part[0])
243245
if len(initials) > 0:
244-
return " ".join(initials)
246+
return self.initials_separator.join(initials)
245247
else:
246248
return self.C.empty_attribute_default
247249

@@ -265,19 +267,25 @@ def initials_list(self) -> list[str]:
265267

266268
def initials(self) -> str:
267269
"""
268-
Return period-delimited initials of the first, middle and optionally last name.
270+
Return formatted initials for the name, controlled by
271+
``initials_format``, ``initials_delimiter``, and ``initials_separator``.
269272
270-
:param bool include_last_name: Include the last name as part of the initials
271-
:rtype: str
273+
``initials_delimiter`` is appended after each individual initial.
274+
``initials_separator`` is placed between consecutive initials within
275+
a name group (first, middle, or last). Both can be set as
276+
``Constants`` attributes or as ``HumanName`` constructor kwargs.
272277
273-
.. doctest::
278+
.. doctest::
274279
275-
>>> name = HumanName("Sir Bob Andrew Dole")
276-
>>> name.initials()
277-
"B. A. D."
278-
>>> name = HumanName("Sir Bob Andrew Dole", initials_format="{first} {middle}")
279-
>>> name.initials()
280-
"B. A."
280+
>>> name = HumanName("Sir Bob Andrew Dole")
281+
>>> name.initials()
282+
"B. A. D."
283+
>>> name = HumanName("Sir Bob Andrew Dole", initials_format="{first} {middle}")
284+
>>> name.initials()
285+
"B. A."
286+
>>> name = HumanName("Doe, John A.", initials_delimiter="", initials_separator="")
287+
>>> name.initials()
288+
"J A D"
281289
"""
282290

283291
first_initials_list = [self.__process_initial__(name, True) for name in self.first_list if name]
@@ -289,11 +297,11 @@ def initials(self) -> str:
289297
# output. A fully-empty result falls back to empty_attribute_default,
290298
# matching the other attribute accessors (e.g. ``first``).
291299
initials_dict = {
292-
"first": (self.initials_delimiter + " ").join(first_initials_list) + self.initials_delimiter
300+
"first": (self.initials_delimiter + self.initials_separator).join(first_initials_list) + self.initials_delimiter
293301
if len(first_initials_list) else "",
294-
"middle": (self.initials_delimiter + " ").join(middle_initials_list) + self.initials_delimiter
302+
"middle": (self.initials_delimiter + self.initials_separator).join(middle_initials_list) + self.initials_delimiter
295303
if len(middle_initials_list) else "",
296-
"last": (self.initials_delimiter + " ").join(last_initials_list) + self.initials_delimiter
304+
"last": (self.initials_delimiter + self.initials_separator).join(last_initials_list) + self.initials_delimiter
297305
if len(last_initials_list) else ""
298306
}
299307

tests/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ def assertFalse(self, expr: object, msg: object = None) -> None:
3939
def assertIn(self, member: object, container: object, msg: object = None) -> None:
4040
assert member in container, msg # type: ignore[operator]
4141

42+
def assertNotEqual(self, first: object, second: object, msg: object = None) -> None:
43+
assert first != second, msg
44+
4245
def assertNotIn(self, member: object, container: object, msg: object = None) -> None:
4346
assert member not in container, msg # type: ignore[operator]
4447

tests/test_initials.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,16 @@ def test_initials_delimiter(self) -> None:
6969
def test_initials_delimiter_constants(self) -> None:
7070
from nameparser.config import CONSTANTS
7171
_orig = CONSTANTS.initials_delimiter
72-
CONSTANTS.initials_delimiter = ";"
73-
hn = HumanName("Doe, John A. Kenneth, Jr.")
74-
self.m(hn.initials(), "J; A; K; D;", hn)
75-
CONSTANTS.initials_delimiter = _orig
72+
try:
73+
CONSTANTS.initials_delimiter = ";"
74+
hn = HumanName("Doe, John A. Kenneth, Jr.")
75+
self.m(hn.initials(), "J; A; K; D;", hn)
76+
finally:
77+
CONSTANTS.initials_delimiter = _orig
78+
79+
def test_initials_separator_default_on_constants(self) -> None:
80+
from nameparser.config import CONSTANTS
81+
self.assertEqual(CONSTANTS.initials_separator, " ")
7682

7783
def test_initials_list(self) -> None:
7884
hn = HumanName("Andrew Boris Petersen")
@@ -90,6 +96,44 @@ def test_initials_with_prefix(self) -> None:
9096
hn = HumanName("Alex van Johnson")
9197
self.m(hn.initials_list(), ["A", "J"], hn)
9298

99+
def test_initials_delimiter_empty_string_kwarg(self) -> None:
100+
# Regression: initials_delimiter='' was silently ignored due to `or` defaulting
101+
hn = HumanName("Doe, John A.", initials_delimiter="")
102+
self.m(hn.initials(), "J A D", hn)
103+
104+
def test_initials_format_empty_string_kwarg(self) -> None:
105+
# Regression: initials_format='' was silently ignored due to `or` defaulting
106+
hn = HumanName("Doe, John A.")
107+
hn2 = HumanName("Doe, John A.", initials_format="")
108+
self.assertNotEqual(hn.initials(), hn2.initials())
109+
# "".format(...) returns ""; collapse_whitespace returns "" which falls through
110+
# to empty_attribute_default (may be "" or None depending on config variant).
111+
self.assertFalse(hn2.initials())
112+
113+
def test_initials_separator_kwarg(self) -> None:
114+
# initials_separator="" with initials_format="{first}{middle}{last}" gives
115+
# period-separated initials with no spaces — a common academic citation style
116+
hn = HumanName(
117+
"Doe, John A. Kenneth",
118+
initials_separator="",
119+
initials_format="{first}{middle}{last}",
120+
)
121+
self.m(hn.initials(), "J.A.K.D.", hn)
122+
123+
def test_initials_separator_custom_value(self) -> None:
124+
# Non-empty custom separator exercising __process_initial__ on a multi-word
125+
# token. "Van Berg" is a single name part whose two words produce two initials
126+
# joined by initials_separator.
127+
hn = HumanName("", initials_separator="-", initials_delimiter=".")
128+
result = hn.__process_initial__("Van Berg", firstname=True)
129+
self.assertEqual(result, "V-B")
130+
131+
def test_str_default_behavior_unchanged(self) -> None:
132+
# Regression guard for the `or` → `is not None` change in __str__:
133+
# the default path (no string_format kwarg) must still produce the expected string.
134+
hn = HumanName("John Doe")
135+
self.assertEqual(str(hn), "John Doe")
136+
93137
def test_constructor_first(self) -> None:
94138
hn = HumanName(first="TheName")
95139
self.assertFalse(hn.unparsable)
@@ -126,3 +170,43 @@ def test_constructor_multiple(self) -> None:
126170
self.m(hn.first, "TheName", hn)
127171
self.m(hn.last, "lastname", hn)
128172
self.m(hn.title, "mytitle", hn)
173+
174+
def test_initials_separator_kwarg_multiword_part(self) -> None:
175+
# Regression: initials_separator kwarg must flow into __process_initial__
176+
# for multi-word name parts, not just into the initials() join calls.
177+
hn = HumanName("", initials_separator="")
178+
result = hn.__process_initial__("Van Berg", firstname=True)
179+
self.assertEqual(result, "VB")
180+
181+
def test_string_format_empty_string_kwarg(self) -> None:
182+
# Regression: string_format='' was silently ignored due to `or` defaulting
183+
hn = HumanName("John Doe", string_format="")
184+
self.assertEqual(str(hn), "")
185+
186+
def test_initials_separator_empty_multi_part_middle(self) -> None:
187+
# Full workflow from issue #152: empty delimiter + separator + compact format
188+
# gives fully concatenated initials with no spaces or punctuation.
189+
# Spaces between groups come from initials_format, so that must also be set.
190+
hn = HumanName(
191+
"Doe, John A. Kenneth",
192+
initials_delimiter="",
193+
initials_separator="",
194+
initials_format="{first}{middle}{last}",
195+
)
196+
self.m(hn.initials(), "JAKD", hn)
197+
198+
def test_initials_separator_constants_multi_part_middle(self) -> None:
199+
from nameparser.config import CONSTANTS
200+
_orig_d = CONSTANTS.initials_delimiter
201+
_orig_s = CONSTANTS.initials_separator
202+
_orig_f = CONSTANTS.initials_format
203+
try:
204+
CONSTANTS.initials_delimiter = ""
205+
CONSTANTS.initials_separator = ""
206+
CONSTANTS.initials_format = "{first}{middle}{last}"
207+
hn = HumanName("Doe, John A. Kenneth")
208+
self.m(hn.initials(), "JAKD", hn)
209+
finally:
210+
CONSTANTS.initials_delimiter = _orig_d
211+
CONSTANTS.initials_separator = _orig_s
212+
CONSTANTS.initials_format = _orig_f

0 commit comments

Comments
 (0)