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

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"""
4Facilities providing classes for handling configuration for communication protocols
5and devices.
6"""
8import dataclasses
9import json
10from abc import ABC, abstractmethod
11from importlib import import_module
12from typing import Dict, Sequence
14from .utils.typing import is_generic_type_hint, check_generic_type
17def _has_default_value(f: dataclasses.Field):
18 return not isinstance(f.default, dataclasses._MISSING_TYPE)
21# Hooks of configdataclass
22def _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 """
29_configclass_hooks = {
30 "clean_values": _clean_values,
31}
34# Methods of configdataclass
35def ___post_init__(self):
36 self._check_types()
37 self.clean_values()
40def _force_value(self, fieldname, value):
41 """
42 Forces a value to a dataclass field despite the class being frozen.
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`.
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)
55@classmethod # type: ignore
56def _keys(cls) -> Sequence[str]:
57 """
58 Returns a list of all configdataclass fields key-names.
60 :return: a list of strings containing all keys.
61 """
62 return [f.name for f in dataclasses.fields(cls)]
65@classmethod # type: ignore
66def _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.
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)]
76@classmethod # type: ignore
77def _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.
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)}
87def __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 )
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}
120def 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.
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).
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 """
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)
153 return dataclasses.dataclass(cls, frozen=frozen)
155 if direct_decoration:
156 return decorator(direct_decoration)
158 return decorator
161class ConfigurationMixin(ABC):
162 """
163 Mixin providing configuration to a class.
164 """
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.
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 """
180 if not configuration:
181 configuration = {}
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.")
196 @staticmethod
197 @abstractmethod
198 def config_cls():
199 """
200 Return the default configdataclass class.
202 :return: a reference to the default configdataclass class
203 """
205 @property
206 def config(self):
207 """
208 ConfigDataclass property.
210 :return: the configuration
211 """
213 return self._configuration
215 @classmethod
216 def from_json(cls, filename: str):
217 """
218 Instantiate communication protocol using configuration from a JSON file.
220 :param filename: Path and filename to the JSON configuration
221 """
223 configuration = cls._configuration_load_json(filename)
224 return cls(configuration)
226 def configuration_save_json(self, path: str) -> None:
227 """
228 Save current configuration as JSON file.
230 :param path: path to the JSON file.
231 """
233 self._configuration_save_json(dataclasses.asdict(self._configuration), path)
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.
242 :param path: Path to the JSON configuration file.
243 :return: Dictionary containing the parameters read from the JSON file.
244 """
246 with open(path, "r") as fp:
247 return json.load(fp)
249 @staticmethod
250 def _configuration_save_json(configuration: Dict[str, object], path: str) -> None:
251 """
252 Store a configuration dict to a JSON file.
254 :param configuration: configuration dictionary
255 :param path: path to the JSON file.
256 """
258 with open(path, "w") as fp:
259 json.dump(configuration, fp, indent=4)
262@configdataclass
263class EmptyConfig:
264 """
265 Empty configuration dataclass.
266 """