Skip to content

Commit 3c229ae

Browse files
authored
[ty] dataclass_transform: Support for fields with an alias (#20961)
## Summary closes astral-sh/ty#1385 ## Conformance tests Two false positives removed, as expected. ## Test Plan New Markdown tests
1 parent 44d9063 commit 3c229ae

File tree

4 files changed

+87
-15
lines changed

4 files changed

+87
-15
lines changed

crates/ty_python_semantic/resources/mdtest/dataclasses/dataclass_transform.md

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ checkers do not seem to support this either.
451451
```py
452452
from typing_extensions import dataclass_transform, Any
453453

454-
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
454+
def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ...
455455
@dataclass_transform(field_specifiers=(fancy_field,))
456456
def fancy_model[T](cls: type[T]) -> type[T]:
457457
...
@@ -460,15 +460,15 @@ def fancy_model[T](cls: type[T]) -> type[T]:
460460
@fancy_model
461461
class Person:
462462
id: int = fancy_field(init=False)
463-
name: str = fancy_field()
463+
internal_name: str = fancy_field(alias="name")
464464
age: int | None = fancy_field(kw_only=True)
465465

466466
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
467467

468468
alice = Person("Alice", age=30)
469469

470470
reveal_type(alice.id) # revealed: int
471-
reveal_type(alice.name) # revealed: str
471+
reveal_type(alice.internal_name) # revealed: str
472472
reveal_type(alice.age) # revealed: int | None
473473
```
474474

@@ -477,7 +477,7 @@ reveal_type(alice.age) # revealed: int | None
477477
```py
478478
from typing_extensions import dataclass_transform, Any
479479

480-
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
480+
def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ...
481481
@dataclass_transform(field_specifiers=(fancy_field,))
482482
class FancyMeta(type):
483483
def __new__(cls, name, bases, namespace):
@@ -488,15 +488,15 @@ class FancyBase(metaclass=FancyMeta): ...
488488

489489
class Person(FancyBase):
490490
id: int = fancy_field(init=False)
491-
name: str = fancy_field()
491+
internal_name: str = fancy_field(alias="name")
492492
age: int | None = fancy_field(kw_only=True)
493493

494494
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
495495

496496
alice = Person("Alice", age=30)
497497

498498
reveal_type(alice.id) # revealed: int
499-
reveal_type(alice.name) # revealed: str
499+
reveal_type(alice.internal_name) # revealed: str
500500
reveal_type(alice.age) # revealed: int | None
501501
```
502502

@@ -505,7 +505,7 @@ reveal_type(alice.age) # revealed: int | None
505505
```py
506506
from typing_extensions import dataclass_transform, Any
507507

508-
def fancy_field(*, init: bool = True, kw_only: bool = False) -> Any: ...
508+
def fancy_field(*, init: bool = True, kw_only: bool = False, alias: str | None = None) -> Any: ...
509509
@dataclass_transform(field_specifiers=(fancy_field,))
510510
class FancyBase:
511511
def __init_subclass__(cls):
@@ -514,15 +514,15 @@ class FancyBase:
514514

515515
class Person(FancyBase):
516516
id: int = fancy_field(init=False)
517-
name: str = fancy_field()
517+
internal_name: str = fancy_field(alias="name")
518518
age: int | None = fancy_field(kw_only=True)
519519

520520
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int | None) -> None
521521

522522
alice = Person("Alice", age=30)
523523

524524
reveal_type(alice.id) # revealed: int
525-
reveal_type(alice.name) # revealed: str
525+
reveal_type(alice.internal_name) # revealed: str
526526
reveal_type(alice.age) # revealed: int | None
527527
```
528528

@@ -549,6 +549,58 @@ reveal_type(Person.__init__) # revealed: (self: Person, name: str) -> None
549549
Person(name="Alice")
550550
```
551551

552+
### Support for `alias`
553+
554+
The `alias` parameter in field specifiers allows providing an alternative name for the parameter in
555+
the synthesized `__init__` method.
556+
557+
```py
558+
from typing_extensions import dataclass_transform, Any
559+
560+
def field_with_alias(*, alias: str | None = None, kw_only: bool = False) -> Any: ...
561+
@dataclass_transform(field_specifiers=(field_with_alias,))
562+
def model[T](cls: type[T]) -> type[T]:
563+
return cls
564+
565+
@model
566+
class Person:
567+
internal_name: str = field_with_alias(alias="name")
568+
internal_age: int = field_with_alias(alias="age", kw_only=True)
569+
```
570+
571+
The synthesized `__init__` method uses the alias names instead of the actual attribute names:
572+
573+
```py
574+
reveal_type(Person.__init__) # revealed: (self: Person, name: str, *, age: int) -> None
575+
```
576+
577+
We can construct instances using the alias names:
578+
579+
```py
580+
p = Person(name="Alice", age=30)
581+
```
582+
583+
Passing the `name` parameter positionally also works:
584+
585+
```py
586+
p = Person("Alice", age=30)
587+
```
588+
589+
But the attributes are still accessed by their actual names:
590+
591+
```py
592+
reveal_type(p.internal_name) # revealed: str
593+
reveal_type(p.internal_age) # revealed: int
594+
```
595+
596+
Trying to use the actual attribute names in the constructor results in an error:
597+
598+
```py
599+
# error: [unknown-argument] "Argument `internal_age` does not match any known parameter"
600+
# error: [missing-argument] "No argument provided for required parameter `age`"
601+
p = Person(name="Alice", internal_age=30)
602+
```
603+
552604
### With overloaded field specifiers
553605

554606
```py

crates/ty_python_semantic/src/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8000,6 +8000,9 @@ pub struct FieldInstance<'db> {
80008000

80018001
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
80028002
pub kw_only: Option<bool>,
8003+
8004+
/// This name is used to provide an alternative parameter name in the synthesized `__init__` method.
8005+
pub alias: Option<Box<str>>,
80038006
}
80048007

