Skip to content

agent: contextlib.contextmanager does NOT restore state on exception unless try-finally is used #1818

@nathanjmcdougall

Description

@nathanjmcdougall

Lesson learned from PR #1795 (infer dependencies from setup.cfg)

When using @contextmanager to implement UsethisConfig.set(), the cleanup code after yield does NOT run if an exception is raised inside the with block — UNLESS the code uses try-finally.

Root cause

Python's @contextmanager works by re-raising exceptions at the yield point inside the generator. Without a try-finally wrapper around yield, the code after yield never executes when an exception occurs:

@contextmanager
def set(self, *, quiet=None, ...):
    old_quiet = self.quiet
    self.quiet = quiet
    yield  # If exception raised here → code below NEVER runs!
    self.quiet = old_quiet  # ← NEVER runs on exception without try-finally

The fix is:

@contextmanager
def set(self, *, quiet=None, ...):
    old_quiet = self.quiet
    self.quiet = quiet
    try:
        yield
    finally:
        self.quiet = old_quiet  # ← ALWAYS runs, even on exception

How this caused cascading CI failures

  1. My changes made get_dep_groups() require SetupCFGManager() to be locked
  2. Tests without SetupCFGManager() in their context raised UnexpectedSetupCFGIOError
  3. This exception propagated through with usethis_config.set(quiet=True): blocks in production code (_core/tool.py)
  4. Without try-finally, quiet=True was NOT restored after the exception
  5. All subsequent tests produced empty output (since quiet=True suppresses all print functions)

This caused ~80 cascading test failures across test_console.py, test_base.py, and all _tool/impl/base/ test files.

Fix applied in PR #1795

Added try-finally to UsethisConfig.set() in src/usethis/_config.py (commit bfb2905).

Recommendation

Any @contextmanager that modifies shared state MUST wrap the yield in try-finally to guarantee cleanup runs on exceptions:

@contextmanager
def my_context_manager():
    # setup...
    try:
        yield
    finally:
        # cleanup (always runs, even on exception)

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions