Skip to content
v0.1.0latest release21 skills2 categories3 sub-categories22 tested scripts68 reference docsContact us

Pitfalls and Testing

Reference for python-context-manager-writer · 303 lines

Programming / Pythonv1.0.0Updated OCT 20 20266 references1 script

Every common context-manager bug with the symptom, the cause and the fix, the ctx_tool.py rule that catches it, and how to unit-test managers properly.

Contents

Pitfall catalogue

#BugSymptomFixRule
1Resource opened without withResourceWarning, Too many open files, locked SQLite DB, zombie processeswith / ExitStack / closingCTX001
2close() not in finallyLeak only on error paths (hard to reproduce)with or try/finallyCTX001
3lock.acquire() without try/finallyDeadlock after the first exceptionwith lock:CTX002
4Cleanup after yield not in finallyTransaction left open / temp dir left behind when the body raisestry: yield ... finally:CTX003
5Two yields / yield in loop / no yieldRuntimeError: generator didn't stop / didn't yieldExactly one yield per pathCTX004
6__exit__ returns True (or self)Errors silently disappear; code after with runs with bad stateReturn None; suppress specific types onlyCTX005
7__enter__ returns nothingAttributeError: 'NoneType' object has no attribute ... on the as variablereturn selfCTX006
8except around yield without raiseBody errors swallowedRe-raiseCTX007
9Wrong __exit__ signatureTypeError: __exit__() takes 3 positional arguments but 4 were given(self, exc_type, exc, tb) or (self, *exc_info)CTX008
10async def __exit__ / def __aexit__ / @contextmanager on async defTypeError, coroutine never awaitedMatch protocol to with/async withCTX009
11with httpx.AsyncClient()TypeError / AttributeErrorasync withCTX010
12with threading.Lock():Race conditions remainCreate the lock onceCTX011
13torch.no_grad() as a statementMemory blow-up during eval (graph recorded)with torch.no_grad():CTX012
14Setup inside try in a generatorfinally references names never assigned → NameError masks the real errorSetup before try—
15Reusing a @contextmanager objectRuntimeError: generator didn't yield on second withCall the function again—
16Returning a lazy iterator from inside with open(...)ValueError: I/O operation on closed fileMaterialise (list(...)) inside the block—
17return in finallySilently discards the in-flight exception (3.14 warns: SyntaxWarning)Never return/break/continue in finally—
18Mutable state on a shared ContextDecorator instanceWrong timings / crossed spans under concurrencyPer-call state, contextvars—
19with conn: assumed to close (sqlite3/psycopg2)Connection leakclosing(conn) + with conn:—
20multiprocessing.Pool exited before collecting async resultsLost results (terminate() on exit)Call .get() inside the block—

Generator-based manager pitfalls in detail

Cleanup skipped on exception

python
@contextmanager
def gpu_lease(device_pool):                 # BUG
    dev = device_pool.take()
    yield dev
    device_pool.give_back(dev)              # never runs if the body raises -> pool drains


@contextmanager
def gpu_lease(device_pool):                 # FIX
    dev = device_pool.take()
    try:
        yield dev
    finally:
        device_pool.give_back(dev)

Swallowing by accident

python
@contextmanager
def logged(step):                           # BUG: suppresses everything
    try:
        yield
    except Exception as exc:
        log.error("%s failed: %s", step, exc)

@contextmanager
def logged(step):                           # FIX
    try:
        yield
    except Exception as exc:
        log.error("%s failed: %s", step, exc)
        raise

Setup inside try

python
@contextmanager
def connected(dsn):                         # BUG: if connect() raises, finally hits NameError
    try:
        conn = connect(dsn)
        yield conn
    finally:
        conn.close()

@contextmanager
def connected(dsn):                         # FIX
    conn = connect(dsn)
    try:
        yield conn
    finally:
        conn.close()

Yield twice

python
@contextmanager
def with_fallback(primary, backup):         # BUG: second yield after throw()
    try:
        yield primary
    except ConnectionError:
        yield backup                        # RuntimeError: generator didn't stop after throw()

