[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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user