Skip to content

Fix constrained TypeVar narrowing for issubclass - #11951

Open
Guishi Wang (guishiw) wants to merge 1 commit into
microsoft:mainfrom
guishiw:fix/11946-issubclass-typevar
Open

Guishi Wang (guishiw) wants to merge 1 commit into
microsoft:mainfrom
guishiw:fix/11946-issubclass-typevar

Conversation

@guishiw

Copy link
Copy Markdown

fix: #11946

Root cause
Control-flow narrowing for constrained TypeVars recognized only isinstance conditions. An issubclass condition receives type[T], so the return-type checker could not associate the narrowed class with the constrained instance TypeVar T.

Fix strategy
Recognize issubclass in constrained-TypeVar flow analysis and remove one instantiable layer from type[T] before matching it to T. Add positive and negative branch coverage to the existing constrained TypeVar sample.

The relevant test suite passes with 164 tests.

@heejaechang

Copy link
Copy Markdown
Collaborator

🔴 Pyright CLI QA — 3fe67169 — verdict: red

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

Needs review: reportReturnType no longer reported

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

Reproduction and observed results

Create these files in an empty directory:

main.py

from typing import TypeVar, assert_type
reveal_type(0)
class Base:
    pass
class Derived(Base):
    def only_derived(self) -> int:
        return 42
T = TypeVar("T", Base, Derived)
def factory(cls: type[T]) -> T:
    if issubclass(cls, Base):
        return Base()
    return cls()
assert_type(factory(Derived), Derived)
factory(Derived).only_derived()

pyrightconfig.json

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

runtime.py

import sys
from typing import TypeVar
print(sys.version)
print("builtin issubclass:", issubclass, "native generic syntax supported")
class Base: pass
class Derived(Base):
    def only_derived(self) -> int: return 42
T = TypeVar("T", Base, Derived)
def factory(cls: type[T]) -> T:
    if issubclass(cls, Base): return Base()
    return cls()
assert issubclass(Derived, Base)
result = factory(Derived)
assert type(result) is Base
try:
    result.only_derived()
except AttributeError as exc:
    print("confirmed incompatible result:", type(exc).__name__, str(exc))
else:
    raise AssertionError("expected Derived-only member to be absent")
def native[T: (Derived, Base)](cls: type[T]) -> list[T]:
    if issubclass(cls, Base): return [Base()]
    return [cls()]
assert type(native(Derived)[0]) is Base
assert issubclass(str, (str,))
assert issubclass(str, ((str,),))
assert issubclass(str, str | int)
print("native nested variant and equivalent filters executed")

From that directory, run each built Pyright revision:

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

Base 74743ab17950a6bd205935e21808102a610d40a1

{
  "exit_code": 1,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "error",
      "rule": "reportReturnType",
      "message": "Type \"Base\" is not assignable to return type \"T@factory\"\n  Type \"Base\" is not assignable to type \"T@factory\"",
      "range": {
        "start": [
          10,
          15
        ],
        "end": [
          10,
          21
        ]
      }
    },
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"0\" is \"Literal[0]\"",
      "range": {
        "start": [
          1,
          12
        ],
        "end": [
          1,
          13
        ]
      }
    }
  ]
}

PR 3fe6716915a4ff775463a41f6d1dcbe09e69ef06

{
  "exit_code": 0,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"0\" is \"Literal[0]\"",
      "range": {
        "start": [
          1,
          12
        ],
        "end": [
          1,
          13
        ]
      }
    }
  ]
}

Diagnostic ranges use zero-based line and character positions.

Needs review: reportReturnType no longer reported

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

Reproduction and observed results

Create these files in an empty directory:

main.py

from typing import TypeVar, assert_type
reveal_type(0)
T = TypeVar("T", str, int)
def issubclass(cls: type, other: type) -> bool:
    return True
def factory(cls: type[T]) -> T:
    if issubclass(cls, str):
        return "wrong"
    return cls()
