Skip to content

Commit e6862cd

Browse files
fix: wrap yield in try-finally in UsethisConfig.set() and change_cwd() (#1834)
* Initial plan * fix: wrap yield in try-finally in UsethisConfig.set() and change_cwd(), update skill, add tests Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/429f9231-8554-40bd-b168-73c3f152f417 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com>
1 parent 61f88eb commit e6862cd

4 files changed

Lines changed: 60 additions & 7 deletions

File tree

.agents/skills/usethis-python-code/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: Guidelines for Python code design decisions such as when to share v
44
compatibility: usethis, Python
55
license: MIT
66
metadata:
7-
version: "1.8"
7+
version: "1.9"
88
---
99

1010
# Python Code Guidelines
@@ -165,6 +165,7 @@ with _preserved(lock_path):
165165

166166
- **Separate backup and restore helpers.** Splitting setup and teardown into two functions forces every caller to remember both calls and wire up `try`/`finally` correctly. A context manager removes this burden.
167167
- **Forgetting `finally` in the caller.** Without a context manager, it is easy to forget the `finally` block, leaving state unrestored if an exception occurs. A context manager guarantees cleanup.
168+
- **Placing cleanup after `yield` without `try`/`finally` in a `@contextmanager`.** With `@contextmanager`, cleanup code placed after `yield` does NOT run if an exception is raised inside the `with` block. The exception is re-raised at the `yield` point, causing the generator to terminate without reaching the cleanup code. Always wrap `yield` in `try`/`finally` to guarantee cleanup runs on both normal exit and exceptions.
168169

169170
## Ordering functions: the step-down rule
170171

src/usethis/_config.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def copy(self) -> UsethisConfig:
6363
return replace(self)
6464

6565
@contextmanager
66-
def set( # noqa: PLR0913
66+
def set( # noqa: PLR0912, PLR0913
6767
self,
6868
*,
6969
offline: bool | None = None,
@@ -115,8 +115,10 @@ def set( # noqa: PLR0913
115115
if isinstance(project_dir, str):
116116
project_dir = Path(project_dir)
117117
self.project_dir = project_dir
118-
yield
119-
self._restore(old)
118+
try:
119+
yield
120+
finally:
121+
self._restore(old)
120122

121123
def _restore(self, other: UsethisConfig) -> None:
122124
"""Restore all attributes from another configuration instance."""

src/usethis/_test.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ def change_cwd(new_dir: Path) -> Generator[None, None, None]:
2929
"""
3030
old_dir = Path.cwd()
3131
os.chdir(new_dir)
32-
with usethis_config.set(project_dir=new_dir):
33-
yield
34-
os.chdir(old_dir)
32+
try:
33+
with usethis_config.set(project_dir=new_dir):
34+
yield
35+
finally:
36+
os.chdir(old_dir)
3537

3638

3739
def is_offline() -> bool:

tests/usethis/test_config.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,54 @@ def test_set_overrides_cwd(self, tmp_path: Path):
103103
# Assert
104104
assert project_dir == Path("42 Wallaby Way, Sydney")
105105

106+
class TestSetRestoresOnException:
107+
def test_restores_quiet_on_exception(self):
108+
# Arrange
109+
config = UsethisConfig()
110+
assert config.quiet is False
111+
112+
# Act
113+
with pytest.raises(RuntimeError), config.set(quiet=True):
114+
raise RuntimeError
115+
116+
# Assert
117+
assert config.quiet is False
118+
119+
def test_restores_all_fields_on_exception(self, tmp_path: Path):
120+
# Arrange
121+
config = UsethisConfig()
122+
old = config.copy()
123+
124+
# Act
125+
with (
126+
pytest.raises(RuntimeError),
127+
config.set(
128+
offline=True,
129+
quiet=True,
130+
frozen=True,
131+
alert_only=True,
132+
instruct_only=True,
133+
backend=BackendEnum.none,
134+
build_backend=BuildBackendEnum.uv,
135+
disable_pre_commit=True,
136+
subprocess_verbose=True,
137+
project_dir=tmp_path,
138+
),
139+
):
140+
raise RuntimeError
141+
142+
# Assert
143+
assert config.offline == old.offline
144+
assert config.quiet == old.quiet
145+
assert config.frozen == old.frozen
146+
assert config.alert_only == old.alert_only
147+
assert config.instruct_only == old.instruct_only
148+
assert config.backend == old.backend
149+
assert config.build_backend == old.build_backend
150+
assert config.disable_pre_commit == old.disable_pre_commit
151+
assert config.subprocess_verbose == old.subprocess_verbose
152+
assert config.project_dir == old.project_dir
153+
106154
class TestDisableUVSubprocess:
107155
def test_raises_error_when_disabled(self):
108156
# Act & Assert

0 commit comments

Comments
 (0)