Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ Don't use the bare `python3 -m doctest <file>.rst` CLI (no `optionflags`) to che

`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 nameparser/` intentionally excludes `tests/`** (`pyproject.toml`'s `[tool.mypy] packages = ["nameparser"]`) — if you run mypy against `tests/` too you'll see ~40 pre-existing errors; don't treat them as a regression. Most are tests deliberately passing `constants=None` (a documented runtime pattern the `Constants` type hint doesn't capture) or intentionally wrong-typed inputs verifying the code rejects them.
**`uv run mypy nameparser/` intentionally excludes `tests/`** (`pyproject.toml`'s `[tool.mypy] packages = ["nameparser"]`) — if you run mypy against `tests/` too you'll see ~30 pre-existing errors; don't treat them as a regression. Most are intentionally wrong-typed inputs verifying the code rejects them, or `Constants` attribute assignments the config type hints don't capture.

**`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}"`.

Expand Down
7 changes: 4 additions & 3 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -456,10 +456,11 @@ e.g. parsing names on multiple threads.


If you'd prefer new instances to have their own config values, one shortcut is to pass
``None`` as the second argument (or ``constant`` keyword argument) when
``None`` as the second argument (or ``constants`` keyword argument) when
instantiating ``HumanName``. Each instance always has a ``C`` attribute, but if
you didn't pass something falsey to the ``constants`` argument then it's a
reference to the module-level config values with the behavior described above.
you didn't pass ``None`` (or your own :py:class:`~nameparser.config.Constants`
instance) to the ``constants`` argument then it's a reference to the
module-level config values with the behavior described above.

.. doctest:: module config
:options: +ELLIPSIS, +NORMALIZE_WHITESPACE
Expand Down
1 change: 1 addition & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
Release Log
===========
* 1.3.0 - Unreleased
- Fix the ``constants`` constructor argument silently discarding ``Constants`` *subclass* instances: the exact-type check replaced them with fresh defaults, throwing away the caller's configuration. Subclass instances are now used as given; anything that is neither ``None`` nor a ``Constants`` instance now raises ``TypeError`` instead of being silently swapped for defaults (closes #226)
- Fix ``IndexError`` in ``initials()``/``initials_list()`` when a ``*_list`` attribute was assigned directly with an element containing unnormalized whitespace (e.g. ``name.middle_list = ['Q R']``), bypassing the parser's whitespace normalization (closes #232)
- Fix ``HumanName`` acting as its own iterator with a stored cursor: breaking out of a loop, iterating in nested loops, or calling ``len(name)`` mid-loop corrupted subsequent iteration; ``iter(name)`` now returns a fresh independent iterator each time. ``next(name)`` on the instance itself (undocumented) now raises ``TypeError`` — call ``next(iter(name))`` instead (closes #225)
- Fix parsing writing back into the ``Constants`` it reads (usually the shared module-level ``CONSTANTS``): pieces derived while parsing a name — period-joined titles/suffixes like ``"Lt.Gov."`` and conjunction-joined pieces like ``"Mr. and Mrs."`` or ``"von und zu"`` — are now tracked per parse instead of being permanently ``add()``-ed to the config, so parse results no longer depend on which names were parsed earlier in the process and parsing no longer mutates shared state across threads
Expand Down
7 changes: 4 additions & 3 deletions nameparser/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
>>> hn.C.titles.add('dean') # doctest: +SKIP
>>> hn.parse_full_name() # need to run this again after config changes

**Potential Gotcha**: If you do not pass ``None`` as the second argument,
``hn.C`` will be a reference to the module config, possibly yielding
unexpected results. See `Customizing the Parser <customize.html>`_.
**Potential Gotcha**: If you do not pass ``None`` (or your own
:py:class:`Constants` instance) as the second argument, ``hn.C`` will be a
reference to the module config, possibly yielding unexpected results. See
`Customizing the Parser <customize.html>`_.
"""
import re
import sys
Expand Down
22 changes: 16 additions & 6 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ class HumanName:
* :py:attr:`given_names`

:param str full_name: The name string to be parsed.
:param constants constants:
a :py:class:`~nameparser.config.Constants` instance. Pass ``None`` for
`per-instance config <customize.html>`_.
:param constants:
a :py:class:`~nameparser.config.Constants` instance (subclasses are
honored). Pass ``None`` for `per-instance config <customize.html>`_.
Anything else raises ``TypeError``.
:param str encoding: string representing the encoding of your input
:param str string_format: python string formatting
:param str initials_format: python initials string formatting
Expand Down Expand Up @@ -95,7 +96,7 @@ class HumanName:
def __init__(
self,
full_name: str | bytes = "",
constants: Constants = CONSTANTS,
constants: Constants | None = CONSTANTS,
encoding: str = DEFAULT_ENCODING,
string_format: str | None = None,
initials_format: str | None = None,
Expand All @@ -110,9 +111,18 @@ def __init__(
nickname: str | list[str] | None = None,
maiden: str | list[str] | None = None,
) -> None:
if constants is None:
constants = Constants()
elif not isinstance(constants, Constants):
# passing the class itself is the likeliest mistake, and
# reporting it as "got type" would only add confusion
hint = (" (a class was passed; did you mean Constants()?)"
if isinstance(constants, type) else "")
raise TypeError(
"constants must be a Constants instance or None, "
f"got {type(constants).__name__}{hint}"
)
self.C = constants
if type(self.C) is not type(CONSTANTS):
self.C = Constants()

# Lookup entries derived while parsing this instance (period-joined
# titles/suffixes like "Lt.Gov.", conjunction-joined pieces like
Expand Down
20 changes: 20 additions & 0 deletions tests/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ def test_add_title(self) -> None:
self.m(hn.first, "Awanui-a-Rangi", hn)
self.m(hn.last, "Black", hn)

def test_constants_subclass_instance_is_used(self) -> None:
class CustomConstants(Constants):
pass

c = CustomConstants()
c.titles.add('chancellor')
hn = HumanName("Chancellor Jane Smith", constants=c)
self.assertIs(hn.C, c)
self.m(hn.title, "Chancellor", hn)
self.m(hn.first, "Jane", hn)
self.m(hn.last, "Smith", hn)

def test_constants_invalid_type_raises_typeerror(self) -> None:
with pytest.raises(TypeError, match="constants must be"):
HumanName("John Doe", constants="not a Constants") # type: ignore[arg-type]

def test_constants_class_instead_of_instance_raises_with_hint(self) -> None:
with pytest.raises(TypeError, match=r"did you mean Constants\(\)"):
HumanName("John Doe", constants=Constants) # type: ignore[arg-type]

def test_remove_title(self) -> None:
hn = HumanName("Hon Solo", constants=None)
start_len = len(hn.C.titles)
Expand Down
Loading