Coverage for aiocoap / util / dataclass_data.py: 91%
32 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-10 10:42 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-10 10:42 +0000
1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors
2#
3# SPDX-License-Identifier: MIT
5"""Tools to load a typed dataclass from CBOR/JSON/TOML/YAML-model data.
7Unlike what is in the aiocoap.credentials module, this works from the fixed
8assumption that the item is a dataclass (as opposed to having an arbitrary
9constructor), which should ease things.
11**Caveats**:
13* The module expects the data classes' annotations to be types and
14 not strings, and therefore can't be used with types defined under `from
15 __future__ import annotations`.
17* While ``Optional[str]`` and other primitives are supported, child load-store
18 classes need to be dressed as ``| None`` (i.e., a ``Union``). This can be
19 simplified when support for Python 3.13 is dropped, as both versions
20 have the type ``typing.Union`` starting with Python 3.14.
22>>> from dataclasses import dataclass
23>>> from typing import Optional
24>>> @dataclass
25... class Inner(LoadStoreClass):
26... some_text: str
27... some_number: Optional[int]
28>>> @dataclass
29... class Top(LoadStoreClass):
30... x: str
31... y: Inner | None
32>>> Top.load({"x": "test", "y": {"some-text": "one", "some-number": 42}})
33Top(x='test', y=Inner(some_text='one', some_number=42))
34"""
36import dataclasses
37import types
38from typing import Self
41class LoadStoreClass:
42 @classmethod
43 def load(cls, data: dict, prefix: str = "", depth_limit: int = 16) -> Self:
44 """Creates an instance from the given data dictionary.
46 Keys are used to populate fields like in the initializer; dashes ("-")
47 in names are replaced with underscores ("_") so that Python-idiomatic
48 field names (in snake_case) can be used with TOML idiomatic item names
49 (in kebab-case).
51 Values are type-checked against the annotations, and unknown fields are
52 disallowed. When annotations indicate another ``LoadStoreClass``,
53 initialization recurses into that type up to a depth limit.
55 The ``prefix`` is used for error messages: It builds up in the recursive
56 build process and thus gives the user concrete guidance as to where in
57 the top-level item the trouble was. For data loaded from files, it is
58 prudent to give the file name in this argument.
60 This reliably raises ``ValueError`` or its subtypes on unacceptable
61 data as long as the class is set up in a supported way.
62 """
64 assert dataclasses.is_dataclass(cls)
66 prefix = f"{prefix}/" if prefix else prefix
68 fields = {f.name: f for f in dataclasses.fields(cls)}
70 kwargs = {}
72 for key, value in data.items():
73 keyprefix = f"{prefix}{key}"
74 f = key.replace("-", "_")
75 fieldtype = fields[f].type
76 if isinstance(value, fieldtype):
77 pass
78 elif (
79 # The simple case: It *is* that type.
80 isinstance(fieldtype, type)
81 and issubclass(load_as := fieldtype, LoadStoreClass)
82 ) or (
83 # The complex case: It is a union, and we can find out which
84 # one is the one we can use; stored in load_as right away.
85 #
86 # When Python 3.13 support is dropped, types.UnionType can
87 # become types.Union, and we'll gain support for Optioinal too
88 isinstance(fieldtype, types.UnionType)
89 and len(
90 [
91 load_as := x
92 for x in fieldtype.__args__
93 if issubclass(x, LoadStoreClass)
94 ]
95 )
96 == 1
97 ):
98 # The isinstance check is needed for issubclass to work in the
99 # first place; FIXME: rather than assuming it's the top-level
100 # item, get a list of candidate LoadStoreClass subclasses, so
101 # that we can also process Optional[Foo] or even Foo | Bar.
103 # FIXME: allow annotating single distinct non-dict-value, eg.
104 # like Cargo.toml's implicit version in dependencies. (Right
105 # now we can do this can be done at the parent level, maybe
106 # that suffices?).
107 if not isinstance(value, dict):
108 raise ValueError(
109 f"Type mismatch on {keyprefix}: Expected dictionary to populate {load_as.__name__} with, found {type(value).__name__}"
110 )
111 if depth_limit == 0:
112 raise ValueError("Nesting exceeded limit in {keyprefix}")
113 value = load_as.load(
114 value, prefix=keyprefix, depth_limit=depth_limit - 1
115 )
116 else:
117 # FIXME:
118 # - For lists and dicts, evaluate items (can be deferred until we actually have any)
119 # - Special treatment for bytes: Accept {"acii": "hello"} and {"hex": "001122"}
120 # - Special treatment for Path (probably with new filename argument)
121 # - In union handling, support multiple, possibly fanning out by disambiguator keys?
123 # For regular types "__name__ works, but unions and similar don't have one
124 fieldtypename = getattr(fieldtype, "__name__", str(fieldtype))
125 raise ValueError(
126 f"Type mismatch on {keyprefix}: Expected {fieldtypename}, found {type(value).__name__}"
127 )
128 kwargs[f] = value
130 try:
131 return cls(**kwargs)
132 except TypeError as e:
133 missing = [
134 f.name
135 for f in dataclasses.fields(cls)
136 if f.name not in kwargs and f.default is dataclasses.MISSING
137 ]
138 if missing:
139 # The likely case -- what else might go wrong?
140 raise ValueError(
141 f"Construcintg an instance of {cls.__name__} at {prefix}, these items are missing: {', '.join(m.replace('_', '-') for m in missing)}"
142 )
143 else:
144 raise ValueError(
145 f"Constructing instance of {cls.__name__} at {keyprefix} failed for unexpected reasons"
146 ) from e