dataclass tools, extended by multiple dispatch
Project description
dataclassish
Tools from dataclasses, extended to all of Python
Python's dataclasses 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,
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
pip install dataclassish
Documentation
Getting Started
Replacing a @dataclass
In this example we'll show how dataclassish works exactly the same as
dataclasses when working with a @dataclass object.
>>> 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 object. Note that
dataclasses does not work with dict
objects, but with dataclassish it's easy!
>>> 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.
>>> 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.
>>> 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:
>>> p = {"a": Point(1, 2), "b": Point(3, 4), "c": Point(5, 6)}
With replace the nested structure can be updated via:
>>> 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:
>>> 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!
>>> 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:
>>> @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.
>>> 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 has a number of utility functions beyond
replace: fields, asdict, and astuple. dataclassish supports of all
these functions.
>>> 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's:
>>> 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_fieldreturns the field of an object by name.field_keysreturns the names of an object's fields.field_valuesreturns the values of an object's fields.field_itemsreturns the names and values of an object's fields.
>>> 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.
>>> 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!
>>> 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.
>>> 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 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.
>>> 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.
>>> from dataclassish import field_keys
>>> tuple(field_keys(flags.NoFlag, p))
('x', 'y')
As another example, we'll show how to use FilterRepr.
>>> 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
If you enjoyed using this library and would like to cite the software you use then click the link above.
Development
We welcome contributions!
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dataclassish-0.9.0.tar.gz.
File metadata
- Download URL: dataclassish-0.9.0.tar.gz
- Upload date:
- Size: 73.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
310b0d6d00442ca93dbc9b3742a91e25e157582d10fd3e9e453d35767f5d9347
|
|
| MD5 |
2f9889a75f91e5089a4c2e6a09474a99
|
|
| BLAKE2b-256 |
dc67835a8f06347fe630a030fa079028b1984ad81b7369b31dfbbfd4e2877750
|
Provenance
The following attestation bundles were made for dataclassish-0.9.0.tar.gz:
Publisher:
cd.yml on GalacticDynamics/dataclassish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dataclassish-0.9.0.tar.gz -
Subject digest:
310b0d6d00442ca93dbc9b3742a91e25e157582d10fd3e9e453d35767f5d9347 - Sigstore transparency entry: 1155168509
- Sigstore integration time:
-
Permalink:
GalacticDynamics/dataclassish@cb7f949f34186558362a2b419a72b10255a6e6c9 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/GalacticDynamics
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@cb7f949f34186558362a2b419a72b10255a6e6c9 -
Trigger Event:
release
-
Statement type:
File details
Details for the file dataclassish-0.9.0-py3-none-any.whl.
File metadata
- Download URL: dataclassish-0.9.0-py3-none-any.whl
- Upload date:
- Size: 24.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2475cdfed689f2b0fced119f9cb5af8a200fc1b48c7afd25a7296c4a8cfbfe90
|
|
| MD5 |
91615243c4d236dffcd5b9aea53d07e5
|
|
| BLAKE2b-256 |
3720d735e30dd693fe32cad69d13889344c1132b1871f508c49bda04880df8f1
|
Provenance
The following attestation bundles were made for dataclassish-0.9.0-py3-none-any.whl:
Publisher:
cd.yml on GalacticDynamics/dataclassish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dataclassish-0.9.0-py3-none-any.whl -
Subject digest:
2475cdfed689f2b0fced119f9cb5af8a200fc1b48c7afd25a7296c4a8cfbfe90 - Sigstore transparency entry: 1155168513
- Sigstore integration time:
-
Permalink:
GalacticDynamics/dataclassish@cb7f949f34186558362a2b419a72b10255a6e6c9 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/GalacticDynamics
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
cd.yml@cb7f949f34186558362a2b419a72b10255a6e6c9 -
Trigger Event:
release
-
Statement type: