Skip to content

Commit ccbd0fd

Browse files
committed
add strictContextManagerExitTypes setting
1 parent cbdd6d3 commit ccbd0fd

File tree

6 files changed

+177
-8
lines changed

6 files changed

+177
-8
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# fixed handling for context managers that can suppress exceptions
2+
3+
## the problem
4+
5+
if an exception is raised inside a context manager and its `__exit__` method returns `True`, it will be suppressed:
6+
7+
```py
8+
class SuppressError(AbstractContextManager[None, bool]):
9+
@override
10+
def __enter__(self) -> None:
11+
pass
12+
13+
@override
14+
def __exit__(
15+
self,
16+
exc_type: type[BaseException] | None,
17+
exc_value: BaseException | None,
18+
traceback: TracebackType | None,
19+
/,
20+
) -> bool:
21+
return True
22+
```
23+
24+
but if it returns `False` or `None`, the exception will not be suppressed:
25+
26+
```py
27+
class Log(AbstractContextManager[None, Literal[False]]):
28+
@override
29+
def __enter__(self) -> None:
30+
print("entering context manager")
31+
32+
@override
33+
def __exit__(
34+
self,
35+
exc_type: type[BaseException] | None,
36+
exc_value: BaseException | None,
37+
traceback: TracebackType | None,
38+
/,
39+
) -> Literal[False]:
40+
print("exiting context manager")
41+
return False
42+
```
43+
44+
pyright will take this into account when determining reachability:
45+
46+
```py
47+
def raise_exception() -> Never:
48+
raise Exception
49+
50+
with SuppressError():
51+
foo: int = raise_exception()
52+
53+
# when the exception is raised, the context manager exits before foo is assigned to:
54+
print(foo) # error: "foo" is unbound (reportPossiblyUnboundVariable)
55+
```
56+
57+
```py
58+
with Log():
59+
foo: int = raise_exception()
60+
61+
# when the exception is raised, it does not get suppressed so this line can never run:
62+
print(foo) # error: Code is unreachable (reportUnreachable)
63+
```
64+
65+
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`:
66+
67+
```py
68+
class SuppressError(AbstractContextManager[None, bool | None]):
69+
@override
70+
def __enter__(self) -> None:
71+
pass
72+
73+
@override
74+
def __exit__(
75+
self,
76+
exc_type: type[BaseException] | None,
77+
exc_value: BaseException | None,
78+
traceback: TracebackType | None,
79+
/,
80+
) -> bool | None:
81+
return True
82+
83+
84+
with SuppressError():
85+
foo: int = raise_exception()
86+
87+
# this error is wrong because this line is actually reached at runtime:
88+
print(foo) # error: Code is unreachable (reportUnreachable)
89+
```
90+
91+
## the solution
92+
93+
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.
94+
95+
## issues with `@contextmanager`
96+
97+
the reason the `strictContextManagerExitTypes` setting was created to allow users to disable this fix 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:
98+
99+
```py
100+
@contextmanager
101+
def log():
102+
print("entering context manager")
103+
try:
104+
yield
105+
finally:
106+
print("exiting context manager")
107+
108+
with log():
109+
foo: int = get_value()
110+
111+
# basedpyright accounts for the possibility that get_value raised an exception and foo
112+
# was never assigned to, even though this context manager never suppresses exceptions
113+
print(foo) # error: "foo" is unbound (reportPossiblyUnboundVariable)
114+
```
115+
116+
this is because there's no way to tell a type checker whether the function body contains a `try`/`except` statement, which is necessary to suppress exeptions when using the `@contextmanager` decorator:
117+
118+
```py
119+
@contextmanager
120+
def suppress_error():
121+
try:
122+
yield
123+
except:
124+
pass
125+
```
126+
127+
as a workaround, it's recommended to instead use class context managers [like in the examples above](#the-problem) for the following reasons:
128+
129+
- it forces you to be explicit about whether or not the context manager is able to suppress an exception
130+
- it prevents you from accidentally creating a context manager that doesn't run its cleanup if an exception occurs:
131+
```py
132+
@contextmanager
133+
def suppress_error():
134+
print("setup")
135+
yield
136+
# this part won't run if an exception is raised because you forgot to use a try/finally
137+
print("cleanup")
138+
```

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ The following settings determine how different types should be evaluated.
7777

7878
- <a name="strictGenericNarrowing"></a> **strictGenericNarrowing** [boolean]: When a type is narrowed in such a way that its type parameters are not known (eg. using an `isinstance` check), basedpyright will resolve the type parameter to the generic's bound or constraint instead of `Any`. [more info](../benefits-over-pyright/improved-generic-narrowing.md)
7979

80+
- <a name="strictContextManagerExitTypes"></a> **strictContextManagerExitTypes** [boolean]: Assume that a context manager could potentially suppress an exception if its `__exit__` method is typed as returning `bool | None`. [more info](../benefits-over-pyright/fixed-context-manager-exit-types.md)
81+
8082
## Diagnostic Categories
8183

8284
diagnostics can be configured to be reported as any of the following categories:

packages/pyright-internal/src/analyzer/codeFlowEngine.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1968,7 +1968,10 @@ export function getCodeFlowEngine(
19681968
// valid return types here are `bool | None`. if the context manager returns `True` then it suppresses,
19691969
// meaning we only know for sure that the context manager can't swallow exceptions if its return type
19701970
// does not allow `True`.
1971-
const typesToCheck = isUnion(returnType) ? returnType.priv.subtypes : [returnType];
1971+
const typesToCheck =
1972+
getFileInfo(node).diagnosticRuleSet.strictContextManagerExitTypes && isUnion(returnType)
1973+
? returnType.priv.subtypes
1974+
: [returnType];
19721975
const boolType = typesToCheck.find(
19731976
(type): type is ClassType => isClassInstance(type) && ClassType.isBuiltIn(type, 'bool')
19741977
);

packages/pyright-internal/src/common/configOptions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ export interface DiagnosticRuleSet {
412412
*/
413413
failOnWarnings: boolean;
414414
strictGenericNarrowing: boolean;
415+
strictContextManagerExitTypes: boolean;
415416
reportUnreachable: DiagnosticLevel;
416417
reportAny: DiagnosticLevel;
417418
reportExplicitAny: DiagnosticLevel;
@@ -443,6 +444,7 @@ export function getBooleanDiagnosticRules(includeNonOverridable = false) {
443444
DiagnosticRule.deprecateTypingAliases,
444445
DiagnosticRule.disableBytesTypePromotions,
445446
DiagnosticRule.strictGenericNarrowing,
447+
DiagnosticRule.strictContextManagerExitTypes,
446448
];
447449

448450
if (includeNonOverridable) {
@@ -676,6 +678,7 @@ export function getOffDiagnosticRuleSet(): DiagnosticRuleSet {
676678
reportImplicitOverride: 'none',
677679
failOnWarnings: false,
678680
strictGenericNarrowing: false,
681+
strictContextManagerExitTypes: false,
679682
reportUnreachable: 'hint',
680683
reportAny: 'none',
681684
reportExplicitAny: 'none',
@@ -792,6 +795,7 @@ export function getBasicDiagnosticRuleSet(): DiagnosticRuleSet {
792795
reportImplicitOverride: 'none',
793796
failOnWarnings: false,
794797
strictGenericNarrowing: false,
798+
strictContextManagerExitTypes: false,
795799
reportUnreachable: 'hint',
796800
reportAny: 'none',
797801
reportExplicitAny: 'none',
@@ -908,6 +912,7 @@ export function getStandardDiagnosticRuleSet(): DiagnosticRuleSet {
908912
reportImplicitOverride: 'none',
909913
failOnWarnings: false,
910914
strictGenericNarrowing: false,
915+
strictContextManagerExitTypes: false,
911916
reportUnreachable: 'hint',
912917
reportAny: 'none',
913918
reportExplicitAny: 'none',
@@ -1023,6 +1028,7 @@ export const getRecommendedDiagnosticRuleSet = (): DiagnosticRuleSet => ({
10231028
reportImplicitOverride: 'warning',
10241029
failOnWarnings: true,
10251030
strictGenericNarrowing: true,
1031+
strictContextManagerExitTypes: true,
10261032
reportUnreachable: 'warning',
10271033
reportAny: 'warning',
10281034
reportExplicitAny: 'warning',
@@ -1135,6 +1141,7 @@ export const getAllDiagnosticRuleSet = (): DiagnosticRuleSet => ({
11351141
reportImplicitOverride: 'error',
11361142
failOnWarnings: true,
11371143
strictGenericNarrowing: true,
1144+
strictContextManagerExitTypes: true,
11381145
reportUnreachable: 'error',
11391146
reportAny: 'error',
11401147
reportExplicitAny: 'error',
@@ -1248,6 +1255,7 @@ export function getStrictDiagnosticRuleSet(): DiagnosticRuleSet {
12481255
reportImplicitOverride: 'none',
12491256
failOnWarnings: false,
12501257
strictGenericNarrowing: false,
1258+
strictContextManagerExitTypes: false,
12511259
reportUnreachable: 'hint',
12521260
reportAny: 'none',
12531261
reportExplicitAny: 'none',

packages/pyright-internal/src/common/diagnosticRules.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export enum DiagnosticRule {
107107
// basedpyright options:
108108
failOnWarnings = 'failOnWarnings',
109109
strictGenericNarrowing = 'strictGenericNarrowing',
110+
strictContextManagerExitTypes = 'strictContextManagerExitTypes',
110111
reportUnreachable = 'reportUnreachable',
111112
reportAny = 'reportAny',
112113
reportExplicitAny = 'reportExplicitAny',

packages/pyright-internal/src/tests/checker.test.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,30 @@ test('With2', () => {
170170
TestUtils.validateResults(analysisResults, 3);
171171
});
172172

173-
test('context manager where __exit__ returns bool | None', () => {
174-
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['withBased.py']);
175-
TestUtils.validateResultsButBased(analysisResults, {
176-
hints: [
177-
{ code: DiagnosticRule.reportUnreachable, line: 45 },
178-
{ code: DiagnosticRule.reportUnreachable, line: 60 },
179-
],
173+
describe('context manager where __exit__ returns bool | None', () => {
174+
test('strictContextManagerExitTypes=true', () => {
175+
const configOptions = new ConfigOptions(Uri.empty());
176+
configOptions.diagnosticRuleSet.strictContextManagerExitTypes = true;
177+
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['withBased.py'], configOptions);
178+
TestUtils.validateResultsButBased(analysisResults, {
179+
hints: [
180+
{ code: DiagnosticRule.reportUnreachable, line: 45 },
181+
{ code: DiagnosticRule.reportUnreachable, line: 60 },
182+
],
183+
});
184+
});
185+
test('strictContextManagerExitTypes=false', () => {
186+
const configOptions = new ConfigOptions(Uri.empty());
187+
configOptions.diagnosticRuleSet.strictContextManagerExitTypes = false;
188+
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['withBased.py'], configOptions);
189+
TestUtils.validateResultsButBased(analysisResults, {
190+
hints: [
191+
{ code: DiagnosticRule.reportUnreachable, line: 16 },
192+
{ code: DiagnosticRule.reportUnreachable, line: 30 },
193+
{ code: DiagnosticRule.reportUnreachable, line: 45 },
194+
{ code: DiagnosticRule.reportUnreachable, line: 60 },
195+
],
196+
});
180197
});
181198
});
182199

0 commit comments

Comments
 (0)