Skip to content

Commit 3507ff5

Browse files
authored
✨ feat: enable copy.replace for py3.13+ (#42)
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
1 parent b0075cf commit 3507ff5

File tree

7 files changed

+347
-108
lines changed

7 files changed

+347
-108
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ messages_control.disable = [
145145
"line-too-long",
146146
"missing-module-docstring",
147147
"missing-function-docstring",
148+
"no-member", # handled by mypy
148149
"no-value-for-parameter", # for plum-dispatch
150+
"too-many-function-args", # handled by testing
149151
"unused-argument", # handled by ruff
150152
"unused-wildcard-import", # handled by ruff
151153
"wildcard-import", # handled by ruff

src/dataclassish/__init__.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"field_items",
2020
# Classes
2121
"DataclassInstance",
22+
"CanCopyReplace",
2223
"F",
2324
]
2425

@@ -33,11 +34,16 @@
3334
get_field,
3435
replace,
3536
)
36-
from ._src.types import DataclassInstance, F
37+
from ._src.types import CanCopyReplace, DataclassInstance, F
3738
from ._version import version as __version__
3839

3940
# Register dispatches by importing the submodules
4041
# isort: split
41-
from ._src import register_base, register_dataclass, register_mapping
42+
from ._src import (
43+
register_base,
44+
register_copyreplace,
45+
register_dataclass,
46+
register_mapping,
47+
)
4248

43-
del register_base, register_dataclass, register_mapping
49+
del register_base, register_dataclass, register_mapping, register_copyreplace

src/dataclassish/_src/register_base.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
__all__: list[str] = []
44

5+
from collections.abc import Mapping
56
from typing import Any, TypeVar
67

78
from plum import dispatch
89

9-
from .api import fields
10+
from .api import fields, replace
11+
from .types import F
1012

1113
K = TypeVar("K")
1214
V = TypeVar("V")
@@ -37,6 +39,20 @@ def get_field(obj: Any, k: str, /) -> Any:
3739
return getattr(obj, k)
3840

3941