80058008
// The Salsa heap is tracked separately.
@@ -8013,6 +8016,7 @@ impl<'db> FieldInstance<'db> {
80138016
.map(|ty| ty.normalized_impl(db, visitor)),
80148017
self.init(db),
80158018
self.kw_only(db),
8019+
self.alias(db),
80168020
)
80178021
}
80188022
}

crates/ty_python_semantic/src/types/call/bind.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,9 @@ impl<'db> Bindings<'db> {
618618
let kw_only = overload
619619
.parameter_type_by_name("kw_only", true)
620620
.unwrap_or(None);
621+
let alias = overload
622+
.parameter_type_by_name("alias", true)
623+
.unwrap_or(None);
621624

622625
// `dataclasses.field` and field-specifier functions of commonly used
623626
// libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return
@@ -650,6 +653,10 @@ impl<'db> Bindings<'db> {
650653
None
651654
};
652655

656+
let alias = alias
657+
.and_then(Type::as_string_literal)
658+
.map(|literal| Box::from(literal.value(db)));
659+
653660
// `typeshed` pretends that `dataclasses.field()` returns the type of the
654661
// default value directly. At runtime, however, this function returns an
655662
// instance of `dataclasses.Field`. We also model it this way and return
@@ -658,7 +665,7 @@ impl<'db> Bindings<'db> {
658665
// are assignable to `T` if the default type of the field is assignable
659666
// to `T`. Otherwise, we would error on `name: str = field(default="")`.
660667
overload.set_return_type(Type::KnownInstance(KnownInstanceType::Field(
661-
FieldInstance::new(db, default_ty, init, kw_only),
668+
FieldInstance::new(db, default_ty, init, kw_only, alias),
662669
)));
663670
}
664671

crates/ty_python_semantic/src/types/class.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,6 +1341,8 @@ pub(crate) enum FieldKind<'db> {
13411341
init: bool,
13421342
/// Whether or not this field can only be passed as a keyword argument to `__init__`.
13431343
kw_only: Option<bool>,
1344+
/// The name for this field in the `__init__` signature, if specified.
1345+
alias: Option<Box<str>>,
13441346
},
13451347
/// `TypedDict` field metadata
13461348
TypedDict {
@@ -2314,14 +2316,15 @@ impl<'db> ClassLiteral<'db> {
23142316

23152317
let signature_from_fields = |mut parameters: Vec<_>, return_ty: Option<Type<'db>>| {
23162318
for (field_name, field) in self.fields(db, specialization, field_policy) {
2317-
let (init, mut default_ty, kw_only) = match field.kind {
2318-
FieldKind::NamedTuple { default_ty } => (true, default_ty, None),
2319+
let (init, mut default_ty, kw_only, alias) = match &field.kind {
2320+
FieldKind::NamedTuple { default_ty } => (true, *default_ty, None, None),
23192321
FieldKind::Dataclass {
23202322
init,
23212323
default_ty,
23222324
kw_only,
2325+
alias,
23232326
..
2324-
} => (init, default_ty, kw_only),
2327+
} => (*init, *default_ty, *kw_only, alias.as_ref()),
23252328
FieldKind::TypedDict { .. } => continue,
23262329
};
23272330
let mut field_ty = field.declared_ty;
@@ -2387,10 +2390,13 @@ impl<'db> ClassLiteral<'db> {
23872390
let is_kw_only = name == "__replace__"
23882391
|| kw_only.unwrap_or(has_dataclass_param(DataclassFlags::KW_ONLY));
23892392

2393+
// Use the alias name if provided, otherwise use the field name
2394+
let parameter_name = alias.map(Name::new).unwrap_or(field_name);
2395+
23902396
let mut parameter = if is_kw_only {
2391-
Parameter::keyword_only(field_name)
2397+
Parameter::keyword_only(parameter_name)
23922398
} else {
2393-
Parameter::positional_or_keyword(field_name)
2399+
Parameter::positional_or_keyword(parameter_name)
23942400
}
23952401
.with_annotated_type(field_ty);
23962402

@@ -2925,6 +2931,7 @@ impl<'db> ClassLiteral<'db> {
29252931

29262932
let mut init = true;
29272933
let mut kw_only = None;
2934+
let mut alias = None;
29282935
if let Some(Type::KnownInstance(KnownInstanceType::Field(field))) = default_ty {
29292936
default_ty = field.default_type(db);
29302937
if self
@@ -2938,6 +2945,7 @@ impl<'db> ClassLiteral<'db> {
29382945
} else {
29392946
init = field.init(db);
29402947
kw_only = field.kw_only(db);
2948+
alias = field.alias(db);
29412949
}
29422950
}
29432951

@@ -2948,6 +2956,7 @@ impl<'db> ClassLiteral<'db> {
29482956
init_only: attr.is_init_var(),
29492957
init,
29502958
kw_only,
2959+
alias,
29512960
},
29522961
CodeGeneratorKind::TypedDict => {
29532962
let is_required = if attr.is_required() {

0 commit comments

Comments
 (0)