Skip to main content

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 — int → 0, 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 None — str | 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.

Metadata

Release files for dewi-dataclass 2.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for dewi-dataclass 2.0.0
File Size Uploaded
dewi_dataclass-2.0.0.tar.gz 24.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for dewi-dataclass 2.0.0
File Interpreter ABI Platform
dewi_dataclass-2.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 45.3 kB

Release files / dewi_dataclass-2.0.0.tar.gz

Download URL dewi_dataclass-2.0.0.tar.gz
Size 24.3 kB
Tags Source
SHA-256 checksum
How to use checksums
0fbf40cbf8c4903febd56b19277715f5e9948501248e635582a3a99d54327c1a
BLAKE2b-256 checksum
How to use checksums
21e6022ffe94bcb23fe3c9b10fb12d96ceb7a182127862c9ce3ffc360c4aec46
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.5

Release files / dewi_dataclass-2.0.0-py3-none-any.whl

Download URL dewi_dataclass-2.0.0-py3-none-any.whl
Size 20.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3d58cc31f0e2b5343f9bf04738157922b68a3cf4c4757f6aece30b41079d795a
BLAKE2b-256 checksum
How to use checksums
3d8b9e97461ee9c77de59ac8d41863f23145130c29a5c407851b9c77ceffb37d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.5

Release history Release notifications | RSS feed

This release

2.0.0 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page