42+
# ===================================================================
43+
# Replace
44+
45+
46+
def _recursive_replace_helper(obj: object, k: str, v: Any, /) -> Any:
47+
if isinstance(v, F):
48+
out = v.value
49+
elif isinstance(v, Mapping):
50+
out = replace(get_field(obj, k), v)
51+
else:
52+
out = v
53+
return out
54+
55+
4056
# ===================================================================
4157
# Field keys
4258

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Register dispatches for CanCopyReplace objects."""
2+
3+
__all__: list[str] = []
4+
5+
import copy
6+
import sys
7+
from collections.abc import Mapping
8+
from typing import Any
9+
10+
from plum import dispatch
11+
12+
from .register_base import _recursive_replace_helper
13+
from .types import CanCopyReplace
14+
15+
# ===================================================================
16+
# Get field
17+
18+
19+
@dispatch # type: ignore[misc]
20+
def get_field(obj: CanCopyReplace, k: str, /) -> Any:
21+
"""Get a field of a dataclass instance by name.
22+
23+
Examples
24+
--------
25+
>>> from dataclasses import dataclass
26+
>>> from dataclassish import get_field
27+
28+
% invisible-code-block: python
29+
%
30+
% import sys
31+
32+
% skip: start if(sys.version_info < (3, 13), reason="py3.13+")
33+
34+
>>> @dataclass
35+
... class Point:
36+
... x: float
37+
... y: float
38+
39+
>>> p = Point(1.0, 2.0)
40+
>>> get_field(p, "x")
41+
1.0
42+
43+
% skip: end
44+
45+
This works for any object that implements the ``__replace__`` method.
46+
47+
>>> class Point:
48+
... def __init__(self, x, y):
49+
... self.x = x
50+
... self.y = y
51+
... def __replace__(self, **changes):
52+
... return Point(**(self.__dict__ | changes))
53+
... def __repr__(self):
54+
... return f"Point(x={self.x}, y={self.y})"
55+
56+
>>> p = Point(1.0, 2.0)
57+
>>> get_field(p, "x")
58+
1.0
59+
60+
"""
61+
return getattr(obj, k)
62+
63+
64+
# ===================================================================
65+
# Replace
66+
67+
68+
@dispatch
69+
def replace(obj: CanCopyReplace, /, **kwargs: Any) -> CanCopyReplace:
70+
"""Replace the fields of an object.
71+
72+
Examples
73+
--------
74+
>>> from dataclassish import replace
75+
76+
% invisible-code-block: python
77+
%
78+
% import sys
79+
80+
% skip: start if(sys.version_info < (3, 13), reason="py3.13+")
81+
82+
As of Python 3.13, dataclasses implement the ``__replace__`` method.
83+
84+
>>> from dataclasses import dataclass
85+
86+
>>> @dataclass
87+
... class Point:
88+
... x: float
89+
... y: float
90+
91+
>>> p = Point(1.0, 2.0)
92+
>>> p
93+
Point(x=1.0, y=2.0)
94+
95+
>>> replace(p, x=3.0)
96+
Point(x=3.0, y=2.0)
97+
98+
% skip: end
99+
100+
>>> class Point:
101+
... def __init__(self, x, y):
102+
... self.x = x
103+
... self.y = y
104+
... def __replace__(self, **changes):
105+
... return Point(**(self.__dict__ | changes))
106+
... def __repr__(self):
107+
... return f"Point(x={self.x}, y={self.y})"
108+
109+
>>> p = Point(1.0, 2.0)
110+
>>> replace(p, x=2.0)
111+
Point(x=2.0, y=2.0)
112+
113+
The ``__replace__`` method was introduced in Python 3.13 to bring
114+
``dataclasses.replace``-like functionality to any implementing object. The
115+
method is publicly exposed via the ``copy.replace`` function.
116+
117+
% invisible-code-block: python
118+
%
119+
% import sys
120+
121+
% skip: start if(sys.version_info < (3, 13), reason="py3.13+")
122+
123+
>>> import copy
124+
>>> copy.replace(p, x=3.0)
125+
Point(x=3.0, y=2.0)
126+
127+
% skip: end
128+
129+
"""
130+
return (
131+
obj.__replace__(**kwargs)
132+
if sys.version_info < (3, 13)
133+
else copy.replace(obj, **kwargs)
134+
)
135+
136+
137+
@dispatch # type: ignore[no-redef]
138+
def replace(obj: CanCopyReplace, fs: Mapping[str, Any], /) -> CanCopyReplace:
139+
"""Replace the fields of a dataclass instance.
140+
141+
Examples
142+
--------
143+
>>> from dataclasses import dataclass
144+
>>> from dataclassish import replace, F
145+
146+
>>> class Point:
147+
... def __init__(self, x, y):
148+
... self.x = x
149+
... self.y = y
150+
... def __replace__(self, **changes):
151+
... return Point(**(self.__dict__ | changes))
152+
... def __repr__(self):
153+
... return f"Point(x={self.x}, y={self.y})"
154+
155+
>>> @dataclass
156+
... class TwoPoint:
157+
... a: Point
158+
... b: Point
159+
160+
>>> p = TwoPoint(Point(1.0, 2.0), Point(3.0, 4.0))
161+
>>> p
162+
TwoPoint(a=Point(x=1.0, y=2.0), b=Point(x=3.0, y=4.0))
163+
164+
>>> replace(p, {"a": {"x": 5.0}, "b": {"y": 6.0}})
165+
TwoPoint(a=Point(x=5.0, y=2.0), b=Point(x=3.0, y=6.0))
166+
167+
>>> replace(p, {"a": {"x": F({"thing": 5.0})}})
168+
TwoPoint(a=Point(x={'thing': 5.0}, y=2.0),
169+
b=Point(x=3.0, y=4.0))
170+
171+
This also works on mixed-type structures, e.g. a dictionary of objects.
172+
173+
>>> p = {"a": Point(1.0, 2.0), "b": Point(3.0, 4.0)}
174+
>>> replace(p, {"a": {"x": 5.0}, "b": {"y": 6.0}})
175+
{'a': Point(x=5.0, y=2.0), 'b': Point(x=3.0, y=6.0)}
176+
177+
"""
178+
kwargs = {k: _recursive_replace_helper(obj, k, v) for k, v in fs.items()}
179+
return replace(obj, **kwargs)

0 commit comments

Comments
 (0)