Skip to content

GalacticDynamics/dataclassish

Repository files navigation

dataclassish

Tools from dataclasses, extended to all of Python

PyPI: dataclassish PyPI versions: dataclassish dataclassish license

CI status codecov ruff ruff pre-commit


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

PyPI platforms PyPI version

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_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.
>>> 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

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

DOI

If you enjoyed using this library and would like to cite the software you use then click the link above.

Development

Actions Status codecov SPEC 0 — Minimum Supported Dependencies pre-commit ruff

We welcome contributions!