From 656273bf3d51125911991dc15903606705acf269 Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 29 Jul 2025 11:32:01 -0400 Subject: [PATCH] [ty] synthesize `__replace__` for dataclasses (>=3.13) (#19545) ## Summary https://github.com/astral-sh/ty/issues/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 --- .../resources/mdtest/call/replace.md | 70 +++++++++++++++++++ crates/ty_python_semantic/src/types/class.rs | 48 +++++++------ 2 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/call/replace.md diff --git a/crates/ty_python_semantic/resources/mdtest/call/replace.md b/crates/ty_python_semantic/resources/mdtest/call/replace.md new file mode 100644 index 0000000000..b0112c1129 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/call/replace.md @@ -0,0 +1,70 @@ +# `replace` + +The `replace` function and the `replace` protocol were added in Python 3.13: + + +```toml +[environment] +python-version = "3.13" +``` + +## Basic + +```py +from copy import replace +from datetime import time + +t = time(12, 0, 0) +t = replace(t, minute=30) + +reveal_type(t) # revealed: time +``` + +## The `__replace__` protocol + +### Dataclasses + +Dataclasses support the `__replace__` protocol: + +```py +from dataclasses import dataclass +from copy import replace + +@dataclass +class Point: + x: int + y: int + +reveal_type(Point.__replace__) # revealed: (self: Point, *, x: int = int, y: int = int) -> Point +``` + +The `__replace__` method can either be called directly or through the `replace` function: + +```py +a = Point(1, 2) + +b = a.__replace__(x=3, y=4) +reveal_type(b) # revealed: Point + +b = replace(a, x=3, y=4) +reveal_type(b) # revealed: Point +``` + +A call to `replace` does not require all keyword arguments: + +```py +c = a.__replace__(y=4) +reveal_type(c) # revealed: Point + +d = replace(a, y=4) +reveal_type(d) # revealed: Point +``` + +Invalid calls to `__replace__` or `replace` will raise an error: + +```py +e = a.__replace__(x="wrong") # error: [invalid-argument-type] + +# TODO: this should ideally also be emit an error +e = replace(a, x="wrong") +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index b7157dbc08..8ca5516bb1 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1596,7 +1596,10 @@ impl<'db> ClassLiteral<'db> { let field_policy = CodeGeneratorKind::from_class(db, self)?; - let signature_from_fields = |mut parameters: Vec<_>| { + let instance_ty = + Type::instance(db, self.apply_optional_specialization(db, specialization)); + + let signature_from_fields = |mut parameters: Vec<_>, return_ty: Option>| { let mut kw_only_field_seen = false; for ( field_name, @@ -1669,21 +1672,26 @@ impl<'db> ClassLiteral<'db> { } } - let mut parameter = if kw_only_field_seen { + let mut parameter = if kw_only_field_seen || name == "__replace__" { Parameter::keyword_only(field_name) } else { Parameter::positional_or_keyword(field_name) } .with_annotated_type(field_ty); - if let Some(default_ty) = default_ty { + if name == "__replace__" { + // When replacing, we know there is a default value for the field + // (the value that is currently assigned to the field) + // assume this to be the declared type of the field + parameter = parameter.with_default_type(field_ty); + } else if let Some(default_ty) = default_ty { parameter = parameter.with_default_type(default_ty); } parameters.push(parameter); } - let mut signature = Signature::new(Parameters::new(parameters), Some(Type::none(db))); + let mut signature = Signature::new(Parameters::new(parameters), return_ty); signature.inherited_generic_context = self.generic_context(db); Some(CallableType::function_like(db, signature)) }; @@ -1701,16 +1709,13 @@ impl<'db> ClassLiteral<'db> { let self_parameter = Parameter::positional_or_keyword(Name::new_static("self")) // TODO: could be `Self`. - .with_annotated_type(Type::instance( - db, - self.apply_optional_specialization(db, specialization), - )); - signature_from_fields(vec![self_parameter]) + .with_annotated_type(instance_ty); + signature_from_fields(vec![self_parameter], Some(Type::none(db))) } (CodeGeneratorKind::NamedTuple, "__new__") => { let cls_parameter = Parameter::positional_or_keyword(Name::new_static("cls")) .with_annotated_type(KnownClass::Type.to_instance(db)); - signature_from_fields(vec![cls_parameter]) + signature_from_fields(vec![cls_parameter], Some(Type::none(db))) } (CodeGeneratorKind::DataclassLike, "__lt__" | "__le__" | "__gt__" | "__ge__") => { if !has_dataclass_param(DataclassParams::ORDER) { @@ -1721,16 +1726,10 @@ impl<'db> ClassLiteral<'db> { Parameters::new([ Parameter::positional_or_keyword(Name::new_static("self")) // TODO: could be `Self`. - .with_annotated_type(Type::instance( - db, - self.apply_optional_specialization(db, specialization), - )), + .with_annotated_type(instance_ty), Parameter::positional_or_keyword(Name::new_static("other")) // TODO: could be `Self`. - .with_annotated_type(Type::instance( - db, - self.apply_optional_specialization(db, specialization), - )), + .with_annotated_type(instance_ty), ]), Some(KnownClass::Bool.to_instance(db)), ); @@ -1745,15 +1744,20 @@ impl<'db> ClassLiteral<'db> { .place .ignore_possibly_unbound() } + (CodeGeneratorKind::DataclassLike, "__replace__") + if Program::get(db).python_version(db) >= PythonVersion::PY313 => + { + let self_parameter = Parameter::positional_or_keyword(Name::new_static("self")) + .with_annotated_type(instance_ty); + + signature_from_fields(vec![self_parameter], Some(instance_ty)) + } (CodeGeneratorKind::DataclassLike, "__setattr__") => { if has_dataclass_param(DataclassParams::FROZEN) { let signature = Signature::new( Parameters::new([ Parameter::positional_or_keyword(Name::new_static("self")) - .with_annotated_type(Type::instance( - db, - self.apply_optional_specialization(db, specialization), - )), + .with_annotated_type(instance_ty), Parameter::positional_or_keyword(Name::new_static("name")), Parameter::positional_or_keyword(Name::new_static("value")), ]),