guardlist
GuardedList is a drop-in replacement for the built-in list that raises an
error the moment it is modified while a for-loop is iterating over it.
Python already does this for dicts and sets; this package brings the same
protection to lists, which are the one common container where it is
missing.
The problem in Python
If you change the size of a dict or a set while looping over it, Python
notices and raises an error immediately:
d = {"a": 1, "b": 2}
for key in d:
del d[key]
RuntimeError: dictionary changed size during iteration
This is a deliberate safety feature. It exists because changing a container while you are in the middle of iterating over it can silently corrupt the iteration: elements can be skipped, repeated, or the loop can even run forever, all without printing any error at all. Sets behave the same way as dicts.
Lists do not have this protection. If you remove, add, or reorder items in a plain list while a for-loop is iterating over it, nothing warns you. The loop just produces the wrong answer.
How the flaw shows up with plain lists
a. Silent skipping
The most common version of the bug: removing items from a list while looping over it skips the item right after the one you removed.
numbers = [1, 2, 4, 6, 7]
for n in numbers:
if n % 2 == 0:
numbers.remove(n)
print(numbers)
[1, 4, 7]
The 4 should have been removed, since it is even, but it survives. When
2 is removed, everything after it shifts one position to the left, but
the for-loop's internal position counter keeps counting up as if nothing
moved, so it steps over 4 without ever looking at it.
b. The cleanup bug
The same shift happens with any remove() call, not just numbers. This is
a typical "strip empty entries" cleanup that beginners write, and it does
not fully work:
words = ["", "", "hello", ""]
for w in words:
if w == "":
words.remove(w)
print(words)
['hello', '']
Three empty strings existed, but only two were removed. After the first empty string at index 0 is removed, the second empty string shifts into its place, and the loop's position counter skips past it, so it is never even checked for emptiness.
c. Infinite loop
Adding to a list while iterating over it is worse than removing from it: it can make the loop never finish, because you keep creating more items for the loop to reach.
numbers = [1, 2, 3]
for n in numbers:
numbers.append(n)
This never stops on its own: every item that gets visited immediately adds a copy of itself to the end of the list, so there is always one more item waiting. Running it with a safety counter shows the list still growing without end after a thousand iterations:
numbers = [1, 2, 3]
safety_counter = 0
for n in numbers:
numbers.append(n)
safety_counter += 1
if safety_counter > 1000:
print("stopped manually after", safety_counter, "iterations; list length is now", len(numbers))
break
stopped manually after 1001 iterations; list length is now 1004
d. Insert during loop
Inserting near the front of the list while iterating causes items to be revisited, because everything after the insertion point shifts forward into positions the loop has not reached yet:
numbers = [1, 2, 3]
for index, n in enumerate(numbers):
print(n)
if index == 0:
numbers.insert(0, 99)
print("final:", numbers)
1
1
2
3
final: [99, 1, 2, 3]
The value 1 prints twice. On the first pass the loop sees 1 at
position 0 and inserts 99 at position 0. That pushes the original 1
into position 1, which is exactly the next position the loop looks at, so
it sees 1 a second time.
e. Sorting or reversing during loop
Reordering the list mid-loop mixes up which elements get visited, and can cause some to repeat while others are skipped entirely:
numbers = [5, 3, 1, 4, 2]
seen = []
for n in numbers:
seen.append(n)
if n == 3:
numbers.sort()
print("seen:", seen)
seen: [5, 3, 3, 4, 5]
The loop's position counter just walks through index 0, 1, 2, 3, 4 in
order, with no idea that the list underneath it has been rearranged. Once
sort() runs, position 2 no longer holds the value it used to, so 3 gets
seen twice, 5 gets seen twice, and 1 and 2 are never seen at all.
f. Wrong-looking fix
A beginner who gets burned by remove() inside a for-loop often tries
switching to an index-based loop with range(len(...)), expecting that to
avoid the problem. It does not; it just fails in a different, more
confusing way:
numbers = [1, 2, 3, 4, 5]
for i in range(len(numbers)):
if numbers[i] % 2 == 0:
numbers.remove(numbers[i])
IndexError: list index out of range
range(len(numbers)) is computed once, up front, using the original
length of the list. As remove() shrinks the list, the later index values
from that original range stop existing, and the loop eventually asks for
an index that is no longer there.
The fix: GuardedList
pip install guardlist
GuardedList behaves exactly like a normal list, except that it notices
when you try to mutate it during an active iteration and raises an error
right away, instead of letting the loop quietly produce the wrong answer:
from guardlist import GuardedList
numbers = GuardedList([1, 2, 4, 6, 7])
for n in numbers:
if n % 2 == 0:
numbers.remove(n)
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):
The error names the exact method that caused the problem and tells you how
to fix it. The fix is to iterate over a copy of the list, so the mutations
happen to the original list while the loop walks over an unchanging
snapshot. Wrapping the loop in list(...) does this:
from guardlist import GuardedList
numbers = GuardedList([1, 2, 4, 6, 7])
for n in list(numbers):
if n % 2 == 0:
numbers.remove(n)
print(numbers)
[1, 7]
A list comprehension reaches the same correct result without needing a loop at all:
from guardlist import GuardedList
numbers = GuardedList([1, 2, 4, 6, 7])
numbers = GuardedList([n for n in numbers if n % 2 != 0])
print(numbers)
[1, 7]
Every operation GuardedList protects
Each of the following raises IterationMutationError if it is called
while a loop over the same GuardedList is active. The message always
names the method or operation that was attempted.
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.append(4)
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):
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.extend([4, 5])
IterationMutationError: ... modified by 'extend' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.insert(0, 9)
IterationMutationError: ... modified by 'insert' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.remove(2)
IterationMutationError: ... modified by 'remove' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.pop()
IterationMutationError: ... modified by 'pop' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.clear()
IterationMutationError: ... modified by 'clear' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.sort()
IterationMutationError: ... modified by 'sort' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers.reverse()
IterationMutationError: ... modified by 'reverse' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers[0] = 99
IterationMutationError: ... modified by '__setitem__' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
del numbers[0]
IterationMutationError: ... modified by '__delitem__' ...
numbers = GuardedList([1, 2, 3, 4])
for n in numbers:
numbers[1:3] = [8, 9]
IterationMutationError: ... modified by '__setitem__' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers += [4]
IterationMutationError: ... modified by '__iadd__' ...
numbers = GuardedList([1, 2, 3])
for n in numbers:
numbers *= 2
IterationMutationError: ... modified by '__imul__' ...
Slice assignment is reported as __setitem__, since that is the single
method Python calls for both single-item and slice assignment.
What is still allowed during a loop
Anything that only reads the list, without changing it, is always allowed, even while a loop over it is active:
numbers = GuardedList([1, 2, 3, 4, 5])
for n in numbers:
_ = numbers[0]
_ = numbers[1:3]
_ = len(numbers)
_ = 3 in numbers
_ = numbers.count(2)
_ = numbers.index(4)
_ = numbers == [1, 2, 3, 4, 5]
_ = numbers + [6]
_ = sorted(numbers, reverse=True)
print("all read-only operations succeeded during iteration")
all read-only operations succeeded during iteration
numbers + [6] and sorted(numbers, ...) are allowed because they build
and return a brand new list, leaving the original GuardedList untouched.
Nested loops, break, and exceptions
Two loops over the same GuardedList at the same time are fine, since each
loop's iterator adds its own count to the guard, and reading is always
safe:
numbers = GuardedList([1, 2, 3])
pairs = []
for a in numbers:
for b in numbers:
pairs.append((a, b))
print(pairs)
[(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)]
Leaving a loop early with break releases the guard, so the list can be
mutated again right after:
numbers = GuardedList([1, 2, 3])
for n in numbers:
break
numbers.append(4)
print(list(numbers))
[1, 2, 3, 4]
The same is true if an exception is raised inside the loop and caught outside it: the guard is released as the loop is abandoned, so the list is mutable again once the exception has been handled.
numbers = GuardedList([1, 2, 3])
try:
for n in numbers:
raise ValueError("boom")
except ValueError:
pass
numbers.append(4)
print(list(numbers))
[1, 2, 3, 4]
Slices and copies stay guarded
Slicing a GuardedList and calling copy() on one both return a new
GuardedList, so the protection travels with the data. Converting to a
plain list() does not carry the protection over, which is exactly what
you want when you need an unguarded snapshot to iterate over while
mutating the original.
numbers = GuardedList([1, 2, 3, 4, 5])
print(type(numbers[1:3]).__name__)
print(type(numbers.copy()).__name__)
print(type(list(numbers)).__name__)
GuardedList
GuardedList
list
What it does not catch
GuardedList only guards the list itself, the container. It cannot see
changes made through other routes:
Changing an object that is stored inside the list, rather than changing
the list's own structure, is not something GuardedList can detect. If an
element is itself a mutable object such as a list, modifying that inner
object from within a loop is not blocked:
numbers = GuardedList([[1], [2], [3]])
for inner in numbers:
inner.append(99)
print(numbers)
[[1, 99], [2, 99], [3, 99]]
A common attempt at writing the loop by hand, using an index and pop(),
also slips past the guard, because it never calls iter() on the list at
all:
numbers = GuardedList([10, 20, 20, 30, 40])
i = 0
while i < len(numbers):
if numbers[i] % 20 == 0:
numbers.pop(i)
i += 1
print(numbers)
[10, 20, 30]
Every multiple of 20 should be gone, leaving [10, 30], but one 20
survives, with no error raised. This variant uses a while loop with a
manually managed index instead of a for loop, so no iterator over the
list is ever created and GuardedList has no active count to check
against. The fix is the same as elsewhere: iterate over a copy, for
example with list(numbers), or build the result with a list
comprehension instead of mutating the list in place.
Calling the underlying list method directly on the class, instead of
through the instance, also bypasses the guard, since it never goes through
GuardedList's own overridden method:
numbers = GuardedList([1, 2, 3])
it = iter(numbers)
next(it)
list.append(numbers, 4)
print(numbers)
[1, 2, 3, 4]
When to use it
GuardedList is meant to be used where it earns its keep, not everywhere:
- Teaching: it turns a silent, confusing bug into an error message that explains exactly what went wrong and how to fix it, which makes it a good tool for anyone learning how iteration works in Python.
- Shared lists across many functions: if a list is passed around and mutated by code far away from the loop that iterates over it, the extra check catches mistakes that would otherwise be very hard to track down.
- Temporary debugging: swap a suspect
listfor aGuardedList, run the code to find exactly where the mutation happens, then swap it back to a plainlistonce the bug is fixed. - Tests: assert that code under test does not mutate a list while
iterating over it, by passing it a
GuardedListinstead of alist.
How it works
GuardedList keeps an internal counter of how many iterators over it are
currently active. Calling iter() on it, which happens automatically at
the start of every for-loop and also when you call reversed(),
increments the counter. The counter is decremented again once that
iterator is exhausted, explicitly closed, or garbage collected, so break
and exceptions inside a loop correctly release the guard. Every method
that mutates the list in place checks that counter first, and raises
IterationMutationError if it is greater than zero, instead of performing
the mutation. On CPython the guard is released immediately on break or
an exception, because of reference counting; on other interpreters such as
PyPy the release may be delayed until garbage collection runs.
API
GuardedList: a subclass oflistthat raisesIterationMutationErrorwhen mutated while it is being iterated over, and otherwise behaves exactly like a built-in list.IterationMutationError: a subclass ofRuntimeErrorraised byGuardedList; it carries amethod_nameattribute holding the name of the method that triggered the error.
License
MIT
Release files for guardlist 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| guardlist-0.1.0.tar.gz | 15.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| guardlist-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 23.9 kB
Release files / guardlist-0.1.0.tar.gz
| Download URL | guardlist-0.1.0.tar.gz |
|---|---|
| Size | 15.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
de28fca338d66afae91dacbab00bafd0f0dc4928a8394864c18a3a6a5661a002
|
|
BLAKE2b-256 checksum How to use checksums |
398247f9e104c9c47e7bcf02d2212372bf4983447337e19bf0fb64fcd0ab3da6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / guardlist-0.1.0-py3-none-any.whl
| Download URL | guardlist-0.1.0-py3-none-any.whl |
|---|---|
| Size | 8.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2c6b4a3fcab7882b9b67de5d7d50f56b2a258ef52b1e0b4d10fc1f2bd5654e9b
|
|
BLAKE2b-256 checksum How to use checksums |
52c18800ca4ed426185915bbfc909f9884358dcafc9a9ae5ac587daaa6b480ce
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|