[ty] Fix subtyping of `type[Any]` / `type[T]` and protocols (#21678)

## Summary

This is a bugfix for subtyping of `type[Any]` / `type[T]` and protocols.

## Test Plan

Regression test that will only be really meaningful once
https://github.com/astral-sh/ruff/pull/21553 lands.
This commit is contained in:
David Peter 2025-11-28 16:56:22 +01:00 committed by GitHub
parent 566c959add
commit 0084e94f78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 87 additions and 0 deletions

View File

@ -0,0 +1,67 @@
# numpy
```toml
[environment]
python-version = "3.14"
```
## numpy's `dtype`
numpy functions often accept a `dtype` parameter. For example, one of `np.array`'s overloads accepts
a `dtype` parameter of type `DTypeLike | None`. Here, we build up something that resembles numpy's
internals in order to model the type `DTypeLike`. Many details have been left out.
`mini_numpy.py`:
```py
from typing import TypeVar, Generic, Any, Protocol, TypeAlias, runtime_checkable, final
import builtins
_ItemT_co = TypeVar("_ItemT_co", default=Any, covariant=True)
class generic(Generic[_ItemT_co]):
@property
def dtype(self) -> _DTypeT_co:
raise NotImplementedError
_BoolItemT_co = TypeVar("_BoolItemT_co", bound=builtins.bool, default=builtins.bool, covariant=True)
class bool(generic[_BoolItemT_co], Generic[_BoolItemT_co]): ...
@final
class object_(generic): ...
_ScalarT = TypeVar("_ScalarT", bound=generic)
_ScalarT_co = TypeVar("_ScalarT_co", bound=generic, default=Any, covariant=True)
@final
class dtype(Generic[_ScalarT_co]): ...
_DTypeT_co = TypeVar("_DTypeT_co", bound=dtype, default=dtype, covariant=True)
@runtime_checkable
class _SupportsDType(Protocol[_DTypeT_co]):
@property
def dtype(self) -> _DTypeT_co: ...
# TODO: no errors here
# error: [invalid-type-arguments] "Type `typing.TypeVar` is not assignable to upper bound `generic[Any]` of type variable `_ScalarT_co@dtype`"
# error: [invalid-type-arguments] "Type `typing.TypeVar` is not assignable to upper bound `generic[Any]` of type variable `_ScalarT_co@dtype`"
_DTypeLike: TypeAlias = type[_ScalarT] | dtype[_ScalarT] | _SupportsDType[dtype[_ScalarT]]
DTypeLike: TypeAlias = _DTypeLike[Any] | str | None
```
Now we can make sure that a function which accepts `DTypeLike | None` works as expected:
```py
import mini_numpy as np
def accepts_dtype(dtype: np.DTypeLike | None) -> None: ...
accepts_dtype(dtype=np.bool)
accepts_dtype(dtype=np.dtype[np.bool])
accepts_dtype(dtype=object)
accepts_dtype(dtype=np.object_)
accepts_dtype(dtype="U")
```

View File

@ -2468,6 +2468,26 @@ impl<'db> Type<'db> {
}) })
} }
// `type[Any]` is assignable to arbitrary protocols as it has arbitrary attributes
// (this is handled by a lower-down branch), but it is only a subtype of a given
// protocol if `type` is a subtype of that protocol. Similarly, `type[T]` will
// always be assignable to any protocol if `type[<upper bound of T>]` is assignable
// to that protocol (handled lower down), but it is only a subtype of that protocol
// if `type` is a subtype of that protocol.
(Type::SubclassOf(self_subclass_ty), Type::ProtocolInstance(_))
if (self_subclass_ty.is_dynamic() || self_subclass_ty.is_type_var())
&& !relation.is_assignability() =>
{
KnownClass::Type.to_instance(db).has_relation_to_impl(
db,
target,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}
(_, Type::ProtocolInstance(protocol)) => { (_, Type::ProtocolInstance(protocol)) => {
relation_visitor.visit((self, target, relation), || { relation_visitor.visit((self, target, relation), || {
self.satisfies_protocol( self.satisfies_protocol(