Skip to content

Fix exhaustive match checking for final class objects - #11683

Open
Piyush Kulkarni (piy-ushk) wants to merge 4 commits into
microsoft:mainfrom
piy-ushk:fix-match-type-coverage
Open

Fix exhaustive match checking for final class objects#11683
Piyush Kulkarni (piy-ushk) wants to merge 4 commits into
microsoft:mainfrom
piy-ushk:fix-match-type-coverage

Conversation

@piy-ushk

Copy link
Copy Markdown

What was the issue?
In Python, checking type(inst) for exhaustiveness is challenging because class types are "open" by default—meaning inst could be an instance of a subclass, not necessarily the exact base class. Therefore, Pyright correctly flags that subclasses are left "unhandled" if you simply check for the exact class itself.

However, Pyright was not properly narrowing class types when matched against case statements if those classes were explicitly marked as Final (@Final) (which guarantees they cannot have subclasses). While type(inst) is NS.A worked correctly with Final (@Final) classes, the match statement was lacking this Final (@Final) check in its narrowing logic.

The Fix
I added logic to packages/pyright-internal/src/analyzer/patternMatching.ts to check if a class object pattern match involves a Final (@Final) class. Now, if you match against a Final (@Final) class in a match statement, Pyright correctly removes that exact type from the unhandled options, leading to properly passing exhaustiveness checks.

@rchiodo

Rich Chiodo (rchiodo) commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

🔒 Automated review in progress — Rich Chiodo (@rchiodo) is auto-reviewing this PR.

