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
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,36 @@ Model(x=1)
reveal_type(Model.__init__) # revealed: (self: Model, x: int) -> None
```

### Decorator return types are still metadata-only in decorator position

When a `@dataclass_transform()`-decorated function is used as a class decorator, we currently use it
to shape the class like a dataclass but do not yet let an explicit non-class return annotation
replace the public class binding.

```py
from typing import Protocol, TypeVar
from typing_extensions import dataclass_transform

class Wrapped(Protocol):
def f(self) -> int: ...

T = TypeVar("T", bound=object)

@dataclass_transform()
def model(cls: type[T]) -> Wrapped:
raise NotImplementedError

@model
class C:
x: int

reveal_type(C) # revealed: <class 'C'>
reveal_type(C.__init__) # revealed: (self: C, x: int) -> None

# TODO: Decide whether the explicit `Wrapped` return type should replace the public binding here.
C.f() # error: [unresolved-attribute]
```

## `__dataclass_transform__` compatibility

For backwards compatibility with pre-3.11 Python, ty recognizes any function named
Expand Down
342 changes: 337 additions & 5 deletions crates/ty_python_semantic/resources/mdtest/decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,12 +307,10 @@ class AcceptsType:
def __init__(self, cls: type) -> None:
self.cls = cls

# Decorator call is validated, but the type transformation isn't applied yet.
# TODO: Class decorator return types should transform the class binding type.
@AcceptsType
class MyClass: ...

reveal_type(MyClass) # revealed: <class 'MyClass'>
reveal_type(MyClass) # revealed: AcceptsType
```

### Generic class, used as a decorator
Expand Down Expand Up @@ -378,6 +376,340 @@ def decorator(cls: type[int]) -> type[int]:
@decorator
class Baz: ...

# TODO: the revealed type should ideally be `type[int]` (the decorator's return type)
reveal_type(Baz) # revealed: <class 'Baz'>
reveal_type(Baz) # revealed: type[int]
```

Class decorators can also replace the class object with an instance:

```py
from dataclasses import dataclass
from typing import Callable, Generic, Protocol, TypeVar, overload
from typing_extensions import Self

T = TypeVar("T")

class Backend(Protocol):
def get(self, key: str) -> bytes | None: ...

class WrapBackend:
def __init__(self, cls: type[object]) -> None:
self.cls = cls

def get(self, key: str) -> bytes | None:
return None

@WrapBackend
class CacheClient:
def clone(self) -> Self:
reveal_type(self) # revealed: Self@clone
return self

@classmethod
def make(cls) -> Self:
reveal_type(cls) # revealed: type[Self@make]
return cls()

reveal_type(CacheClient) # revealed: WrapBackend
reveal_type(CacheClient.get("x")) # revealed: bytes | None

@WrapBackend
@dataclass
class DataclassThenWrapped:
value: int

reveal_type(DataclassThenWrapped) # revealed: WrapBackend

# error: [no-matching-overload]
@dataclass
@WrapBackend
class WrappedThenDataclass:
value: int

reveal_type(WrappedThenDataclass) # revealed: Unknown
Comment on lines +422 to +428
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This is just me thinking out loud.)

This is an interesting behavior. I think it's correct given that dataclass is receiving an instance of WrapBackend instead of the class itself but I wonder if the diagnostics could be improved to highlight that it's an invalid-argument-type. I don't think this is a priority and it doesn't look easy to do either.


def int_decorator_factory() -> Callable[[type[object]], int]:
def decorator(cls: type[object]) -> int:
return 1
return decorator

# error: [no-matching-overload]
@dataclass
@int_decorator_factory()
class IntThenDataclass:
value: int

reveal_type(IntThenDataclass) # revealed: Unknown

@WrapBackend
class InvalidWrappedBase(1): ... # error: [invalid-base]
Comment thread
charliermarsh marked this conversation as resolved.

reveal_type(InvalidWrappedBase) # revealed: WrapBackend

@WrapBackend
class GenericCacheClient(Generic[T]):
value: T

def get_value(self) -> T:
return self.value

reveal_type(GenericCacheClient) # revealed: WrapBackend

@WrapBackend
class OverloadedCacheClient:
@overload
def get(self, key: str) -> bytes: ...
@overload
def get(self, key: bytes) -> bytes: ...
Comment thread
charliermarsh marked this conversation as resolved.
def get(self, key: str | bytes) -> bytes:
return b""
```

Unannotated class decorators are assumed to preserve the class binding. We do not infer returned
classes from decorator bodies:

```py
def personify(cls):
class Wrapped(cls):
full_name: str

def set_full_name(self, full_name: str) -> None:
self.full_name = full_name

return Wrapped

@personify
class Animal: ...

reveal_type(Animal) # revealed: <class 'Animal'>
reveal_type(Animal()) # revealed: Animal

Animal().set_full_name("John") # error: [unresolved-attribute]
```

This also applies to unannotated callables that are not function definitions:

```py
lambda_decorator = lambda cls: cls

@lambda_decorator
class LambdaDecorated: ...

reveal_type(LambdaDecorated) # revealed: <class 'LambdaDecorated'>

class DecoratorFactory:
def decorator(self, cls):
return cls

decorator_factory = DecoratorFactory()

@decorator_factory.decorator
class BoundMethodDecorated: ...

reveal_type(BoundMethodDecorated) # revealed: <class 'BoundMethodDecorated'>