assert_type(factory(int), int)
factory(int).bit_length()

pyrightconfig.json

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

runtime.py

import sys
from typing import TypeVar
print(sys.version)
T = TypeVar("T", str, int)
def issubclass(cls: type, other: type) -> bool: return True
def factory(cls: type[T]) -> T:
    if issubclass(cls, str): return "wrong"
    return cls()
result = factory(int)
assert type(result) is str
try:
    result.bit_length()
except AttributeError as exc:
    print("confirmed shadowed predicate violates return promise:", type(exc).__name__, str(exc))
else:
    raise AssertionError("expected str to lack bit_length")

From that directory, run each built Pyright revision:

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

Base 74743ab17950a6bd205935e21808102a610d40a1

{
  "exit_code": 1,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "error",
      "rule": "reportReturnType",
      "message": "Type \"Literal['wrong']\" is not assignable to return type \"T@factory\"\n  Type \"Literal['wrong']\" is not assignable to type \"T@factory\"",
      "range": {
        "start": [
          7,
          15
        ],
        "end": [
          7,
          22
        ]
      }
    },
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"0\" is \"Literal[0]\"",
      "range": {
        "start": [
          1,
          12
        ],
        "end": [
          1,
          13
        ]
      }
    }
  ]
}

PR 3fe6716915a4ff775463a41f6d1dcbe09e69ef06

{
  "exit_code": 0,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"0\" is \"Literal[0]\"",
      "range": {
        "start": [
          1,
          12
        ],
        "end": [
          1,
          13
        ]
      }
    }
  ]
}

Diagnostic ranges use zero-based line and character positions.

Needs review: reportReturnType no longer reported

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

Reproduction and observed results

Create these files in an empty directory:

main.py

from typing import TypeVar, assert_type
reveal_type(0)
T = TypeVar("T", str, int)
class FancyStr(str): pass
def factory(cls: type[T], filter_class: type[str]) -> T:
    if issubclass(cls, filter_class):
        return cls()
    return 0
assert_type(factory(str, FancyStr), str)
factory(str, FancyStr).upper()

pyrightconfig.json

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

runtime.py

import sys
from typing import TypeVar
print(sys.version)
print("builtin capability:", issubclass, "type[str] covariance exercised")
T = TypeVar("T", str, int)
class FancyStr(str): pass
def factory(cls: type[T], filter_class: type[str]) -> T:
    if issubclass(cls, filter_class): return cls()
    return 0
assert issubclass(FancyStr, str)
assert not issubclass(str, FancyStr)
result = factory(str, FancyStr)
assert type(result) is int
try:
    result.upper()
except AttributeError as exc:
    print("confirmed feasible str constraint was eliminated:", type(exc).__name__, str(exc))
else:
    raise AssertionError("expected int to lack upper")
assert type(factory(str, str)) is str
print("exact-filter control returns str")

From that directory, run each built Pyright revision:

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

Base 74743ab17950a6bd205935e21808102a610d40a1

{
  "exit_code": 1,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "error",
      "rule": "reportReturnType",
      "message": "Type \"Literal[0]\" is not assignable to return type \"T@factory\"\n  Type \"Literal[0]\" is not assignable to type \"T@factory\"",
      "range": {
        "start": [
          7,
          11
        ],
        "end": [
          7,
          12
        ]
      }
    },
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"0\" is \"Literal[0]\"",
      "range": {
        "start": [
          1,
          12
        ],
        "end": [
          1,
          13
        ]
      }
    }
  ]
}

PR 3fe6716915a4ff775463a41f6d1dcbe09e69ef06

{
  "exit_code": 0,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"0\" is \"Literal[0]\"",
      "range": {
        "start": [
          1,
          12
        ],
        "end": [
          1,
          13
        ]
      }
    }
  ]
}

Diagnostic ranges use zero-based line and character positions.

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.

The type inferred from an argument type[T] is not applied to return type T

2 participants