Skip to content

Commit 75e5ed3

Browse files
derek73claude
andcommitted
Fix HumanName acting as its own iterator with a stored cursor
HumanName.__iter__ returned self, with iteration state in a _count cursor on the instance that was only reset when a loop ran to exhaustion. Breaking out of a loop left the cursor mid-stream so the next loop started there; nested or concurrent loops over the same instance shared one cursor and interfered; and len(name) during a loop consumed and reset the cursor out from under the live iteration. __iter__ now returns a fresh generator over the non-empty members, so every iteration is independent, and __len__ counts the non-empty members directly instead of iterating the object. The instance-level __next__ and the _count cursor are deleted; next(name) on the instance itself (undocumented) now raises TypeError — use next(iter(name)). Closes #225 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 547e113 commit 75e5ed3

3 files changed

Lines changed: 33 additions & 15 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
Release Log
22
===========
33
* 1.3.0 - Unreleased
4+
- 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)
45
- 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
56
- Remove the vestigial ``unparsable`` attribute: the guard that was meant to set it has been unreachable since 2013 (v0.2.9), so it has reported ``False`` for every parsed name for over a decade; check ``len(name) == 0`` to detect an empty parse
67
- Fix ``__hash__`` to lowercase the name like ``__eq__`` does, so equal ``HumanName`` instances hash equal and behave correctly in sets and dicts

nameparser/parser.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ class HumanName:
8080
The original string, untouched by the parser.
8181
"""
8282

83-
_count = 0
8483
_members = ['title', 'first', 'middle', 'last', 'suffix', 'nickname', 'maiden']
8584
_full_name = ''
8685

@@ -164,13 +163,11 @@ def __setstate__(self, state: dict) -> None:
164163
self.__dict__.setdefault(attr, set())
165164

166165
def __iter__(self) -> Iterator[str]:
167-
return self
166+
return (value for member in self._members
167+
if (value := getattr(self, member)))
168168

169169
def __len__(self) -> int:
170-
l = 0
171-
for x in self:
172-
l += 1
173-
return l
170+
return sum(1 for member in self._members if getattr(self, member))
174171

175172
def __eq__(self, other: object) -> bool:
176173
"""
@@ -195,15 +192,6 @@ def __setitem__(self, key: str, value: str) -> None:
195192
else:
196193
raise KeyError("Not a valid HumanName attribute", key)
197194

198-
def __next__(self) -> str:
199-
if self._count >= len(self._members):
200-
self._count = 0
201-
raise StopIteration
202-
else:
203-
c = self._count
204-
self._count = c + 1
205-
return getattr(self, self._members[c]) or next(self)
206-
207195
def __str__(self) -> str:
208196
if self.string_format is not None:
209197
# string_format = "{title} {first} {middle} {last} {suffix} ({nickname})"

tests/test_python_api.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ def test_len(self) -> None:
4040
# documented emptiness check (see usage.rst)
4141
self.assertEqual(len(HumanName("")), 0)
4242

43+
def test_iteration_restarts_after_break(self) -> None:
44+
hn = HumanName("John Doe")
45+
for _ in hn:
46+
break
47+
# a plain loop, not list(hn): list() presizes via __len__, which
48+
# under the old shared-cursor implementation reset the cursor and
49+
# masked this bug
50+
collected = []
51+
for part in hn:
52+
collected.append(part)
53+
self.assertEqual(collected, ["John", "Doe"])
54+
55+
def test_iterators_are_independent(self) -> None:
56+
hn = HumanName("John Doe")
57+
it1 = iter(hn)
58+
it2 = iter(hn)
59+
self.assertEqual(next(it1), "John")
60+
self.assertEqual(next(it2), "John")
61+
self.assertEqual(next(it1), "Doe")
62+
self.assertEqual(next(it2), "Doe")
63+
64+
def test_len_during_iteration(self) -> None:
65+
hn = HumanName("John Doe")
66+
it = iter(hn)
67+
self.assertEqual(next(it), "John")
68+
# len() must count all members and leave the live iterator intact
69+
self.assertEqual(len(hn), 2)
70+
self.assertEqual(next(it), "Doe")
71+
4372
@pytest.mark.skipif(not dill, reason="requires python-dill module to test pickling")
4473
def test_config_pickle(self) -> None:
4574
constants = Constants()

0 commit comments

Comments
 (0)