[ty] dataclass_transform: Support for fields with an alias (#20961)

## Summary

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

## Conformance tests

Two false positives removed, as expected.

## Test Plan

New Markdown tests
This commit is contained in:
David Peter
2025-10-18 20:20:39 +02:00
committed by GitHub
parent 44d9063058
commit 3c229ae58a
4 changed files with 87 additions and 15 deletions

View File

@@ -451,7 +451,7 @@ checkers do not seem to support this either.
```py
from typing_extensions import dataclass_transform, Any
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ...
@dataclass_transform(field_specifiers=(fancy_field,))
def fancy_model[T](cls: type[T]) -> type[T]:
...
@@ -460,7 +460,7 @@ def fancy_model[T](cls: type[T]) -> type[T]:
@fancy_model
class Person:
id: int = fancy_field(init=False)
name: str = fancy_field()
internal_name: str = fancy_field(alias="name")
age: int | None = fancy_field(kw_only=True)
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
@@ -468,7 +468,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int
alice = Person("Alice", age=30)
reveal_type(alice.id) # revealed: int
reveal_type(alice.name) # revealed: str
reveal_type(alice.internal_name) # revealed: str
reveal_type(alice.age) # revealed: int | None
```
@@ -477,7 +477,7 @@ reveal_type(alice.age) # revealed: int | None
```py
from typing_extensions import dataclass_transform, Any
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ...
@dataclass_transform(field_specifiers=(fancy_field,))
class FancyMeta(type):
def __new__(cls, name, bases, namespace):
@@ -488,7 +488,7 @@ class FancyBase(metaclass=FancyMeta): ...
class Person(FancyBase):
id: int = fancy_field(init=False)
name: str = fancy_field()
internal_name: str = fancy_field(alias="name")
age: int | None = fancy_field(kw_only=True)
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
@@ -496,7 +496,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int
alice = Person("Alice", age=30)
reveal_type(alice.id) # revealed: int
reveal_type(alice.name) # revealed: str
reveal_type(alice.internal_name) # revealed: str
reveal_type(alice.age) # revealed: int | None
```
@@ -505,7 +505,7 @@ reveal_type(alice.age) # revealed: int | None
```py
from typing_extensions import dataclass_transform, Any
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ...
@dataclass_transform(field_specifiers=(fancy_field,))
class FancyBase:
def __init_subclass__(cls):
@@ -514,7 +514,7 @@ class FancyBase:
class Person(FancyBase):
id: int = fancy_field(init=False)
name: str = fancy_field()
internal_name: str = fancy_field(alias="name")
age: int | None = fancy_field(kw_only=True)
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
@@ -522,7 +522,7 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int
alice = Person("Alice", age=30)
reveal_type(alice.id) # revealed: int
reveal_type(alice.name) # revealed: str
reveal_type(alice.internal_name) # revealed: str
reveal_type(alice.age) # revealed: int | None
```
@@ -549,6 +549,58 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str) -> None
Person(name="Alice")
```
### Support for `alias`
The `alias` parameter in field specifiers allows providing an alternative name for the parameter in
the synthesized `__init__` method.
```py
from typing_extensions import dataclass_transform, Any
def field_with_alias(*, alias: str | None = None, kw_only: bool = False) -> Any: ...
@dataclass_transform(field_specifiers=(field_with_alias,))
def model[T](cls: type[T]) -> type[T]:
return cls
@model
class Person:
internal_name: str = field_with_alias(alias="name")
internal_age: int = field_with_alias(alias="age", kw_only=True)
```
The synthesized `__init__` method uses the alias names instead of the actual attribute names:
```py
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int) -> None
```
We can construct instances using the alias names:
```py
p = Person(name="Alice", age=30)
```
Passing the `name` parameter positionally also works:
```py
p = Person("Alice", age=30)
```
But the attributes are still accessed by their actual names:
```py
reveal_type(p.internal_name) # revealed: str
reveal_type(p.internal_age) # revealed: int
```
Trying to use the actual attribute names in the constructor results in an error:
```py
# error: [unknown-argument] "Argument `internal_age` does not match any known parameter"
# error: [missing-argument] "No argument provided for required parameter `age`"
p = Person(name="Alice", internal_age=30)
```
### With overloaded field specifiers
```py

