Skip to content

Commit 5425f98

Browse files
authored
Merge pull request #65 from kraken-tech/meshy/contextmanager-only-decorator
Rework how `savepoint` avoids use as a decorator
2 parents d028d82 + 6a1d2e5 commit 5425f98

5 files changed

Lines changed: 216 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1313

1414
### Removed
1515

16-
- `django_subatomic.db.NotADecorator` is no longer a part of the public API.
17-
It has been renamed to `_NotADecorator`.
18-
This implementation detail was not intended for general use,
19-
and may be removed in a future release.
16+
- `_contextmanager_without_decorator`, `_NonDecoratorContextManager`, `NotADecorator`.
17+
These implementation details were not intended to be a part of our public API.
2018

2119
## [0.1.1] - 2025-09-20
2220

src/django_subatomic/_utils.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import functools
2+
from collections.abc import Callable, Generator
3+
from contextlib import AbstractContextManager
4+
from types import TracebackType
5+
from typing import Literal
6+
7+
8+
def contextmanager[**P_Args, T_YieldType](
9+
func: Callable[P_Args, Generator[T_YieldType]],
10+
) -> Callable[P_Args, AbstractContextManager[T_YieldType]]:
11+
"""
12+
Make a generator function into a context manager.
13+
14+
Similar to `contextlib.contextmanager`, except it does not allow the
15+
returned function to be used as a decorator
16+
"""
17+
18+
@functools.wraps(func)
19+
def wrapper(
20+
*args: P_Args.args, **kwargs: P_Args.kwargs
21+
) -> AbstractContextManager[T_YieldType]:
22+
gen = func(*args, **kwargs)
23+
return _ContextManagerOnly(gen)
24+
25+
return wrapper
26+
27+
28+
class DidNotYield(Exception):
29+
pass
30+
31+
32+
class UnexpectedSecondYield(Exception):
33+
pass
34+
35+
36+
class _ContextManagerOnly[T_YieldType]:
37+
"""
38+
Wraps a generator to act as a context manager.
39+
"""
40+
41+
def __init__(self, gen: Generator[T_YieldType]) -> None:
42+
self.gen = gen
43+
44+
def __enter__(self) -> T_YieldType:
45+
try:
46+
# Run the generator up until the 'yield'.
47+
return next(self.gen)
48+
except StopIteration as e:
49+
raise DidNotYield from e
50+
51+
def __exit__(
52+
self,
53+
exc_type: type[BaseException] | None,
54+
exc_val: BaseException | None,
55+
exc_tb: TracebackType | None,
56+
) -> Literal[True] | None:
57+
# An exception was raised from the 'with' block.
58+
if exc_val is not None:
59+
try:
60+
# Throw the exception into the generator's 'yield' point.
61+
self.gen.throw(exc_val)
62+
except StopIteration:
63+
# The generator handled the exception. Returning `True`
64+
# suppresses the exception from propagating.
65+
return True
66+
else:
67+
# The generator handled the exception but then yielded again.
68+
raise UnexpectedSecondYield
69+
70+
# The 'with' block completed without exception.
71+
else:
72+
try:
73+
# Run the generator after the 'yield'.
74+
next(self.gen)
75+
except StopIteration:
76+
# A clean exit.
77+
return None
78+
else:
79+
# The generator yielded a second time.
80+
raise UnexpectedSecondYield

src/django_subatomic/db.py

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
from django.conf import settings
1010
from django.db import transaction as django_transaction
1111

12+
from . import _utils
13+
1214

1315
if TYPE_CHECKING:
1416
from collections.abc import Callable, Generator, Iterator
15-
from typing import NoReturn
1617

1718

1819
@contextlib.contextmanager
@@ -63,44 +64,7 @@ def transaction_if_not_already(*, using: str | None = None) -> Iterator[None]:
6364
yield
6465

6566

