@deep_dataclass¶
- deep_dataclasses.deep_dataclass(cls=None, *, coerce_dicts=True, autosnake=False, **dataclass_kwargs)[source]¶
Class decorator that promotes inner class definitions to
@dataclassfields.Each un-annotated inner class whose
__qualname__matchesOuterClass.InnerNameis recursively processed with@deep_dataclassand promoted to a field whosedefault_factoryconstructs a default instance. The result is a fully valid@dataclasscompatible withdataclasses.asdict,dataclasses.fields,repr,==, and all standard dataclass tooling.Can be used with or without arguments:
@deep_dataclass @deep_dataclass() @deep_dataclass(autosnake=True, frozen=True)
- Parameters:
cls (
type, optional) – The class being decorated. Supplied automatically by Python when the decorator is used without parentheses;Nonewhen called with keyword arguments.coerce_dicts (
bool, defaultTrue) –When
True, injects a__post_init__hook that recursively coerces anydictargument into its declared dataclass type. Coercion descends through:SomeDataclass— direct coercion.Optional[T]— coerces the inner type when the value is a dict.Union[A, B, ...]— selects the dataclass variant whose field names cover all keys in the dict, preferring the variant with the fewest unfilled fields (exact match wins).Dict[K, T]— coerces each dict value toT.List[T]— coerces each list element toT.Tuple[T, ...]— coerces tuple elements to their declared types.
A user-defined
__post_init__is preserved and called after coercion. Incompatible withinit=False.autosnake (
bool, defaultFalse) – WhenTrue, PascalCase inner class names are converted to snake_case field names (e.g.class AdamSolverbecomes fieldadam_solver). The original PascalCase name is retained as a class attribute so thateval(repr(obj))round-trips correctly.**dataclass_kwargs – Forwarded verbatim to
dataclasses.dataclass()(e.g.frozen=True,order=True,eq=False).
- Returns:
The decorated class as a
@dataclasswith all inner classes promoted to fields. If the class was already a@dataclasswhen decorated, its__init__is wrapped to add coercion rather than re-applying@dataclass.- Return type:
type- Raises:
TypeError – If
init=Falseis passed together withcoerce_dicts=True.TypeError – If a mutable default value is not a list, dict, or set.
TypeError – If a list, dict, or set default contains non-primitive values.
TypeError – If a subclass adds mandatory fields after a parent with defaulted fields.
Notes
Mutable defaults (
list,dict,set) are automatically wrapped withfield(default_factory=...). Empty containers use the type itself as the factory; non-empty containers use a lambda. Elements must be primitive types (int,str,float,bool,None).Inner classes decorated with
auxiliary()are processed and kept as class attributes but are not promoted to standalone fields. Use this for shared type definitions — for example, an element type for aList[Plugin]field — that should not appear on their own.Annotated names (
name: typeorname: type = default) are never treated as inner classes, even when they hold a type value.Fields without defaults (mandatory annotations) are placed before fields with defaults, satisfying the restriction imposed by
@dataclass.from __future__ import annotations(PEP 563) is fully compatible; inner-class type hints are resolved viavars(cls)at coercion time.
Examples
Basic nested configuration:
>>> @deep_dataclass ... class Config: ... class Optimizer: ... lr: float = 1e-3 ... momentum: float = 0.9 ... class Scheduler: ... step_size: int = 10 ... gamma: float = 0.1 ... epochs: int = 100 >>> Config().Optimizer.lr 0.001 >>> Config().epochs 100
Mutable defaults are wrapped automatically:
>>> cfg = Config(Optimizer={"lr": 0.01}) >>> cfg.Optimizer.lr 0.01 >>> isinstance(cfg.Optimizer, Config.Optimizer) True
Round-trip through
asdict:>>> from dataclasses import asdict >>> Config(**asdict(cfg)) == cfg True
autosnakeconverts PascalCase inner class names to snake_case fields:>>> @deep_dataclass(autosnake=True) ... class Model: ... class TransformerEncoder: ... num_layers: int = 6 >>> Model().transformer_encoder.num_layers 6 >>> Model().TransformerEncoder.num_layers # PascalCase alias preserved 6
@auxiliarymarks an inner class as a type helper without creating a field for it:>>> from typing import List >>> from dataclasses import field >>> @deep_dataclass ... class Experiment: ... tags: list = [] ... scores: list = [1, 2, 3] ... meta: dict = {} >>> Experiment().tags is Experiment().tags False
Overview¶
@deep_dataclass is a drop-in replacement for @dataclass that additionally
promotes nested class definitions into fields automatically. Instead of declaring
the type, the field name, and the default_factory three times for every nested
class, you write the class inline and the decorator does the rest.
The result is always a fully valid @dataclass. Every stdlib tool —
dataclasses.fields, dataclasses.asdict, dataclasses.astuple, repr, ==,
__hash__, frozen, slots, order, kw_only — works exactly as it does on a
class decorated with plain @dataclass. Third-party libraries that consume
dataclasses (dacite, dataclass-wizard, pydantic dataclasses, cattrs, …) are
likewise fully compatible.
Basic syntax¶
Without @deep_dataclass, a two-level hierarchy requires explicit wiring at every
level:
from dataclasses import dataclass, field
@dataclass
class Config:
@dataclass
class Optimizer:
lr: float = 1e-3
momentum: float = 0.9
optimizer: Optimizer = field(default_factory=Optimizer)
epochs: int = 100
With @deep_dataclass the inner class is picked up automatically:
from deep_dataclasses import deep_dataclass
@deep_dataclass
class Config:
class Optimizer:
lr: float = 1e-3
momentum: float = 0.9
epochs: int = 100
Both produce an identical dataclass. The inner Optimizer class is also
processed by @deep_dataclass, so it too is a valid dataclass and can itself
contain further nested classes.
Differences from plain @dataclass¶
Behaviour |
|
|
|---|---|---|
Nested class → field |
Manual (3× repetition) |
Automatic |
Mutable defaults ( |
|
Auto-wrapped with |
|
Broken for nested dicts |
Fixed (coerces dicts on construction) |
Inner class accessible as attribute |
Not guaranteed |
Always, even after promotion to a field |
|
✅ |
✅ forwarded verbatim |
stdlib / third-party compatibility |
✅ |
✅ (produces a standard dataclass) |
One important difference: @deep_dataclass injects a __post_init__ that coerces
dict arguments to their declared types (see Dict coercion). If
you define your own __post_init__, it is preserved and called after coercion.
autosnake — PascalCase fields to snake_case¶
By default the field name on the outer class matches the inner class name exactly:
@deep_dataclass
class Model:
class TransformerEncoder:
num_layers: int = 6
m = Model()
m.TransformerEncoder.num_layers # 6
With autosnake=True, the field name is converted to snake_case while the
PascalCase name is kept as a class attribute alias, so both spellings work:
@deep_dataclass(autosnake=True)
class Model:
class TransformerEncoder:
num_layers: int = 6
m = Model()
m.transformer_encoder.num_layers # 6 ← snake_case field
m.TransformerEncoder.num_layers # 6 ← PascalCase alias (also works)
The alias means that eval(repr(m)) round-trips correctly regardless of which
spelling appears in repr.
autosnake is applied recursively, so inner-of-inner class names are also
converted.
Default values¶
Primitive and immutable defaults¶
Primitives (int, str, float, bool, None), tuples, and frozensets can be
assigned directly as defaults — they are immutable and safe to share between
instances:
@deep_dataclass
class Config:
lr: float = 1e-3
tag: str = "baseline"
device: str = "cpu"
coords: tuple = (0, 0)
allowed_ids: frozenset = frozenset([1, 2, 3])
Mutable collection defaults¶
Plain @dataclass raises ValueError if you assign a list, dict, or set
directly as a default. @deep_dataclass wraps them automatically:
@deep_dataclass
class Config:
tags: list = [] # → field(default_factory=list)
meta: dict = {} # → field(default_factory=dict)
ids: set = set() # → field(default_factory=set)
scores: list = [1, 2, 3] # → field(default_factory=lambda: [1, 2, 3])
mapping: dict = {'k': 'v'} # → field(default_factory=lambda: {'k': 'v'})
Each instance receives its own independent copy — the usual shared-mutable-default bug does not apply.
Elements of non-empty collection defaults must be primitive types (int, str,
float, bool, None). Non-primitive elements raise TypeError at decoration
time:
@deep_dataclass
class Bad:
items: list = [object()] # TypeError at decoration time
If you need a non-primitive default, use field(default_factory=...) explicitly:
from dataclasses import field
@deep_dataclass
class Config:
plugins: list = field(default_factory=list)
Typed collection defaults¶
For typed fields such as List[Plugin], always use field(default_factory=...):
from typing import List
from dataclasses import field
from deep_dataclasses import deep_dataclass, auxiliary
@deep_dataclass
class Pipeline:
@auxiliary
class Plugin:
name: str = ""
enabled: bool = True
plugins: List[Plugin] = field(default_factory=list)
Dict coercion¶
When a @deep_dataclass is constructed with a plain dict where a typed
dataclass field is expected, the dict is automatically coerced to the correct type.
This is what makes the asdict round-trip work:
cfg = Config(Optimizer={"lr": 0.01})
isinstance(cfg.Optimizer, Config.Optimizer) # True
Coercion is recursive and works through:
Field type |
Coercion behaviour |
|---|---|
|
|
|
coerces the inner type; passes |
|
each list element coerced to |
|
each element coerced to its declared type |
|
all elements coerced to |
|
each value coerced to |
|
each element coerced to |
|
best-match variant selected (see below) |
Coercion only touches dict values that correspond to a dataclass type.
Non-dataclass fields (int, str, List[str], …) are passed through unchanged.
To disable coercion entirely, pass coerce_dicts=False:
@deep_dataclass(coerce_dicts=False)
class Config:
class Optimizer:
lr: float = 1e-3
Note: coerce_dicts=True (the default) is incompatible with init=False.
@dataclass compatibility note¶
Dict coercion works with plain @dataclass fields too. Any field typed as a
dataclass (standard or deep) will have its dict values coerced as long as the
outer class is decorated with @deep_dataclass.
Union best-match coercion¶
When a field is typed as Union[A, B, ...] and a dict is supplied,
@deep_dataclass selects the variant whose declared field names best cover the
keys in the dict. The algorithm:
For each dataclass variant in the union, count how many of the dict’s keys are valid field names.
Reject any variant that has a key in the dict that it does not declare.
Among the remaining candidates, pick the one with the fewest unfilled fields (i.e. the closest match). An exact match — where every field in the variant is supplied — always wins.
from typing import Union
from dataclasses import field
from deep_dataclasses import deep_dataclass, auxiliary
@deep_dataclass
class Config:
@auxiliary
class TrainMode:
lr: float = 1e-3
pseudo_batch_size: int = 32
@auxiliary
class TestMode:
metric: str = "accuracy"
folds: int = 5
mode: Union[TrainMode, TestMode] = field(default_factory=TrainMode)
cfg = Config(mode={"lr": 0.05}) # 'lr' is only in TrainMode
isinstance(cfg.mode, Config.TrainMode) # True
cfg = Config(mode={"metric": "f1"}) # 'metric' is only in TestMode
isinstance(cfg.mode, Config.TestMode) # True
Non-dataclass union variants (str, int, …) are not considered for matching;
if no dataclass variant matches, the value is passed through unchanged.
The @auxiliary decorator¶
By default every inner class becomes a field on the outer class. @auxiliary
marks a class as a type-only helper — it is processed into a proper dataclass
and kept accessible as a class attribute, but is not promoted to a standalone
field:
from deep_dataclasses import deep_dataclass, auxiliary
from typing import List
from dataclasses import field
@deep_dataclass
class Pipeline:
@auxiliary
class Stage:
name: str = ""
enabled: bool = True
stages: List[Stage] = field(default_factory=list)
"Stage" in {f.name for f in dataclasses.fields(Pipeline)} # False
"stages" in {f.name for f in dataclasses.fields(Pipeline)} # True
@auxiliary is the right choice whenever an inner class is needed as:
The element type of a
List[T]orSet[T]fieldOne arm of a
Union[A, B]fieldThe value type of a
Dict[K, V]field
Inheritance and @auxiliary: The @auxiliary marker is not inherited.
A subclass of an @auxiliary class that is not itself decorated with @auxiliary
will be promoted to a field as normal:
@deep_dataclass
class App:
@auxiliary
class BaseService:
host: str = "localhost"
port: int = 8000
class WebService(BaseService): # not @auxiliary — becomes a field
path: str = "/"
class ApiService(BaseService): # not @auxiliary — becomes a field
version: str = "v1"
app = App()
app.WebService.host # "localhost" — inherits from BaseService
app.ApiService.port # 8000
Inheritance¶
@deep_dataclass classes can inherit from other @deep_dataclass or plain
@dataclass classes. Fields from the parent are included in the child as with
standard dataclasses:
@deep_dataclass
class Base:
class Logging:
level: str = "INFO"
debug: bool = False
@deep_dataclass
class Child(Base):
class Model:
num_layers: int = 6
name: str = "default"
The usual dataclass rule applies: a child cannot declare mandatory (no-default)
fields after a parent that has fields with defaults. @deep_dataclass enforces
this at decoration time with a clear error message.
Passing @dataclass keyword arguments¶
All keyword arguments not recognised by @deep_dataclass itself are forwarded
to dataclasses.dataclass():
@deep_dataclass(frozen=True)
class ImmutableConfig:
lr: float = 1e-3
@deep_dataclass(order=True)
class SortableConfig:
priority: int = 0
@deep_dataclass(slots=True) # Python 3.10+
class SlottedConfig:
x: int = 0
Because the result is a standard dataclass, all of these options behave exactly as documented in the stdlib.