Repository: jorenham/optype

Issue:
stdlib `copy` integration
Add protocols:

- `CanCopy`
- `CanDeepCopy`
- `CanReplace`

Discussion:

Patch:
diff --git a/README.md b/README.md
index 8b5bbb8..458affa 100644
--- a/README.md
+++ b/README.md
@@ -1244,11 +1244,53 @@ Interfaces for emulating buffer types using the [buffer protocol][BP].
 
 [BP]: https://docs.python.org/3/reference/datamodel.html#python-buffer-protocol
 
+### `copy`
+
+For the [`copy`][CP] standard library, `optype` provides the following
+interfaces:
+
+<table>
+    <tr>
+        <th align="center">operator</th>
+        <th colspan="2" align="center">operand</th>
+    </tr>
+    <tr>
+        <td>expression</td>
+        <td>method(s)</td>
+        <th>type</th>
+    </tr>
+    <tr>
+        <td><code>copy.copy(_)</code></td>
+        <td><code>__copy__</code></td>
+        <td><code>CanCopy[T]</code></td>
+    </tr>
+    <tr>
+        <td><code>copy.deepcopy(_, memo={})</code></td>
+        <td><code>__deepcopy__</code></td>
+        <td><code>CanDeepcopy[T]</code></td>
+    </tr>
+    <tr>
+        <td><code>copy.replace(_, **changes: V)</code> (Python 3.13+)</td>
+        <td><code>__replace__</code></td>
+        <td><code>CanReplace[T, V]</code></td>
+    </tr>
+</table>
+
+And for convenience, there are the runtime-checkable aliases for all three
+interfaces, with `T` bound to `Self`. These are roughly equivalent to:
+
+```python
+type CanCopySelf = CanCopy[CanCopySelf]
+type CanDeepcopySelf = CanDeepcopy[CanDeepcopySelf]
+type CanReplaceSelf[V] = CanReplace[CanReplaceSelf[V], V]
+```
+
+[CP]: https://docs.python.org/3.13/library/copy.html
+
 ## Future plans
 
 - Support for Python versions before 3.12 (#19).
 - More standard library protocols, e.g.
-    - `copy` (#20)
     - `dataclasses` (#21)
     - `pickle` (#22).
     - `typing.NamedTuple` (#23)
diff --git a/optype/__init__.py b/optype/__init__.py
index 273c175..c145f26 100644
--- a/optype/__init__.py
+++ b/optype/__init__.py
@@ -16,6 +16,10 @@ __all__ = (
     'CanCeil',
     'CanComplex',
     'CanContains',
+    'CanCopy',
+    'CanCopySelf',
+    'CanDeepcopy',
+    'CanDeepcopySelf',
     'CanDelattr',
     'CanDelete',
     'CanDelitem',
@@ -86,6 +90,8 @@ __all__ = (
     'CanRTruediv',
     'CanRXor',
     'CanReleaseBuffer',
+    'CanReplace',
+    'CanReplaceSelf',
     'CanRepr',
     'CanReversed',
     'CanRound',
@@ -305,6 +311,10 @@ from ._can import (
     CanCeil,
     CanComplex,
     CanContains,
+    CanCopy,
+    CanCopySelf,
+    CanDeepcopy,
+    CanDeepcopySelf,
     CanDelattr,
     CanDelete,
     CanDelitem,
@@ -375,6 +385,8 @@ from ._can import (
     CanRTruediv,
     CanRXor,
     CanReleaseBuffer,
+    CanReplace,
+    CanReplaceSelf,
     CanRepr,
     CanReversed,
     CanRound,
diff --git a/optype/_can.py b/optype/_can.py
index b33f876..98d6b4a 100644
--- a/optype/_can.py
+++ b/optype/_can.py
@@ -700,3 +700,45 @@ class CanAExit[R](Protocol):
 
 @runtime_checkable
 class CanAsyncWith[V, R](CanAEnter[V], CanAExit[R], Protocol): ...
+
+
+# `copy` stdlib
+# https://docs.python.org/3.13/library/copy.html
+
+
+@runtime_checkable
+class CanCopy[T](Protocol):
+    """Support for creating shallow copies through `copy.copy`."""
+    def __copy__(self) -> T: ...
+
+
+@runtime_checkable
+class CanDeepcopy[T](Protocol):
+    """Support for creating deep copies through `copy.deepcopy`."""
+    def __deepcopy__(self, memo: dict[int, Any], /) -> T: ...
+
+
+@runtime_checkable
+class CanReplace[T, V](Protocol):
+    """Support for `copy.replace` in Python 3.13+."""
+    def __replace__(self, /, **changes: V) -> T: ...
+
+
+@runtime_checkable
+class CanCopySelf(CanCopy['CanCopySelf'], Protocol):
+    """Variant of `CanCopy` that returns `Self` (as it should)."""
+    @override
+    def __copy__(self) -> Self: ...
+
+
+class CanDeepcopySelf(CanDeepcopy['CanDeepcopySelf'], Protocol):
+    """Variant of `CanDeepcopy` that returns `Self` (as it should)."""
+    @override
+    def __deepcopy__(self, memo: dict[int, Any], /) -> Self: ...
+
+
+@runtime_checkable
+class CanReplaceSelf[V](CanReplace['CanReplaceSelf[Any]', V], Protocol):
+    """Variant of `CanReplace` that returns `Self`."""
+    @override
+    def __replace__(self, /, **changes: V) -> Self: ...
diff --git a/pyproject.toml b/pyproject.toml
index fa20291..05adc53 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -198,12 +198,6 @@ extend-ignore = [
     "SLF001",   # private-member-access
 ]
 
-[tool.ruff.lint.flake8-quotes]
-inline-quotes = "single"
-
-[tool.ruff.lint.flake8-type-checking]
-strict = true
-
 [tool.ruff.lint.isort]
 case-sensitive = true
 combine-as-imports = true
@@ -212,6 +206,17 @@ known-first-party = ["optype"]
 lines-between-types = 0
 lines-after-imports = 2
 
+[tool.ruff.lint.flake8-quotes]
+inline-quotes = "single"
+
+[tool.ruff.lint.flake8-type-checking]
+strict = true
+
+[tool.ruff.lint.pylint]
+allow-dunder-method-names = [
+    "__replace__",  # used by `copy.replace` in Python 3.13+
+]
+
 [tool.ruff.format]
 # keep in sync with .editorconfig
 line-ending = "lf"


Test patch:
diff --git a/tests/test_protocols.py b/tests/test_protocols.py
index 0116625..796158e 100644
--- a/tests/test_protocols.py
+++ b/tests/test_protocols.py
@@ -86,12 +86,25 @@ def test_name_matches_dunder(cls: type):
     members = get_protocol_members(cls)
     assert members
 
-    member_count = len(members)
+    own_members: frozenset[str]
     parents = list(filter(is_protocol, cls.mro()[1:]))
+    # parents = [is_protocol(parent) for parent in cls.mro()[1:]]
+    if parents:
+        overridden = {
+            member for member in members
+            if callable(f := getattr(cls, member))
+            and getattr(f, '__override__', False)
+        }
+        own_members = members - overridden
+    else:
+        own_members = members
+
+    member_count = len(members)
+    own_member_count = len(own_members)
 
     # this test should probably be split up...
 
-    if member_count > 1:
+    if member_count > min(1, own_member_count):
         # ensure #parent protocols == #members (including inherited)
         assert len(parents) == member_count