66-
class _NotADecorator(Exception):
67-
"""
68-
Raised when a context manager is mistakenly used as a decorator.
69-
"""
70-
71-
72-
class _NonDecoratorContextManager[T_Co](contextlib._GeneratorContextManager[T_Co]): # noqa: SLF001
73-
"""
74-
Hacked version of contextlib._GeneratorContextManager that prevents use as a decorator.
75-
76-
Use _contextmanager_without_decorator to create instances of this class.
77-
78-
This is a weird hack, but it beats copying the bulk of the
79-
contextlib.contextmanager code into our own codebase.
80-
"""
81-
82-
def __call__(self, func: object) -> NoReturn:
83-
raise _NotADecorator
84-
85-
86-
def _contextmanager_without_decorator[**P, T_Co](
87-
func: Callable[P, Generator[T_Co, None, None]], /
88-
) -> Callable[P, _NonDecoratorContextManager[T_Co]]:
89-
"""
90-
Decorate a generator function to make it a context manager.
91-
92-
This is pretty much the same as `contextlib.contextmanager`,
93-
but it prevents use as a decorator.
94-
"""
95-
96-
@functools.wraps(func)
97-
def helper(*args: P.args, **kwds: P.kwargs) -> _NonDecoratorContextManager[T_Co]:
98-
return _NonDecoratorContextManager(func, args, kwds)
99-
100-
return helper
101-
102-
103-
@_contextmanager_without_decorator
67+
@_utils.contextmanager
10468
def savepoint(*, using: str | None = None) -> Generator[None, None, None]:
10569
"""
10670
Create a database savepoint.

tests/test_db.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,10 @@ def test_not_a_decorator(self) -> None:
261261
"""
262262
`savepoint` cannot be used as a decorator.
263263
"""
264-
with pytest.raises(db._NotADecorator): # noqa: SLF001
264+
expected_error = "'_ContextManagerOnly' object is not callable"
265+
with pytest.raises(TypeError, match=expected_error):
265266

266-
@db.savepoint()
267+
@db.savepoint() # type: ignore[operator]
267268
def inner() -> None: ...
268269

269270
@test.part_of_a_transaction()

tests/test_utils.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from collections.abc import Generator
2+
3+
import pytest
4+
5+
from django_subatomic import _utils as utils
6+
7+
8+
class _AnError(Exception):
9+
pass
10+
11+
12+
class TestContextManager:
13+
"""
14+
Tests for the `contextmanager` decorator.
15+
"""
16+
17+
def test_context_manager(self) -> None:
18+
"""
19+
Generators that yield once can be used as context managers.
20+
"""
21+
22+
@utils.contextmanager
23+
def good_example(steps: list[str]) -> Generator[str, None, None]:
24+
steps.append("enter")
25+
yield "yielded"
26+
steps.append("exit")
27+
28+
steps: list[str] = []
29+
with good_example(steps) as yielded:
30+
steps.append("body")
31+
32+
assert steps == ["enter", "body", "exit"]
33+
assert yielded == "yielded"
34+
35+
def test_context_body_raises_exception(self) -> None:
36+
"""
37+
Exceptions raised in the context body are propagated.
38+
"""
39+
40+
@utils.contextmanager
41+
def basic_context() -> Generator[None, None, None]:
42+
yield
43+
44+
with pytest.raises(_AnError):
45+
with basic_context():
46+
raise _AnError
47+
48+
def test_context_manager_handles_exception(self) -> None:
49+
"""
50+
Context managers may handle exceptions raised in the context body.
51+
"""
52+
53+
@utils.contextmanager
54+
def handle_exception() -> Generator[None, None, None]:
55+
# Make sure the error is raised, but don't propagate it.
56+
with pytest.raises(_AnError):
57+
yield
58+
59+
with handle_exception():
60+
raise _AnError
61+
62+
def test_context_manager_handles_exception_but_then_yields(self) -> None:
63+
"""
64+
A second yield must not happen after an exception is handled.
65+
"""
66+
67+
@utils.contextmanager
68+
def handle_exception_but_yield_twice() -> Generator[None, None, None]:
69+
try:
70+
yield
71+
except _AnError:
72+
yield
73+
74+
with pytest.raises(utils.UnexpectedSecondYield):
75+
with handle_exception_but_yield_twice():
76+
raise _AnError
77+
78+
def test_fails_without_yield(self) -> None:
79+
"""
80+
Decorated functions must yield once.
81+
"""
82+
83+
@utils.contextmanager
84+
def yield_not_run() -> Generator[None, None, None]:
85+
if False:
86+
# Yield makes this a generator, but the yield is never run.
87+
yield # type: ignore[unreachable]
88+
89+
with pytest.raises(utils.DidNotYield):
90+
with yield_not_run():
91+
...
92+
93+
def test_fails_on_multiple_yields(self) -> None:
94+
"""
95+
Decorated functions must not yield multiple times.
96+
"""
97+
98+
@utils.contextmanager
99+
def yields_twice(steps: list[str]) -> Generator[None, None, None]:
100+
steps.append("enter")
101+
yield
102+
steps.append("between")
103+
yield # This line causes the failure.
104+
# The following line will never be reached.
105+
steps.append("after") # pragma: no cover
106+
107+
steps: list[str] = []
108+
with pytest.raises(utils.UnexpectedSecondYield):
109+
with yields_twice(steps):
110+
steps.append("body")
111+
112+
assert steps == ["enter", "body", "between"]
113+
114+
def test_does_not_work_as_a_decorator(self) -> None:
115+
"""
116+
Functions decorated with `contextmanager` cannot be used as decorators.
117+
"""
118+
119+
@utils.contextmanager
120+
def not_a_decorator() -> Generator[None, None, None]:
121+
yield # pragma: no cover
122+
123+
with pytest.raises(
124+
TypeError, match="takes 0 positional arguments but 1 was given"
125+
):
126+
# This doesn't work as a decorator.
127+
@not_a_decorator # type: ignore[call-arg]
128+
def not_reached() -> None: ...

0 commit comments

Comments
 (0)