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

1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors 

2# 

3# SPDX-License-Identifier: MIT 

4 

5"""Tools to load a typed dataclass from CBOR/JSON/TOML/YAML-model data. 

6 

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. 

10 

11**Caveats**: 

12 

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`. 

16 

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. 

21 

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""" 

35 

36import dataclasses 

37import types 

38from typing import Self 

39 

40 

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. 

45 

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). 

50 

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. 

54 

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. 

59 

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 """ 

63 

64 assert dataclasses.is_dataclass(cls) 

65 

66 prefix = f"{prefix}/" if prefix else prefix 

67 

68 fields = {f.name: f for f in dataclasses.fields(cls)} 

69 

70 kwargs = {} 

71 

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. 

102 

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? 

122 

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 

129 

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