-
Notifications
You must be signed in to change notification settings - Fork 30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix context managers that return bool | None
incorrectly being treated as if they can never suppress exceptions
#111
Open
DetachHead
wants to merge
4
commits into
main
Choose a base branch
from
fix-context-manager-unreachable
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
62b5bd0
fix context managers that return `bool | None` incorrectly being trea…
DetachHead b73e995
add test for context managers that return `bool | None`, `False | Non…
DetachHead 1217f49
add `None` context manager test
DetachHead 5c23134
add `strictContextManagerExitTypes` setting
DetachHead File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
153 changes: 153 additions & 0 deletions
153
docs/benefits-over-pyright/fixed-context-manager-exit-types.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
# fixed handling for context managers that can suppress exceptions | ||
|
||
## the problem | ||
|
||
if an exception is raised inside a context manager and its `__exit__` method returns `True`, it will be suppressed: | ||
|
||
```py | ||
class SuppressError(AbstractContextManager[None, bool]): | ||
@override | ||
def __enter__(self) -> None: | ||
pass | ||
|
||
@override | ||
def __exit__( | ||
self, | ||
exc_type: type[BaseException] | None, | ||
exc_value: BaseException | None, | ||
traceback: TracebackType | None, | ||
/, | ||
) -> bool: | ||
return True | ||
``` | ||
|
||
but if it returns `False` or `None`, the exception will not be suppressed: | ||
|
||
```py | ||
class Log(AbstractContextManager[None, Literal[False]]): | ||
@override | ||
def __enter__(self) -> None: | ||
print("entering context manager") | ||
|
||
@override | ||
def __exit__( | ||
self, | ||
exc_type: type[BaseException] | None, | ||
exc_value: BaseException | None, | ||
traceback: TracebackType | None, | ||
/, | ||
) -> Literal[False]: | ||
print("exiting context manager") | ||
return False | ||
``` | ||
|
||
pyright will take this into account when determining reachability: | ||
|
||
```py | ||
def raise_exception() -> Never: | ||
raise Exception | ||
|
||
with SuppressError(): # see definition for `SuppressError` above | ||
foo: int = raise_exception() | ||
|
||
# when the exception is raised, the context manager exits before foo is assigned to: | ||
print(foo) # error: "foo" is unbound (reportPossiblyUnboundVariable) | ||
``` | ||
|
||
```py | ||
with Log(): # see definition for `Log` above | ||
foo: int = raise_exception() | ||
|
||
# when the exception is raised, it does not get suppressed so this line can never run: | ||
print(foo) # error: Code is unreachable (reportUnreachable) | ||
``` | ||
|
||
however, due to [a bug in mypy](https://github.com/python/mypy/issues/8766) that [pyright blindly copied and accepted as the "standard"](https://github.com/microsoft/pyright/issues/6034#issuecomment-1738941412), a context manager will incorrectly be treated as if it never suppresses exceptions if its return type is a union of `bool | None`: | ||
|
||
```py | ||
class SuppressError(AbstractContextManager[None, bool | None]): | ||
@override | ||
def __enter__(self) -> None: | ||
pass | ||
|
||
@override | ||
def __exit__( | ||
self, | ||
exc_type: type[BaseException] | None, | ||
exc_value: BaseException | None, | ||
traceback: TracebackType | None, | ||
/, | ||
) -> bool | None: | ||
return True | ||
|
||
|
||
with SuppressError(): | ||
foo: int = raise_exception() | ||
|
||
# this error is wrong because this line is actually reached at runtime: | ||
print(foo) # error: Code is unreachable (reportUnreachable) | ||
``` | ||
|
||
## the solution | ||
|
||
basedpyright introduces a new setting, `strictContextManagerExitTypes` to address this issue. when enabled, context managers where the `__exit__` dunder returns `bool | None` are treated the same way as context managers that return `bool` or `Literal[True]`. put simply, if `True` is assignable to the return type, then it's treated as if it can suppress exceptions. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add this to the playground |
||
|
||
## issues with `@contextmanager` | ||
|
||
the reason we support disabling this fix using the `strictContextManagerExitTypes` setting is because it will cause all context managers decorated with `@contextlib.contextmanager` to be treated as if they can suppress an exception, even if they never do: | ||
DetachHead marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
```py | ||
@contextmanager | ||
def log(): | ||
print("entering context manager") | ||
try: | ||
yield | ||
finally: | ||
print("exiting context manager") | ||
|
||
with log(): | ||
foo: int = get_value() | ||
|
||
# basedpyright accounts for the possibility that get_value raised an exception and foo | ||
# was never assigned to, even though this context manager never suppresses exceptions | ||
print(foo) # error: "foo" is unbound (reportPossiblyUnboundVariable) | ||
``` | ||
|
||
this is because there's no way to tell a type checker whether the function body wraps the `yield` statement inside a `try`/`except` statement, which is necessary to suppress exeptions when using the `@contextmanager` decorator: | ||
|
||
```py | ||
@contextmanager | ||
def suppress_error(): | ||
try: | ||
yield | ||
except: | ||
pass | ||
``` | ||
|
||
due to this limitation in the type system, the `@contextmanager` dectorator always modifies the return type of generator functions from `Iterator[T]` to `_GeneratorContextManager[T]`, which extends `AbstractContextManager[T, bool | None]`. | ||
|
||
```py | ||
# contextlib.pyi | ||
|
||
def contextmanager(func: Callable[_P, Iterator[_T_co]]) -> Callable[_P, _GeneratorContextManager[_T_co]]: ... | ||
|
||
class _GeneratorContextManager(_GeneratorContextManagerBase, AbstractContextManager[_T_co, bool | None], ContextDecorator): | ||
... | ||
``` | ||
|
||
and since `bool | None` is used for the return type of `__exit__`, basedpyright will assume that all `@contextllib.contextmanager`'s have the ability to suppress exceptions when `strictContextManagerExitTypes` is enabled. | ||
|
||
as a workaround, it's recommended to instead use class context managers [like in the examples above](#the-problem) for the following reasons: | ||
|
||
- it forces you to be explicit about whether or not the context manager is able to suppress an exception | ||
- it prevents you from accidentally creating a context manager that doesn't run its cleanup if an exception occurs: | ||
```py | ||
@contextmanager | ||
def suppress_error(): | ||
print("setup") | ||
yield | ||
# this part won't run if an exception is raised because you forgot to use a try/finally | ||
print("cleanup") | ||
``` | ||
|
||
if you're dealing with third party modules where the usage of `@contextmanager` decorator is unavoidable, it may be best to just disable `strictContextManagerExitTypes` instead. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import contextlib | ||
from types import TracebackType | ||
from typing import Literal | ||
|
||
class BoolOrNone(contextlib.AbstractContextManager[None]): | ||
def __exit__( | ||
self, | ||
__exc_type: type[BaseException] | None, | ||
__exc_value: BaseException | None, | ||
__traceback: TracebackType | None, | ||
) -> bool | None: | ||
... | ||
|
||
def _(): | ||
with BoolOrNone(): | ||
raise Exception | ||
print(1) # reachable | ||
|
||
class TrueOrNone(contextlib.AbstractContextManager[None]): | ||
def __exit__( | ||
self, | ||
__exc_type: type[BaseException] | None, | ||
__exc_value: BaseException | None, | ||
__traceback: TracebackType | None, | ||
) -> Literal[True] | None: | ||
... | ||
|
||
def _(): | ||
with TrueOrNone(): | ||
raise Exception | ||
print(1) # reachable | ||
|
||
|
||
class FalseOrNone(contextlib.AbstractContextManager[None]): | ||
def __exit__( | ||
self, | ||
__exc_type: type[BaseException] | None, | ||
__exc_value: BaseException | None, | ||
__traceback: TracebackType | None, | ||
) -> Literal[False] | None: | ||
... | ||
|
||
def _(): | ||
with FalseOrNone(): | ||
raise Exception | ||
print(1) # unreachable | ||
|
||
|
||
class OnlyNone(contextlib.AbstractContextManager[None]): | ||
def __exit__( | ||
self, | ||
__exc_type: type[BaseException] | None, | ||
__exc_value: BaseException | None, | ||
__traceback: TracebackType | None, | ||
) -> None: | ||
... | ||
|
||
def _(): | ||
with OnlyNone(): | ||
raise Exception | ||
print(1) # unreachable |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe something like this instead