A context manager written with @contextmanager is a function with one yield. Everything before the yield is entry; everything after is exit.
from contextlib import contextmanager
@contextmanager
def watching():
setup() # runs on entry
yield # the block runs here
teardown() # runs on exit — but NOT if the block raised
# The exception propagates out through the yield, so anything after it
# is skipped unless you catch it:
@contextmanager
def watching():
setup()
try:
yield
finally:
teardown() # now it always runs
- The yield is where the block goes
@contextmanager turns a generator into a context manager: the code before the single yield is __enter__, the code after it is __exit__, and whatever you yield is what `as` binds. That is the entire mechanism, and once you see it, `with` stops being a special form and becomes a function call with a body.
- An exception in the block comes out of the yield
If the block raises, the exception is thrown into the generator at the yield — so the lines after it never run unless they are in a finally, and a try/except around the yield can swallow the block’s exception entirely. That last part is the danger: a post-condition check that catches too much turns a real failure into a confusing complaint about something changing.
- Do not check when the block already failed
If the block blew up halfway through, the value very probably did change — and reporting that instead of the actual exception buries the cause. Here, the right structure is not try/finally at all: let the exception propagate and simply do not run the comparison. The first error is almost always the interesting one.
contextlib.contextmanager turns a generator into a context manager. Read the value before the yield, read it again after, and compare.
Do not wrap the yield in try/finally. If the block raises, the exception should carry straight on out — and because the code after the yield is skipped when that happens, doing nothing is exactly the behaviour you want.
Compare with != so that equal-but-not-identical values pass. Put both values in the message — f'{message}: {before!r} -> {after!r}' — because the two numbers are the whole diagnosis.
from contextlib import contextmanager @____ def unchanged(get_value, message): before = ____() ____ after = get_value() if before ____ after: raise ____(f'{message}: {before!r} -> {after!r}')
from contextlib import contextmanager
@contextmanager
def unchanged(get_value, message):
before = get_value()
# No try/finally: if the block raises, the exception propagates from
# here and everything below is skipped — which is exactly right. The
# first failure is the interesting one.
yield
after = get_value()
if before != after:
raise ValueError(f"{message}: {before!r} -> {after!r}")
Almost every context manager you will read has one, because most of them release something — a lock, a file, a connection — and that has to happen whatever went wrong. This one asserts something instead, and an assertion after a failure is noise at best and misleading at worst. Knowing which kind you are writing tells you whether the finally belongs there.
The question is whether the value changed, not whether the object was replaced — a list rebuilt with the same contents has not changed in any sense the caller cares about. It does mean a value with no meaningful __eq__ falls back to identity and will report spurious changes, which is worth knowing before you point this at an object of your own.
repr shows '10' and 10 differently, and shows an empty string as '' rather than as nothing at all. In a message whose entire job is to let somebody see what moved, the difference between str and repr is frequently the difference between a five-second diagnosis and a debugger.
This is a debugging and testing instrument, not production defence: a check that reads the value twice cannot see a change that was made and undone, it costs a read on every block, and by the time it fires the damage is done. If you want something genuinely not to change, make it immutable — a frozen dataclass, a tuple, a frozenset — and the guard becomes unnecessary. In tests, pytest fixtures and unittest.mock already give you cleaner ways to assert that nothing was called or written. Reach for this when you are hunting a specific 'who moved that' bug in code you cannot easily change.
- Yield the recorded value, so the block can use it with `as`, and check that nothing else has to change.
- Add a tolerance for floats so a value that drifts by rounding does not trigger it — and reuse the reasoning from the basket-total exercise.
- Write the opposite — `changed`, which raises when a block was supposed to modify something and did not — and see how much of the structure is shared.