diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 558b9a6..20bb558 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,7 @@ repos: hooks: - id: blacken-docs additional_dependencies: [black==24.*] + exclude: README.md - repo: https://github.com/pre-commit/pre-commit-hooks rev: "v5.0.0" diff --git a/README.md b/README.md index 1c89bf1..ba91135 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,10 @@
-
@@ -46,144 +44,208 @@ pip install dataclassish
## Documentation
-[![Documentation Status][rtd-badge]][rtd-link]
-
-WIP. But if you've worked with a
-[`dataclass`](https://docs.python.org/3/library/dataclasses.html) then you
-basically already know everything you need to know.
+- [Getting Started](#getting-started)
+ - [Working with a `@dataclass`](#working-with-a-dataclass)
+ - [Working with a `dict`](#working-with-a-dict)
+ - [The `__replace__` Method](#the-__replace__-method)
+ - [Registering in a Custom Type](#registering-in-a-custom-type)
+- [Adding a Second Argument](#adding-a-second-argument)
+- [dataclass tools](#dataclass-tools)
+- [More tools](#more-tools)
+- [Converters](#converters)
+- [Flags](#flags)
### Getting Started
+#### Replacing a `@dataclass`
+
In this example we'll show how `dataclassish` works exactly the same as
[`dataclasses`][dataclasses-link] when working with a `@dataclass` object.
-```python
-from dataclassish import replace
-from dataclasses import dataclass
+```pycon
+>>> from dataclassish import replace
+>>> from dataclasses import dataclass
+>>> @dataclass(frozen=True)
+... class Point:
+... x: float | int
+... y: float | int
-@dataclass
-class Point:
- x: float
- y: float
+>>> p = Point(1.0, 2.0)
+>>> p
+Point(x=1.0, y=2.0)
-p = Point(1.0, 2.0)
-print(p)
-# Point(x=1.0, y=2.0)
+>>> p2 = replace(p, x=3.0)
+>>> p2
+Point(x=3.0, y=2.0)
-p2 = replace(p, x=3.0)
-print(p2)
-# Point(x=3.0, y=2.0)
```
-Now we'll work with a [`dict`][dict-link] object. Note that you cannot use tools
-from [`dataclasses`][dataclasses-link] with [`dict`][dict-link] objects.
+#### Replacing a `dict`
-```python
-from dataclassish import replace
+Now we'll work with a [`dict`][dict-link] object. Note that
+[`dataclasses`][dataclasses-link] does _not_ work with [`dict`][dict-link]
+objects, but with `dataclassish` it's easy!
+
+```pycon
+>>> from dataclassish import replace
-p = {"x": 1, "y": 2.0}
-print(p)
-# {'x': 1, 'y': 2.0}
+>>> p = {"x": 1, "y": 2.0}
+>>> p
+{'x': 1, 'y': 2.0}
-p2 = replace(p, x=3.0)
-print(p2)
-# {'x': 3.0, 'y': 2.0}
+>>> p2 = replace(p, x=3.0)
+>>> p2
+{'x': 3.0, 'y': 2.0}
# If we try to `replace` a value that isn't in the dict, we'll get an error
-try:
- replace(p, z=None)
-except ValueError as e:
- print(e)
-# invalid keys {'z'}.
+>>> try:
+... replace(p, z=None)
+... except ValueError as e:
+... print(e)
+invalid keys {'z'}.
+
```
-Registering in a custom type is very easy! Let's make a custom object and define
-how `replace` will operate on it.
+#### Replacing via the `__replace__` Method
-```python
-from typing import Any
-from plum import dispatch
+In Python 3.13+ objects can implement the `__replace__` method to define how
+`copy.replace` should operate on them. This was directly inspired by
+`dataclass.replace`, and is a nice generalization to more general Python
+objects. `dataclassish` too supports this method.
+```pycon
+>>> class HasReplace:
+... def __init__(self, a, b):
+... self.a = a
+... self.b = b
+... def __repr__(self) -> str:
+... return f"HasReplace(a={self.a},b={self.b})"
+... def __replace__(self, **changes):
+... return type(self)(**(self.__dict__ | changes))
-class MyClass:
- def __init__(self, a, b, c):
- self.a = a
- self.b = b
- self.c = c
+>>> obj = HasReplace(1, 2)
+>>> obj
+HasReplace(a=1,b=2)
- def __repr__(self) -> str:
- return f"MyClass(a={self.a},b={self.b},c={self.c})"
+>>> obj2 = replace(obj, b=3)
+>>> obj2
+HasReplace(a=1,b=3)
+
+```
+#### Replacing a Custom Type
-@dispatch
-def replace(obj: MyClass, **changes: Any) -> MyClass:
- current_args = {k: getattr(obj, k) for k in "abc"}
- updated_args = current_args | changes
- return MyClass(**updated_args)
+Let's say there's a custom object that we want to use `replace` on, but which
+doesn't have a `__replace__` method (or which we want more control over using a
+second argument, discussed later). Registering in a custom type is very easy!
+Let's make a custom object and define how `replace` will operate on it.
+```pycon
+>>> from typing import Any
+>>> from plum import dispatch
-obj = MyClass(1, 2, 3)
-print(obj)
-# MyClass(a=1,b=2,c=3)
+>>> class MyClass:
+... def __init__(self, a, b, c):
+... self.a = a
+... self.b = b
+... self.c = c
+... def __repr__(self) -> str:
+... return f"MyClass(a={self.a},b={self.b},c={self.c})"
+
+
+>>> @dispatch
+... def replace(obj: MyClass, **changes: Any) -> MyClass:
+... current_args = {k: getattr(obj, k) for k in "abc"}
+... updated_args = current_args | changes
+... return MyClass(**updated_args)
+
+
+>>> obj = MyClass(1, 2, 3)
+>>> obj
+MyClass(a=1,b=2,c=3)
+
+>>> obj2 = replace(obj, c=4.0)
+>>> obj2
+MyClass(a=1,b=2,c=4.0)
-obj2 = replace(obj, c=4.0)
-print(obj2)
-# MyClass(a=1,b=2,c=4.0)
```
-### Adding a Second Argument
+### Nested Replacement
`replace` can also accept a second positional argument which is a dictionary
-specifying a nested replacement. For example consider the following dict:
+specifying a nested replacement. For example consider the following dict of
+Point objects:
+
+```pycon
+>>> p = {"a": Point(1, 2), "b": Point(3, 4), "c": Point(5, 6)}
-```python
-p = {"a": {"a1": 1, "a2": 2}, "b": {"b1": 3, "b2": 4}, "c": {"c1": 5, "c2": 6}}
```
-With `replace` the sub-dicts can be updated via:
+With `replace` the nested structure can be updated via:
+
+```pycon
+>>> replace(p, {"a": {"x": 1.5}, "b": {"y": 4.5}, "c": {"x": 5.5}})
+{'a': Point(x=1.5, y=2), 'b': Point(x=3, y=4.5), 'c': Point(x=5.5, y=6)}
-```python
-replace(p, {"a": {"a1": 1.5}, "b": {"b2": 4.5}, "c": {"c1": 5.5}})
-# {'a': {'a1': 1.5, 'a2': 2}, 'b': {'b1': 3, 'b2': 4.5}, 'c': {'c1': 5.5, 'c2': 6}}
```
-In contrast in pure Python this would be:
+In contrast in pure Python this would be very challenging. Expand the example
+below to see how this might be done.
-```python
-from copy import deepcopy
+
Expand for detailed example
+
+This is a bad approach, updating the frozen dataclasses in place:
+
+```pycon
+>>> from copy import deepcopy
+
+>>> newp = deepcopy(p)
+>>> object.__setattr__(newp["a"], "x", 1.5)
+>>> object.__setattr__(newp["b"], "y", 4.5)
+>>> object.__setattr__(newp["c"], "x", 5.5)
-newp = deepcopy(p)
-newp["a"]["a1"] = 1.5
-newp["b"]["b2"] = 4.5
-newp["c"]["c1"] = 5.5
```
-And this is the simplest case, where the mutability of a [`dict`][dict-link]
-allows us to copy the full object and update it after. Note that we had to use
-[`deepcopy`](https://docs.python.org/3/library/copy.html#copy.deepcopy) to avoid
-mutating the sub-dicts. So what if the objects are immutable?
+A better way might be to create an entirely new object!
-```python
-@dataclass(frozen=True)
-class Object:
- x: float | dict
- y: float
+```pycon
+>>> newp = {"a": Point(1.5, p["a"].y),
+... "b": Point(p["b"].x, 4.5),
+... "c": Point(5.5, p["c"].y)}
+
+```
+This isn't so good either.
-@dataclass(frozen=True)
-class Collection:
- a: Object
- b: Object
+