Skip to content

Commit 656273b

Browse files
thejchapsharkdp
andauthored
[ty] synthesize __replace__ for dataclasses (>=3.13) (#19545)
## Summary astral-sh/ty#111 adds support for the new `copy.replace` and `__replace__` protocol [added in 3.13](https://docs.python.org/3/whatsnew/3.13.html#copy) - docs: https://docs.python.org/3/library/copy.html#object.__replace__ - some discussion on pyright/mypy implementations: https://discuss.python.org/t/dataclass-transform-and-replace/69067 ### Burndown - [x] add tests - [x] implement `__replace__` - [ ] [collections.namedtuple()](https://docs.python.org/3/library/collections.html#collections.namedtuple) - [x] [dataclasses.dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) ## Test Plan new mdtests --------- Co-authored-by: David Peter <[email protected]>
1 parent 81867ea commit 656273b

File tree

2 files changed

+96
-22
lines changed

2 files changed

+96
-22
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# `replace`
2+
3+
The `replace` function and the `replace` protocol were added in Python 3.13:
4+
<https://docs.python.org/3/whatsnew/3.13.html#copy>
5+
6+
```toml
7+
[environment]
8+
python-version = "3.13"
9+
```
10+
11+
## Basic
12+
13+
```py
14+
from copy import replace
15+
from datetime import time
16+
17+
t = time(12, 0, 0)
18+
t = replace(t, minute=30)
19+
20+
reveal_type(t) # revealed: time
21+
```
22+
23+
## The `__replace__` protocol
24+
25+
### Dataclasses
26+
27+
Dataclasses support the `__replace__` protocol:
28+
29+
```py
30+
from dataclasses import dataclass
31+
from copy import replace
32+
33+
@dataclass
34+
class Point:
35+
x: int
36+
y: int
37+
38+
reveal_type(Point.__replace__) # revealed: (self: Point, *, x: int = int, y: int = int) -> Point
39+
```
40+
41+
The `__replace__` method can either be called directly or through the `replace` function:
42+
43+
```py
44+
a = Point(1, 2)
45+
46+
b = a.__replace__(x=3, y=4)
47+
reveal_type(b) # revealed: Point
48+
49+
b = replace(a, x=3, y=4)
50+
reveal_type(b) # revealed: Point
51+
```
52+
53+
A call to `replace` does not require all keyword arguments:
54+
55+
```py
56+
c = a.__replace__(y=4)
57+
reveal_type(c) # revealed: Point
58+
59+
d = replace(a, y=4)
60+
reveal_type(d) # revealed: Point
61+
```
62+
63+
Invalid calls to `__replace__` or `replace` will raise an error:
64+
65+
```py
66+
e = a.__replace__(x="wrong") # error: [invalid-argument-type]
67+
68+
# TODO: this should ideally also be emit an error
69+
e = replace(a, x="wrong")
70+
```

crates/ty_python_semantic/src/types/class.rs

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,10 @@ impl<'db> ClassLiteral<'db> {
15961596

15971597
let field_policy = CodeGeneratorKind::from_class(db, self)?;
15981598

1599-
let signature_from_fields = |mut parameters: Vec<_>| {
1599+
let instance_ty =
1600+
Type::instance(db, self.apply_optional_specialization(db, specialization));
1601+
1602+
let signature_from_fields = |mut parameters: Vec<_>, return_ty: Option<Type<'db>>| {
16001603
let mut kw_only_field_seen = false;
16011604
for (
16021605
field_name,
@@ -1669,21 +1672,26 @@ impl<'db> ClassLiteral<'db> {
16691672
}
16701673
}
16711674

1672-
let mut parameter = if kw_only_field_seen {
1675+
let mut parameter = if kw_only_field_seen || name == "__replace__" {
16731676
Parameter::keyword_only(field_name)
16741677
} else {
16751678
Parameter::positional_or_keyword(field_name)
16761679
}
16771680
.with_annotated_type(field_ty);
16781681

1679-
if let Some(default_ty) = default_ty {
1682+
if name == "__replace__" {
1683+
// When replacing, we know there is a default value for the field
1684+
// (the value that is currently assigned to the field)
1685+
// assume this to be the declared type of the field
1686+
parameter = parameter.with_default_type(field_ty);
1687+
} else if let Some(default_ty) = default_ty {
16801688
parameter = parameter.with_default_type(default_ty);
16811689
}
16821690

16831691
parameters.push(parameter);
16841692
}
16851693

1686-
let mut signature = Signature::new(Parameters::new(parameters), Some(Type::none(db)));
1694+
let mut signature = Signature::new(Parameters::new(parameters), return_ty);
16871695
signature.inherited_generic_context = self.generic_context(db);
16881696
Some(CallableType::function_like(db, signature))
16891697
};
@@ -1701,16 +1709,13 @@ impl<'db> ClassLiteral<'db> {
17011709

17021710
let self_parameter = Parameter::positional_or_keyword(Name::new_static("self"))
17031711
// TODO: could be `Self`.
1704-
.with_annotated_type(Type::instance(
1705-
db,
1706-
self.apply_optional_specialization(db, specialization),
1707-
));
1708-
signature_from_fields(vec![self_parameter])
1712+
.with_annotated_type(instance_ty);
1713+
signature_from_fields(vec![self_parameter], Some(Type::none(db)))
17091714
}
17101715
(CodeGeneratorKind::NamedTuple, "__new__") => {
17111716
let cls_parameter = Parameter::positional_or_keyword(Name::new_static("cls"))
17121717
.with_annotated_type(KnownClass::Type.to_instance(db));
1713-
signature_from_fields(vec![cls_parameter])
1718+
signature_from_fields(vec![cls_parameter], Some(Type::none(db)))
17141719
}
17151720
(CodeGeneratorKind::DataclassLike, "__lt__" | "__le__" | "__gt__" | "__ge__") => {
17161721
if !has_dataclass_param(DataclassParams::ORDER) {
@@ -1721,16 +1726,10 @@ impl<'db> ClassLiteral<'db> {
17211726
Parameters::new([
17221727
Parameter::positional_or_keyword(Name::new_static("self"))
17231728
// TODO: could be `Self`.
1724-
.with_annotated_type(Type::instance(
1725-
db,
1726-
self.apply_optional_specialization(db, specialization),
1727-
)),
1729+
.with_annotated_type(instance_ty),
17281730
Parameter::positional_or_keyword(Name::new_static("other"))
17291731
// TODO: could be `Self`.
1730-
.with_annotated_type(Type::instance(
1731-
db,
1732-
self.apply_optional_specialization(db, specialization),
1733-
)),
1732+
.with_annotated_type(instance_ty),
17341733
]),
17351734
Some(KnownClass::Bool.to_instance(db)),
17361735
);
@@ -1745,15 +1744,20 @@ impl<'db> ClassLiteral<'db> {
17451744
.place
17461745
.ignore_possibly_unbound()
17471746
}
1747+
(CodeGeneratorKind::DataclassLike, "__replace__")
1748+
if Program::get(db).python_version(db) >= PythonVersion::PY313 =>
1749+
{
1750+
let self_parameter = Parameter::positional_or_keyword(Name::new_static("self"))
1751+
.with_annotated_type(instance_ty);
1752+
1753+
signature_from_fields(vec![self_parameter], Some(instance_ty))
1754+
}
17481755
(CodeGeneratorKind::DataclassLike, "__setattr__") => {
17491756
if has_dataclass_param(DataclassParams::FROZEN) {
17501757
let signature = Signature::new(
17511758
Parameters::new([
17521759
Parameter::positional_or_keyword(Name::new_static("self"))
1753-
.with_annotated_type(Type::instance(
1754-
db,
1755-
self.apply_optional_specialization(db, specialization),
1756-
)),
1760+
.with_annotated_type(instance_ty),
17571761
Parameter::positional_or_keyword(Name::new_static("name")),
17581762
Parameter::positional_or_keyword(Name::new_static("value")),
17591763
]),

0 commit comments

Comments
 (0)