Skip to content

Commit 084cbfe

Browse files
committed
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.
1 parent f76037a commit 084cbfe

2 files changed

Lines changed: 40 additions & 2 deletions

File tree

stdlib/@tests/test_cases/check_copy.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import copy
44
import sys
5+
from dataclasses import dataclass
56
from typing import Generic, TypeVar
67
from typing_extensions import Self, assert_type
78

@@ -37,3 +38,28 @@ def __replace__(self, value: str) -> Box[str]:
3738
box1: Box[int] = Box(42)
3839
box2 = copy.replace(box1, val="spam")
3940
assert_type(box2, Box[str])
41+
42+
43+
# Regression test for #15973: replace() must preserve a bound TypeVar whose
44+
# __replace__ returns Self (the dataclass/namedtuple case), rather than widening
45+
# to the bound.
46+
@dataclass(frozen=True)
47+
class BaseConfig:
48+
pg_ssl_key: str | None = None
49+
50+
51+
@dataclass(frozen=True)
52+
class SubConfig(BaseConfig):
53+
pg_host: str = "localhost"
54+
55+
56+
_ConfigT = TypeVar("_ConfigT", bound=BaseConfig)
57+
58+
if sys.version_info >= (3, 13):
59+
60+
def replace_config(config: _ConfigT) -> _ConfigT:
61+
result = copy.replace(config, pg_ssl_key="replaced")
62+
assert_type(result, _ConfigT)
63+
return result
64+
65+
assert_type(copy.replace(SubConfig(), pg_host="example.com"), SubConfig)

stdlib/copy.pyi

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import sys
2-
from typing import Any, Protocol, TypeVar, type_check_only
2+
from typing import Any, Protocol, TypeVar, overload, type_check_only
3+
from typing_extensions import Self
34

45
__all__ = ["Error", "copy", "deepcopy"]
56

67
_T = TypeVar("_T")
78
_RT_co = TypeVar("_RT_co", covariant=True)
89

910
@type_check_only
10-
class _SupportsReplace(Protocol[_RT_co]):
11+
class _SupportsReplaceSelf(Protocol):
1112
# In reality doesn't support args, but there's no great way to express this.
13+
def __replace__(self, /, *_: Any, **changes: Any) -> Self: ...
14+
15+
@type_check_only
16+
class _SupportsReplace(Protocol[_RT_co]):
1217
def __replace__(self, /, *_: Any, **changes: Any) -> _RT_co: ...
1318

19+
_SR = TypeVar("_SR", bound=_SupportsReplaceSelf)
20+
1421
# None in CPython but non-None in Jython
1522
PyStringMap: Any
1623

@@ -21,6 +28,11 @@ def copy(x: _T) -> _T: ...
2128
if sys.version_info >= (3, 13):
2229
__all__ += ["replace"]
2330
# The types accepted by `**changes` match those of `obj.__replace__`.
31+
# When `__replace__` returns `Self`, keep the argument's own type (so a bound
32+
# TypeVar is preserved); otherwise return whatever `__replace__` declares.
33+
@overload
34+
def replace(obj: _SR, /, **changes: Any) -> _SR: ...
35+
@overload
2436
def replace(obj: _SupportsReplace[_RT_co], /, **changes: Any) -> _RT_co: ...
2537

2638
class Error(Exception): ...

0 commit comments

Comments
 (0)