From 0403f9df7455573034fae3f57bb5b86128a11d03 Mon Sep 17 00:00:00 2001 From: Joren Hammudoglu Date: Fri, 26 Sep 2025 02:00:44 +0200 Subject: [PATCH 1/6] Refine the `copy._SupportsReplace.__replace__` signature --- stdlib/copy.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index 10d2f0ae3710..5379d01f1b3d 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -9,8 +9,8 @@ _SR = TypeVar("_SR", bound=_SupportsReplace) @type_check_only class _SupportsReplace(Protocol): - # In reality doesn't support args, but there's no other great way to express this. - def __replace__(self, *args: Any, **kwargs: Any) -> Self: ... + # Usually there are *some* kwargs, but there's no great way to express this. + def __replace__(self, /) -> Self: ... # None in CPython but non-None in Jython PyStringMap: Any From b93847dd85701d2eff22de96c373433a816d2121 Mon Sep 17 00:00:00 2001 From: jorenham Date: Fri, 26 Sep 2025 02:31:01 +0200 Subject: [PATCH 2/6] support for asymmetric `__replace__` in `copy.replace` --- stdlib/copy.pyi | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index 5379d01f1b3d..8a7d0976b703 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -1,16 +1,17 @@ import sys from typing import Any, Protocol, TypeVar, type_check_only -from typing_extensions import Self +from typing_extensions import ParamSpec __all__ = ["Error", "copy", "deepcopy"] _T = TypeVar("_T") -_SR = TypeVar("_SR", bound=_SupportsReplace) +_Tss = ParamSpec("_Tss") +_RT_co = TypeVar("_RT_co", covariant=True) @type_check_only -class _SupportsReplace(Protocol): - # Usually there are *some* kwargs, but there's no great way to express this. - def __replace__(self, /) -> Self: ... +class _SupportsReplace(Protocol[_Tss, _RT_co]): + # In reality doesn't support args, but there's no other great way to express this. + def __replace__(self, /, *_: _Tss.args, **changes: _Tss.kwargs) -> _RT_co: ... # None in CPython but non-None in Jython PyStringMap: Any @@ -21,7 +22,7 @@ def copy(x: _T) -> _T: ... if sys.version_info >= (3, 13): __all__ += ["replace"] - def replace(obj: _SR, /, **changes: Any) -> _SR: ... + def replace(obj: _SupportsReplace[..., _RT_co], /, **changes: Any) -> _RT_co: ... class Error(Exception): ... From 6ba4ca7e33f5a2599385cc55afdba86a5ab10234 Mon Sep 17 00:00:00 2001 From: jorenham Date: Fri, 26 Sep 2025 23:40:52 +0200 Subject: [PATCH 3/6] `copy.replace` regression test for asymmetric `__replace__` --- stdlib/@tests/test_cases/check_copy.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/stdlib/@tests/test_cases/check_copy.py b/stdlib/@tests/test_cases/check_copy.py index 7ea25f029944..fd3700d54e08 100644 --- a/stdlib/@tests/test_cases/check_copy.py +++ b/stdlib/@tests/test_cases/check_copy.py @@ -2,6 +2,7 @@ import copy import sys +from typing import Any, Generic, TypeVar from typing_extensions import Self, assert_type @@ -19,3 +20,20 @@ def __replace__(self, val: int) -> Self: obj = ReplaceableClass(42) cpy = copy.replace(obj, val=23) assert_type(cpy, ReplaceableClass) + + +_T_co = TypeVar("_T_co", covariant=True) + + +class Box(Generic[_T_co]): + def __init__(self, value: _T_co, /) -> None: + self.value = value + + def __replace__(self, value: Any) -> Box[Any]: + return Box(value) + + +if sys.version_info >= (3, 13): + box1: Box[int] = Box(42) + box2 = copy.replace(box1, val="spam") + assert_type(box2, Box[Any]) From 4449936463df0a2a588036d3cd72e3f02c442913 Mon Sep 17 00:00:00 2001 From: jorenham Date: Tue, 30 Sep 2025 19:55:40 +0200 Subject: [PATCH 4/6] don't use `ParamSpec` `copy._SupportsReplace` --- stdlib/copy.pyi | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index 8a7d0976b703..bdd9edb98b09 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -1,17 +1,15 @@ import sys from typing import Any, Protocol, TypeVar, type_check_only -from typing_extensions import ParamSpec __all__ = ["Error", "copy", "deepcopy"] _T = TypeVar("_T") -_Tss = ParamSpec("_Tss") _RT_co = TypeVar("_RT_co", covariant=True) @type_check_only -class _SupportsReplace(Protocol[_Tss, _RT_co]): - # In reality doesn't support args, but there's no other great way to express this. - def __replace__(self, /, *_: _Tss.args, **changes: _Tss.kwargs) -> _RT_co: ... +class _SupportsReplace(Protocol[_RT_co]): + # In reality doesn't support args, but there's no great way to express this. + def __replace__(self, /, *_: Any, **changes: Any) -> _RT_co: ... # None in CPython but non-None in Jython PyStringMap: Any @@ -22,7 +20,7 @@ def copy(x: _T) -> _T: ... if sys.version_info >= (3, 13): __all__ += ["replace"] - def replace(obj: _SupportsReplace[..., _RT_co], /, **changes: Any) -> _RT_co: ... + def replace(obj: _SupportsReplace[_RT_co], /, **changes: Any) -> _RT_co: ... class Error(Exception): ... From 8f09cc4344c2adbca952be8d139f1b0a60c664e1 Mon Sep 17 00:00:00 2001 From: jorenham Date: Tue, 30 Sep 2025 20:01:28 +0200 Subject: [PATCH 5/6] add comment explaining the use of `Any` in `copy.replace` Co-authored-by: Sebastian Rittau --- stdlib/copy.pyi | 1 + 1 file changed, 1 insertion(+) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index bdd9edb98b09..373899ea2635 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -20,6 +20,7 @@ def copy(x: _T) -> _T: ... if sys.version_info >= (3, 13): __all__ += ["replace"] + # The types accepted by `**changes` match those of `obj.__replace__`. def replace(obj: _SupportsReplace[_RT_co], /, **changes: Any) -> _RT_co: ... class Error(Exception): ... From ce839dfeab95304dd69c9e2618103a0d666567d6 Mon Sep 17 00:00:00 2001 From: jorenham Date: Tue, 30 Sep 2025 20:04:46 +0200 Subject: [PATCH 6/6] avoid using `Any` in the `copy.replace` tests Co-authored-by: Sebastian Rittau --- stdlib/@tests/test_cases/check_copy.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stdlib/@tests/test_cases/check_copy.py b/stdlib/@tests/test_cases/check_copy.py index fd3700d54e08..c9d4fa877e91 100644 --- a/stdlib/@tests/test_cases/check_copy.py +++ b/stdlib/@tests/test_cases/check_copy.py @@ -2,7 +2,7 @@ import copy import sys -from typing import Any, Generic, TypeVar +from typing import Generic, TypeVar from typing_extensions import Self, assert_type @@ -29,11 +29,11 @@ class Box(Generic[_T_co]): def __init__(self, value: _T_co, /) -> None: self.value = value - def __replace__(self, value: Any) -> Box[Any]: + def __replace__(self, value: str) -> Box[str]: return Box(value) if sys.version_info >= (3, 13): box1: Box[int] = Box(42) box2 = copy.replace(box1, val="spam") - assert_type(box2, Box[Any]) + assert_type(box2, Box[str])