A context manager cannot re-run the body. Retry logic belongs in a loop around the with, or in a function that takes a callable:

python
def with_fallback(fn, primary, backup):
    try:
        return fn(primary)
    except ConnectionError:
        return fn(backup)

StopIteration and GeneratorExit

  • A StopIteration raised by the body is thrown into the generator; PEP 479 turns it into RuntimeError inside the generator, and contextlib unwraps that so the original StopIteration propagates out of the with. You do not need to handle it, but never return from an except StopIteration around the yield (that suppresses it).
  • Do not catch GeneratorExit around the yield. It is raised when the generator is closed without __exit__ (e.g. cm.__enter__() called by hand and the object dropped); yielding again raises RuntimeError: generator ignored GeneratorExit.

Exceptions inside __exit__ and cleanup ordering

  • Exceptions in __exit__ replace the body's exception (the original survives as __context__). Log rather than raise secondary cleanup failures when an exception is already in flight (see protocol-reference.md).
  • Several cleanups in one __exit__: nest try/finally so each runs, or push them on an ExitStack in acquisition order (unwound LIFO).
  • Order matters: close the consumer before the producer (HTTP response before client, cursor before connection, writer before file).
  • 3.11+: with except*/ExceptionGroup, a manager may receive an ExceptionGroup (e.g. from a TaskGroup in its body). Use exc.subgroup(...)/split to check for types, or contextlib.suppress (3.12+ handles groups).

Generators and lazy iteration escaping the block

python
def read_chunks(path):                              # BUG
    with open(path) as f:
        return (line.strip() for line in f)         # generator escapes; file closed on return

def read_chunks(path):                              # FIX 1: materialise
    with open(path) as f:
        return [line.strip() for line in f]

def iter_chunks(path):                              # FIX 2: make the function itself a generator
    with open(path) as f:
        for line in f:
            yield line.strip()

FIX 2 keeps the file open until the generator is exhausted or closed. If consumers may stop early, close it explicitly: with closing(iter_chunks(p)) as it:. Same for async generators with aclosing (3.10).

Same trap with DB cursors and streaming HTTP responses: returning r.iter_lines() from inside with client.stream(...) as r:.

Thread safety

QuestionAnswer
Is a @contextmanager function thread-safe?Each call creates its own generator, so per-entry state is isolated. The effects (globals it touches) may not be.
Is a class-based manager thread-safe?Only if each thread uses its own instance, or state is protected/thread-local.
Can I share one threading.Lock across with blocks in many threads?Yes — that is the point.
Are redirect_stdout, chdir, env overrides thread-safe?No — process-global.
decimal.localcontext, torch.no_grad, torch.autocast?Thread-local: safe.
asyncio tasks?Use contextvars; thread-locals are shared between tasks on one thread.

Testing context managers

Test these behaviours for every manager:

  1. Setup happens and the as value is correct.
  2. Teardown happens on normal exit.
  3. Teardown happens when the body raises, and the exception propagates.
  4. Teardown happens on early return/break.
  5. If setup fails, teardown is not attempted on unacquired resources.
  6. Reuse/re-entrancy behaves as documented.
  7. Async: teardown happens on cancellation.
python
import asyncio
import os

import pytest

from mypkg.ctx import atomic_write, temp_env, transaction


def test_temp_env_restores_on_error(monkeypatch):
    monkeypatch.setenv("OPENAI_API_KEY", "real")
    with pytest.raises(ZeroDivisionError):
        with temp_env(OPENAI_API_KEY="test"):
            assert os.environ["OPENAI_API_KEY"] == "test"
            1 / 0                                         # exception must propagate...
    assert os.environ["OPENAI_API_KEY"] == "real"         # ...and state must be restored


def test_atomic_write_keeps_old_file_on_error(tmp_path):
    target = tmp_path / "manifest.json"
    target.write_text("old")
    with pytest.raises(ValueError):
        with atomic_write(target) as f:
            f.write("partial")
            raise ValueError
    assert target.read_text() == "old"
    assert list(tmp_path.iterdir()) == [target]           # no stray temp files


def test_transaction_rolls_back(mocker):
    conn = mocker.Mock()
    with pytest.raises(KeyError):
        with transaction(conn):
            raise KeyError("x")
    conn.rollback.assert_called_once()
    conn.commit.assert_not_called()


def test_cleanup_on_cancel():
    closed = []

    class Res:
        async def __aenter__(self):
            return self

        async def __aexit__(self, *exc):
            closed.append(exc[0])

    async def body():
        async with Res():
            await asyncio.sleep(10)

    async def main():
        t = asyncio.create_task(body())
        await asyncio.sleep(0)
        t.cancel()
        with pytest.raises(asyncio.CancelledError):
            await t

    asyncio.run(main())
    assert closed == [asyncio.CancelledError]

Driving the protocol by hand (useful for asserting exact __exit__ return values):

python
def test_exit_does_not_suppress():
    cm = SkipOnRateLimit()
    cm.__enter__()
    assert cm.__exit__(KeyError, KeyError("k"), None) is False
    assert cm.__exit__(RateLimitError, RateLimitError(), None) is True

Mocking a context manager returned by a factory (e.g. httpx.Client()):

python
from unittest import mock

def test_fetch_uses_client(monkeypatch):
    fake_client = mock.MagicMock()
    fake_client.__enter__.return_value = fake_client       # MagicMock supports dunder config
    fake_client.get.return_value.json.return_value = {"ok": True}
    monkeypatch.setattr("mypkg.fetch.httpx.Client", lambda **kw: fake_client)
    assert fetch_status() == {"ok": True}
    fake_client.__exit__.assert_called_once()

unittest.mock.AsyncMock / MagicMock (3.8+) support __aenter__/__aexit__ the same way.

Detecting leaks at runtime

ToolHow
python -X dev or -W error::ResourceWarningUnclosed files/sockets become warnings/errors
pytest -W error::ResourceWarningFail tests that leak; add filterwarnings = error::ResourceWarning to config
tracemalloc.start(25)ResourceWarning messages then include the allocation traceback
lsof -p <pid> / ls /proc/<pid>/fdCount open descriptors in a long-running worker
asyncio debug mode (PYTHONASYNCIODEBUG=1)Reports never-awaited coroutines and slow callbacks
ctx_tool.py leaks src/Static scan before review
bash
python -X dev -W error::ResourceWarning -m pytest -q
python scripts/ctx_tool.py leaks src/ --strict
More in Python

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.

python-exception-handling-designer

Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.

python-idiomatic-code-reviewer

Review Python code for non-idiomatic, unpythonic or risky patterns and rewrite it into clean modern Python: PEP 8 / PEP 20, EAFP vs LBYL, iteration idioms (enumerate, zip, comprehensions, any/all), unpacking, f-strings, pathlib, truthiness, `is None`, mutable defaults, walrus, match statements, collections / itertools / functools, logging instead of print, with-blocks and resource handling. Includes AI-engineering checks: hardcoded API keys, time.sleep or requests inside async def, missing HTTP timeouts, unbounded retry loops, model clients built at import time, prompt string concatenation, sequential awaits, eval() on LLM output. Ships a 46-rule AST reviewer with severities and suggested rewrites.

python-type-hints-annotator

Add accurate, modern type hints to Python and fix mypy / pyright (pyrefly, ty) errors: builtin generics, X | None, TypeAlias and the 3.12 type statement, TypeVar and PEP 695 generics, Protocol, Callable / ParamSpec / Concatenate, TypedDict (Required, NotRequired, ReadOnly), Literal, Final, ClassVar, overload, Annotated, TypeGuard / TypeIs, Self, Never, TypeVarTuple, async types, Iterable vs Sequence vs Mapping, TYPE_CHECKING, stubs, py.typed, gradual strictness. AI-engineering focus: typed LLM message dicts and tool schemas, pydantic vs TypedDict, numpy NDArray, torch / jaxtyping shapes, streaming generators, async clients, callbacks, generic VectorStore protocols, typed config. Ships an AST audit: coverage gate, legacy-typing rewrites, inferred types.