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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ uv run pytest # --doctest-modules is set in pyproject.toml, so doctests run aut
uv run pytest tests/test_python_api.py
uv run pytest tests/test_python_api.py::HumanNamePythonTests::test_utf8

# Type check
uv run mypy nameparser/
# Type check (covers nameparser/ and tests/, per pyproject.toml's [tool.mypy] packages)
uv run mypy

# Lint
uv run ruff check nameparser/
Expand Down Expand Up @@ -162,7 +162,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 ~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.
**`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}"`.

Expand Down
6 changes: 3 additions & 3 deletions nameparser/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[overrid

__rxor__ = __xor__

def add_with_encoding(self, s: str, encoding: str | None = None) -> None:
def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None:
"""
Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an
explicit `encoding` parameter to specify the encoding of binary strings that
Expand Down Expand Up @@ -486,7 +486,7 @@ class Constants:
maiden_delimiters: TupleManager[re.Pattern[str] | str]
_pst: Set[str] | None

string_format = "{title} {first} {middle} {last} {suffix} ({nickname})"
string_format: str | None = "{title} {first} {middle} {last} {suffix} ({nickname})"
"""
The default string format use for all new `HumanName` instances.
"""
Expand Down Expand Up @@ -515,7 +515,7 @@ class Constants:
spacing from the template is still applied.
"""

suffix_delimiter = None
suffix_delimiter: str | None = None
"""
If set, an additional delimiter used to split suffix groups after
comma-splitting. For example, setting ``suffix_delimiter=" - "`` allows
Expand Down
8 changes: 4 additions & 4 deletions nameparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def __getitem__(self, key: slice | str) -> str | list[str]:
else:
return getattr(self, key)

def __setitem__(self, key: str, value: str) -> None:
def __setitem__(self, key: str, value: str | list[str] | None) -> None:
if key in self._members:
self._set_list(key, value)
else:
Expand Down Expand Up @@ -696,7 +696,7 @@ def is_leading_title(self, piece: str) -> bool:
"""
return self.is_title(piece) or bool(self.C.regexes.period_abbreviation.match(piece))

def is_conjunction(self, piece: str) -> bool:
def is_conjunction(self, piece: str | list[str]) -> bool:
"""Is in the conjunctions set — config or derived earlier in this
parse (e.g. ``"of the"``) — and not :py:func:`is_an_initial()`."""
if isinstance(piece, list):
Expand All @@ -708,7 +708,7 @@ def is_conjunction(self, piece: str) -> bool:
or piece.lower() in self._derived_conjunctions) \
and not self.is_an_initial(piece)

def is_prefix(self, piece: str) -> bool:
def is_prefix(self, piece: str | list[str]) -> bool:
"""
Lowercased, leading/trailing-periods-stripped version of piece is in the
:py:data:`~nameparser.config.prefixes.PREFIXES` set, or was derived as
Expand Down Expand Up @@ -765,7 +765,7 @@ def is_roman_numeral(self, value: str) -> bool:
"""
return bool(self.C.regexes.roman_numeral.match(value))

