Skip to content

Repository files navigation

DEWI dataclass

A small, dependency-free package providing Node: a typed dict tree.

A Node is a collections.abc.MutableMapping whose members live in the instance __dict__, so each member can be reached both as an attribute and as a mapping key. That dual nature is the point of the class: a tree of typed objects that can still be walked by key, loaded wholesale from parsed YAML or JSON, and converted back to plain dicts and lists for output.

It was written around the time dataclasses (PEP 557) appeared, and shares some ground with it and with attrs — the @frozen decorator is the clearest borrowing — but it is not a reimplementation of either. Where @dataclass gives you a record with a generated __init__, Node gives you a mapping that fills in typed defaults from annotations. See Compared to @dataclass below.

Unlike a plain dict, a missing entry raises AttributeError rather than KeyError.

Requires Python 3.10 or newer. No runtime dependencies.

Basic usage

At its simplest, an enhanced dict:

from dewi_dataclass import DataClass

val = DataClass()

# use as a regular dict
val['key1'] = 42
# ...and as a class member
print(val.key1)   # 42

# a missing item raises AttributeError, not KeyError
print(val['missing'])
print(val.missing)

DataClass is an alias of Node, and DataList of NodeList; both names are exported from dewi_dataclass. The rest of this document uses the DataClass spelling.

Keys need not be valid Python identifiers — val['a-key'] = 1 works, and the value is then reachable with getattr(val, 'a-key').

Members cannot be deleted. Both del val['key1'] and del val.key1 raise TypeError.

Subclasses

The features come alive in a subclass.

Members from annotations

A member that carries only a type annotation is created on first read, from the annotated type's no-argument constructor. If that is good enough, the subclass needs no __init__ at all:

class Data(DataClass):
    x: int
    y: list[str]

d = Data()
print(d.x)  # 0
print(d.y)  # []

A class-level default is used when there is one:

class Point(DataClass):
    x: int = 42

Two things to know about this mechanism:

  • Reading a member stores it. Before d.x is read, 'x' in d is true (annotations count for membership) but len(d) does not include it. After the read it does. So len(), iteration and as_dict() reflect which members have been touched.

  • What each annotation produces:

    • a bare type or a parameterised builtin is constructed — int0, str'', list[str][], dict[str, int]{}, and any DataClass subclass → a new instance;
    • DataList[Item]DataList(Item), with the member type taken from the subscript. A bare DataList annotation carries no member type and is rejected;
    • a union containing Nonestr | None, Optional[int] — → None. A union without None has no obvious default and is rejected;
    • a string annotation, from a forward reference or from from __future__ import annotations, is resolved against the module the class was defined in, then handled as above.

    Anything that cannot produce a default raises NodeDefinitionError, naming the class and saying what to do instead.

A mutable class-level default would be shared between instances, so it is rejected outright — see below.

What the class definition rejects

Two mistakes are caught when the subclass is created, both raising NodeDefinitionError (a TypeError).

Reserved names. Because DataClass is a MutableMapping, a member named after one of the mapping methods would be shadowed by the method and never reach the annotation machinery:

class Bad(DataClass):
    items: list[int]   # ❌ NodeDefinitionError

The reserved names are clear, get, items, keys, pop, popitem, setdefault, update and values — read from collections.abc.MutableMapping at import, so a name added to it in a future Python is caught rather than silently swallowing a member.

Assigning such a name in __init__ without annotating it does work, because the instance __dict__ wins over a method, so that case is left alone. The name is still a poor choice.

Mutable defaults. A mutable class-level default would be shared by every instance:

class Bad(DataClass):
    values_: list = []    # ❌ NodeDefinitionError

The fix is to delete the default. The annotation on its own already gives each instance a fresh list on first read:

class Fine(DataClass):
    values_: list         # ✅ each instance gets its own []

Immutable defaults — numbers, strings, tuples — are unaffected. An unannotated class attribute is a plain class constant and is never touched.

create() and create_from()

create() builds an instance from keyword arguments and rejects anything that is neither an existing member nor an annotated one:

class Point(DataClass):
    x: int

    def __init__(self):
        self.y = 4

Point.create(x=3)        # ok
Point.create(x=3, y=22)  # ok -- y was set in __init__
Point.create(z=1)        # ❌ AttributeError: z

create_from(a_dict) does the same from a dict but accepts unknown keys, adding them to the instance. Use create() when the input is meant to match a known schema, create_from() when it is arbitrary data.

The check applies to the top level only: a nested dict passed for a Node member is not validated.

