Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\configuration.py: 73%

# Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\configuration.py : 73%

## 92 statements

Hot-keys on this page

r m x p toggle line displays

j k next/prev highlighted chunk

0 (zero) top of page

1 (one) first highlighted chunk

1# Copyright (c) 2019-2020 ETH Zurich, SIS ID and HVL D-ITET

2#

3"""

4 Facilities providing classes for handling configuration for communication protocols

5 and devices.

6"""

7

8 import dataclasses

9 import json

10 from abc import ABC, abstractmethod

11 from importlib import import_module

12 from typing import Dict, Sequence

13

14 from .utils.typing import is_generic_type_hint, check_generic_type

15

16

17 def _has_default_value(f: dataclasses.Field):

18 return not isinstance(f.default, dataclasses._MISSING_TYPE)

19

20

21# Hooks of configdataclass

22 def _clean_values(self):

23 """

24 Cleans and enforces configuration values. Does nothing by default, but may be

25 overridden to add custom configuration value checks.

26 """

27

28

29 _configclass_hooks = {

30 "clean_values": _clean_values,

31}

32

33

34# Methods of configdataclass

35 def ___post_init__(self):

36 self._check_types()

37 self.clean_values()

38

39

40 def _force_value(self, fieldname, value):

41 """

42 Forces a value to a dataclass field despite the class being frozen.

43

44 NOTE: you can define `post_force_value` method with same signature as this method

45 to do extra processing after `value` has been forced on `fieldname`.

46

47 :param fieldname: name of the field

48 :param value: value to assign

49 """

50 object.__setattr__(self, fieldname, value)

51 if hasattr(self, "post_force_value"):

52 self.post_force_value(fieldname, value)

53

54

55@classmethod # type: ignore

56 def _keys(cls) -> Sequence[str]:

57 """

58 Returns a list of all configdataclass fields key-names.

59

60 :return: a list of strings containing all keys.

61 """

62 return [f.name for f in dataclasses.fields(cls)]

63

64

65@classmethod # type: ignore

66 def _required_keys(cls) -> Sequence[str]:

67 """

68 Returns a list of all configdataclass fields, that have no default value assigned

69 and need to be specified on instantiation.

70

71 :return: a list of strings containing all required keys.

72 """

73 return [f.name for f in dataclasses.fields(cls) if not _has_default_value(f)]

74

75

76@classmethod # type: ignore

77 def _optional_defaults(cls) -> Dict[str, object]:

78 """

79 Returns a list of all configdataclass fields, that have a default value assigned

80 and may be optionally specified on instantiation.

81

82 :return: a list of strings containing all optional keys.

83 """

84 return {f.name: f.default for f in dataclasses.fields(cls) if _has_default_value(f)}

85

86

87 def __check_types(self):

88 mod = import_module(self.__module__)

89 for field in dataclasses.fields(self):

90 name = field.name

91 value = getattr(self, name)

92 type_ = field.type

93 if isinstance(type_, str): # `from __future__ import annotations` in use

94 try:

95 # built-in types

96 type_ = eval(type_)

97 except NameError:

98 # module-level defined type

99 type_ = getattr(mod, type_)

100 if is_generic_type_hint(type_):

101 check_generic_type(value, type_, name=name)

102 elif not isinstance(value, type_):

103 raise TypeError(

104 "Type of field `{}` is `{}` and does not match `{}`.".format(

105 name, type(value), type_

106 )

107 )

108

109

110 _configclass_methods = {

111 "__post_init__": ___post_init__,

112 "force_value": _force_value,

113 "keys": _keys,

114 "required_keys": _required_keys,

115 "optional_defaults": _optional_defaults,

116 "_check_types": __check_types,

117}

118

119

120 def configdataclass(direct_decoration=None, frozen=True):

