[ty] Support dataclass-transform field_specifiers (#20888)

## Summary

Add support for the `field_specifiers` parameter on
`dataclass_transform` decorator calls.

closes https://github.com/astral-sh/ty/issues/1068

## Conformance test results

All true positives ✔️ 

## Ecosystem analysis

* `trio`: this is the kind of change that I would expect from this PR.
The code makes use of a dataclass `Outcome` with a `_unwrapped: bool =
attr.ib(default=False, eq=False, init=False)` field that is excluded
from the `__init__` signature, so we now see a bunch of
constructor-call-related errors going away.
* `home-assistant/core`: They have a `domain: str = attr.ib(init=False,
repr=False)` field and then use
  ```py
    @domain.default
    def _domain_default(self) -> str:
        # …
  ```
This accesses the `default` attribute on `dataclasses.Field[…]` with a
type of `default: _T | Literal[_MISSING_TYPE.MISSING]`, so we get those
"Object of type `_MISSING_TYPE` is not callable" errors. I don't really
understand how that is supposed to work. Even if `_MISSING_TYPE` would
be absent from that union, what does this try to call? pyright also
issues an error and it doesn't seem to work at runtime? So this looks
like a true positive?
* `attrs`: Similar here. There are some new diagnostics on code that
tries to access `.validator` on a field. This *does* work at runtime,
but I'm not sure how that is supposed to type-check (without a [custom
plugin](2c6c395935/mypy/plugins/attrs.py (L575-L602))).
pyright errors on this as well.
* A handful of new false positives because we don't support `alias` yet

## Test Plan

Updated tests.
This commit is contained in:
David Peter 2025-10-16 20:49:11 +02:00 committed by GitHub
parent 2bffef5966
commit 8dad58de37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 475 additions and 198 deletions

View file

@ -461,7 +461,7 @@ The [`typing.dataclass_transform`] specification also allows classes (such as `d
to be listed in `field_specifiers`, but it is currently unclear how this should work, and other type
checkers do not seem to support this either.
### Basic example
### For function-based transformers
```py
from typing_extensions import dataclass_transform, Any
@ -478,11 +478,8 @@ class Person:
name: str = fancy_field()
age: int | None = fancy_field(kw_only=True)
# TODO: Should be `(self: Person, name: str, *, age: int | None) -> None`
reveal_type(Person.__init__) # revealed: (self: Person, id: int = Any, name: str = Any, age: int | None = Any) -> None
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
# TODO: No error here
# error: [invalid-argument-type]
alice = Person("Alice", age=30)
reveal_type(alice.id) # revealed: int
@ -490,6 +487,145 @@ reveal_type(alice.name) # revealed: str
reveal_type(alice.age) # revealed: int | None
```
### For metaclass-based transformers
```py
from typing_extensions import dataclass_transform, Any
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
@dataclass_transform(field_specifiers=(fancy_field,))
class FancyMeta(type):
def __new__(cls, name, bases, namespace):
...
return super().__new__(cls, name, bases, namespace)
class FancyBase(metaclass=FancyMeta): ...
class Person(FancyBase):
id: int = fancy_field(init=False)
name: str = fancy_field()
age: int | None = fancy_field(kw_only=True)
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
alice = Person("Alice", age=30)
reveal_type(alice.id) # revealed: int
reveal_type(alice.name) # revealed: str
reveal_type(alice.age) # revealed: int | None
```
### For base-class-based transformers
```py
from typing_extensions import dataclass_transform, Any
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
@dataclass_transform(field_specifiers=(fancy_field,))
class FancyBase:
def __init_subclass__(cls):
...
super().__init_subclass__()
class Person(FancyBase):
id: int = fancy_field(init=False)
name: str = fancy_field()
age: int | None = fancy_field(kw_only=True)
# TODO: should be (self: Person, name: str = Unknown, *, age: int | None = Unknown) -> None
reveal_type(Person.__init__) # revealed: def __init__(self) -> None
# TODO: shouldn't be an error
# error: [too-many-positional-arguments]
# error: [unknown-argument]
alice = Person("Alice", age=30)
reveal_type(alice.id) # revealed: int
reveal_type(alice.name) # revealed: str
reveal_type(alice.age) # revealed: int | None
```
### With default arguments
Field specifiers can have default arguments that should be respected:
```py
from typing_extensions import dataclass_transform, Any
def fancy_field(*, init: bool = False) -> Any: ...
@dataclass_transform(field_specifiers=(fancy_field,))
def fancy_model[T](cls: type[T]) -> type[T]:
...
return cls
@fancy_model
class Person:
id: int = fancy_field()
name: str = fancy_field(init=True)
reveal_type(Person.__init__) # revealed: (self: Person, name: str) -> None
Person(name="Alice")
```
### With overloaded field specifiers
```py
from typing_extensions import dataclass_transform, overload, Any
@overload
def fancy_field(*, init: bool = True) -> Any: ...
@overload
def fancy_field(*, kw_only: bool = False) -> Any: ...
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
@dataclass_transform(field_specifiers=(fancy_field,))
def fancy_model[T](cls: type[T]) -> type[T]:
...
return cls
@fancy_model
class Person:
id: int = fancy_field(init=False)
name: str = fancy_field()
age: int | None = fancy_field(kw_only=True)
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
```
### Nested dataclass-transformers
Make sure that models are only affected by the field specifiers of their own transformer:
```py
from typing_extensions import dataclass_transform, Any
from dataclasses import field
def outer_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
@dataclass_transform(field_specifiers=(outer_field,))
def outer_model[T](cls: type[T]) -> type[T]:
# ...
return cls
def inner_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
@dataclass_transform(field_specifiers=(inner_field,))
def inner_model[T](cls: type[T]) -> type[T]:
# ...
return cls
@outer_model
class Outer:
@inner_model
class Inner:
inner_a: int = inner_field(init=False)
inner_b: str = outer_field(init=False)
outer_a: int = outer_field(init=False)
outer_b: str = inner_field(init=False)
reveal_type(Outer.__init__) # revealed: (self: Outer, outer_b: str = Any) -> None
reveal_type(Outer.Inner.__init__) # revealed: (self: Inner, inner_b: str = Any) -> None
```
## Overloaded dataclass-like decorators
In the case of an overloaded decorator, the `dataclass_transform` decorator can be applied to the