DataList: a list of DataClass objects

DataList is a list that remembers the type of its members, so raw dicts loaded into it are converted to that type:

from dewi_dataclass import DataClass, DataList

class Module(DataClass):
    name: str

    def __init__(self):
        self.name = ''

class Params(DataClass):
    modules: DataList[Module]   # the member type comes from the subscript

params = Params()
params.modules.load_from([dict(name='first'), dict(name='second')])
print(type(params.modules[0]))   # <class 'Module'>

DataList[Module] is read at runtime: the member is created as DataList(Module) on first access, so no __init__ is needed. Assigning it explicitly still works and is equivalent. A bare DataList annotation carries no member type and raises NodeDefinitionError.

type_ survives copy.copy() and copy.deepcopy(), but not slicing: params.modules[:] is a plain list.

load_from(): load a dict hierarchy, e.g. from YAML

import yaml

params = Params()
params.load_from(yaml.safe_load(...))

# or, creating the object at the same time
params = Params.create_from(yaml.safe_load(...))

The rules, per key:

  • a key naming an existing DataClass or DataList member is loaded into, recursively — the existing object is kept, not replaced;
  • any other known key is assigned verbatim, with no type coercion;
  • an unknown key raises AttributeError if raise_error=True;
  • otherwise an unknown key is added, and its value converted by shape:
    • a dict becomes a DataClass;
    • a tuple is first turned into a list;
    • a list of mappings becomes a DataList of plain DataClass objects, and a list holding no mapping stays a plain list — so an empty list stays a list.

A DataList holds a single member type, so a list mixing mappings and scalars is malformed rather than something to guess at: it raises TypeError naming the member, the index and the offending value. Both orders are rejected.

raise_error reaches every level: a typo nested inside a child node, or inside an item of a DataList, is caught as well as one at the top. create() sets it; create_from() and load_from() do not.

Which structures are validated

You rarely need raise_error, because @frozen already decides this, per node. A frozen class is closed to new member names, so a key the class does not declare cannot be assigned — by attribute, by item, or by load_from():

@frozen
class Point(DataClass):
    x: int

    def __init__(self):
        self.x = 0

Point().load_from(dict(x=1))            # ✅
Point().load_from(dict(nmae=1))         # ❌ FrozenNodeError: nmae

A plain data class is open, so it takes whatever the document carries. That is what you want for a bag of values whose keys are not known in advance.

Strictness is decided by each node's own class, not inherited from its parent, so the two mix freely in one tree:

@frozen
class Context(DataClass):
    args: DataClass                     # the context is closed...

    def __init__(self):
        self.args = DataClass()         # ...but args is an open bag

ctx = Context()
ctx.args.anything = 1                   # ✅ the option parser decides these
ctx.new_field = 1                       # ❌ FrozenNodeError: new_field

So: freeze the classes whose shape you know, and leave open the ones filled from outside. Validation follows from the declaration, with no flag to pass.

Frozen types

Any data class can be frozen. Frozen means the set of member names is closed — the values stay mutable, and a subclass may still add names by annotating them. It applies to this class only, not to whatever its members hold. Closing the names is what makes a frozen class validate the documents loaded into it, as described above.

A frozen class rejects a name it does not declare, even in its own __init__:

from dewi_dataclass import DataClass, FrozenNodeError, frozen

@frozen
class Point(DataClass):
    x: float
    y: float

    def __init__(self):
        self.z = 42    # ❌ FrozenNodeError -- z is not annotated

point = Point()
point.z = 0.0          # ❌ FrozenNodeError
point['z'] = 0.0       # ❌ FrozenNodeError too -- __setitem__ uses setattr
point.x = 1.0          # ✅ ok -- values stay mutable

A subclass extends a frozen class by annotating the new member. The annotation is what makes the name acceptable, so it is required whether or not __init__ also assigns it:

class Point3D(Point):
    z: float           # ✅ required

    def __init__(self):
        super().__init__()
        self.z = 0.0   # ✅ ok, because z is annotated above

class Point3Dv2(Point):
    def __init__(self):
        super().__init__()
        self.z = 0.0   # ❌ FrozenNodeError -- no annotation for z

Watch out for one thing in particular: an annotated assignment inside __init__ looks like a declaration but is not one. Python records no annotation for an attribute target, so this fails:

@frozen
class Amount(DataClass):
    def __init__(self):
        self.price: float = 0.0   # ❌ FrozenNodeError: price

