Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -7765,6 +7765,163 @@ def _(flag: bool) -> None:
reveal_type(infer_producer(cls)) # revealed: int | str
```

## Default specialization of generic meta-protocol constructors

Checking a bare generic class against `type[Protocol]` does not provide constructor arguments from
which to infer a specialization. Its constructed instance therefore uses the class's default type
arguments, or `Unknown` for parameters without defaults.

```toml
[environment]
python-version = "3.13"
```

```py
from typing import Protocol

class HasValue[T](Protocol):
@property
def value(self) -> T: ...

class Defaulted[T = int]:
value: T

class NoDefault[T]:
value: T

defaulted: type[HasValue[int]] = Defaulted
wrong_default: type[HasValue[str]] = Defaulted # error: [invalid-assignment]
unspecified: type[HasValue[str]] = NoDefault
```

Generic inference exposes these type arguments: `int` for `Defaulted` and `Unknown` for `NoDefault`.
Explicit class arguments take precedence over defaults.

```py
def infer[T](cls: type[HasValue[T]]) -> T:
return cls().value

reveal_type(infer(Defaulted)) # revealed: int
reveal_type(infer(NoDefault)) # revealed: Unknown
reveal_type(infer(Defaulted[str])) # revealed: str

explicit: type[HasValue[str]] = Defaulted[str]
wrong: type[HasValue[int]] = Defaulted[str] # error: [invalid-assignment]
```

Inference from a union of bare classes combines their default arguments.

```py
class StringDefaulted[T = str]:
value: T

def infer_union(flag: bool) -> None:
cls = Defaulted if flag else StringDefaulted
reveal_type(infer(cls)) # revealed: int | str
```

A parameter already annotated with a specialization retains that specialization, including type
variables supplied by its caller.

```py
def preserve[T](cls: type[Defaulted[T]]) -> T:
reveal_type(infer(cls)) # revealed: T@preserve
return infer(cls)
```

An ordinary method is checked on both the constructed instance and the class object. Default
specialization also preserves the method's unbound signature.

```py
class Getter[T](Protocol):
def get(self) -> T: ...

class DefaultedGetter[T = int]:
def get(self) -> T:
raise NotImplementedError

def infer_getter[T](cls: type[Getter[T]]) -> T:
return cls().get()

reveal_type(infer_getter(DefaultedGetter)) # revealed: int
reveal_type(infer_getter(DefaultedGetter[str])) # revealed: str
wrong_getter: type[Getter[str]] = DefaultedGetter # error: [invalid-assignment]
```

`functools.partial` has special constructor handling. Its omitted type argument also becomes
`Unknown`, while an explicit argument is preserved.

```py
from functools import partial
from typing import Any

class CallableInstance[T](Protocol):
def __call__(self, *args: Any, **kwargs: Any) -> T: ...

def infer_return[T](cls: type[CallableInstance[T]]) -> T:
raise NotImplementedError

reveal_type(infer_return(partial[str])) # revealed: str
reveal_type(infer_return(partial)) # revealed: Unknown

bad_partial: type[CallableInstance[int]] = partial[str] # error: [invalid-assignment]
```

## Generic meta-protocol constructors with a custom return type

Default specialization still uses the effective constructor return. `Factory` does not have the
required instance attribute itself, but its `__new__` returns a `Product` that does.

```toml
[environment]
python-version = "3.13"
```

```py
from typing import Protocol

class HasValue[T](Protocol):
@property
def value(self) -> T: ...

class Product[T]:
value: T

class Factory[T = int]:
def __new__(cls) -> Product[T]:
raise NotImplementedError

defaulted: type[HasValue[int]] = Factory
explicit: type[HasValue[str]] = Factory[str]
wrong: type[HasValue[str]] = Factory # error: [invalid-assignment]
```

A constructor can return a type unrelated to its class's default arguments. Those arguments do not
replace an explicitly declared return type.

```py
class StringFactory[T = int]:
def __new__(cls) -> Product[str]:
raise NotImplementedError

strings: type[HasValue[str]] = StringFactory
integers: type[HasValue[int]] = StringFactory # error: [invalid-assignment]
```

A metaclass can override construction independently of the generic class's default arguments. Its
`__call__` return still determines which instance protocol the class satisfies.

```py
class StringFactoryMeta(type):
def __call__(self) -> Product[str]:
raise NotImplementedError

class MetaFactory[T = int](metaclass=StringFactoryMeta): ...

meta_strings: type[HasValue[str]] = MetaFactory
meta_integers: type[HasValue[int]] = MetaFactory # error: [invalid-assignment]
```

## Generic substitution of `type[Protocol]`

Passing `type[P]` through a generic identity function preserves its structural meaning, including
Expand Down
4 changes: 2 additions & 2 deletions crates/ty_python_semantic/src/types/generics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4296,7 +4296,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> {
for element in union.elements(db) {
self.infer_map_impl(
formal_protocol,
element.bindings(db, self.env).return_type(db, self.env),
element.instance_type_for_meta_protocol(db, self.env),
polarity,
seen,
)?;
Expand All @@ -4305,7 +4305,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> {
}
return self.infer_map_impl(
formal_protocol,
actual.bindings(db, self.env).return_type(db, self.env),
actual.instance_type_for_meta_protocol(db, self.env),
polarity,
seen,
);
Expand Down
16 changes: 15 additions & 1 deletion crates/ty_python_semantic/src/types/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ impl<'db> Type<'db> {
SynthesizedProtocolType::new(ProtocolInterface::with_methods(db, env, methods)),
))
}

/// Return the constructed type used in meta-protocol matching and inference.
///
/// There are no constructor arguments here from which to infer the class's type arguments.
/// Use its defaults, as ordinary class-member lookup does, so that class-scoped typevars
/// do not escape through the constructor return type into protocol inference.
pub(super) fn instance_type_for_meta_protocol(
self,
db: &'db dyn Db,
env: &ProgramEnvironment<'db>,
) -> Self {
let constructor_ty = self.to_class_type(db).map_or(self, Type::from);
constructor_ty.bindings(db, env).return_type(db, env)
}
}

/// A type representing the set of runtime objects which are instances of a certain nominal class.
Expand Down Expand Up @@ -986,7 +1000,7 @@ impl<'c, 'db> TypeRelationChecker<'_, 'c, 'db> {
Type::ClassLiteral(_) | Type::SubclassOf(_) | Type::GenericAlias(_)
);

let constructed_ty = meta_ty.bindings(db, env).return_type(db, env);
let constructed_ty = meta_ty.instance_type_for_meta_protocol(db, env);
self.check_type_pair(db, constructed_ty, Type::ProtocolInstance(protocol))
.and(db, self.constraints, || {
self.check_meta_protocol_members(db, constructed_ty, meta_ty, protocol)
Expand Down
Loading