def is_suffix(self, piece: str) -> bool:
def is_suffix(self, piece: str | list[str]) -> bool:
"""
Is in the suffixes set — or was derived as a period-joined suffix
earlier in this parse (e.g. ``"JD.CPA"``) — and not
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ dev = [
]

[tool.mypy]
packages = ["nameparser"]
packages = ["nameparser", "tests"]

[[tool.mypy.overrides]]
module = [
Expand Down
23 changes: 15 additions & 8 deletions tests/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import pickle
import re
import timeit
from typing import Any

import pytest

Expand Down Expand Up @@ -201,7 +202,7 @@ def test_tuplemanager_bare_string_raises_typeerror(self) -> None:
# sequence element #0 has length 1; 2 is required" naming no argument
# and suggesting no fix (#242)
with pytest.raises(TypeError, match=r"wrap it in a list"):
TupleManager('ab')
TupleManager('ab') # type: ignore[arg-type]

def test_tuplemanager_bytes_raises_with_decode_hint(self) -> None:
with pytest.raises(TypeError, match=r"decode it first"):
Expand All @@ -211,11 +212,11 @@ def test_tuplemanager_string_element_raises_typeerror(self) -> None:
# the silent variant: an iterable of 2-character strings is a valid
# dict-update sequence, so each one shreds into a key/value pair
with pytest.raises(TypeError, match=r"key and a value"):
TupleManager(['ab', 'cd'])
TupleManager(['ab', 'cd']) # type: ignore[arg-type]

def test_constants_capitalization_exceptions_string_elements_raise(self) -> None:
with pytest.raises(TypeError, match=r"key and a value"):
Constants(capitalization_exceptions=['ii'])
Constants(capitalization_exceptions=['ii']) # type: ignore[list-item]

def test_tuplemanager_accepts_mapping_and_pairs(self) -> None:
# the guard must not reject the two legitimate constructor shapes
Expand Down Expand Up @@ -300,7 +301,13 @@ def test_clear_removes_all_entries(self) -> None:

def test_empty_attribute_default(self) -> None:
from nameparser.config import CONSTANTS
CONSTANTS.empty_attribute_default = None
# empty_attribute_default has no explicit annotation (mypy infers str
# from the '' default), but None is documented/supported here -- see
# the doctest on the attribute's docstring in config/__init__.py.
# Not widened to str | None like string_format/suffix_delimiter
# because it cascades into ~8 public str-typed properties (title,
# first, middle, last, suffix, nickname, initials()).
CONSTANTS.empty_attribute_default = None # type: ignore[assignment]
hn = HumanName("")
self.m(hn.title, None, hn)
self.m(hn.first, None, hn)
Expand All @@ -311,7 +318,7 @@ def test_empty_attribute_default(self) -> None:

def test_empty_attribute_on_instance(self) -> None:
hn = HumanName("", None)
hn.C.empty_attribute_default = None
hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above
self.m(hn.title, None, hn)
self.m(hn.first, None, hn)
self.m(hn.middle, None, hn)
Expand All @@ -321,7 +328,7 @@ def test_empty_attribute_on_instance(self) -> None:

def test_none_empty_attribute_string_formatting(self) -> None:
hn = HumanName("", None)
hn.C.empty_attribute_default = None
hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above
self.assertEqual('', str(hn), hn)

def test_add_constant_with_explicit_encoding(self) -> None:
Expand Down Expand Up @@ -360,7 +367,7 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None:
def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None:
"""An instance-level scalar override must survive a pickle round-trip."""
c = Constants()
c.empty_attribute_default = None
c.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above

# Safe: round-tripping a Constants the test just built, not untrusted data.
restored = pickle.loads(pickle.dumps(c))
Expand Down Expand Up @@ -729,7 +736,7 @@ def _config_snapshot(constants: Constants) -> dict:
added to ``Constants`` later is watched automatically, with no
attribute list to keep in sync.
"""
snap = {}
snap: dict[str, Any] = {}
for attr, value in constants.__getstate__().items():
if isinstance(value, SetManager):
snap[attr] = set(value)
Expand Down
10 changes: 8 additions & 2 deletions tests/test_initials.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ def test_initials_empty_part_with_none_default_not_literal_none(self) -> None:
# used to be interpolated by str.format as the literal "None" (e.g.
# "John Doe" -> "J. None D."). Empty parts must render as ''.
hn = HumanName("John Doe", constants=None)
hn.C.empty_attribute_default = None
# empty_attribute_default has no explicit annotation (mypy infers str
# from the '' default), but None is documented/supported here -- see
# the doctest on the attribute's docstring in config/__init__.py. Not
# widened to str | None like string_format/suffix_delimiter because
# it cascades into ~8 public str-typed properties (title, first,
# middle, last, suffix, nickname, initials()).
hn.C.empty_attribute_default = None # type: ignore[assignment]
self.assertEqual(hn.initials(), "J. D.")
self.assertTrue("None" not in hn.initials())

Expand All @@ -34,7 +40,7 @@ def test_initials_all_empty_returns_empty_attribute_default(self) -> None:
# empty_attribute_default (here None), matching the first/last accessors,
# rather than rendering the literal "None None None".
hn = HumanName("", constants=None)
hn.C.empty_attribute_default = None
hn.C.empty_attribute_default = None # type: ignore[assignment] # see test above
self.assertEqual(hn.initials(), None)

def test_initials_middle_name_all_prefixes(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_output_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def test_keep_non_emojis(self) -> None:
def test_keep_emojis(self) -> None:
from nameparser.config import Constants
constants = Constants()
constants.regexes.emoji = False
constants.regexes.emoji = False # type: ignore[assignment]
hn = HumanName("∫≜⩕ Smith😊", constants)
self.m(hn.first, "∫≜⩕", hn)
self.m(hn.last, "Smith😊", hn)
Expand Down
10 changes: 5 additions & 5 deletions tests/test_python_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,9 @@ def test_assignment_to_attribute(self) -> None:
hn.suffix = "test"
self.m(hn.suffix, "test", hn)
with pytest.raises(TypeError):
hn.suffix = [['test']]
hn.suffix = [['test']] # type: ignore[list-item]
with pytest.raises(TypeError):
hn.suffix = {"test": "test"}
hn.suffix = {"test": "test"} # type: ignore[assignment]

def test_assign_list_to_attribute(self) -> None:
hn = HumanName("John A. Kenneth Doe, Jr.")
Expand Down Expand Up @@ -460,9 +460,9 @@ def test_setitem(self) -> None:
hn['last'] = ['test', 'test2']
self.m(hn['last'], "test test2", hn)
with pytest.raises(TypeError):
hn["suffix"] = [['test']]
hn["suffix"] = [['test']] # type: ignore[list-item]
with pytest.raises(TypeError):
hn["suffix"] = {"test": "test"}
hn["suffix"] = {"test": "test"} # type: ignore[assignment]

def test_setitem_invalid_key_raises_keyerror(self) -> None:
hn = HumanName("Dr. John A. Kenneth Doe, Jr.")
Expand Down Expand Up @@ -622,7 +622,7 @@ def test_override_conjunctions(self) -> None:
self.assertTrue(sorted(hn.C.conjunctions) == sorted(var))

def test_override_capitalization_exceptions(self) -> None:
var = TupleManager([("spaces", re.compile(r"\s+")),])
var = TupleManager([("abc", "ABC")])
C = Constants(capitalization_exceptions=var)
hn = HumanName(constants=C)
self.assertTrue(hn.C.capitalization_exceptions == var)
Loading