Skip to main content

dataclass tools, extended by multiple dispatch

Project description

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

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

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!

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dataclassish-0.9.0.tar.gz (73.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dataclassish-0.9.0-py3-none-any.whl (24.5 kB view details)

Uploaded Python 3

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

Hashes for dataclassish-0.9.0.tar.gz
Algorithm Hash digest
SHA256 310b0d6d00442ca93dbc9b3742a91e25e157582d10fd3e9e453d35767f5d9347
MD5 2f9889a75f91e5089a4c2e6a09474a99
BLAKE2b-256 dc67835a8f06347fe630a030fa079028b1984ad81b7369b31dfbbfd4e2877750

See more details on using hashes here.

Provenance

The following attestation bundles were made for dataclassish-0.9.0.tar.gz:

Publisher: cd.yml on GalacticDynamics/dataclassish

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for dataclassish-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2475cdfed689f2b0fced119f9cb5af8a200fc1b48c7afd25a7296c4a8cfbfe90
MD5 91615243c4d236dffcd5b9aea53d07e5
BLAKE2b-256 3720d735e30dd693fe32cad69d13889344c1132b1871f508c49bda04880df8f1

See more details on using hashes here.

Provenance

The following attestation bundles were made for dataclassish-0.9.0-py3-none-any.whl:

Publisher: cd.yml on GalacticDynamics/dataclassish

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page