View File

@@ -8000,6 +8000,9 @@ pub struct FieldInstance<'db> {
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
pub kw_only: Option<bool>,
/// This name is used to provide an alternative parameter name in the synthesized `__init__` method.
pub alias: Option<Box<str>>,
}
// The Salsa heap is tracked separately.
@@ -8013,6 +8016,7 @@ impl<'db> FieldInstance<'db> {
.map(|ty| ty.normalized_impl(db, visitor)),
self.init(db),
self.kw_only(db),
self.alias(db),
)
}
}

View File

@@ -618,6 +618,9 @@ impl<'db> Bindings<'db> {
let kw_only = overload
.parameter_type_by_name("kw_only", true)
.unwrap_or(None);
let alias = overload
.parameter_type_by_name("alias", true)
.unwrap_or(None);
// `dataclasses.field` and field-specifier functions of commonly used
// libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return
@@ -650,6 +653,10 @@ impl<'db> Bindings<'db> {
None
};
let alias = alias
.and_then(Type::as_string_literal)
.map(|literal| Box::from(literal.value(db)));
// `typeshed` pretends that `dataclasses.field()` returns the type of the
// default value directly. At runtime, however, this function returns an
// instance of `dataclasses.Field`. We also model it this way and return
@@ -658,7 +665,7 @@ impl<'db> Bindings<'db> {
// are assignable to `T` if the default type of the field is assignable
// to `T`. Otherwise, we would error on `name: str = field(default="")`.
overload.set_return_type(Type::KnownInstance(KnownInstanceType::Field(
FieldInstance::new(db, default_ty, init, kw_only),
FieldInstance::new(db, default_ty, init, kw_only, alias),
)));
}

View File

@@ -1341,6 +1341,8 @@ pub(crate) enum FieldKind<'db> {
init: bool,
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
kw_only: Option<bool>,
/// The name for this field in the `__init__` signature, if specified.
alias: Option<Box<str>>,
},
/// `TypedDict` field metadata
TypedDict {
@@ -2314,14 +2316,15 @@ impl<'db> ClassLiteral<'db> {
let signature_from_fields = |mut parameters: Vec<_>, return_ty: Option<Type<'db>>| {
for (field_name, field) in self.fields(db, specialization, field_policy) {
let (init, mut default_ty, kw_only) = match field.kind {
FieldKind::NamedTuple { default_ty } => (true, default_ty, None),
let (init, mut default_ty, kw_only, alias) = match &field.kind {
FieldKind::NamedTuple { default_ty } => (true, *default_ty, None, None),
FieldKind::Dataclass {
init,
default_ty,
kw_only,
alias,
..
} => (init, default_ty, kw_only),
} => (*init, *default_ty, *kw_only, alias.as_ref()),
FieldKind::TypedDict { .. } => continue,
};
let mut field_ty = field.declared_ty;
@@ -2387,10 +2390,13 @@ impl<'db> ClassLiteral<'db> {
let is_kw_only = name == "__replace__"
|| kw_only.unwrap_or(has_dataclass_param(DataclassFlags::KW_ONLY));
// Use the alias name if provided, otherwise use the field name
let parameter_name = alias.map(Name::new).unwrap_or(field_name);
let mut parameter = if is_kw_only {
Parameter::keyword_only(field_name)
Parameter::keyword_only(parameter_name)
} else {
Parameter::positional_or_keyword(field_name)
Parameter::positional_or_keyword(parameter_name)
}
.with_annotated_type(field_ty);
@@ -2925,6 +2931,7 @@ impl<'db> ClassLiteral<'db> {
let mut init = true;
let mut kw_only = None;
let mut alias = None;
if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty {
default_ty = field.default_type(db);
if self
@@ -2938,6 +2945,7 @@ impl<'db> ClassLiteral<'db> {
} else {
init = field.init(db);
kw_only = field.kw_only(db);
alias = field.alias(db);
}
}
@@ -2948,6 +2956,7 @@ impl<'db> ClassLiteral<'db> {
init_only: attr.is_init_var(),
init,
kw_only,
alias,
},
CodeGeneratorKind::TypedDict => {
let is_required = if attr.is_required() {