Skip to content

Commit e851706

Browse files
committed
[ty] synthesize __replace__ for >=3.13
1 parent 8c0743d commit e851706

File tree

5 files changed

+123
-10
lines changed

5 files changed

+123
-10
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Replace
2+
3+
The replace function and protocol 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+
## `replace()` function
12+
13+
It is present in the `copy` module.
14+
15+
```py
16+
from copy import replace
17+
```
18+
19+
## `__replace__` protocol
20+
21+
```toml
22+
[environment]
23+
python-version = "3.13"
24+
```
25+
26+
### Dataclasses
27+
28+
```py
29+
from dataclasses import dataclass
30+
from copy import replace
31+
32+
@dataclass
33+
class Point:
34+
x: int
35+
y: int
36+
37+
a = Point(1, 2)
38+
39+
# It accepts keyword arguments
40+
reveal_type(a.__replace__) # revealed: (*, x: int = int, y: int = int) -> Point
41+
b = a.__replace__(x=3, y=4)
42+
reveal_type(b) # revealed: Point
43+
b = replace(a, x=3, y=4)
44+
reveal_type(b) # revealed: Point
45+
46+
# It does not require all keyword arguments
47+
c = a.__replace__(x=3)
48+
reveal_type(c) # revealed: Point
49+
d = replace(a, x=3)
50+
reveal_type(d) # revealed: Point
51+
52+
e = a.__replace__(x="wrong") # error: [invalid-argument-type]
53+
e = replace(a, x="wrong") # TODO(thejchap): error: [invalid-argument-type]
54+
```

crates/ty_python_semantic/src/module_resolver/module.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ pub enum KnownModule {
264264
Typing,
265265
Sys,
266266
Abc,
267+
Copy,
267268
Dataclasses,
268269
Collections,
269270
Inspect,
@@ -292,6 +293,7 @@ impl KnownModule {
292293
Self::Sys => "sys",
293294
Self::Abc => "abc",
294295
Self::Dataclasses => "dataclasses",
296+
Self::Copy => "copy",
295297
Self::Collections => "collections",
296298
Self::Inspect => "inspect",
297299
Self::TypeCheckerInternals => "_typeshed._type_checker_internals",

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ use ruff_db::parsed::parsed_module;
1212
use smallvec::{SmallVec, smallvec, smallvec_inline};
1313

1414
use super::{Argument, CallArguments, CallError, CallErrorKind, InferContext, Signature, Type};
15+
use crate::Program;
1516
use crate::db::Db;
1617
use crate::dunder_all::dunder_all_names;
17-
use crate::place::{Boundness, Place};
18+
use crate::place::{Boundness, Place, PlaceAndQualifiers};
1819
use crate::types::diagnostic::{
1920
CALL_NON_CALLABLE, CONFLICTING_ARGUMENT_FORMS, INVALID_ARGUMENT_TYPE, MISSING_ARGUMENT,
2021
NO_MATCHING_OVERLOAD, PARAMETER_ALREADY_ASSIGNED, TOO_MANY_POSITIONAL_ARGUMENTS,
@@ -29,11 +30,11 @@ use crate::types::signatures::{Parameter, ParameterForm, Parameters};
2930
use crate::types::tuple::{Tuple, TupleLength, TupleType};
3031
use crate::types::{
3132
BoundMethodType, ClassLiteral, DataclassParams, FieldInstance, KnownClass, KnownInstanceType,
32-
MethodWrapperKind, PropertyInstanceType, SpecialFormType, TypeMapping, UnionType,
33-
WrapperDescriptorKind, enums, ide_support, todo_type,
33+
MemberLookupPolicy, MethodWrapperKind, PropertyInstanceType, SpecialFormType, TypeMapping,
34+
UnionType, WrapperDescriptorKind, enums, ide_support, todo_type,
3435
};
3536
use ruff_db::diagnostic::{Annotation, Diagnostic, SubDiagnostic, SubDiagnosticSeverity};
36-
use ruff_python_ast as ast;
37+
use ruff_python_ast::{self as ast, PythonVersion};
3738

3839
/// Binding information for a possible union of callables. At a call site, the arguments must be
3940
/// compatible with _all_ of the types in the union for the call to be valid.
@@ -948,6 +949,37 @@ impl<'db> Bindings<'db> {
948949
}
949950
}
950951

952+
Some(KnownFunction::Replace)
953+
if Program::get(db).python_version(db) >= PythonVersion::PY313 =>
954+
{
955+
let [Some(instance_ty), _] = overload.parameter_types() else {
956+
continue;
957+
};
958+
let PlaceAndQualifiers {
959+
place: Place::Type(Type::Callable(attr_ty), _),
960+
qualifiers: _,
961+
} = instance_ty.class_member_with_policy(
962+
db,
963+
"__replace__".into(),
964+
MemberLookupPolicy::MRO_NO_OBJECT_FALLBACK,
965+
)
966+
else {
967+
continue;
968+
};
969+
let Some(
970+
_signature @ Signature {
971+
return_ty: Some(return_ty),
972+
..
973+
},
974+
) = attr_ty.signatures(db).overloads.first()
975+
else {
976+
continue;
977+
};
978+
// TODO(thejchap): set param types on overload
979+
// println!("params: {:?}", signature.parameters());
980+
overload.set_return_type(*return_ty);
981+
}
982+
951983
_ => {
952984
// Ideally, either the implementation, or exactly one of the overloads
953985
// of the function can have the dataclass_transform decorator applied.

crates/ty_python_semantic/src/types/class.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,7 @@ 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 signature_from_fields = |mut parameters: Vec<_>, return_ty: Option<Type<'db>>| {
16001600
let mut kw_only_field_seen = false;
16011601
for (
16021602
field_name,
@@ -1669,21 +1669,27 @@ impl<'db> ClassLiteral<'db> {
16691669
}
16701670
}
16711671

1672-
let mut parameter = if kw_only_field_seen {
1672+
// For the `__replace__` signature, force to kw only
1673+
let mut parameter = if kw_only_field_seen || name == "__replace__" {
16731674
Parameter::keyword_only(field_name)
16741675
} else {
16751676
Parameter::positional_or_keyword(field_name)
16761677
}
16771678
.with_annotated_type(field_ty);
16781679

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

16831689
parameters.push(parameter);
16841690
}
16851691

1686-
let mut signature = Signature::new(Parameters::new(parameters), Some(Type::none(db)));
1692+
let mut signature = Signature::new(Parameters::new(parameters), return_ty);
16871693
signature.inherited_generic_context = self.generic_context(db);
16881694
Some(CallableType::function_like(db, signature))
16891695
};
@@ -1705,12 +1711,12 @@ impl<'db> ClassLiteral<'db> {
17051711
db,
17061712
self.apply_optional_specialization(db, specialization),
17071713
));
1708-
signature_from_fields(vec![self_parameter])
1714+
signature_from_fields(vec![self_parameter], Some(Type::none(db)))
17091715
}
17101716
(CodeGeneratorKind::NamedTuple, "__new__") => {
17111717
let cls_parameter = Parameter::positional_or_keyword(Name::new_static("cls"))
17121718
.with_annotated_type(KnownClass::Type.to_instance(db));
1713-
signature_from_fields(vec![cls_parameter])
1719+
signature_from_fields(vec![cls_parameter], Some(Type::none(db)))
17141720
}
17151721
(CodeGeneratorKind::DataclassLike, "__lt__" | "__le__" | "__gt__" | "__ge__") => {
17161722
if !has_dataclass_param(DataclassParams::ORDER) {
@@ -1745,6 +1751,18 @@ impl<'db> ClassLiteral<'db> {
17451751
.place
17461752
.ignore_possibly_unbound()
17471753
}
1754+
(CodeGeneratorKind::DataclassLike, "__replace__")
1755+
if Program::get(db).python_version(db) >= PythonVersion::PY313 =>
1756+
{
1757+
let self_parameter =
1758+
Parameter::positional_or_keyword(Name::new_static("self")).with_annotated_type(
1759+
Type::instance(db, self.apply_optional_specialization(db, specialization)),
1760+
);
1761+
let instance_ty =
1762+
Type::instance(db, self.apply_optional_specialization(db, specialization));
1763+
1764+
signature_from_fields(vec![self_parameter], Some(instance_ty))
1765+
}
17481766
(CodeGeneratorKind::DataclassLike, "__setattr__") => {
17491767
if has_dataclass_param(DataclassParams::FROZEN) {
17501768
let signature = Signature::new(

crates/ty_python_semantic/src/types/function.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,9 @@ pub enum KnownFunction {
10531053
/// `dataclasses.field`
10541054
Field,
10551055

1056+
/// `copy.replace`
1057+
Replace,
1058+
10561059
/// `inspect.getattr_static`
10571060
GetattrStatic,
10581061

@@ -1133,6 +1136,9 @@ impl KnownFunction {
11331136
Self::Dataclass | Self::Field => {
11341137
matches!(module, KnownModule::Dataclasses)
11351138
}
1139+
Self::Replace => {
1140+
matches!(module, KnownModule::Copy)
1141+
}
11361142
Self::GetattrStatic => module.is_inspect(),
11371143
Self::IsAssignableTo
11381144
| Self::IsDisjointFrom
@@ -1412,6 +1418,7 @@ pub(crate) mod tests {
14121418
KnownFunction::AbstractMethod => KnownModule::Abc,
14131419

14141420
KnownFunction::Dataclass | KnownFunction::Field => KnownModule::Dataclasses,
1421+
KnownFunction::Replace => KnownModule::Copy,
14151422

14161423
KnownFunction::GetattrStatic => KnownModule::Inspect,
14171424

0 commit comments

Comments
 (0)