isInstantiableClass(valueSubtypeExpanded) &&
isSameWithoutLiteralValue(subjectSubtypeExpanded, valueSubtypeExpanded)
) {
if (ClassType.isFinal(subjectSubtypeExpanded) || !subjectSubtypeExpanded.priv.includeSubclasses) {

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

This condition exceeds the repository's enforced 120-character Prettier formatting limit. Please wrap the clauses before merging.

if (ClassType.isFinal(subjectSubtypeExpanded) || !subjectSubtypeExpanded.priv.includeSubclasses) {
return undefined;
}
}

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 regression coverage showing that a final class-object match is exhaustive and an equivalent non-final class remains non-exhaustive. This analyzer behavior change currently has no test specification.

@rchiodo

Copy link
Copy Markdown
Collaborator

Verification: The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Result: needs-more-tests

Summary: The targeted `typeEvaluator6` tests could not run because no trusted sandbox image was configured. The PR adds no tests, and existing match tests do not cover exhaustive value matching of final class objects. The new behavior therefore needs direct regression coverage.

Test runs: 1 not run

  • ⚠️ Not run | typeEvaluator6 match-pattern tests | pnpm exec jest typeEvaluator6.test --runInBand
⚠️ typeEvaluator6 match-pattern tests diagnostic output
Container verification could not start: no trusted sandbox image is configured for microsoft/pyright.
@rchiodo Rich Chiodo (rchiodo) added the review-auto:changes-requested Automated review: posted blocking findings to address. label Aug 27, 2026
@piy-ushk

Copy link
Copy Markdown
Author

Thanks for the review Rich Chiodo (@rchiodo) I've pushed an update addressing both pieces of feedback:

  1. Formatting: I wrapped the if condition in patternMatching.ts to respect the 120-character limit.
  2. Regression Testing: I added a new test file (matchClassFinal.py) and hooked it up in typeEvaluator6.test.ts. The new tests provide explicit regression coverage verifying that:
    • A match case targeting a @final class object is properly resolved as exhaustive.
    • An equivalent non-final class object correctly remains non-exhaustive (emitting the expected unhandled type error).
isInstantiableClass(valueSubtypeExpanded) &&
isSameWithoutLiteralValue(subjectSubtypeExpanded, valueSubtypeExpanded)
) {
if (

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

Could this guard account for final classes with metaclasses that override __eq__? A value pattern compares with ==, not identity, so type(inst) can fail to match the class at runtime even when its nominal type is exact; removing it here could incorrectly suppress an exhaustive-match diagnostic. Please add coverage and retain the subtype when equality semantics are nonstandard.

@rchiodo

Copy link
Copy Markdown
Collaborator

Verification: The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Result: could-not-verify

Summary: The PR adds targeted coverage for final and non-final class-object match exhaustiveness. No tests executed because the trusted verification sandbox could not start: no image is configured for `microsoft/pyright`. The committed test was confirmed as new, but runtime behavior remains unverified.

Test runs: 1 not run

  • ⚠️ Not run | MatchClassFinal | pnpm exec jest typeEvaluator6.test -t "MatchClassFinal" --runInBand --forceExit
⚠️ MatchClassFinal diagnostic output
Container verification could not start: No trusted sandbox image is configured for microsoft/pyright.

@rchiodo Rich Chiodo (rchiodo) left a comment

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.

Approved via Review Center.

@rchiodo Rich Chiodo (rchiodo) added review-auto:approved Automated review: no blocking findings (approval posted). and removed review-auto:changes-requested Automated review: posted blocking findings to address. labels Aug 27, 2026
@piy-ushk

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@piy-ushk

Copy link
Copy Markdown
Author

Thanks for pointing that out, Rich Chiodo (@rchiodo) great catch . I didn't consider the case where a custom metaclass overrides __eq__.

I've pushed another update that ensures we only remove the subtype if it has standard equality semantics (its effective metaclass is the built-in type, ABCMeta, or EnumMeta). If a custom metaclass is used, the subtype is retained and the diagnostic remains active.

I also added regression tests for the custom metaclass case to ensure that it correctly remains non-exhaustive.

const isStandardEquality =
!metaclass ||
(isClass(metaclass) &&
ClassType.isBuiltIn(metaclass, ['type', 'ABCMeta', 'EnumMeta']));

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 allowlist also rejects class Meta(type): pass, so final classes using the inherited type.__eq__ remain incorrectly non-exhaustive. Resolve __eq__ through the metaclass MRO and accept it when inherited unchanged from type; add a no-override custom-metaclass regression to confirm the behavior.

@rchiodo

Copy link
Copy Markdown
Collaborator

Verification: The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Result: could-not-verify

Summary: The PR adds targeted coverage for final, non-final, and custom-metaclass class-object matches. No test could execute because the trusted sandbox image for `microsoft/pyright` is not configured and local execution was unauthorized. The new test appears to cover all introduced behavior, but runtime verification was impossible.

Test runs: 1 not run

  • ⚠��� Not run | MatchClassFinal | cd packages\pyright-internal && pnpm exec jest typeEvaluator6.test -t "MatchClassFinal" --runInBand --forceExit
⚠️ MatchClassFinal diagnostic output
Container verification could not start: no trusted sandbox image is configured for microsoft/pyright, and local execution was not authorized for this PR HEAD.
@piy-ushk

Copy link
Copy Markdown
Author

Again, Thank you for the review.
The above fix correctly uses lookUpClassMember(metaclass, eq) to walk the metaclass's full MRO to find where eq is actually defined. If it traces back to a standard built-in (type, object, ABCMeta, EnumMeta), it's safe to narrow exhaustively. If a custom metaclass defines its own eq, pyright correctly refuses to narrow.

@rchiodo

Copy link
Copy Markdown
Collaborator

Verification: The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Result: could-not-verify

Summary: Verification could not run because no trusted sandbox image is configured for `microsoft/pyright`, and local execution was not authorized. The PR adds targeted coverage for final classes, non-final classes, overridden metaclass equality, and inherited standard equality. No test result can be claimed.

Test runs: 1 not run

  • ⚠️ Not run | MatchClassFinal | cd packages\pyright-internal && pnpm exec jest typeEvaluator6.test -t MatchClassFinal --runInBand
⚠️ MatchClassFinal diagnostic output
Container verification could not start: no trusted sandbox image is configured for microsoft/pyright; local execution was not authorized.

@bschnurr Bill Schnurr (bschnurr) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved via Review Center.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-auto:approved Automated review: no blocking findings (approval posted).

3 participants