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
53 changes: 51 additions & 2 deletions packages/pyright-internal/src/analyzer/typeGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,22 @@ export function getTypeNarrowingCallback(
};
};
}

// Look for X == <class> or X != <class>.
if (isInstantiableClass(rightType)) {
return (type: Type) => {
return {
type: narrowTypeForClassComparison(
evaluator,
type,
rightType,
adjIsPositiveTest,
/* isIsOperator */ false
),
isIncomplete: !!rightTypeResult.isIncomplete,

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:373
Verified behavior is operand-order dependent: cls == Sub1 narrows, but Sub1 == cls does not. If equality narrowing remains supported, handle the reversed form or explicitly document and test why it must remain directional.

[verified]

};
};
}
}

// Look for X[<literal>] == <literal> or X[<literal>] != <literal>
Expand Down Expand Up @@ -2585,17 +2601,47 @@ function narrowTypeForTypeIs(evaluator: TypeEvaluator, type: Type, classTypes: C
return combineTypes(typesToCombine);
}

function hasCustomEqualityMetaclass(classType: ClassType): boolean {
const metaclass = classType.shared.effectiveMetaclass;
if (metaclass && isClass(metaclass)) {
if (
lookUpClassMember(
metaclass,
'__eq__',
MemberAccessFlags.SkipTypeBaseClass | MemberAccessFlags.SkipObjectBaseClass
) ||
lookUpClassMember(
metaclass,
'__ne__',
MemberAccessFlags.SkipTypeBaseClass | MemberAccessFlags.SkipObjectBaseClass
)
) {
return true;

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.

Issue · Please address or respond

Checking only the declared type[Base] metaclass is insufficient here. An open type[Base] can hold a runtime subclass with a custom metaclass whose __eq__ makes cls == Sub1 true without identity, but this path narrows it to type[Sub1]. Restrict equality narrowing to statically closed alternatives or conservatively retain open class hierarchies.

}
}
return false;
}

// Attempts to narrow a type based on a comparison with a class using "is" or
// "is not". This pattern is sometimes used for sentinels.
// "is not", or "==" or "!=".
function narrowTypeForClassComparison(
evaluator: TypeEvaluator,
referenceType: Type,
classType: ClassType,
isPositiveTest: boolean
isPositiveTest: boolean,
isIsOperator = true
): Type {
if (!isIsOperator && hasCustomEqualityMetaclass(classType)) {
return referenceType;
}

return mapSubtypes(referenceType, (subtype) => {
let concreteSubtype = evaluator.makeTopLevelTypeVarsConcrete(subtype);

if (!isIsOperator && isInstantiableClass(concreteSubtype) && hasCustomEqualityMetaclass(concreteSubtype)) {

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.

Issue · Please address or respond

📍 packages/pyright-internal/src/analyzer/typeGuards.ts:2641
This narrowing is verified unsound for open types like type[Base]: a runtime subclass can introduce a metaclass whose __eq__ makes it compare equal to Sub1, even though it is unrelated to Sub1. Restrict equality narrowing to alternatives whose equality semantics are statically closed—such as exact/final classes—and add a regression using a subclass-defined custom metaclass.

[verified]

return subtype;
}

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.

Issue · Please address or respond

For ==/!=, this guard still lets an instance-typed object reach the existing object special case below, which narrows x: object to type[int] after if x == int:. An arbitrary instance can implement __eq__ that returns true for int without being that class object, so this is an unsound false narrowing. Restrict equality-based class narrowing to instantiable-class reference subtypes, and add a regression sample for an instance whose __eq__ matches a class object.

if (isPositiveTest) {
if (
isClassInstance(concreteSubtype) &&
Expand All @@ -2614,6 +2660,9 @@ function narrowTypeForClassComparison(

if (isClass(concreteSubtype)) {
if (TypeBase.isInstance(concreteSubtype)) {
if (!isIsOperator) {
return subtype;
}
return ClassType.isBuiltIn(concreteSubtype, 'object') ? classType : undefined;
}

Expand Down
51 changes: 51 additions & 0 deletions packages/pyright-internal/src/tests/samples/typeGuard4.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# This sample tests type narrowing when comparing class types
# with equality (== and !=) operators against class objects.

from typing import TypeVar, assert_type, final

class Base: pass
class Sub1(Base): pass

@final
class Sub2(Base): pass

T = TypeVar("T", bound=Base)

def test_eq_concrete(cls: type[Base]) -> type[Sub1]:
if cls == Sub1:
assert_type(cls, type[Sub1])
return cls
raise ValueError()

def test_neq_concrete(cls: type[Sub1] | type[Sub2]):
if cls != Sub2:
assert_type(cls, type[Sub1])

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

Add a negative-boundary case for comparison with a non-@final class, where the negative branch must not narrow. The current coverage would still pass if the isFinal guard in narrowTypeForClassComparison were removed. [verified]


def test_neq_non_final(cls: type[Sub1] | type[Sub2]):
if cls != Sub1:
assert_type(cls, type[Sub1] | type[Sub2])

def test_eq_typevar(cls: type[T]) -> type[Sub1]:
if cls == Sub1:
assert_type(cls, type[Sub1])
return cls
raise ValueError()

class CustomMeta(type):
def __eq__(cls, other: object) -> bool:
return True

class Custom1(metaclass=CustomMeta): pass
class Custom2(metaclass=CustomMeta): pass

def test_eq_custom_meta(cls: type[Custom1] | type[Custom2]):
if cls == Custom1:
assert_type(cls, type[Custom1] | type[Custom2])

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

This test returns at the RHS custom-metaclass guard, since both operands use CustomMeta, so it cannot exercise the new per-subtype LHS guard. Add an ordinary-RHS/custom-metaclass-LHS case and a __ne__-only metaclass case.

class EqualityDummy:
def __eq__(self, other: object) -> bool:
return True

def test_eq_instance_object(x: object):
if x == Sub1:
assert_type(x, object)

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/tests/samples/typeGuard4.py:34
The custom-metaclass test places custom equality on both operands, so the RHS early return masks coverage of the per-subtype LHS guard; add a case with an ordinary RHS and custom-metaclass LHS, plus a __ne__-only case. EqualityDummy currently participates in no assertion, so remove it or use it in a meaningful regression.

[verified]

5 changes: 5 additions & 0 deletions packages/pyright-internal/src/tests/typeEvaluator6.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ test('TypeGuard3', () => {
TestUtils.validateResults(analysisResults, 0);
});

test('TypeGuard4', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typeGuard4.py']);
TestUtils.validateResults(analysisResults, 0);
});

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.

Issue · Please address or respond

Add equivalent regression coverage under packages/pylance-internal/src/tests/ so this user-visible narrowing change is exercised through the Pylance harness; the pyright-internal test can remain as supplemental coverage.

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.

Issue · Please address or respond

Please add equivalent regression coverage under packages/pylance-internal/src/tests/. This Pyright change currently has only Pyright-harness coverage, contrary to the required Pylance-first test-placement rule.

test('TypeIs1', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typeIs1.py']);
TestUtils.validateResults(analysisResults, 2);
Expand Down