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..f1a25066beb7 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 @@ -20,7 +27,13 @@ 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): ...