Skip to content

Preserve abstract classes through identity decorators - #11710

Draft
Bill Schnurr (bschnurr) wants to merge 1 commit into
microsoft:mainfrom
bschnurr:fix-11411-abstract-identity-decorators
Draft

Bill Schnurr (bschnurr) wants to merge 1 commit into
microsoft:mainfrom
bschnurr:fix-11411-abstract-identity-decorators

Conversation

@bschnurr

Copy link
Copy Markdown
Member

Description

Preserve abstract-class instantiation diagnostics through proven identity decorators while allowing decorators that genuinely replace a class to return concrete results.

How you figured out what to do

I traced decorated class evaluation and abstract-use diagnostics through decorators.ts, typeEvaluator.ts, and the checker. A generic decorator returning type[T] obscured the original abstract class, but type signatures alone were insufficient because replacement decorators can have the same apparent shape.

Implementation

Record class identity preservation only when the applied decorator can be proven from its body and declarations to return its class argument unchanged. Recover the underlying class for abstract-use diagnostics only in that proven case; replacements, casts, reassignments, and function-decorated lookalikes retain existing behavior.

Screenshots or GIFs are not applicable because this change affects analyzer or language-service behavior and has no visual UI.

Testing

Added checker coverage and local/imported samples for abstract protocols and ABCs, stacked decorators, concrete implementations, replacement returns, casts, reassignments, and false-positive boundaries. A 565-test checker/evaluator selection passed.

Addresses

Fixes #11411

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

@heejaechang

Heejae Chang (heejaechang) commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

🔴 Pyright CLI QA — b576ee46 — verdict: red

Automated CLI checks compared this change with its base revision.
9 focused check(s) completed.

Needs review: reportAbstractUsage newly reported

The PR reports reportAbstractUsage for this example; the base does not. Is this change intended?

Reproduction and observed results

Create these files in an empty directory:

main.py

from abc import ABC, abstractmethod
from typing import reveal_type

class Base(ABC):
    @abstractmethod
    def value(self) -> int: ...

def identity(cls: type[Base]) -> type[Base]:
    return cls

@identity
class Selected(Base):
    pass

def construct() -> Base:
    return Selected()

class Concrete(Base):
    def value(self) -> int:
        return 42

Selected = Concrete
result = construct()
assert result.value() == 42
reveal_type(result)

pyrightconfig.json

{"include":["."],"pythonVersion":"3.13","typeCheckingMode":"basic"}

From that directory, run each built Pyright revision:

node <pyright-cli-entrypoint> --outputjson -p .

Base fa9b200906fadc093e862ce62ec1afa985c7233d

{
  "exit_code": 0,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"result\" is \"Base\"",
      "range": {
        "start": [
          24,
          12
        ],
        "end": [
          24,
          18
        ]
      }
    }
  ]
}

PR b576ee46f40098c4df2bbfc424efc2e8667b2a92

{
  "exit_code": 1,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "error",
      "rule": "reportAbstractUsage",
      "message": "Cannot instantiate abstract class \"Selected\"\n  \"Base.value\" is not implemented",
      "range": {
        "start": [
          15,
          11
        ],
        "end": [
          15,
          21
        ]
      }
    },
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"result\" is \"Base\"",
      "range": {
        "start": [
          24,
          12
        ],
        "end": [
          24,
          18
        ]
      }
    }
  ]
}

Diagnostic ranges use zero-based line and character positions.

Needs review: reportAbstractUsage newly reported

The PR reports reportAbstractUsage for this example; the base does not. Is this change intended?

Reproduction and observed results

Create these files in an empty directory:

main.py

from abc import ABC, abstractmethod
from typing import assert_type

class Base(ABC):
    @abstractmethod
    def value(self) -> int: ...

class Concrete(Base):
    def value(self) -> int:
        return 42

class Decorator:
    def decorate(self, cls: type[Base]) -> type[Base]:
        return cls