class CallableDecorator:
def __call__(self, cls):
return cls

callable_decorator = CallableDecorator()

@callable_decorator
class CallableInstanceDecorated: ...

reveal_type(CallableInstanceDecorated) # revealed: <class 'CallableInstanceDecorated'>

class ExplicitReturnDecorator(Generic[T]):
def __call__(self, cls) -> T:
raise NotImplementedError

explicit_return_decorator = ExplicitReturnDecorator()

@explicit_return_decorator
class ExplicitReturnCallableInstanceDecorated: ...

reveal_type(ExplicitReturnCallableInstanceDecorated) # revealed: Unknown
Comment thread
charliermarsh marked this conversation as resolved.

specialized_explicit_return_decorator = ExplicitReturnDecorator[int]()

@specialized_explicit_return_decorator
class SpecializedExplicitReturnCallableInstanceDecorated: ...

reveal_type(SpecializedExplicitReturnCallableInstanceDecorated) # revealed: int
```

An unknown class decorator still makes the class binding unknown:

```py
# error: [unresolved-reference] "Name `unknown_class_decorator` used when not defined"
@unknown_class_decorator
class UnknownDecorated: ...

reveal_type(UnknownDecorated) # revealed: Unknown
```

An unannotated class decorator preserves the result of earlier decorators:

```py
def unannotated_identity(cls):
return cls

@unannotated_identity
@WrapBackend
class WrappedThenUnannotated: ...

reveal_type(WrappedThenUnannotated) # revealed: WrapBackend
```

Metadata decorators still apply above an unannotated class-preserving decorator:

```py
from typing_extensions import deprecated

def unannotated_identity(cls):
return cls

@deprecated("use OtherClass")
@unannotated_identity
class DeprecatedThenUnannotated: ...

DeprecatedThenUnannotated() # error: [deprecated] "use OtherClass"
```

If a class decorator returns the original class object, we preserve the class binding so it can
still be used in annotations and as a base class:

```py
from typing import TypeVar

T = TypeVar("T", bound=object)

def identity_class_decorator(cls: type[T]) -> type[T]:
return cls

@identity_class_decorator
class PreservedClass: ...

reveal_type(PreservedClass) # revealed: <class 'PreservedClass'>

class DerivedPreservedClass(PreservedClass):
value: PreservedClass
```

Class decorator factories that preserve the original class object also preserve the class binding:

```py
from collections.abc import Callable
from typing import Any, TypeVar, overload

DecoratorT = TypeVar("DecoratorT", bound=object)
DecoratedClass = type[DecoratorT]

@overload
def identity_class_decorator_factory(cls: DecoratedClass, **kwargs: Any) -> DecoratedClass: ...
@overload
def identity_class_decorator_factory(
**kwargs: Any,
) -> Callable[[DecoratedClass], DecoratedClass]: ...
def identity_class_decorator_factory(
cls: DecoratedClass | None = None, **kwargs: Any
) -> DecoratedClass | Callable[[DecoratedClass], DecoratedClass]:
def decorator(inner_cls: DecoratedClass) -> DecoratedClass:
return inner_cls

if cls is not None:
return decorator(cls)
return decorator

@identity_class_decorator_factory(frozen=True)
class FactoryPreservedClass: ...

reveal_type(FactoryPreservedClass) # revealed: <class 'FactoryPreservedClass'>

class DerivedFactoryPreservedClass(FactoryPreservedClass):
value: FactoryPreservedClass
```

Class decorators can return intersections that expose attributes added to the decorated class
object:

```py
from ty_extensions import Intersection
from typing import Protocol, TypeVar

class Resource:
def fetch(self) -> str:
return "data"

class ResourceEnabled(Protocol):
resource: Resource

SchemaT = TypeVar("SchemaT")

def register(cls: type[SchemaT]) -> Intersection[type[SchemaT], ResourceEnabled]:
return cls

@register
class UserSchema:
id: int

reveal_type(UserSchema.resource.fetch()) # revealed: str
```

Metadata decorators stacked above an intersection-returning class decorator still apply to the
original class object, while preserving the extra intersection members:

```py
from dataclasses import dataclass
from ty_extensions import Intersection
from typing import Protocol, TypeVar

class Resource:
def fetch(self) -> str:
return "data"

class ResourceEnabled(Protocol):
resource: Resource

SchemaT = TypeVar("SchemaT")

def register(cls: type[SchemaT]) -> Intersection[type[SchemaT], ResourceEnabled]:
return cls

@dataclass
@register
class RegisteredDataclass:
id: int

reveal_type(RegisteredDataclass.resource.fetch()) # revealed: str
reveal_type(RegisteredDataclass(1)) # revealed: RegisteredDataclass
```

Class-preserving decorators stacked above an intersection-returning class decorator preserve the
existing intersection members:

```py
from ty_extensions import Intersection
from typing import Protocol, TypeVar

class Resource:
def fetch(self) -> str:
return "data"

class ResourceEnabled(Protocol):
resource: Resource

SchemaT = TypeVar("SchemaT")

def register(cls: type[SchemaT]) -> Intersection[type[SchemaT], ResourceEnabled]:
return cls

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

@identity
@register
class RegisteredIdentity:
id: int

reveal_type(RegisteredIdentity.resource.fetch()) # revealed: str
```
Loading
Loading