From 9f9dd6182c34be883362edec75184a903961265e Mon Sep 17 00:00:00 2001 From: piy-ushk Date: Thu, 27 Aug 2026 16:06:06 +0530 Subject: [PATCH 1/4] Fix exhaustive match checking for final class objects --- .../pyright-internal/src/analyzer/patternMatching.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/pyright-internal/src/analyzer/patternMatching.ts b/packages/pyright-internal/src/analyzer/patternMatching.ts index 62847394d58a..bfbea67b47cf 100644 --- a/packages/pyright-internal/src/analyzer/patternMatching.ts +++ b/packages/pyright-internal/src/analyzer/patternMatching.ts @@ -1334,6 +1334,15 @@ function narrowTypeBasedOnValuePattern( (subjectSubtypeExpanded) => { // If this is a negative test, see if it's an enum value. if (!isPositiveTest) { + if ( + isInstantiableClass(subjectSubtypeExpanded) && + isInstantiableClass(valueSubtypeExpanded) && + isSameWithoutLiteralValue(subjectSubtypeExpanded, valueSubtypeExpanded) + ) { + if (ClassType.isFinal(subjectSubtypeExpanded) || !subjectSubtypeExpanded.priv.includeSubclasses) { + return undefined; + } + } if ( isClassInstance(subjectSubtypeExpanded) && isClassInstance(valueSubtypeExpanded) && From c5499be1249d1e283998b45ae9a79779760f5a11 Mon Sep 17 00:00:00 2001 From: piy-ushk Date: Thu, 27 Aug 2026 19:44:40 +0530 Subject: [PATCH 2/4] Address PR feedback: fix formatting and add regression test --- .../src/analyzer/patternMatching.ts | 5 ++- .../src/tests/samples/matchClassFinal.py | 42 +++++++++++++++++++ .../src/tests/typeEvaluator6.test.ts | 8 ++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/pyright-internal/src/tests/samples/matchClassFinal.py diff --git a/packages/pyright-internal/src/analyzer/patternMatching.ts b/packages/pyright-internal/src/analyzer/patternMatching.ts index bfbea67b47cf..212d193f1f91 100644 --- a/packages/pyright-internal/src/analyzer/patternMatching.ts +++ b/packages/pyright-internal/src/analyzer/patternMatching.ts @@ -1339,7 +1339,10 @@ function narrowTypeBasedOnValuePattern( isInstantiableClass(valueSubtypeExpanded) && isSameWithoutLiteralValue(subjectSubtypeExpanded, valueSubtypeExpanded) ) { - if (ClassType.isFinal(subjectSubtypeExpanded) || !subjectSubtypeExpanded.priv.includeSubclasses) { + if ( + ClassType.isFinal(subjectSubtypeExpanded) || + !subjectSubtypeExpanded.priv.includeSubclasses + ) { return undefined; } } diff --git a/packages/pyright-internal/src/tests/samples/matchClassFinal.py b/packages/pyright-internal/src/tests/samples/matchClassFinal.py new file mode 100644 index 000000000000..69ac405a0096 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/matchClassFinal.py @@ -0,0 +1,42 @@ +# pyright: reportMatchNotExhaustive=true + +from typing import final + +class Base: + pass + +@final +class A1(Base): + pass + +@final +class B1(Base): + pass + +class NS1: + A1 = A1 + B1 = B1 + +def exhaustive_final(inst: A1 | B1): + match type(inst): + case NS1.A1: + pass + case NS1.B1: + pass + +class A2(Base): + pass + +class B2(Base): + pass + +class NS2: + A2 = A2 + B2 = B2 + +def non_exhaustive_non_final(inst: A2 | B2): + match type(inst): + case NS2.A2: + pass + case NS2.B2: + pass diff --git a/packages/pyright-internal/src/tests/typeEvaluator6.test.ts b/packages/pyright-internal/src/tests/typeEvaluator6.test.ts index 652e0c1479f7..33793e1eb57a 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator6.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator6.test.ts @@ -623,6 +623,14 @@ test('MatchMapping1', () => { TestUtils.validateResults(analysisResults, 2); }); +test('MatchClassFinal', () => { + const configOptions = new ConfigOptions(Uri.empty()); + + configOptions.defaultPythonVersion = pythonVersion3_12; + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['matchClassFinal.py'], configOptions); + TestUtils.validateResults(analysisResults, 1); // 1 error for the non-exhaustive match +}); + test('MatchLiteral1', () => { const configOptions = new ConfigOptions(Uri.empty()); From 460963d5daefdd302359d768de81b72a6ddca9dd Mon Sep 17 00:00:00 2001 From: piy-ushk Date: Thu, 27 Aug 2026 20:02:39 +0530 Subject: [PATCH 3/4] Address PR feedback: add custom metaclass check for class matching --- .../src/analyzer/patternMatching.ts | 15 ++++++++++-- .../src/tests/samples/matchClassFinal.py | 23 +++++++++++++++++++ .../src/tests/typeEvaluator6.test.ts | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/pyright-internal/src/analyzer/patternMatching.ts b/packages/pyright-internal/src/analyzer/patternMatching.ts index 212d193f1f91..ace6127e000d 100644 --- a/packages/pyright-internal/src/analyzer/patternMatching.ts +++ b/packages/pyright-internal/src/analyzer/patternMatching.ts @@ -1339,9 +1339,20 @@ function narrowTypeBasedOnValuePattern( isInstantiableClass(valueSubtypeExpanded) && isSameWithoutLiteralValue(subjectSubtypeExpanded, valueSubtypeExpanded) ) { + // A value pattern compares with ==, not identity. Ensure that the + // class has standard equality semantics by checking if its metaclass + // is the standard type (or ABCMeta) and hasn't been overridden with a + // custom metaclass that might implement a custom __eq__. + const metaclass = subjectSubtypeExpanded.shared.effectiveMetaclass; + const isStandardEquality = + !metaclass || + (isClass(metaclass) && + ClassType.isBuiltIn(metaclass, ['type', 'ABCMeta', 'EnumMeta'])); + if ( - ClassType.isFinal(subjectSubtypeExpanded) || - !subjectSubtypeExpanded.priv.includeSubclasses + isStandardEquality && + (ClassType.isFinal(subjectSubtypeExpanded) || + !subjectSubtypeExpanded.priv.includeSubclasses) ) { return undefined; } diff --git a/packages/pyright-internal/src/tests/samples/matchClassFinal.py b/packages/pyright-internal/src/tests/samples/matchClassFinal.py index 69ac405a0096..be617531d5f1 100644 --- a/packages/pyright-internal/src/tests/samples/matchClassFinal.py +++ b/packages/pyright-internal/src/tests/samples/matchClassFinal.py @@ -40,3 +40,26 @@ def non_exhaustive_non_final(inst: A2 | B2): pass case NS2.B2: pass + +class Meta(type): + def __eq__(self, other): + return False + +@final +class C1(metaclass=Meta): + pass + +@final +class D1(metaclass=Meta): + pass + +class NS3: + C1 = C1 + D1 = D1 + +def non_exhaustive_custom_meta(inst: C1 | D1): + match type(inst): + case NS3.C1: + pass + case NS3.D1: + pass diff --git a/packages/pyright-internal/src/tests/typeEvaluator6.test.ts b/packages/pyright-internal/src/tests/typeEvaluator6.test.ts index 33793e1eb57a..51e5a6b51709 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator6.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator6.test.ts @@ -628,7 +628,7 @@ test('MatchClassFinal', () => { configOptions.defaultPythonVersion = pythonVersion3_12; const analysisResults = TestUtils.typeAnalyzeSampleFiles(['matchClassFinal.py'], configOptions); - TestUtils.validateResults(analysisResults, 1); // 1 error for the non-exhaustive match + TestUtils.validateResults(analysisResults, 2); // 1 error for non-final, 1 error for custom metaclass }); test('MatchLiteral1', () => { From ad68a05918b84d3313cf767f0a4c5b9cdf37e328 Mon Sep 17 00:00:00 2001 From: piy-ushk Date: Thu, 27 Aug 2026 20:31:40 +0530 Subject: [PATCH 4/4] Address PR feedback: use lookUpClassMember to resolve __eq__ in metaclass MRO --- .../src/analyzer/patternMatching.ts | 16 ++++++++------ .../src/tests/samples/matchClassFinal.py | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/pyright-internal/src/analyzer/patternMatching.ts b/packages/pyright-internal/src/analyzer/patternMatching.ts index ace6127e000d..928e3d5ce910 100644 --- a/packages/pyright-internal/src/analyzer/patternMatching.ts +++ b/packages/pyright-internal/src/analyzer/patternMatching.ts @@ -1339,15 +1339,17 @@ function narrowTypeBasedOnValuePattern( isInstantiableClass(valueSubtypeExpanded) && isSameWithoutLiteralValue(subjectSubtypeExpanded, valueSubtypeExpanded) ) { - // A value pattern compares with ==, not identity. Ensure that the - // class has standard equality semantics by checking if its metaclass - // is the standard type (or ABCMeta) and hasn't been overridden with a - // custom metaclass that might implement a custom __eq__. const metaclass = subjectSubtypeExpanded.shared.effectiveMetaclass; + let eqClass: ClassType | undefined; + if (metaclass && isInstantiableClass(metaclass)) { + const eqMember = lookUpClassMember(metaclass, '__eq__'); + if (eqMember && isClass(eqMember.classType)) { + eqClass = eqMember.classType; + } + } + const isStandardEquality = - !metaclass || - (isClass(metaclass) && - ClassType.isBuiltIn(metaclass, ['type', 'ABCMeta', 'EnumMeta'])); + !eqClass || ClassType.isBuiltIn(eqClass, ['type', 'object', 'ABCMeta', 'EnumMeta']); if ( isStandardEquality && diff --git a/packages/pyright-internal/src/tests/samples/matchClassFinal.py b/packages/pyright-internal/src/tests/samples/matchClassFinal.py index be617531d5f1..786aca8c5be4 100644 --- a/packages/pyright-internal/src/tests/samples/matchClassFinal.py +++ b/packages/pyright-internal/src/tests/samples/matchClassFinal.py @@ -63,3 +63,25 @@ def non_exhaustive_custom_meta(inst: C1 | D1): pass case NS3.D1: pass + +class MetaNoOverride(type): + pass + +@final +class E1(metaclass=MetaNoOverride): + pass + +@final +class F1(metaclass=MetaNoOverride): + pass + +class NS4: + E1 = E1 + F1 = F1 + +def exhaustive_custom_meta_no_override(inst: E1 | F1): + match type(inst): + case NS4.E1: + pass + case NS4.F1: + pass