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)
| File | Size | Uploaded | |
|---|---|---|---|
| dewi_dataclass-2.0.0.tar.gz | 24.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|