From 42df0239568076e80961663248fe2a5852d4b656 Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:33:33 +0000 Subject: [PATCH 1/4] Make `copy.replace` more strongly typed --- stdlib/copy.pyi | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index 373899ea2635..3c4cb07f43f1 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -1,15 +1,17 @@ import sys from typing import Any, Protocol, TypeVar, type_check_only +from typing_extensions import ParamSpec __all__ = ["Error", "copy", "deepcopy"] _T = TypeVar("_T") _RT_co = TypeVar("_RT_co", covariant=True) +_P = ParamSpec("_P") @type_check_only -class _SupportsReplace(Protocol[_RT_co]): +class _SupportsReplace(Protocol[_P, _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: ... + def __replace__(self, /, *_: _P.args, **changes: _P.kwargs) -> _RT_co: ... # None in CPython but non-None in Jython PyStringMap: Any @@ -20,8 +22,12 @@ 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: ... + + def replace( + obj: _SupportsReplace[_P, _RT_co], /, + *_: _P.args, # does not accept positional arguments at runtime + **changes: _P.kwargs, + ) -> _RT_co: ... class Error(Exception): ... From 51685bf21a0555cd16a70b55d8764ef08844cdd1 Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:51:02 +0000 Subject: [PATCH 2/4] Format code with black --- stdlib/copy.pyi | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index 3c4cb07f43f1..aec0d031f8b6 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -24,9 +24,7 @@ if sys.version_info >= (3, 13): __all__ += ["replace"] def replace( - obj: _SupportsReplace[_P, _RT_co], /, - *_: _P.args, # does not accept positional arguments at runtime - **changes: _P.kwargs, + obj: _SupportsReplace[_P, _RT_co], /, *_: _P.args, **changes: _P.kwargs # does not accept positional arguments at runtime ) -> _RT_co: ... class Error(Exception): ... From 9d38af38d9925dbdc73834ccaacf374ae525e73f Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:54:22 +0000 Subject: [PATCH 3/4] Fix stdlib test for `copy.replace` --- 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 c9d4fa877e91..59896851d737 100644 --- a/stdlib/@tests/test_cases/check_copy.py +++ b/stdlib/@tests/test_cases/check_copy.py @@ -10,7 +10,7 @@ class ReplaceableClass: def __init__(self, val: int) -> None: self.val = val - def __replace__(self, val: int) -> Self: + def __replace__(self, /, *, val: int) -> Self: cpy = copy.copy(self) cpy.val = val return cpy @@ -29,11 +29,11 @@ class Box(Generic[_T_co]): def __init__(self, value: _T_co, /) -> None: self.value = value - def __replace__(self, value: str) -> Box[str]: + 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") + box2 = copy.replace(box1, value="spam") assert_type(box2, Box[str]) From b7b15d4bf48c49143d1455a98ef8cc64339c0bbf Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:01:53 +0000 Subject: [PATCH 4/4] Add exemption for copy.replace not matching runtime signature --- stdlib/@tests/stubtest_allowlists/py313.txt | 3 +++ stdlib/@tests/stubtest_allowlists/py314.txt | 2 ++ 2 files changed, 5 insertions(+) diff --git a/stdlib/@tests/stubtest_allowlists/py313.txt b/stdlib/@tests/stubtest_allowlists/py313.txt index 12553bf684ee..f3df7cc2248c 100644 --- a/stdlib/@tests/stubtest_allowlists/py313.txt +++ b/stdlib/@tests/stubtest_allowlists/py313.txt @@ -2,6 +2,9 @@ # New errors in Python 3.13 # ========================= +# No way to express keyword-only ParamSpec +copy.replace + # ==================================== # Pre-existing errors from Python 3.12 diff --git a/stdlib/@tests/stubtest_allowlists/py314.txt b/stdlib/@tests/stubtest_allowlists/py314.txt index 0fcdcbb84d97..98045f819fd2 100644 --- a/stdlib/@tests/stubtest_allowlists/py314.txt +++ b/stdlib/@tests/stubtest_allowlists/py314.txt @@ -45,6 +45,8 @@ importlib.util.Loader.exec_module # See Lib/importlib/_abc.py. Might be defined # Pre-existing errors from Python 3.13 # ==================================== +# No way to express keyword-only ParamSpec +copy.replace # ======= # >= 3.12