121 """

122 Decorator to make a class a configdataclass. Types in these dataclasses are

123 enforced. Implement a function clean_values(self) to do additional checking on

124 value ranges etc.

125

126 It is possible to inherit from a configdataclass and re-decorate it with

127 @configdataclass. In a subclass, default values can be added to existing fields.

128 Note: adding additional non-default fields is prone to errors, since the order

129 has to be respected through the whole chain (first non-default fields, only then

130 default-fields).

131

132 :param frozen: defaults to True. False allows to later change configuration values.

133 Attention: if configdataclass is not frozen and a value is changed, typing is

134 not enforced anymore!

135 """

136

137 def decorator(cls):

138 for name, method in _configclass_methods.items():

139 if name in cls.__dict__:

140 raise AttributeError(

141 "configdataclass {!r} cannot define {!r} method".format(

142 cls.__name__,

143 name,

144 )

145 )

146 setattr(cls, name, method)

147 for name, hook in _configclass_hooks.items():

148 if not hasattr(cls, name):

149 setattr(cls, name, hook)

150 if not hasattr(cls, "is_configdataclass"):

151 setattr(cls, "is_configdataclass", True)

152

153 return dataclasses.dataclass(cls, frozen=frozen)

154

155 if direct_decoration:

156 return decorator(direct_decoration)

157

158 return decorator

159

160

161 class ConfigurationMixin(ABC):

162 """

163 Mixin providing configuration to a class.

164 """

165

166 # omitting type hint of `configuration` on purpose, because type hinting

167 # configdataclass is not possible. Union[Dict[str, object], object] resolves to

168 # object.

169 def __init__(self, configuration) -> None:

170 """

171 Constructor for the configuration mixin.

172

173 :param configuration: is the configuration provided either as:

174 * a dict with string keys and values, then the default config dataclass

175 will be used

176 * a configdataclass object

177 * None, then the config_cls() with no parameters is instantiated

178 """

179

180 if not configuration:

181 configuration = {}

182

183 if hasattr(configuration, "is_configdataclass"):

184 self._configuration = configuration

185 elif isinstance(configuration, Dict):

186 default_configdataclass = self.config_cls()

187 if not hasattr(default_configdataclass, "is_configdataclass"):

188 raise TypeError(

189 "Default configdataclass is not a configdataclass. Is"

190 "the decorator `@configdataclass` applied?"

191 )

192 self._configuration = default_configdataclass(**configuration)

193 else:

194 raise TypeError("configuration is not a dictionary or configdataclass.")

195

196 @staticmethod

197 @abstractmethod

198 def config_cls():

199 """

200 Return the default configdataclass class.

201

202 :return: a reference to the default configdataclass class

203 """

204

205 @property

206 def config(self):

207 """

208 ConfigDataclass property.

209

210 :return: the configuration

211 """

212

213 return self._configuration

214

215 @classmethod

216 def from_json(cls, filename: str):

217 """

218 Instantiate communication protocol using configuration from a JSON file.

219

220 :param filename: Path and filename to the JSON configuration

221 """

222

223 configuration = cls._configuration_load_json(filename)

224 return cls(configuration)

225

226 def configuration_save_json(self, path: str) -> None:

227 """

228 Save current configuration as JSON file.

229

230 :param path: path to the JSON file.

231 """

232

233 self._configuration_save_json(dataclasses.asdict(self._configuration), path)

234

235 @staticmethod

236 def _configuration_load_json(path: str) -> Dict[str, object]:

237 """

238 Load configuration from JSON file and return Dict. This method is only used

239 during construction, if not directly a configuration is given but rather a

240 path to a JSON config file.

241

242 :param path: Path to the JSON configuration file.

243 :return: Dictionary containing the parameters read from the JSON file.

244 """

245

246 with open(path, "r") as fp:

247 return json.load(fp)

248

249 @staticmethod

250 def _configuration_save_json(configuration: Dict[str, object], path: str) -> None:

251 """

252 Store a configuration dict to a JSON file.

253

254 :param configuration: configuration dictionary

255 :param path: path to the JSON file.

256 """

257

258 with open(path, "w") as fp:

259 json.dump(configuration, fp, indent=4)

260

261

262@configdataclass

263 class EmptyConfig:

264 """

265 Empty configuration dataclass.

266 """