From 3df1b9f06550f9f8ab4ddfd8899b491a710b5bc7 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Mon, 15 Jun 2026 09:58:24 -0700 Subject: [PATCH 1/2] Fix context manager exception inference for generic __exit__ return types isExceptionContextManager read the unspecialized declared __exit__/__aexit__ return type, so a generic context manager whose exit return type is a TypeVar (e.g. ContextManager[bool]) was treated as non-suppressing. Use FunctionType.getEffectiveReturnType so the solved type argument is used instead. Fixes https://github.com/microsoft/pyright/issues/11483 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md | 48 +++++++++++++++++++ .../src/analyzer/codeFlowEngine.ts | 43 +++++++++-------- .../src/tests/checker.test.ts | 6 +++ .../src/tests/samples/with7.py | 46 ++++++++++++++++++ 4 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 .github/plans/future/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md create mode 100644 packages/pyright-internal/src/tests/samples/with7.py diff --git a/.github/plans/future/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md b/.github/plans/future/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md new file mode 100644 index 000000000000..9dc52337a703 --- /dev/null +++ b/.github/plans/future/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md @@ -0,0 +1,48 @@ +# Fix #11483 — Incorrect type inference from context manager `__exit__` return type with Generic + +Status: ✅ Complete (pending pipeline push/PR) + +Issue: https://github.com/microsoft/pyright/issues/11483 + +## Problem + +When a generic context manager annotates its `__exit__` (or `__aexit__`) return +type with a `TypeVar` (e.g. `class ContextManager(Generic[T])` whose `__exit__` +returns `T` with `T` bound to `bool | None`), pyright determines exception +suppression from the **unspecialized declared** return type instead of the +solved type argument. As a result `ContextManager[bool]` was treated as +non-suppressing, so code-flow narrowing after the `with` block was wrong +(`reveal_type(foo)` was `Literal[1]` instead of `Literal["str", 1]`). + +## Root cause (debugger-verified) + +`isExceptionContextManager` in `packages/pyright-internal/src/analyzer/codeFlowEngine.ts` +read `exitType.shared.declaredReturnType`, which is the raw declared return type. + +Debugger evidence at the `bool` case breakpoint: +- `exitType.shared.declaredReturnType.shared.name` = `"T"` (TypeVar — the bug) +- `exitType.priv.specializedTypes.returnType.shared.name` = `"bool"` (solved) + +Because the declared type was the TypeVar `T`, the `ClassType.isBuiltIn(returnType, 'bool')` +check never matched and `cmSwallowsExceptions` stayed `false`. + +## Fix + +Use `FunctionType.getEffectiveReturnType(exitType, /* includeInferred */ false)`, +which prefers `priv.specializedTypes.returnType` over `shared.declaredReturnType`. +This returns the specialized `bool` / `None` and covers both `__exit__` and +`__aexit__` (the async Coroutine-unwrapping logic is preserved). + +## Tests + +- Sample: `packages/pyright-internal/src/tests/samples/with7.py` — generic sync + and async context managers specialized to `None` and `bool`, asserting the + narrowed type after the block via `assert_type`. +- Test case: `With7` in `packages/pyright-internal/src/tests/checker.test.ts`. + +## Validation + +- Red → green: With7 failed before the fix (4 assert_type mismatches), passes after. +- Debugger confirmed declared=`T` vs specialized=`bool`/`None`. +- `checker.test` (72) and `typeEvaluator1-8` (1163) suites pass. +- Prettier, ESLint (legacy config), and `tsc --noEmit` clean. diff --git a/packages/pyright-internal/src/analyzer/codeFlowEngine.ts b/packages/pyright-internal/src/analyzer/codeFlowEngine.ts index f9440c4ff815..916d297e5384 100644 --- a/packages/pyright-internal/src/analyzer/codeFlowEngine.ts +++ b/packages/pyright-internal/src/analyzer/codeFlowEngine.ts @@ -1962,27 +1962,32 @@ export function getCodeFlowEngine( const exitMethodName = isAsync ? '__aexit__' : '__exit__'; const exitType = evaluator.getBoundMagicMethod(cmType, exitMethodName); - if (exitType && isFunction(exitType) && exitType.shared.declaredReturnType) { - let returnType = exitType.shared.declaredReturnType; - - // If it's an __aexit__ method, its return type will typically be wrapped - // in a Coroutine, so we need to extract the return type from the third - // type argument. - if (isAsync) { - if ( - isClassInstance(returnType) && - ClassType.isBuiltIn(returnType, ['Coroutine', 'CoroutineType']) && - returnType.priv.typeArgs && - returnType.priv.typeArgs.length >= 3 - ) { - returnType = returnType.priv.typeArgs[2]; + if (exitType && isFunction(exitType)) { + // Use the effective (specialized) return type rather than the declared + // return type so that generic context managers whose __exit__ return + // type is a TypeVar are evaluated using the solved type argument. + let returnType = FunctionType.getEffectiveReturnType(exitType, /* includeInferred */ false); + + if (returnType) { + // If it's an __aexit__ method, its return type will typically be wrapped + // in a Coroutine, so we need to extract the return type from the third + // type argument. + if (isAsync) { + if ( + isClassInstance(returnType) && + ClassType.isBuiltIn(returnType, ['Coroutine', 'CoroutineType']) && + returnType.priv.typeArgs && + returnType.priv.typeArgs.length >= 3 + ) { + returnType = returnType.priv.typeArgs[2]; + } } - } - cmSwallowsExceptions = false; - if (isClassInstance(returnType) && ClassType.isBuiltIn(returnType, 'bool')) { - if (returnType.priv.literalValue === undefined || returnType.priv.literalValue === true) { - cmSwallowsExceptions = true; + cmSwallowsExceptions = false; + if (isClassInstance(returnType) && ClassType.isBuiltIn(returnType, 'bool')) { + if (returnType.priv.literalValue === undefined || returnType.priv.literalValue === true) { + cmSwallowsExceptions = true; + } } } } diff --git a/packages/pyright-internal/src/tests/checker.test.ts b/packages/pyright-internal/src/tests/checker.test.ts index 0cd914a326af..75c25ecc617b 100644 --- a/packages/pyright-internal/src/tests/checker.test.ts +++ b/packages/pyright-internal/src/tests/checker.test.ts @@ -212,6 +212,12 @@ test('With6', () => { TestUtils.validateResults(analysisResults, 0); }); +test('With7', () => { + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['with7.py']); + + TestUtils.validateResults(analysisResults, 0); +}); + test('Mro1', () => { const analysisResults = TestUtils.typeAnalyzeSampleFiles(['mro1.py']); diff --git a/packages/pyright-internal/src/tests/samples/with7.py b/packages/pyright-internal/src/tests/samples/with7.py new file mode 100644 index 000000000000..4b8ff33b1c85 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/with7.py @@ -0,0 +1,46 @@ +# This sample tests that the exception-swallowing behavior of a context +# manager is determined from the specialized __exit__ / __aexit__ return +# type when the context manager is generic and the return type is annotated +# with a TypeVar. + +from typing import Generic, Literal, Self, TypeVar, assert_type + +T = TypeVar("T", bound="bool | None") + + +class ContextManager(Generic[T]): + def __enter__(self) -> Self: ... + def __exit__(self, exc_type, exc_val, exc_tb) -> T: ... + + +# The __exit__ return type specializes to None, so the exception is never +# suppressed and the entire body must have run. +foo = "str" +with ContextManager[None]() as ctx: + foo = 1 +assert_type(foo, Literal[1]) + + +# The __exit__ return type specializes to bool, so the exception may have +# been suppressed and the body may not have run. +bar = "str" +with ContextManager[bool]() as ctx: + bar = 1 +assert_type(bar, Literal["str", 1]) + + +class AsyncContextManager(Generic[T]): + async def __aenter__(self) -> Self: ... + async def __aexit__(self, exc_type, exc_val, exc_tb) -> T: ... + + +async def func1() -> None: + baz = "str" + async with AsyncContextManager[None]() as ctx: + baz = 1 + assert_type(baz, Literal[1]) + + qux = "str" + async with AsyncContextManager[bool]() as ctx: + qux = 1 + assert_type(qux, Literal["str", 1]) From ac239d8b16c137bbedbcf7fb597abd56bad01c9e Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Mon, 15 Jun 2026 10:38:35 -0700 Subject: [PATCH 2/2] Apply formatting fixes --- .../FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md | 1 + 1 file changed, 1 insertion(+) rename .github/plans/{future => archived}/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md (98%) diff --git a/.github/plans/future/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md b/.github/plans/archived/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md similarity index 98% rename from .github/plans/future/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md rename to .github/plans/archived/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md index 9dc52337a703..d5a73760e79e 100644 --- a/.github/plans/future/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md +++ b/.github/plans/archived/FIX_11483_CONTEXT_MANAGER_GENERIC_EXIT_PLAN.md @@ -1,4 +1,5 @@ # Fix #11483 — Incorrect type inference from context manager `__exit__` return type with Generic +## Status: ✅ Complete Status: ✅ Complete (pending pipeline push/PR)