Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/pyright-internal/src/analyzer/typeGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2137,15 +2137,20 @@ function narrowTypeForContainerType(
return referenceType;
}

// Determine which tuple types can be eliminated. Only "None" and
// literal types can be handled here.
// Determine which tuple types can be eliminated. Only "None",
// literal types, and final instantiable classes can be handled here.
// Non-final instantiable classes (type[A]) cannot be eliminated in negative
// tests because a subclass SubA(A) reaches the negative branch at runtime
// (since SubA != A).
const typesToEliminate: Type[] = [];
containerType.priv.tupleTypeArgs.forEach((tupleEntry) => {
if (!tupleEntry.isUnbounded) {
if (isNoneInstance(tupleEntry.type)) {
typesToEliminate.push(tupleEntry.type);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

📍 packages/pyright-internal/src/analyzer/typeGuards.ts:2149

The implementation deliberately narrows only final class objects, but the PR title, summary, and reproduction describe non-final ClassA and ClassB as narrowing away. Update the PR text and example to use @final classes so the documented behavior matches the verified safety boundary.

[verified]

} else if (isClassInstance(tupleEntry.type) && isLiteralType(tupleEntry.type)) {
typesToEliminate.push(tupleEntry.type);
} else if (isInstantiableClass(tupleEntry.type) && ClassType.isFinal(tupleEntry.type)) {
typesToEliminate.push(tupleEntry.type);
}
}
});
Expand Down
5 changes: 5 additions & 0 deletions packages/pyright-internal/src/tests/checker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,3 +721,8 @@ test('Deprecated8', () => {
const analysisResults2 = TestUtils.typeAnalyzeSampleFiles(['deprecated8.py'], configOptions);
TestUtils.validateResults(analysisResults2, 4);
});

test('TypeNarrowingContainer1', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typeNarrowingContainer1.py']);
TestUtils.validateResults(analysisResults, 0);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# This sample tests negative type narrowing for tuple membership checks (in / not in)
# containing instantiable class objects (type[T]).

from typing import final
from typing_extensions import assert_type

@final
class FinalClassA: pass

@final
class FinalClassB: pass

class ClassC: pass

class NonFinalClassA: pass
class SubA(NonFinalClassA): pass


def test_in_final_class_tuple(x: type[FinalClassA] | type[FinalClassB] | type[ClassC]):
if x in (FinalClassA, FinalClassB):
assert_type(x, type[FinalClassA] | type[FinalClassB])
else:
assert_type(x, type[ClassC])

def test_not_in_final_class_tuple(x: type[FinalClassA] | type[FinalClassB] | type[ClassC]):
if x not in (FinalClassA, FinalClassB):
assert_type(x, type[ClassC])
else:
assert_type(x, type[FinalClassA] | type[FinalClassB])

def test_not_in_non_final_class_tuple(x: type[NonFinalClassA] | type[ClassC]):
if x not in (NonFinalClassA,):
# SubA is a subclass of NonFinalClassA. At runtime, SubA in (NonFinalClassA,)
# evaluates to False, so SubA reaches this negative branch. Therefore,
# type[NonFinalClassA] must not be eliminated when NonFinalClassA is not final.
assert_type(x, type[NonFinalClassA] | type[ClassC])
else:
assert_type(x, type[NonFinalClassA])