guardlist 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- guardlist-0.1.0/LICENSE +21 -0
- guardlist-0.1.0/PKG-INFO +599 -0
- guardlist-0.1.0/README.md +576 -0
- guardlist-0.1.0/pyproject.toml +32 -0
- guardlist-0.1.0/setup.cfg +4 -0
- guardlist-0.1.0/src/guardlist/__init__.py +8 -0
- guardlist-0.1.0/src/guardlist/core.py +119 -0
- guardlist-0.1.0/src/guardlist.egg-info/PKG-INFO +599 -0
- guardlist-0.1.0/src/guardlist.egg-info/SOURCES.txt +10 -0
- guardlist-0.1.0/src/guardlist.egg-info/dependency_links.txt +1 -0
- guardlist-0.1.0/src/guardlist.egg-info/top_level.txt +1 -0
- guardlist-0.1.0/tests/test_guardlist.py +204 -0
guardlist-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Khalid Sulaiman Al-Mulaify
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
guardlist-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: guardlist
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A list subclass that raises an error when modified during iteration
|
|
5
|
+
Author-email: Khalid Sulaiman Al-Mulaify <khalidpythonist@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: list,iteration,safety,fail-fast,mutation
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# guardlist
|
|
25
|
+
|
|
26
|
+
`GuardedList` is a drop-in replacement for the built-in `list` that raises an
|
|
27
|
+
error the moment it is modified while a for-loop is iterating over it.
|
|
28
|
+
Python already does this for dicts and sets; this package brings the same
|
|
29
|
+
protection to lists, which are the one common container where it is
|
|
30
|
+
missing.
|
|
31
|
+
|
|
32
|
+
## The problem in Python
|
|
33
|
+
|
|
34
|
+
If you change the size of a `dict` or a `set` while looping over it, Python
|
|
35
|
+
notices and raises an error immediately:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
d = {"a": 1, "b": 2}
|
|
39
|
+
for key in d:
|
|
40
|
+
del d[key]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
RuntimeError: dictionary changed size during iteration
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This is a deliberate safety feature. It exists because changing a container
|
|
48
|
+
while you are in the middle of iterating over it can silently corrupt the
|
|
49
|
+
iteration: elements can be skipped, repeated, or the loop can even run
|
|
50
|
+
forever, all without printing any error at all. Sets behave the same way as
|
|
51
|
+
dicts.
|
|
52
|
+
|
|
53
|
+
Lists do not have this protection. If you remove, add, or reorder items in
|
|
54
|
+
a plain list while a for-loop is iterating over it, nothing warns you. The
|
|
55
|
+
loop just produces the wrong answer.
|
|
56
|
+
|
|
57
|
+
## How the flaw shows up with plain lists
|
|
58
|
+
|
|
59
|
+
### a. Silent skipping
|
|
60
|
+
|
|
61
|
+
The most common version of the bug: removing items from a list while
|
|
62
|
+
looping over it skips the item right after the one you removed.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
numbers = [1, 2, 4, 6, 7]
|
|
66
|
+
for n in numbers:
|
|
67
|
+
if n % 2 == 0:
|
|
68
|
+
numbers.remove(n)
|
|
69
|
+
print(numbers)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
[1, 4, 7]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The `4` should have been removed, since it is even, but it survives. When
|
|
77
|
+
`2` is removed, everything after it shifts one position to the left, but
|
|
78
|
+
the for-loop's internal position counter keeps counting up as if nothing
|
|
79
|
+
moved, so it steps over `4` without ever looking at it.
|
|
80
|
+
|
|
81
|
+
### b. The cleanup bug
|
|
82
|
+
|
|
83
|
+
The same shift happens with any `remove()` call, not just numbers. This is
|
|
84
|
+
a typical "strip empty entries" cleanup that beginners write, and it does
|
|
85
|
+
not fully work:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
words = ["", "", "hello", ""]
|
|
89
|
+
for w in words:
|
|
90
|
+
if w == "":
|
|
91
|
+
words.remove(w)
|
|
92
|
+
print(words)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
['hello', '']
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Three empty strings existed, but only two were removed. After the first
|
|
100
|
+
empty string at index 0 is removed, the second empty string shifts into
|
|
101
|
+
its place, and the loop's position counter skips past it, so it is never
|
|
102
|
+
even checked for emptiness.
|
|
103
|
+
|
|
104
|
+
### c. Infinite loop
|
|
105
|
+
|
|
106
|
+
Adding to a list while iterating over it is worse than removing from it: it
|
|
107
|
+
can make the loop never finish, because you keep creating more items for
|
|
108
|
+
the loop to reach.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
numbers = [1, 2, 3]
|
|
112
|
+
for n in numbers:
|
|
113
|
+
numbers.append(n)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
This never stops on its own: every item that gets visited immediately adds
|
|
117
|
+
a copy of itself to the end of the list, so there is always one more item
|
|
118
|
+
waiting. Running it with a safety counter shows the list still growing
|
|
119
|
+
without end after a thousand iterations:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
numbers = [1, 2, 3]
|
|
123
|
+
safety_counter = 0
|
|
124
|
+
for n in numbers:
|
|
125
|
+
numbers.append(n)
|
|
126
|
+
safety_counter += 1
|
|
127
|
+
if safety_counter > 1000:
|
|
128
|
+
print("stopped manually after", safety_counter, "iterations; list length is now", len(numbers))
|
|
129
|
+
break
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
stopped manually after 1001 iterations; list length is now 1004
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### d. Insert during loop
|
|
137
|
+
|
|
138
|
+
Inserting near the front of the list while iterating causes items to be
|
|
139
|
+
revisited, because everything after the insertion point shifts forward
|
|
140
|
+
into positions the loop has not reached yet:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
numbers = [1, 2, 3]
|
|
144
|
+
for index, n in enumerate(numbers):
|
|
145
|
+
print(n)
|
|
146
|
+
if index == 0:
|
|
147
|
+
numbers.insert(0, 99)
|
|
148
|
+
print("final:", numbers)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
1
|
|
153
|
+
1
|
|
154
|
+
2
|
|
155
|
+
3
|
|
156
|
+
final: [99, 1, 2, 3]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The value `1` prints twice. On the first pass the loop sees `1` at
|
|
160
|
+
position 0 and inserts `99` at position 0. That pushes the original `1`
|
|
161
|
+
into position 1, which is exactly the next position the loop looks at, so
|
|
162
|
+
it sees `1` a second time.
|
|
163
|
+
|
|
164
|
+
### e. Sorting or reversing during loop
|
|
165
|
+
|
|
166
|
+
Reordering the list mid-loop mixes up which elements get visited, and can
|
|
167
|
+
cause some to repeat while others are skipped entirely:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
numbers = [5, 3, 1, 4, 2]
|
|
171
|
+
seen = []
|
|
172
|
+
for n in numbers:
|
|
173
|
+
seen.append(n)
|
|
174
|
+
if n == 3:
|
|
175
|
+
numbers.sort()
|
|
176
|
+
print("seen:", seen)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
seen: [5, 3, 3, 4, 5]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The loop's position counter just walks through index 0, 1, 2, 3, 4 in
|
|
184
|
+
order, with no idea that the list underneath it has been rearranged. Once
|
|
185
|
+
`sort()` runs, position 2 no longer holds the value it used to, so `3` gets
|
|
186
|
+
seen twice, `5` gets seen twice, and `1` and `2` are never seen at all.
|
|
187
|
+
|
|
188
|
+
### f. Wrong-looking fix
|
|
189
|
+
|
|
190
|
+
A beginner who gets burned by `remove()` inside a for-loop often tries
|
|
191
|
+
switching to an index-based loop with `range(len(...))`, expecting that to
|
|
192
|
+
avoid the problem. It does not; it just fails in a different, more
|
|
193
|
+
confusing way:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
numbers = [1, 2, 3, 4, 5]
|
|
197
|
+
for i in range(len(numbers)):
|
|
198
|
+
if numbers[i] % 2 == 0:
|
|
199
|
+
numbers.remove(numbers[i])
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
IndexError: list index out of range
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`range(len(numbers))` is computed once, up front, using the original
|
|
207
|
+
length of the list. As `remove()` shrinks the list, the later index values
|
|
208
|
+
from that original range stop existing, and the loop eventually asks for
|
|
209
|
+
an index that is no longer there.
|
|
210
|
+
|
|
211
|
+
## The fix: GuardedList
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
pip install guardlist
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`GuardedList` behaves exactly like a normal list, except that it notices
|
|
218
|
+
when you try to mutate it during an active iteration and raises an error
|
|
219
|
+
right away, instead of letting the loop quietly produce the wrong answer:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from guardlist import GuardedList
|
|
223
|
+
|
|
224
|
+
numbers = GuardedList([1, 2, 4, 6, 7])
|
|
225
|
+
for n in numbers:
|
|
226
|
+
if n % 2 == 0:
|
|
227
|
+
numbers.remove(n)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
guardlist.IterationMutationError: GuardedList was modified by 'remove' while a for-loop was iterating over it. Iterate over a copy instead, for example: for item in list(my_list):
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The error names the exact method that caused the problem and tells you how
|
|
235
|
+
to fix it. The fix is to iterate over a copy of the list, so the mutations
|
|
236
|
+
happen to the original list while the loop walks over an unchanging
|
|
237
|
+
snapshot. Wrapping the loop in `list(...)` does this:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from guardlist import GuardedList
|
|
241
|
+
|
|
242
|
+
numbers = GuardedList([1, 2, 4, 6, 7])
|
|
243
|
+
for n in list(numbers):
|
|
244
|
+
if n % 2 == 0:
|
|
245
|
+
numbers.remove(n)
|
|
246
|
+
print(numbers)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
[1, 7]
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
A list comprehension reaches the same correct result without needing a
|
|
254
|
+
loop at all:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from guardlist import GuardedList
|
|
258
|
+
|
|
259
|
+
numbers = GuardedList([1, 2, 4, 6, 7])
|
|
260
|
+
numbers = GuardedList([n for n in numbers if n % 2 != 0])
|
|
261
|
+
print(numbers)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
[1, 7]
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Every operation GuardedList protects
|
|
269
|
+
|
|
270
|
+
Each of the following raises `IterationMutationError` if it is called
|
|
271
|
+
while a loop over the same `GuardedList` is active. The message always
|
|
272
|
+
names the method or operation that was attempted.
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
numbers = GuardedList([1, 2, 3])
|
|
276
|
+
for n in numbers:
|
|
277
|
+
numbers.append(4)
|
|
278
|
+
```
|
|
279
|
+
```
|
|
280
|
+
GuardedList was modified by 'append' while a for-loop was iterating over it. Iterate over a copy instead, for example: for item in list(my_list):
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
numbers = GuardedList([1, 2, 3])
|
|
285
|
+
for n in numbers:
|
|
286
|
+
numbers.extend([4, 5])
|
|
287
|
+
```
|
|
288
|
+
```
|
|
289
|
+
IterationMutationError: ... modified by 'extend' ...
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
numbers = GuardedList([1, 2, 3])
|
|
294
|
+
for n in numbers:
|
|
295
|
+
numbers.insert(0, 9)
|
|
296
|
+
```
|
|
297
|
+
```
|
|
298
|
+
IterationMutationError: ... modified by 'insert' ...
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
numbers = GuardedList([1, 2, 3])
|
|
303
|
+
for n in numbers:
|
|
304
|
+
numbers.remove(2)
|
|
305
|
+
```
|
|
306
|
+
```
|
|
307
|
+
IterationMutationError: ... modified by 'remove' ...
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
numbers = GuardedList([1, 2, 3])
|
|
312
|
+
for n in numbers:
|
|
313
|
+
numbers.pop()
|
|
314
|
+
```
|
|
315
|
+
```
|
|
316
|
+
IterationMutationError: ... modified by 'pop' ...
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
numbers = GuardedList([1, 2, 3])
|
|
321
|
+
for n in numbers:
|
|
322
|
+
numbers.clear()
|
|
323
|
+
```
|
|
324
|
+
```
|
|
325
|
+
IterationMutationError: ... modified by 'clear' ...
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
numbers = GuardedList([1, 2, 3])
|
|
330
|
+
for n in numbers:
|
|
331
|
+
numbers.sort()
|
|
332
|
+
```
|
|
333
|
+
```
|
|
334
|
+
IterationMutationError: ... modified by 'sort' ...
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
numbers = GuardedList([1, 2, 3])
|
|
339
|
+
for n in numbers:
|
|
340
|
+
numbers.reverse()
|
|
341
|
+
```
|
|
342
|
+
```
|
|
343
|
+
IterationMutationError: ... modified by 'reverse' ...
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
numbers = GuardedList([1, 2, 3])
|
|
348
|
+
for n in numbers:
|
|
349
|
+
numbers[0] = 99
|
|
350
|
+
```
|
|
351
|
+
```
|
|
352
|
+
IterationMutationError: ... modified by '__setitem__' ...
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
numbers = GuardedList([1, 2, 3])
|
|
357
|
+
for n in numbers:
|
|
358
|
+
del numbers[0]
|
|
359
|
+
```
|
|
360
|
+
```
|
|
361
|
+
IterationMutationError: ... modified by '__delitem__' ...
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
numbers = GuardedList([1, 2, 3, 4])
|
|
366
|
+
for n in numbers:
|
|
367
|
+
numbers[1:3] = [8, 9]
|
|
368
|
+
```
|
|
369
|
+
```
|
|
370
|
+
IterationMutationError: ... modified by '__setitem__' ...
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
numbers = GuardedList([1, 2, 3])
|
|
375
|
+
for n in numbers:
|
|
376
|
+
numbers += [4]
|
|
377
|
+
```
|
|
378
|
+
```
|
|
379
|
+
IterationMutationError: ... modified by '__iadd__' ...
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
numbers = GuardedList([1, 2, 3])
|
|
384
|
+
for n in numbers:
|
|
385
|
+
numbers *= 2
|
|
386
|
+
```
|
|
387
|
+
```
|
|
388
|
+
IterationMutationError: ... modified by '__imul__' ...
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Slice assignment is reported as `__setitem__`, since that is the single
|
|
392
|
+
method Python calls for both single-item and slice assignment.
|
|
393
|
+
|
|
394
|
+
## What is still allowed during a loop
|
|
395
|
+
|
|
396
|
+
Anything that only reads the list, without changing it, is always allowed,
|
|
397
|
+
even while a loop over it is active:
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
numbers = GuardedList([1, 2, 3, 4, 5])
|
|
401
|
+
for n in numbers:
|
|
402
|
+
_ = numbers[0]
|
|
403
|
+
_ = numbers[1:3]
|
|
404
|
+
_ = len(numbers)
|
|
405
|
+
_ = 3 in numbers
|
|
406
|
+
_ = numbers.count(2)
|
|
407
|
+
_ = numbers.index(4)
|
|
408
|
+
_ = numbers == [1, 2, 3, 4, 5]
|
|
409
|
+
_ = numbers + [6]
|
|
410
|
+
_ = sorted(numbers, reverse=True)
|
|
411
|
+
print("all read-only operations succeeded during iteration")
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
all read-only operations succeeded during iteration
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`numbers + [6]` and `sorted(numbers, ...)` are allowed because they build
|
|
419
|
+
and return a brand new list, leaving the original `GuardedList` untouched.
|
|
420
|
+
|
|
421
|
+
## Nested loops, break, and exceptions
|
|
422
|
+
|
|
423
|
+
Two loops over the same `GuardedList` at the same time are fine, since each
|
|
424
|
+
loop's iterator adds its own count to the guard, and reading is always
|
|
425
|
+
safe:
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
numbers = GuardedList([1, 2, 3])
|
|
429
|
+
pairs = []
|
|
430
|
+
for a in numbers:
|
|
431
|
+
for b in numbers:
|
|
432
|
+
pairs.append((a, b))
|
|
433
|
+
print(pairs)
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
```
|
|
437
|
+
[(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)]
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Leaving a loop early with `break` releases the guard, so the list can be
|
|
441
|
+
mutated again right after:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
numbers = GuardedList([1, 2, 3])
|
|
445
|
+
for n in numbers:
|
|
446
|
+
break
|
|
447
|
+
numbers.append(4)
|
|
448
|
+
print(list(numbers))
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
```
|
|
452
|
+
[1, 2, 3, 4]
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The same is true if an exception is raised inside the loop and caught
|
|
456
|
+
outside it: the guard is released as the loop is abandoned, so the list is
|
|
457
|
+
mutable again once the exception has been handled.
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
numbers = GuardedList([1, 2, 3])
|
|
461
|
+
try:
|
|
462
|
+
for n in numbers:
|
|
463
|
+
raise ValueError("boom")
|
|
464
|
+
except ValueError:
|
|
465
|
+
pass
|
|
466
|
+
numbers.append(4)
|
|
467
|
+
print(list(numbers))
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
```
|
|
471
|
+
[1, 2, 3, 4]
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Slices and copies stay guarded
|
|
475
|
+
|
|
476
|
+
Slicing a `GuardedList` and calling `copy()` on one both return a new
|
|
477
|
+
`GuardedList`, so the protection travels with the data. Converting to a
|
|
478
|
+
plain `list()` does not carry the protection over, which is exactly what
|
|
479
|
+
you want when you need an unguarded snapshot to iterate over while
|
|
480
|
+
mutating the original.
|
|
481
|
+
|
|
482
|
+
```python
|
|
483
|
+
numbers = GuardedList([1, 2, 3, 4, 5])
|
|
484
|
+
print(type(numbers[1:3]).__name__)
|
|
485
|
+
print(type(numbers.copy()).__name__)
|
|
486
|
+
print(type(list(numbers)).__name__)
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
```
|
|
490
|
+
GuardedList
|
|
491
|
+
GuardedList
|
|
492
|
+
list
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## What it does not catch
|
|
496
|
+
|
|
497
|
+
`GuardedList` only guards the list itself, the container. It cannot see
|
|
498
|
+
changes made through other routes:
|
|
499
|
+
|
|
500
|
+
Changing an object that is stored inside the list, rather than changing
|
|
501
|
+
the list's own structure, is not something `GuardedList` can detect. If an
|
|
502
|
+
element is itself a mutable object such as a list, modifying that inner
|
|
503
|
+
object from within a loop is not blocked:
|
|
504
|
+
|
|
505
|
+
```python
|
|
506
|
+
numbers = GuardedList([[1], [2], [3]])
|
|
507
|
+
for inner in numbers:
|
|
508
|
+
inner.append(99)
|
|
509
|
+
print(numbers)
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
```
|
|
513
|
+
[[1, 99], [2, 99], [3, 99]]
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
A common attempt at writing the loop by hand, using an index and `pop()`,
|
|
517
|
+
also slips past the guard, because it never calls `iter()` on the list at
|
|
518
|
+
all:
|
|
519
|
+
|
|
520
|
+
```python
|
|
521
|
+
numbers = GuardedList([10, 20, 20, 30, 40])
|
|
522
|
+
i = 0
|
|
523
|
+
while i < len(numbers):
|
|
524
|
+
if numbers[i] % 20 == 0:
|
|
525
|
+
numbers.pop(i)
|
|
526
|
+
i += 1
|
|
527
|
+
print(numbers)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
```
|
|
531
|
+
[10, 20, 30]
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Every multiple of 20 should be gone, leaving `[10, 30]`, but one `20`
|
|
535
|
+
survives, with no error raised. This variant uses a `while` loop with a
|
|
536
|
+
manually managed index instead of a `for` loop, so no iterator over the
|
|
537
|
+
list is ever created and `GuardedList` has no active count to check
|
|
538
|
+
against. The fix is the same as elsewhere: iterate over a copy, for
|
|
539
|
+
example with `list(numbers)`, or build the result with a list
|
|
540
|
+
comprehension instead of mutating the list in place.
|
|
541
|
+
|
|
542
|
+
Calling the underlying `list` method directly on the class, instead of
|
|
543
|
+
through the instance, also bypasses the guard, since it never goes through
|
|
544
|
+
`GuardedList`'s own overridden method:
|
|
545
|
+
|
|
546
|
+
```python
|
|
547
|
+
numbers = GuardedList([1, 2, 3])
|
|
548
|
+
it = iter(numbers)
|
|
549
|
+
next(it)
|
|
550
|
+
list.append(numbers, 4)
|
|
551
|
+
print(numbers)
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
```
|
|
555
|
+
[1, 2, 3, 4]
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
## When to use it
|
|
559
|
+
|
|
560
|
+
`GuardedList` is meant to be used where it earns its keep, not everywhere:
|
|
561
|
+
|
|
562
|
+
- Teaching: it turns a silent, confusing bug into an error message that
|
|
563
|
+
explains exactly what went wrong and how to fix it, which makes it a
|
|
564
|
+
good tool for anyone learning how iteration works in Python.
|
|
565
|
+
- Shared lists across many functions: if a list is passed around and
|
|
566
|
+
mutated by code far away from the loop that iterates over it, the extra
|
|
567
|
+
check catches mistakes that would otherwise be very hard to track down.
|
|
568
|
+
- Temporary debugging: swap a suspect `list` for a `GuardedList`, run the
|
|
569
|
+
code to find exactly where the mutation happens, then swap it back to a
|
|
570
|
+
plain `list` once the bug is fixed.
|
|
571
|
+
- Tests: assert that code under test does not mutate a list while
|
|
572
|
+
iterating over it, by passing it a `GuardedList` instead of a `list`.
|
|
573
|
+
|
|
574
|
+
## How it works
|
|
575
|
+
|
|
576
|
+
`GuardedList` keeps an internal counter of how many iterators over it are
|
|
577
|
+
currently active. Calling `iter()` on it, which happens automatically at
|
|
578
|
+
the start of every for-loop and also when you call `reversed()`,
|
|
579
|
+
increments the counter. The counter is decremented again once that
|
|
580
|
+
iterator is exhausted, explicitly closed, or garbage collected, so `break`
|
|
581
|
+
and exceptions inside a loop correctly release the guard. Every method
|
|
582
|
+
that mutates the list in place checks that counter first, and raises
|
|
583
|
+
`IterationMutationError` if it is greater than zero, instead of performing
|
|
584
|
+
the mutation. On CPython the guard is released immediately on `break` or
|
|
585
|
+
an exception, because of reference counting; on other interpreters such as
|
|
586
|
+
PyPy the release may be delayed until garbage collection runs.
|
|
587
|
+
|
|
588
|
+
## API
|
|
589
|
+
|
|
590
|
+
- `GuardedList`: a subclass of `list` that raises `IterationMutationError`
|
|
591
|
+
when mutated while it is being iterated over, and otherwise behaves
|
|
592
|
+
exactly like a built-in list.
|
|
593
|
+
- `IterationMutationError`: a subclass of `RuntimeError` raised by
|
|
594
|
+
`GuardedList`; it carries a `method_name` attribute holding the name of
|
|
595
|
+
the method that triggered the error.
|
|
596
|
+
|
|
597
|
+
## License
|
|
598
|
+
|
|
599
|
+
MIT
|