From 084cbfe5cc502314b6caae6958022fd99bc1a497 Mon Sep 17 00:00:00 2001 From: apoorva-01 Date: Sat, 11 Jul 2026 06:01:18 +0530 Subject: [PATCH 1/2] Fix copy.replace widening a bound TypeVar to its bound The covariant protocol from #14786 loses a bound TypeVar whose __replace__ returns Self (dataclasses, namedtuples). Add a Self overload that keeps the argument's type, falling back to the covariant one so Box[int] -> Box[str] still works. --- stdlib/@tests/test_cases/check_copy.py | 26 ++++++++++++++++++++++++++ stdlib/copy.pyi | 16 ++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/stdlib/@tests/test_cases/check_copy.py b/stdlib/@tests/test_cases/check_copy.py index c9d4fa877e91..412b9f44d59a 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 dataclasses import dataclass from typing import Generic, TypeVar from typing_extensions import Self, assert_type @@ -37,3 +38,28 @@ def __replace__(self, value: str) -> Box[str]: box1: Box[int] = Box(42) box2 = copy.replace(box1, val="spam") assert_type(box2, Box[str]) + + +# Regression test for #15973: replace() must preserve a bound TypeVar whose +# __replace__ returns Self (the dataclass/namedtuple case), rather than widening +# to the bound. +@dataclass(frozen=True) +class BaseConfig: + pg_ssl_key: str | None = None + + +@dataclass(frozen=True) +class SubConfig(BaseConfig): + pg_host: str = "localhost" + + +_ConfigT = TypeVar("_ConfigT", bound=BaseConfig) + +if sys.version_info >= (3, 13): + + def replace_config(config: _ConfigT) -> _ConfigT: + result = copy.replace(config, pg_ssl_key="replaced") + assert_type(result, _ConfigT) + return result + + assert_type(copy.replace(SubConfig(), pg_host="example.com"), SubConfig) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index 373899ea2635..63c33387ec89 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -1,5 +1,6 @@ import sys -from typing import Any, Protocol, TypeVar, type_check_only +from typing import Any, Protocol, TypeVar, overload, type_check_only +from typing_extensions import Self __all__ = ["Error", "copy", "deepcopy"] @@ -7,10 +8,16 @@ _T = TypeVar("_T") _RT_co = TypeVar("_RT_co", covariant=True) @type_check_only -class _SupportsReplace(Protocol[_RT_co]): +class _SupportsReplaceSelf(Protocol): # In reality doesn't support args, but there's no great way to express this. + def __replace__(self, /, *_: Any, **changes: Any) -> Self: ... + +@type_check_only +class _SupportsReplace(Protocol[_RT_co]): def __replace__(self, /, *_: Any, **changes: Any) -> _RT_co: ... +_SR = TypeVar("_SR", bound=_SupportsReplaceSelf) + # None in CPython but non-None in Jython PyStringMap: Any @@ -21,6 +28,11 @@ def copy(x: _T) -> _T: ... if sys.version_info >= (3, 13): __all__ += ["replace"] # The types accepted by `**changes` match those of `obj.__replace__`. + # When `__replace__` returns `Self`, keep the argument's own type (so a bound + # TypeVar is preserved); otherwise return whatever `__replace__` declares. + @overload + def replace(obj: _SR, /, **changes: Any) -> _SR: ... + @overload def replace(obj: _SupportsReplace[_RT_co], /, **changes: Any) -> _RT_co: ... class Error(Exception): ... From 9afdca5cc6790b215a1c4b9611d3a12ab0beb0b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:42:56 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks --- stdlib/copy.pyi | 1 + 1 file changed, 1 insertion(+) diff --git a/stdlib/copy.pyi b/stdlib/copy.pyi index 63c33387ec89..f1a25066beb7 100644 --- a/stdlib/copy.pyi +++ b/stdlib/copy.pyi @@ -27,6 +27,7 @@ def copy(x: _T) -> _T: ... if sys.version_info >= (3, 13): __all__ += ["replace"] + # The types accepted by `**changes` match those of `obj.__replace__`. # When `__replace__` returns `Self`, keep the argument's own type (so a bound # TypeVar is preserved); otherwise return whatever `__replace__` declares.