https://github.com/galacticdynamics/dataclassish
tools from `dataclasses`, extended to all of Python
https://github.com/galacticdynamics/dataclassish
dataclasses-python
Last synced: 9 months ago
JSON representation
tools from `dataclasses`, extended to all of Python
- Host: GitHub
- URL: https://github.com/galacticdynamics/dataclassish
- Owner: GalacticDynamics
- License: mit
- Created: 2024-07-17T04:44:50.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2025-04-09T01:08:58.000Z (9 months ago)
- Last Synced: 2025-04-15T02:06:48.587Z (9 months ago)
- Topics: dataclasses-python
- Language: Python
- Homepage: https://zenodo.org/doi/10.5281/zenodo.13357978
- Size: 133 KB
- Stars: 3
- Watchers: 2
- Forks: 2
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Contributing: .github/CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README
dataclassish
Tools from dataclasses, extended to all of Python
---
Python's [`dataclasses`][dataclasses-link] provides tools for working with
objects, but only compatible `@dataclass` objects. 😢 This repository is a
superset of those tools and extends them to work on ANY Python object you want!
🎉 You can easily register in object-specific methods and use a unified
interface for object manipulation. 🕶️
For example,
```python
from dataclassish import replace # New object, replacing select fields
d1 = {"a": 1, "b": 2.0, "c": "3"}
d2 = replace(d1, c=3 + 0j)
print(d2)
# {'a': 1, 'b': 2.0, 'c': (3+0j)}
```
## Installation
[![PyPI platforms][pypi-platforms]][pypi-link]
[![PyPI version][pypi-version]][pypi-link]
```bash
pip install dataclassish
```
## Documentation
- [Getting Started](#getting-started)
- [Replacing a `@dataclass`](#replacing-a-dataclass)
- [Replacing a `dict`](#replacing-a-dict)
- [Replacing via the `__replace__` Method](#replacing-via-the-__replace__-method)
- [Replacing a Custom Type](#replacing-a-custom-type)
- [Nested Replacement](#nested-replacement)
- [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.
```pycon
>>> from dataclassish import replace
>>> from dataclasses import dataclass
>>> @dataclass(frozen=True)
... class Point:
... x: float | int
... y: float | int
>>> p = Point(1.0, 2.0)
>>> p
Point(x=1.0, y=2.0)
>>> p2 = replace(p, x=3.0)
>>> p2
Point(x=3.0, y=2.0)
```
#### Replacing a `dict`
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}
>>> p
{'x': 1, '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'}.
```
#### Replacing via the `__replace__` Method
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))
>>> obj = HasReplace(1, 2)
>>> obj
HasReplace(a=1,b=2)
>>> obj2 = replace(obj, b=3)
>>> obj2
HasReplace(a=1,b=3)
```
#### Replacing a Custom Type
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
>>> 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)
```
### Nested Replacement
`replace` can also accept a second positional argument which is a dictionary
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)}
```
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)}
```
In contrast in pure Python this would be very challenging. Expand the example
below to see how this might be done.
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)
```
A better way might be to create an entirely new object!
```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.
`dataclassish.replace` is a one-liner that can work on any object (if it has a
registered means to do so), regardless of mutability or nesting. Consider this
fully immutable structure:
```pycon
>>> @dataclass(frozen=True)
... class Object:
... x: float | dict
... y: float
>>> @dataclass(frozen=True)
... class Collection:
... a: Object
... b: Object
>>> p = Collection(Object(1.0, 2.0), Object(3.0, 4.0))
>>> p
Collection(a=Object(x=1.0, y=2.0), b=Object(x=3.0, y=4.0))
>>> replace(p, {"a": {"x": 5.0}, "b": {"y": 6.0}})
Collection(a=Object(x=5.0, y=2.0), b=Object(x=3.0, y=6.0))
```
With `replace` this remains a one-liner. Replace pieces of any structure,
regardless of nesting.
To disambiguate dictionary fields from nested structures, use the `F` marker.
```pycon
>>> from dataclassish import F
>>> replace(p, {"a": {"x": F({"thing": 5.0})}})
Collection(a=Object(x={'thing': 5.0}, y=2.0),
b=Object(x=3.0, y=4.0))
```
### dataclass tools
[`dataclasses`][dataclasses-link] has a number of utility functions beyond
`replace`: `fields`, `asdict`, and `astuple`. `dataclassish` supports of all
these functions.
```pycon
>>> from dataclassish import fields, asdict, astuple
>>> p = Point(1.0, 2.0)
>>> fields(p)
(Field(name='x',...), Field(name='y',...))
>>> asdict(p)
{'x': 1.0, 'y': 2.0}
>>> astuple(p)
(1.0, 2.0)
```
`dataclassish` extends these functions to [`dict`][dict-link]'s:
```pycon
>>> p = {"x": 1, "y": 2.0}
>>> fields(p)
(Field(name='x',...), Field(name='y',...))
>>> asdict(p)
{'x': 1, 'y': 2.0}
>>> astuple(p)
(1, 2.0)
```
Support for custom objects can be implemented similarly to `replace`.
### More tools
In addition to the `dataclasses` tools, `dataclassish` provides a few more
utilities.
- `get_field` returns the field of an object by name.
- `field_keys` returns the names of an object's fields.
- `field_values` returns the values of an object's fields.
- `field_items` returns the names and values of an object's fields.
```pycon
>>> from dataclassish import get_field, field_keys, field_values, field_items
>>> p = Point(1.0, 2.0)
>>> get_field(p, "x")
1.0
>>> field_keys(p)
('x', 'y')
>>> field_values(p)
(1.0, 2.0)
>>> field_items(p)
(('x', 1.0), ('y', 2.0))
```
These functions work on any object that has been registered in, not just
`@dataclass` objects.
```pycon
>>> p = {"x": 1, "y": 2.0}
>>> get_field(p, "x")
1
>>> field_keys(p)
dict_keys(['x', 'y'])
>>> field_values(p)
dict_values([1, 2.0])
>>> field_items(p)
dict_items([('x', 1), ('y', 2.0)])
```
### Converters
While `dataclasses.field` itself does not allow for converters (See PEP 712)
many dataclasses-like libraries do. A very short, very non-exhaustive list
includes: `attrs` and `equinox`. The module `dataclassish.converters` provides a
few useful converter functions. If you need more, check out `attrs`!
```pycon
>>> from attrs import define, field
>>> from dataclassish.converters import Optional, Unless
>>> @define
... class Class1:
... attr: int | None = field(default=None, converter=Optional(int))
... """attr is converted to an int or kept as None."""
>>> obj = Class1()
>>> print(obj.attr)
None
>>> obj = Class1(attr=1.0)
>>> obj.attr
1
>>> @define
... class Class2:
... attr: float | int = field(converter=Unless(int, converter=float))
... """attr is converted to a float, unless it's an int."""
>>> obj = Class2(1)
>>> obj.attr
1
>>> obj = Class2("1")
>>> obj.attr
1.0
```
This library also provide a lightweight dataclass-like decorator and field
function that supports these converters and converters in general.
```pycon
>>> from dataclassish.converters import dataclass, field
>>> @dataclass
... class MyClass:
... a: int | None = field(converter=Optional(int))
... b: str = field(converter=str.upper)
>>> obj = MyClass(a="1", b="hello")
>>> obj
MyClass(a=1, b='HELLO')
>>> obj = MyClass(a=None, b="there")
>>> obj
MyClass(a=None, b='THERE')
```
### Flags
`dataclassish` provides flags for customizing the behavior of functions. For
example, the [`coordinax`](https://pypi.org/project/coordinax/) package, which
depends on `dataclassish`, uses a flag `AttrFilter` to filter out fields from
consideration by the functions in `dataclassish`.
`dataclassish` provides a few built-in flags and flag-related utilities.
```pycon
>>> from dataclassish import flags
>>> flags.__all__
['FlagConstructionError', 'AbstractFlag', 'NoFlag', 'FilterRepr']
```
Where `AbstractFlag` is the base class for flags, `NoFlag` is a flag that does
nothing, and `FilterRepr` will filter out any fields with `repr=True`.
`FlagConstructionError` is an error that is raised when a flag is constructed
incorrectly.
As a quick example, we'll show how to use `NoFlag`.
```pycon
>>> from dataclassish import field_keys
>>> tuple(field_keys(flags.NoFlag, p))
('x', 'y')
```
As another example, we'll show how to use `FilterRepr`.
```pycon
>>> from dataclasses import field
>>> @dataclass
... class Point:
... x: float
... y: float = field(repr=False)
>>> obj = Point(1.0, 2.0)
>>> field_keys(flags.FilterRepr, obj)
('x',)
```
## Citation
[![DOI][zenodo-badge]][zenodo-link]
If you enjoyed using this library and would like to cite the software you use
then click the link above.
## Development
[![Actions Status][actions-badge]][actions-link]
[![codecov][codecov-badge]][codecov-link]
[![SPEC 0 — Minimum Supported Dependencies][spec0-badge]][spec0-link]
[![pre-commit][pre-commit-badge]][pre-commit-link]
[![ruff][ruff-badge]][ruff-link]
We welcome contributions!
[actions-badge]: https://github.com/GalacticDynamics/dataclassish/workflows/CI/badge.svg
[actions-link]: https://github.com/GalacticDynamics/dataclassish/actions
[codecov-badge]: https://codecov.io/gh/GalacticDynamics/dataclassish/graph/badge.svg
[codecov-link]: https://codecov.io/gh/GalacticDynamics/dataclassish
[pre-commit-badge]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit
[pre-commit-link]: https://pre-commit.com
[pypi-link]: https://pypi.org/project/dataclassish/
[pypi-platforms]: https://img.shields.io/pypi/pyversions/dataclassish
[pypi-version]: https://img.shields.io/pypi/v/dataclassish
[ruff-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json
[ruff-link]: https://docs.astral.sh/ruff/
[spec0-badge]: https://img.shields.io/badge/SPEC-0-green?labelColor=%23004811&color=%235CA038
[spec0-link]: https://scientific-python.org/specs/spec-0000/
[zenodo-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.13357978.svg
[zenodo-link]: https://zenodo.org/doi/10.5281/zenodo.13357978
[dataclasses-link]: https://docs.python.org/3/library/dataclasses.html
[dict-link]: https://docs.python.org/3.8/library/stdtypes.html#dict