Skip to content

Commit c5a9fac

Browse files
derek73claude
andauthored
Add tests/ to mypy scope; fix real type gaps it surfaces (#250)
* Add tests/ to mypy scope and fix the type gaps it surfaces Widens a handful of source annotations that were too narrow for already-supported behavior (str|None config scalars, list[str] accepted by is_prefix/is_conjunction/is_suffix and __setitem__, bytes accepted by add_with_encoding), and adds targeted type: ignore comments on the lines that deliberately violate the type contract to assert a TypeError/behavior, matching the existing convention in test_constants.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix AGENTS.md staleness introduced by this PR's mypy scope change Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Explain why empty_attribute_default's type:ignores exist Per code review: the AGENTS.md guidance added in this branch says to widen an annotation rather than ignore when runtime already supports the wider type, but empty_attribute_default is that exact case and was left un-widened. Document the deliberate scope decision (avoids cascading into ~8 public str-typed properties) at each ignore site instead of leaving it unexplained. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 94b253c commit c5a9fac

8 files changed

Lines changed: 40 additions & 27 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ uv run pytest # --doctest-modules is set in pyproject.toml, so doctests run aut
2323
uv run pytest tests/test_python_api.py
2424
uv run pytest tests/test_python_api.py::HumanNamePythonTests::test_utf8
2525

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

2929
# Lint
3030
uv run ruff check nameparser/
@@ -162,7 +162,7 @@ Don't use the bare `python3 -m doctest <file>.rst` CLI (no `optionflags`) to che
162162

163163
`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.
164164

165-
**`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.
165+
**`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.
166166

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

nameparser/config/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[overrid
193193

194194
__rxor__ = __xor__
195195

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

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

518-
suffix_delimiter = None
518+
suffix_delimiter: str | None = None
519519
"""
520520
If set, an additional delimiter used to split suffix groups after
521521
comma-splitting. For example, setting ``suffix_delimiter=" - "`` allows

nameparser/parser.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ def __getitem__(self, key: slice | str) -> str | list[str]:
228228
else:
229229
return getattr(self, key)
230230

231-
def __setitem__(self, key: str, value: str) -> None:
231+
def __setitem__(self, key: str, value: str | list[str] | None) -> None:
232232
if key in self._members:
233233
self._set_list(key, value)
234234
else:
@@ -696,7 +696,7 @@ def is_leading_title(self, piece: str) -> bool:
696696
"""
697697
return self.is_title(piece) or bool(self.C.regexes.period_abbreviation.match(piece))
698698

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

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

768-
def is_suffix(self, piece: str) -> bool:
768+
def is_suffix(self, piece: str | list[str]) -> bool:
769769
"""
770770
Is in the suffixes set — or was derived as a period-joined suffix
771771
earlier in this parse (e.g. ``"JD.CPA"``) — and not

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ dev = [
4949
]
5050

5151
[tool.mypy]
52-
packages = ["nameparser"]
52+
packages = ["nameparser", "tests"]
5353

5454
[[tool.mypy.overrides]]
5555
module = [

tests/test_constants.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pickle
33
import re
44
import timeit
5+
from typing import Any
56

67
import pytest
78

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

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

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

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

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

312319
def test_empty_attribute_on_instance(self) -> None:
313320
hn = HumanName("", None)
314-
hn.C.empty_attribute_default = None
321+
hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above
315322
self.m(hn.title, None, hn)
316323
self.m(hn.first, None, hn)
317324
self.m(hn.middle, None, hn)
@@ -321,7 +328,7 @@ def test_empty_attribute_on_instance(self) -> None:
321328

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

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

365372
# Safe: round-tripping a Constants the test just built, not untrusted data.
366373
restored = pickle.loads(pickle.dumps(c))
@@ -729,7 +736,7 @@ def _config_snapshot(constants: Constants) -> dict:
729736
added to ``Constants`` later is watched automatically, with no
730737
attribute list to keep in sync.
731738
"""
732-
snap = {}
739+
snap: dict[str, Any] = {}
733740
for attr, value in constants.__getstate__().items():
734741
if isinstance(value, SetManager):
735742
snap[attr] = set(value)

tests/test_initials.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ def test_initials_empty_part_with_none_default_not_literal_none(self) -> None:
2525
# used to be interpolated by str.format as the literal "None" (e.g.
2626
# "John Doe" -> "J. None D."). Empty parts must render as ''.
2727
hn = HumanName("John Doe", constants=None)
28-
hn.C.empty_attribute_default = None
28+
# empty_attribute_default has no explicit annotation (mypy infers str
29+
# from the '' default), but None is documented/supported here -- see
30+
# the doctest on the attribute's docstring in config/__init__.py. Not
31+
# widened to str | None like string_format/suffix_delimiter because
32+
# it cascades into ~8 public str-typed properties (title, first,
33+
# middle, last, suffix, nickname, initials()).
34+
hn.C.empty_attribute_default = None # type: ignore[assignment]
2935
self.assertEqual(hn.initials(), "J. D.")
3036
self.assertTrue("None" not in hn.initials())
3137

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

4046
def test_initials_middle_name_all_prefixes(self) -> None:

tests/test_output_format.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def test_keep_non_emojis(self) -> None:
130130
def test_keep_emojis(self) -> None:
131131
from nameparser.config import Constants
132132
constants = Constants()
133-
constants.regexes.emoji = False
133+
constants.regexes.emoji = False # type: ignore[assignment]
134134
hn = HumanName("∫≜⩕ Smith😊", constants)
135135
self.m(hn.first, "∫≜⩕", hn)
136136
self.m(hn.last, "Smith😊", hn)

tests/test_python_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,9 @@ def test_assignment_to_attribute(self) -> None:
262262
hn.suffix = "test"
263263
self.m(hn.suffix, "test", hn)
264264
with pytest.raises(TypeError):
265-
hn.suffix = [['test']]
265+
hn.suffix = [['test']] # type: ignore[list-item]
266266
with pytest.raises(TypeError):
267-
hn.suffix = {"test": "test"}
267+
hn.suffix = {"test": "test"} # type: ignore[assignment]
268268

269269
def test_assign_list_to_attribute(self) -> None:
270270
hn = HumanName("John A. Kenneth Doe, Jr.")
@@ -460,9 +460,9 @@ def test_setitem(self) -> None:
460460
hn['last'] = ['test', 'test2']
461461
self.m(hn['last'], "test test2", hn)
462462
with pytest.raises(TypeError):
463-
hn["suffix"] = [['test']]
463+
hn["suffix"] = [['test']] # type: ignore[list-item]
464464
with pytest.raises(TypeError):
465-
hn["suffix"] = {"test": "test"}
465+
hn["suffix"] = {"test": "test"} # type: ignore[assignment]
466466

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

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

0 commit comments

Comments
 (0)