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.
@@ -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.
@@ -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