mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 10:45:37 +08:00
[ty] Fix constructor calls on narrowed type-variable intersections (#27493)
## Summary In #27449, we start treating calls to `type[object]` strictly (using `object.__init__` rather than the overly forgiving `type.__call__`). This also means that `type[T] & type[SomeType]` intersections (which can easily arise with a parameter `cls: type[T]` which is then narrowed via `issubclass(cls, SomeType)`) start validating the `type[T]` portion against `object.__init__` (assuming `T` has no upper bound besides `object`). That means if the call to `type[SomeType]` fails, we now get confusing double diagnostics also complaining about the failed call to `object.__init__`. This PR resolves that problem separately in a general way: if we have an intersection `type[T] & type[SomeClass]`, where `SomeClass` is a subclass of the upper bound of `T`, we don't try calling both constructors; we just try the constructor of `SomeClass`. (But we preserve the full intersection as receiver, so that the result of the call will still be `T & SomeClass`, not just `SomeClass`.) Since all classes are subclasses of `object`, this means we don't try calling `object.__init__` or emit errors about it when they are redundant and confusing in an intersection with some more specific `type[...]`. (This is only relevant to intersections with `type[T]` where `T` is a typevar, since a normal `type[Base] & type[Child]` intersection would immediately simplify to `type[Child]`. That simplification doesn't occur with a typevar, since we need to preserve the typevar identity; it may actually represent a narrower type, not its upper bound.) - Resolve constructor calls on narrowed `type[T] & type[Child]` intersections using the applicable subclass constructor instead of treating the type-variable bound as an independent alternative. - Preserve the precise `T & Child` result, report only subclass-constructor argument errors, and retain specialized generic constructors, built-in behavior, independent metaclass callables, and explicit `__new__` / metaclass `__call__` return types. ## Test plan - Added constructor mdtests for `issubclass`-narrowed bounded, constrained, and unbounded type variables; valid return types; rejected arguments; and deduplicated diagnostics. - Covered explicitly specialized generic constructors, literal-preserving built-in constructors, `Self` returns, non-instance `__new__` and metaclass `__call__` returns, and intersections with independent metaclass callables. Ecosystem changes are both correct/improvements.
This commit is contained in:
1 parent
ea933ff891
commit
2ddadb9937
5 files changed
+238
-11
No files matched your search
@@ -1512,6 +1512,193 @@ def f(cls: type[T]):
|
||||
reveal_type(cls(1, "foo")) # revealed: T@f
|
||||
```
|
||||
|
||||
## Intersection constructors
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
### Narrowed bound type variables
|
||||
|
||||
Narrowing a bounded class type with `issubclass` must use the subclass constructor while preserving
|
||||
both the original type variable and the narrowed subclass in the return type.
|
||||
|
||||
```py
|
||||
class Base:
|
||||
def __init__(self, value: str) -> None: ...
|
||||
|
||||
class IntConstructor(Base):
|
||||
def __init__(self, value: int) -> None: ...
|
||||
|
||||
def valid[T: Base](cls: type[T]) -> T:
|
||||
if issubclass(cls, IntConstructor):
|
||||
reveal_type(cls) # revealed: type[T@valid] & type[IntConstructor]
|
||||
reveal_type(cls(1)) # revealed: T@valid & IntConstructor
|
||||
return cls(1)
|
||||
return cls("ok")
|
||||
```
|
||||
|
||||
An argument accepted by the bound's constructor must still be rejected by the narrowed subclass.
|
||||
Arguments rejected by both constructors produce only the subclass constructor's diagnostic.
|
||||
|
||||
```py
|
||||
def invalid_arguments[T: Base](cls: type[T]) -> None:
|
||||
if issubclass(cls, IntConstructor):
|
||||
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
|
||||
cls("wrong")
|
||||
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `None`"
|
||||
cls(None)
|
||||
```
|
||||
|
||||
### Narrowed constrained type variables
|
||||
|
||||
Narrowing a constrained type variable selects the matching constructor without losing the original
|
||||
type variable in the return type.
|
||||
|
||||
```py
|
||||
class StringConstructor:
|
||||
def __init__(self, value: str) -> None: ...
|
||||
|
||||
class IntConstructor:
|
||||
def __init__(self, value: int) -> None: ...
|
||||
|
||||
def construct[T: (StringConstructor, IntConstructor)](cls: type[T]) -> None:
|
||||
if issubclass(cls, IntConstructor):
|
||||
reveal_type(cls) # revealed: type[T@construct] & type[IntConstructor]
|
||||
reveal_type(cls(1)) # revealed: T@construct & IntConstructor
|
||||
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
|
||||
cls("wrong")
|
||||
```
|
||||
|
||||
### Narrowed unbounded type variables
|
||||
|
||||
The narrowed subclass constructor also determines which arguments are valid when the original type
|
||||
variable has no upper bound.
|
||||
|
||||
```py
|
||||
class IntConstructor:
|
||||
def __init__(self, value: int) -> None: ...
|
||||
|
||||
def construct[T](cls: type[T]) -> None:
|
||||
if issubclass(cls, IntConstructor):
|
||||
reveal_type(cls) # revealed: type[T@construct] & type[IntConstructor]
|
||||
reveal_type(cls(1)) # revealed: T@construct & IntConstructor
|
||||
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
|
||||
cls("wrong")
|
||||
```
|
||||
|
||||
### Specialized generic constructors
|
||||
|
||||
A generic constructor provider must retain its explicit specialization when validating arguments and
|
||||
preserve the original type variable in its return type.
|
||||
|
||||
```py
|
||||
from ty_extensions import Intersection
|
||||
|
||||
class Box[S]:
|
||||
def __init__(self, value: S) -> None: ...
|
||||
|
||||
def construct[T](cls: Intersection[type[T], type[Box[int]]]) -> None:
|
||||
reveal_type(cls(1)) # revealed: T@construct & Box[int]
|
||||
# error: [invalid-argument-type] "Argument to `Box.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
|
||||
cls("wrong")
|
||||
```
|
||||
|
||||
### Built-in constructor behavior
|
||||
|
||||
Narrowing to a final built-in class must retain its specialized constructor behavior and the
|
||||
original type variable.
|
||||
|
||||
```py
|
||||
def construct[T](cls: type[T]) -> None:
|
||||
if issubclass(cls, bool):
|
||||
reveal_type(cls) # revealed: type[T@construct] & <class 'bool'>
|
||||
reveal_type(cls(1)) # revealed: T@construct & Literal[True]
|
||||
```
|
||||
|
||||
### `Self` constructor returns
|
||||
|
||||
An explicit `Self` return still represents the constructed instance, so narrowing must preserve the
|
||||
original type variable and validate the subclass initializer.
|
||||
|
||||
```py
|
||||
from typing import Self
|
||||
|
||||
class Base:
|
||||
def __init__(self, value: str) -> None: ...
|
||||
|
||||
class NewChild(Base):
|
||||
def __new__(cls, value: object) -> Self:
|
||||
return object.__new__(cls)
|
||||
|
||||
def __init__(self, value: int) -> None: ...
|
||||
|
||||
def construct[T: Base](cls: type[T]) -> None:
|
||||
if issubclass(cls, NewChild):
|
||||
reveal_type(cls(1)) # revealed: T@construct & NewChild
|
||||
# error: [invalid-argument-type] "Argument to `NewChild.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
|
||||
cls("wrong")
|
||||
```
|
||||
|
||||
### Non-instance `__new__` returns
|
||||
|
||||
A constructor explicitly returning a non-instance type must retain that return type instead of
|
||||
intersecting it with the original type variable.
|
||||
|
||||
```py
|
||||
class Base:
|
||||
def __init__(self, value: str) -> None: ...
|
||||
|
||||
class ReturnsString(Base):
|
||||
def __new__(cls, value: int) -> str:
|
||||
return str(value)
|
||||
|
||||
def construct[T: Base](cls: type[T]) -> None:
|
||||
if issubclass(cls, ReturnsString):
|
||||
reveal_type(cls(1)) # revealed: str
|
||||
```
|
||||
|
||||
### Non-instance metaclass `__call__` returns
|
||||
|
||||
A custom metaclass's explicit non-instance return similarly takes precedence over the original type
|
||||
variable.
|
||||
|
||||
```py
|
||||
class StringFactory(type):
|
||||
def __call__(cls, value: int) -> str:
|
||||
return str(value)
|
||||
|
||||
class Base:
|
||||
def __init__(self, value: str) -> None: ...
|
||||
|
||||
class Factory(Base, metaclass=StringFactory): ...
|
||||
|
||||
def construct[T: Base](cls: type[T]) -> None:
|
||||
if issubclass(cls, Factory):
|
||||
reveal_type(cls(1)) # revealed: str
|
||||
```
|
||||
|
||||
### Independent metaclass callables
|
||||
|
||||
An intersection of a class-object type and a metaclass instance retains both independent callables.
|
||||
Arguments accepted by only one callable use that callable's return type.
|
||||
|
||||
```py
|
||||
from ty_extensions import Intersection
|
||||
|
||||
class StringBase:
|
||||
def __init__(self, value: str) -> None: ...
|
||||
|
||||
class IntMeta(type):
|
||||
def __call__(cls, value: int) -> str:
|
||||
return str(value)
|
||||
|
||||
def construct(cls: Intersection[type[StringBase], IntMeta]) -> None:
|
||||
reveal_type(cls(1)) # revealed: str
|
||||
reveal_type(cls("ok")) # revealed: StringBase
|
||||
```
|
||||
|
||||
## Union of constructors
|
||||
|
||||
```py
|
||||
|
||||
@@ -223,22 +223,16 @@ def narrowed_subclass[T](cls: type[T]) -> T:
|
||||
return cls()
|
||||
```
|
||||
|
||||
An invalid call after narrowing should report only the narrowed constructor's argument error.
|
||||
Existing intersection-call issues currently add a redundant error from the original upper bound:
|
||||
Invalid positional and keyword arguments each produce only the narrowed subclass constructor's
|
||||
diagnostic:
|
||||
|
||||
```py
|
||||
def narrowed_invalid[T](cls: type[T]) -> None:
|
||||
if issubclass(cls, IntConstructor):
|
||||
# TODO: Only report `invalid-argument-type`; the upper-bound constructor also reports
|
||||
# `too-many-positional-arguments` and describes the attempted intersection as `Never`.
|
||||
# error: [too-many-positional-arguments]
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
|
||||
cls("wrong")
|
||||
|
||||
# TODO: Only report `invalid-argument-type`; the upper-bound constructor also reports
|
||||
# `unknown-argument` for the same call.
|
||||
# error: [unknown-argument]
|
||||
# error: [invalid-argument-type]
|
||||
# error: [invalid-argument-type] "Argument to `IntConstructor.__init__` is incorrect: Expected `int`, found `Literal["wrong"]`"
|
||||
cls(value="wrong")
|
||||
```
|
||||
|
||||
|
||||
@@ -5254,6 +5254,32 @@ impl<'db> Type<'db> {
|
||||
.map(|element| element.bindings(db, env)),
|
||||
),
|
||||
|
||||
// A narrowed `type[T: Base] & type[Child]` still needs to construct `T & Child`,
|
||||
// but its constructor must come from `Child`, not from `Base` as an independent,
|
||||
// competing alternative. Flattening the projected instance lets intersection
|
||||
// simplification select that constructor without discarding unrelated providers.
|
||||
Type::Intersection(intersection)
|
||||
if intersection.positive(db).iter().all(|element| {
|
||||
// A metaclass instance also has an instance-space projection, but it can
|
||||
// provide an independent `__call__`. Only simplify actual class-object
|
||||
// variants so `type[Base] & Meta` retains both callable candidates.
|
||||
matches!(
|
||||
element.resolve_type_alias(db),
|
||||
Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_)
|
||||
)
|
||||
}) && let Some(instance_type) = self.to_instance_approximation(db, env)
|
||||
&& let Type::NominalInstance(lookup_instance) =
|
||||
instance_type.flatten_typevars(db, env)
|
||||
&& let Some(bindings) = {
|
||||
let bindings = lookup_instance.to_meta_type(db, env).bindings(db, env);
|
||||
bindings.has_only_constructor_items().then_some(bindings)
|
||||
} =>
|
||||
{
|
||||
bindings
|
||||
.with_constructed_instance_type(db, instance_type)
|
||||
.with_callable_type(self)
|
||||
}
|
||||
|
||||
Type::Intersection(intersection) => Bindings::from_intersection(
|
||||
self,
|
||||
intersection
|
||||
|
||||
@@ -811,6 +811,15 @@ impl<'db> Bindings<'db> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the overall receiver without replacing individual constructor callables.
|
||||
pub(crate) fn with_callable_type(mut self, callable_type: Type<'db>) -> Self {
|
||||
self.callable_type = callable_type;
|
||||
for element in &mut self.elements {
|
||||
element.callable_type = callable_type;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_constructed_instance_type(
|
||||
mut self,
|
||||
db: &'db dyn Db,
|
||||
@@ -916,6 +925,12 @@ impl<'db> Bindings<'db> {
|
||||
.filter_map(CallableItem::as_constructor)
|
||||
}
|
||||
|
||||
/// Return whether every callable uses ordinary constructor binding semantics.
|
||||
pub(crate) fn has_only_constructor_items(&self) -> bool {
|
||||
self.iter_callable_items()
|
||||
.all(|item| item.as_constructor().is_some())
|
||||
}
|
||||
|
||||
fn iter_constructor_items_mut(&mut self) -> impl Iterator<Item = &mut ConstructorBinding<'db>> {
|
||||
self.iter_callable_items_mut()
|
||||
.filter_map(CallableItem::as_constructor_mut)
|
||||
|
||||
@@ -641,7 +641,12 @@ impl<'db> ConstructorBinding<'db> {
|
||||
db: &'db dyn Db,
|
||||
env: &ProgramEnvironment<'db>,
|
||||
) -> Option<ClassLiteral<'db>> {
|
||||
self.constructed_instance_type()
|
||||
let instance_type = self.constructed_instance_type();
|
||||
let lookup_instance = match instance_type {
|
||||
Type::Intersection(_) => instance_type.flatten_typevars(db, env),
|
||||
_ => instance_type,
|
||||
};
|
||||
lookup_instance
|
||||
.as_nominal_instance()
|
||||
// TODO may need to handle `Type::KnownInstance` here as well?
|
||||
.map(|instance| instance.class(db, env).class_literal(db))
|
||||
|
||||
Reference in new issue
Block a user