FrozenNodeError subclasses AttributeError and keeps the member name as args[0], so existing except AttributeError handlers keep working; its message explains which class is frozen and what to declare.

Note this freezes names, not values. That is the difference from dataclasses.dataclass(frozen=True), which makes the values immutable and the instance hashable.

A data class is never hashable, frozen or not: it is a MutableMapping, and MutableMapping sets __hash__ to None. So an instance cannot be a dict key or a set member. Use a value derived from it — a tuple of the fields you care about, or tuple(sorted(as_dict(node).items())) for a shallow tree — when you need one.

fields() and replace()

fields() lists the declared members of a class or instance, base classes included:

from dewi_dataclass import DataClass, fields

class Point(DataClass):
    x: int
    y: int

fields(Point)          # {'x': int, 'y': int}

Only declared members appear; a key added at runtime is in the mapping itself, not in fields().

replace() returns a deep copy with some members changed, leaving the original alone. Names are checked as create() checks them, so a typo raises rather than quietly adding a member:

from dewi_dataclass import replace

updated = replace(config, name='other')
updated = replace(config, driver=dict(headless=True))   # merges into the child
replace(config, nmae='typo')                            # ❌ AttributeError

Passing a dict for a child node merges into it — only the keys named are touched — rather than replacing the child wholesale.

as_dict(): convert to core Python types

Serializers need plain dicts and lists. as_dict() converts a tree of DataClass and DataList objects into dict and list:

from dewi_dataclass import DataClass, as_dict

print(point.as_dict())
# or the function form, after attrs.asdict()
print(as_dict(point))

# typical use
yaml.dump(as_dict(config), stream=sys.stdout, default_flow_style=False)

DataList has the corresponding as_list().

Conversion is recursive and reaches everywhere, not just direct members:

  • DataClass and any mapping (dict, defaultdict, ...) become dict
  • DataList, list and tuple become list
  • anything else is returned as it is

So a DataClass stored inside a plain list or dict is converted too. A tuple becomes a list, because that is what YAML and JSON can represent — and because load_from() already turns tuples into lists, so the round trip is stable.

Containers are rebuilt, so the result never shares a list or dict with the original; mutating the result cannot reach back into the tree. Values that are neither a data class nor a container — an Enum, a datetime, a bytearray, an arbitrary object — are passed through by reference, not copied. Copying them could be wrong or expensive, and a serializer needs the original anyway.

Such a value is also the one thing that can still put a !!python/ tag in YAML output — an Enum member, for instance. That is only a problem if something other than Python has to read the file. If the same program both writes and reads it, loading with yaml.Loader (rather than yaml.safe_load) reconstructs the value and nothing more is needed. For output meant to be portable, give the dumper a representer for the type, or convert it before dumping.

The serialization module

The conversion lives in dewi_dataclass.serialization as free functions, because conversion is about a value, whatever its type — a method can only ever see self, which is why it could not reach into plain containers:

from dewi_dataclass.serialization import as_dict, as_list, convert, load_into

as_dict(node)              # a Node       -> dict
as_list(node_list)         # a list       -> list
convert(anything)          # any value, the general entry point
load_into(node, data)      # the implementation behind load_from()

The methods on DataClass and DataList remain as thin wrappers, so node.as_dict() and node.load_from(...) are unaffected.

This package does not depend on PyYAML and registers no YAML representers; the serializer glue lives in the calling project.

Compared to @dataclass

Aspect @dataclass DataClass (Node)
Mechanism generates methods at class creation resolves at attribute-access time
Storage attributes (or __slots__) __dict__, exposed as a Mapping
__init__ generated from the fields write your own; always zero-argument
Construction with values Point(1, 2) Point.create(x=1, y=2)
Field set closed open; @frozen closes the names
Defaults = value or default_factory = value, or the annotated type's constructor
Mutable default ValueError at class creation allowed, and shared between instances
Missing value TypeError at construction fabricated on first read
__eq__ generated, type-sensitive from Mapping -- equal to a plain dict
Hashable with frozen=True never
Immutability frozen=True: values immutable @frozen: member names fixed
Deserialization none load_from() / create_from()
Serialization dataclasses.asdict() as_dict() / as_list()
Introspection fields(), is_dataclass() none
Keys identifiers only any string

Use @dataclass for a record with a fixed set of fields. Use DataClass when the data is a tree that has to be loaded from, and written back to, dicts — especially when parts of it are only known at runtime.

About

A dataclass implementation, a dict-like hierarchy like in a JSON or YAML file

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages