Skip to content

Commit 7b4c5fe

Browse files
authored
Merge pull request #235 from derek73/fix/issue-225-iterator-cursor
Fix HumanName acting as its own iterator with a stored cursor
2 parents 547e113 + a6cb4e0 commit 7b4c5fe

3 files changed

Lines changed: 46 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,48 @@ 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+
72+
def test_instance_is_not_its_own_iterator(self) -> None:
73+
# iterator state must never live on the instance; see release log
74+
# for the next(name) -> next(iter(name)) migration
75+
hn = HumanName("John Doe")
76+
with pytest.raises(TypeError):
77+
next(hn) # type: ignore[call-overload]
78+
79+
def test_iterating_empty_name_yields_nothing(self) -> None:
80+
collected = []
81+
for part in HumanName(""):
82+
collected.append(part)
83+
self.assertEqual(collected, [])
84+
4385
@pytest.mark.skipif(not dill, reason="requires python-dill module to test pickling")
4486
def test_config_pickle(self) -> None:
4587
constants = Constants()

0 commit comments

Comments
 (0)