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.
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.
The features come alive in a subclass.
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 = 42Two things to know about this mechanism:
Reading a member stores it. Before
d.xis read,'x' in dis true (annotations count for membership) butlen(d)does not include it. After the read it does. Solen(), iteration andas_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 anyDataClasssubclass → a new instance; DataList[Item]→DataList(Item), with the member type taken from the subscript. A bareDataListannotation carries no member type and is rejected;- a union containing
None—str | None,Optional[int]— →None. A union withoutNonehas 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 bare type or a parameterised builtin is constructed —
A mutable class-level default would be shared between instances, so it is rejected outright — see below.
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] # ❌ NodeDefinitionErrorThe 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 = [] # ❌ NodeDefinitionErrorThe 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() 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: zcreate_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 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.
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
DataClassorDataListmember 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
AttributeErrorifraise_error=True; - otherwise an unknown key is added, and its value converted by shape:
- a
dictbecomes aDataClass; - a
tupleis first turned into alist; - a list of mappings becomes a
DataListof plainDataClassobjects, and a list holding no mapping stays a plain list — so an empty list stays a list.
- a
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.
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: nmaeA 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_fieldSo: 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.
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 mutableA 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 zWatch 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: priceFrozenNodeError 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() 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') # ❌ AttributeErrorPassing a dict for a child node merges into it — only the keys named are touched — rather than replacing the child wholesale.
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:
DataClassand any mapping (dict,defaultdict, ...) becomedictDataList,listandtuplebecomelist- 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 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.
| 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.