class Materializer(Decorator):
    def decorate(self, cls: type[Base]) -> type[Base]:
        return Concrete

def get_decorator() -> Decorator:
    return Materializer()

@get_decorator().decorate
class Replaced(Base):
    pass

result = Replaced()
assert_type(result, Base)
assert result.value() == 42

pyrightconfig.json

{"include":["."],"pythonVersion":"3.13","typeCheckingMode":"basic"}

From that directory, run each built Pyright revision:

node <pyright-cli-entrypoint> --outputjson -p .

Base fa9b200906fadc093e862ce62ec1afa985c7233d

{
  "exit_code": 0,
  "diagnostics": []
}

PR b576ee46f40098c4df2bbfc424efc2e8667b2a92

{
  "exit_code": 1,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "error",
      "rule": "reportAbstractUsage",
      "message": "Cannot instantiate abstract class \"Replaced\"\n  \"Base.value\" is not implemented",
      "range": {
        "start": [
          26,
          9
        ],
        "end": [
          26,
          19
        ]
      }
    }
  ]
}

Diagnostic ranges use zero-based line and character positions.

@bschnurr

Copy link
Copy Markdown
Member Author

/benchmark

@github-actions

Copy link
Copy Markdown
Contributor

Compared candidate e715724e6a7bce1a012e6699cd90b3be075b1430 against its first parent 9bf95be56744123da06567463df1eb5d6fe81896.

View the full release history charts with the base and PR results.

Execution time history

Execution time history

Peak memory history

Peak memory history

Download the chart artifact.

Type checker benchmark

🟢 No performance regressions detected.

Regression threshold: 20.0%
Statistic: median
Variance guard: >1.0s time and >100.0 MB memory

Package Checker Files checked Time Time delta Peak memory Memory delta Status
ansible pyright 583 18.219s +1.4% 1082.0 MB -0.5% 🟢 Pass
click pyright 17 2.674s +0.2% 355.0 MB -0.1% 🟢 Pass
homeassistant pyright 9850 78.149s +0.0% 6060.9 MB -0.4% 🟢 Pass
numpy pyright 356 33.698s +0.8% 1339.1 MB -1.4% 🟢 Pass
pandas pyright 1459 130.364s -1.0% 3518.1 MB -3.6% 🟢 Pass
pytest pyright 243 12.422s +0.6% 843.6 MB -0.1% 🟢 Pass
requests pyright 19 2.348s -0.2% 342.7 MB -1.0% 🟢 Pass
torch pyright 2400 135.585s -1.1% 4796.6 MB -3.7% 🟢 Pass
transformers pyright 2901 84.187s -2.9% 4966.3 MB -1.0% 🟢 Pass

Pyright stats

Package Parsed/bound Checked Find Read Tokenize Parse Imports Bind Check Cycles
ansible 989 583 0.040s 0.087s 0.390s 0.740s 0.203s 0.997s 14.713s 0.000s
click 111 17 0.000s 0.013s 0.103s 0.180s 0.030s 0.237s 1.817s 0.000s
homeassistant 11147 9850 0.213s 0.503s 1.767s 2.997s 1.360s 4.030s 65.750s 0.000s
numpy 603 356 0.010s 0.057s 0.343s 0.720s 0.250s 0.807s 30.937s 0.000s
pandas 1895 1459 0.060s 0.213s 1.140s 2.460s 0.283s 2.483s 123.190s 0.000s
pytest 542 243 0.020s 0.053s 0.347s 0.537s 0.110s 0.723s 10.210s 0.000s
requests 168 19 0.000s 0.017s 0.120s 0.223s 0.057s 0.267s 1.360s 0.000s
torch 3079 2400 0.140s 0.377s 2.010s 3.637s 0.597s 4.407s 122.887s 0.000s
transformers 3535 2901 0.060s 0.303s 1.497s 2.430s 0.670s 6.317s 74.093s 0.000s

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Abstract class instantiation not detected when decorated by a generic method that returns type[T]

2 participants