From e8a283a810bbfd5563e1ddbd4ea132d6408b62a1 Mon Sep 17 00:00:00 2001 From: Justin Chapman Date: Thu, 24 Jul 2025 21:18:52 -0400 Subject: [PATCH 1/2] [ty] synthesize __replace__ for >=3.13 --- .../resources/mdtest/replace.md | 49 +++++++++++++++++++ crates/ty_python_semantic/src/types/class.rs | 49 ++++++++++--------- 2 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/replace.md diff --git a/crates/ty_python_semantic/resources/mdtest/replace.md b/crates/ty_python_semantic/resources/mdtest/replace.md new file mode 100644 index 0000000000000..ba28061123853 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/replace.md @@ -0,0 +1,49 @@ +# Replace + +The replace function and protocol added in Python 3.13: + + +```toml +[environment] +python-version = "3.13" +``` + +## `replace()` function + +It is present in the `copy` module. + +```py +from copy import replace +``` + +## `__replace__` protocol + +### Dataclasses + +```py +from dataclasses import dataclass +from copy import replace + +@dataclass +class Point: + x: int + y: int + +a = Point(1, 2) + +# It accepts keyword arguments +reveal_type(a.__replace__) # revealed: (*, x: int = int, y: int = int) -> Point +b = a.__replace__(x=3, y=4) +reveal_type(b) # revealed: Point +b = replace(a, x=3, y=4) +reveal_type(b) # revealed: Point + +# It does not require all keyword arguments +c = a.__replace__(x=3) +reveal_type(c) # revealed: Point +d = replace(a, x=3) +reveal_type(d) # revealed: Point + +e = a.__replace__(x="wrong") # error: [invalid-argument-type] +e = replace(a, x="wrong") # TODO: error: [invalid-argument-type] +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index b7157dbc083ed..cba2d904066d0 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,27 @@ impl<'db> ClassLiteral<'db> { } } - let mut parameter = if kw_only_field_seen { + // For the `__replace__` signature, force to kw only + 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 +1710,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 +1727,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 +1745,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")), ]), From c312396cd86acc3f74e8b1ab19fd2fb42f07f624 Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 29 Jul 2025 17:22:08 +0200 Subject: [PATCH 2/2] Minor adjustments --- .../resources/mdtest/call/replace.md | 70 +++++++++++++++++++ .../resources/mdtest/replace.md | 49 ------------- crates/ty_python_semantic/src/types/class.rs | 1 - 3 files changed, 70 insertions(+), 50 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/call/replace.md delete mode 100644 crates/ty_python_semantic/resources/mdtest/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 0000000000000..b0112c1129766 --- /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/resources/mdtest/replace.md b/crates/ty_python_semantic/resources/mdtest/replace.md deleted file mode 100644 index ba28061123853..0000000000000 --- a/crates/ty_python_semantic/resources/mdtest/replace.md +++ /dev/null @@ -1,49 +0,0 @@ -# Replace - -The replace function and protocol added in Python 3.13: - - -```toml -[environment] -python-version = "3.13" -``` - -## `replace()` function - -It is present in the `copy` module. - -```py -from copy import replace -``` - -## `__replace__` protocol - -### Dataclasses - -```py -from dataclasses import dataclass -from copy import replace - -@dataclass -class Point: - x: int - y: int - -a = Point(1, 2) - -# It accepts keyword arguments -reveal_type(a.__replace__) # revealed: (*, x: int = int, y: int = int) -> Point -b = a.__replace__(x=3, y=4) -reveal_type(b) # revealed: Point -b = replace(a, x=3, y=4) -reveal_type(b) # revealed: Point - -# It does not require all keyword arguments -c = a.__replace__(x=3) -reveal_type(c) # revealed: Point -d = replace(a, x=3) -reveal_type(d) # revealed: Point - -e = a.__replace__(x="wrong") # error: [invalid-argument-type] -e = replace(a, x="wrong") # TODO: error: [invalid-argument-type] -``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index cba2d904066d0..8ca5516bb18fe 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1672,7 +1672,6 @@ impl<'db> ClassLiteral<'db> { } } - // For the `__replace__` signature, force to kw only let mut parameter = if kw_only_field_seen || name == "__replace__" { Parameter::keyword_only(field_name) } else {