Coverage for .tox/cov/lib/python3.10/site-packages/confattr/config.py: 100%
270 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-22 14:03 +0200
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-22 14:03 +0200
1#!./runmodule.sh
3import enum
4import typing
5from collections.abc import Iterable, Iterator, Container, Sequence, Callable
7if typing.TYPE_CHECKING:
8 from typing_extensions import Self
11VALUE_TRUE = 'true'
12VALUE_FALSE = 'false'
13VALUE_NONE = 'none'
14VALUE_AUTO = 'auto'
16TYPES_REQUIRING_UNIT = {int, float}
17CONTAINER_TYPES = {list}
20ConfigId = typing.NewType('ConfigId', str)
22T_co = typing.TypeVar('T_co', covariant=True)
23T_KEY = typing.TypeVar('T_KEY')
24T = typing.TypeVar('T')
27class Config(typing.Generic[T_co]):
29 '''
30 Each instance of this class represents a setting which can be changed in a config file.
32 This class implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`_ to return :attr:`value` if an instance of this class is accessed as an instance attribute.
33 If you want to get this object you need to access it as a class attribute.
34 '''
36 LIST_SEP = ','
38 #: A mapping of all :class:`Config` instances. The key in the mapping is the :attr:`key` attribute. The value is the :class:`Config` instance. New :class:`Config` instances add themselves automatically in their constructor.
39 instances: 'dict[str, Config[typing.Any]]' = {}
41 default_config_id = ConfigId('general')
43 #: The value of this setting.
44 value: 'T_co'
46 #: The unit of :attr:`value` if :attr:`value` is a number.
47 unit: 'str|None'
49 #: A description of this setting or a description for each allowed value.
50 help: 'str|dict[T_co, str]|None'
52 #: The values which are allowed for this setting. Trying to set this setting to a different value in the config file is considered an error. If you set this setting in the program the value is *not* checked.
53 allowed_values: 'Sequence[T_co]|None'
55 def __init__(self,
56 key: str,
57 default: T_co, *,
58 help: 'str|dict[T_co, str]|None' = None,
59 unit: 'str|None' = None,
60 parent: 'DictConfig[typing.Any, T_co]|None' = None,
61 allowed_values: 'Sequence[T_co]|None' = None,
62 ):
63 '''
64 :param key: The name of this setting in the config file
65 :param default: The default value of this setting
66 :param help: A description of this setting
67 :param unit: The unit of an int or float value
68 :param parent: Applies only if this is part of a :class:`DictConfig`
69 :param allowed_values: The possible values this setting can have. Values read from a config file or an environment variable are checked against this. The :paramref:`default` value is *not* checked.
71 :const:`T_co` can be one of:
72 * :class:`str`
73 * :class:`int`
74 * :class:`float`
75 * :class:`bool`
76 * a subclass of :class:`enum.Enum` (the value used in the config file is the name in lower case letters with hyphens instead of underscores)
77 * a class where :meth:`__str__` returns a string representation which can be passed to the constructor to create an equal object. \
78 A help which is written to the config file must be provided as a str in the class attribute :attr:`help` or by calling :meth:`Set.set_help_for_type`. \
79 If that class has a str attribute :attr:`type_name` this is used instead of the class name inside of config file.
80 * a :class:`list` of any of the afore mentioned data types. The list may not be empty when it is passed to this constructor so that the item type can be derived but it can be emptied immediately afterwards. (The type of the items is not dynamically enforced—that's the job of a static type checker—but the type is mentioned in the help.)
82 :raises ValueError: if key is not unique
83 :raises ValueError: if :paramref:`default` is an empty list because the first element is used to infer the data type to which a value given in a config file is converted
84 :raises TypeError: if this setting is a number or a list of numbers and :paramref:`unit` is not given
85 '''
86 self._key = key
87 self.value = default
88 self.type = type(default)
89 self.help = help
90 self.unit = unit
91 self.parent = parent
92 self.allowed_values = allowed_values
94 if self.type == list:
95 if not default:
96 raise ValueError('I cannot infer the type from an empty list')
97 self.item_type = type(default[0]) # type: ignore [index] # mypy does not understand that I just checked that default is a list
98 needs_unit = self.item_type in TYPES_REQUIRING_UNIT
99 else:
100 needs_unit = self.type in TYPES_REQUIRING_UNIT
101 if needs_unit and self.unit is None:
102 raise TypeError(f'missing argument unit for {self.key}, pass an empty string if the number really has no unit')
104 cls = type(self)
105 if key in cls.instances:
106 raise ValueError(f'duplicate config key {key!r}')
107 cls.instances[key] = self
109 @property
110 def key(self) -> str:
111 '''The name of this setting which is used in the config file. This must be unique.'''
112 return self._key
114 @key.setter
115 def key(self, key: str) -> None:
116 if key in self.instances:
117 raise ValueError(f'duplicate config key {key!r}')
118 del self.instances[self._key]
119 self._key = key
120 self.instances[key] = self
123 @typing.overload
124 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self':
125 pass
127 @typing.overload
128 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> T_co:
129 pass
131 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'T_co|Self':
132 if instance is None:
133 return self
135 return self.value
137 def __set__(self: 'Config[T]', instance: typing.Any, value: T) -> None:
138 self.value = value
140 def __repr__(self) -> str:
141 return '%s(%s, ...)' % (type(self).__name__, ', '.join(repr(a) for a in (self.key, self.value)))
143 def set_value(self: 'Config[T]', config_id: 'ConfigId|None', value: T) -> None:
144 '''
145 This method is just to provide a common interface for :class:`Config` and :class:`MultiConfig`.
146 If you know that you are dealing with a normal :class:`Config` you can set :attr:`value` directly.
147 '''
148 if config_id is None:
149 config_id = self.default_config_id
150 if config_id != self.default_config_id:
151 raise ValueError(f'{self.key} cannot be set for specific groups, config_id must be the default {self.default_config_id!r} not {config_id!r}')
152 self.value = value
154 def parse_value(self, value: str) -> T_co:
155 '''
156 Parse a value to the data type of this setting.
158 :param value: The value to be parsed
159 :raises ValueError: if :paramref:`value` is invalid
160 '''
161 return self.parse_value_part(self.type, value)
163 def parse_value_part(self, t: 'type[T]', value: str) -> T:
164 '''
165 Parse a value to the given data type.
167 :param t: The data type to which :paramref:`value` shall be parsed
168 :param value: The value to be parsed
169 :raises ValueError: if :paramref:`value` is invalid
170 '''
171 if t == str:
172 value = value.replace(r'\n', '\n')
173 out = typing.cast(T, value)
174 elif t == int:
175 out = typing.cast(T, int(value, base=0))
176 elif t == float:
177 out = typing.cast(T, float(value))
178 elif t == bool:
179 if value == VALUE_TRUE:
180 out = typing.cast(T, True)
181 elif value == VALUE_FALSE:
182 out = typing.cast(T, False)
183 else:
184 raise ValueError(f'invalid value for {self.key}: {value!r} (should be {self.format_allowed_values_or_type(t)})')
185 elif t == list:
186 return typing.cast(T, [self.parse_value_part(self.item_type, v) for v in value.split(self.LIST_SEP)])
187 elif issubclass(t, enum.Enum):
188 for enum_item in t:
189 if self.format_any_value(typing.cast(T, enum_item)) == value:
190 out = typing.cast(T, enum_item)
191 break
192 else:
193 raise ValueError(f'invalid value for {self.key}: {value!r} (should be {self.format_allowed_values_or_type(t)})')
194 else:
195 try:
196 out = t(value) # type: ignore [call-arg]
197 except Exception as e:
198 raise ValueError(f'invalid value for {self.key}: {value!r} ({e})')
200 if self.allowed_values is not None and out not in self.allowed_values:
201 raise ValueError(f'invalid value for {self.key}: {value!r} (should be {self.format_allowed_values_or_type(t)})')
202 return out
205 def format_allowed_values_or_type(self, t: 'type[typing.Any]|None' = None) -> str:
206 out = self.format_allowed_values(t)
207 if out:
208 return 'one of ' + out
210 out = self.format_type(t)
212 # getting the article right is not so easy, so a user can specify the correct article with type_article
213 # this also gives the possibility to omit the article
214 # https://en.wiktionary.org/wiki/Appendix:English_articles#Indefinite_singular_articles
215 if hasattr(self.type, 'type_article'):
216 article = getattr(self.type, 'type_article')
217 if not article:
218 return out
219 assert isinstance(article, str)
220 return article + ' ' + out
221 if out[0].lower() in 'aeio':
222 return 'an ' + out
223 return 'a ' + out
225 def get_allowed_values(self, t: 'type[typing.Any]|None' = None) -> 'Iterable[object]':
226 if t is None:
227 t = self.type
228 allowed_values: 'Iterable[typing.Any]'
229 if t not in CONTAINER_TYPES and self.allowed_values is not None:
230 allowed_values = self.allowed_values
231 elif t == bool:
232 allowed_values = (True, False)
233 elif issubclass(t, enum.Enum):
234 allowed_values = t
235 else:
236 allowed_values = []
237 return allowed_values
239 def get_stringified_allowed_values(self, t: 'type[typing.Any]|None' = None) -> 'Iterable[str]':
240 for val in self.get_allowed_values(t):
241 yield self.format_any_value(val)
243 def format_allowed_values(self, t: 'type[typing.Any]|None' = None) -> str:
244 out = ', '.join(self.get_stringified_allowed_values(t))
245 if out and self.unit:
246 out += ' (unit: %s)' % self.unit
247 return out
250 def wants_to_be_exported(self) -> bool:
251 return True
253 def format_type(self, t: 'type[typing.Any]|None' = None) -> str:
254 if t is None:
255 if self.type is list:
256 t = self.item_type
257 item_type = self.format_allowed_values(t)
258 if not item_type:
259 item_type = self.format_type(t)
260 return 'comma separated list of %s' % item_type
262 t = self.type
264 out = getattr(t, 'type_name', t.__name__)
265 if self.unit:
266 out += ' in %s' % self.unit
267 return out
269 def format_value(self, config_id: 'ConfigId|None') -> str:
270 return self.format_any_value(self.value)
272 def format_any_value(self, value: typing.Any) -> str:
273 if isinstance(value, str):
274 value = value.replace('\n', r'\n')
275 if isinstance(value, enum.Enum):
276 return value.name.lower().replace('_', '-')
277 if isinstance(value, bool):
278 return VALUE_TRUE if value else VALUE_FALSE
279 if isinstance(value, list):
280 return self.LIST_SEP.join(self.format_any_value(v) for v in value)
281 return str(value)
284class DictConfig(typing.Generic[T_KEY, T]):
286 '''
287 A container for several settings which belong together.
288 It can be indexed like a normal :class:`dict` but internally the items are stored in :class:`Config` instances.
290 In contrast to a :class:`Config` instance it does *not* make a difference whether an instance of this class is accessed as a type or instance attribute.
291 '''
293 def __init__(self,
294 key_prefix: str,
295 default_values: 'dict[T_KEY, T]', *,
296 ignore_keys: 'Container[T_KEY]' = set(),
297 unit: 'str|None' = None,
298 help: 'str|None' = None,
299 allowed_values: 'Sequence[T]|None' = None,
300 ) -> None:
301 '''
302 :param key_prefix: A common prefix which is used by :meth:`format_key` to generate the :attr:`~Config.key` by which the setting is identified in the config file
303 :param default_values: The content of this container. A :class:`Config` instance is created for each of these values (except if the key is contained in :paramref:`ignore_keys`). See :meth:`format_key`.
304 :param ignore_keys: All items which have one of these keys are *not* stored in a :class:`Config` instance, i.e. cannot be set in the config file.
305 :param unit: The unit of all items
306 :param help: A help for all items
307 :param allowed_values: The values which the items can have
309 :raises ValueError: if a key is not unique
310 '''
311 self._values: 'dict[T_KEY, Config[T]]' = {}
312 self._ignored_values: 'dict[T_KEY, T]' = {}
313 self.allowed_values = allowed_values
315 self.key_prefix = key_prefix
316 self.unit = unit
317 self.help = help
318 self.ignore_keys = ignore_keys
320 for key, val in default_values.items():
321 self[key] = val
323 def format_key(self, key: T_KEY) -> str:
324 '''
325 Generate a key by which the setting can be identified in the config file based on the dict key by which the value is accessed in the python code.
327 :return: :paramref:`~DictConfig.key_prefix` + dot + :paramref:`key`
328 '''
329 if isinstance(key, enum.Enum):
330 key_str = key.name.lower().replace('_', '-')
331 elif isinstance(key, bool):
332 key_str = VALUE_TRUE if key else VALUE_FALSE
333 else:
334 key_str = str(key)
336 return '%s.%s' % (self.key_prefix, key_str)
338 def __setitem__(self: 'DictConfig[T_KEY, T]', key: T_KEY, val: T) -> None:
339 if key in self.ignore_keys:
340 self._ignored_values[key] = val
341 return
343 c = self._values.get(key)
344 if c is None:
345 self._values[key] = self.new_config(self.format_key(key), val, unit=self.unit, help=self.help)
346 else:
347 c.value = val
349 def new_config(self: 'DictConfig[T_KEY, T]', key: str, default: T, *, unit: 'str|None', help: 'str|dict[T, str]|None') -> Config[T]:
350 '''
351 Create a new :class:`Config` instance to be used internally
352 '''
353 return Config(key, default, unit=unit, help=help, parent=self, allowed_values=self.allowed_values)
355 def __getitem__(self, key: T_KEY) -> T:
356 if key in self.ignore_keys:
357 return self._ignored_values[key]
358 else:
359 return self._values[key].value
361 def get(self, key: T_KEY, default: 'T|None' = None) -> 'T|None':
362 try:
363 return self[key]
364 except KeyError:
365 return default
367 def __repr__(self) -> str:
368 values = {key:val.value for key,val in self._values.items()}
369 values.update({key:val for key,val in self._ignored_values.items()})
370 return '%s(%r, ignore_keys=%r, ...)' % (type(self).__name__, values, self.ignore_keys)
372 def __contains__(self, key: T_KEY) -> bool:
373 if key in self.ignore_keys:
374 return key in self._ignored_values
375 else:
376 return key in self._values
378 def __iter__(self) -> 'Iterator[T_KEY]':
379 yield from self._values
380 yield from self._ignored_values
382 def iter_keys(self) -> 'Iterator[str]':
383 '''
384 Iterate over the keys by which the settings can be identified in the config file
385 '''
386 for cfg in self._values.values():
387 yield cfg.key
390# ========== settings which can have different values for different groups ==========
392class MultiConfig(Config[T_co]):
394 '''
395 A setting which can have different values for different objects.
397 This class implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`_ to return one of the values in :attr:`values` depending on a ``config_id`` attribute of the owning object if an instance of this class is accessed as an instance attribute.
398 If there is no value for the ``config_id`` in :attr:`values` :attr:`value` is returned instead.
399 If the owning instance does not have a ``config_id`` attribute an :class:`AttributeError` is raised.
401 In the config file a group can be opened with ``[config-id]``.
402 Then all following ``set`` commands set the value for the specified config id.
403 '''
405 #: A list of all config ids for which a value has been set in any instance of this class (regardless of via code or in a config file and regardless of whether the value has been deleted later on). This list is cleared by :meth:`reset`.
406 config_ids: 'list[ConfigId]' = []
408 #: Stores the values for specific objects.
409 values: 'dict[ConfigId, T_co]'
411 #: Stores the default value which is used if no value for the object is defined in :attr:`values`.
412 value: 'T_co'
414 @classmethod
415 def reset(cls) -> None:
416 '''
417 Clear :attr:`config_ids` and clear :attr:`values` for all instances in :attr:`Config.instances`
418 '''
419 cls.config_ids.clear()
420 for cfg in Config.instances.values():
421 if isinstance(cfg, MultiConfig):
422 cfg.values.clear()
424 def __init__(self,
425 key: str,
426 default: T_co, *,
427 unit: 'str|None' = None,
428 help: 'str|dict[T_co, str]|None' = None,
429 parent: 'MultiDictConfig[typing.Any, T_co]|None' = None,
430 allowed_values: 'Sequence[T_co]|None' = None,
431 check_config_id: 'Callable[[MultiConfig[T_co], ConfigId], None]|None' = None,
432 ) -> None:
433 '''
434 :param key: The name of this setting in the config file
435 :param default: The default value of this setting
436 :param help: A description of this setting
437 :param unit: The unit of an int or float value
438 :param parent: Applies only if this is part of a :class:`MultiDictConfig`
439 :param allowed_values: The possible values this setting can have. Values read from a config file or an environment variable are checked against this. The :paramref:`default` value is *not* checked.
440 :param check_config_id: Is called every time a value is set in the config file (except if the config id is :attr:`~Config.default_config_id`—that is always allowed). The callback should raise a :class:`~confattr.ParseException` if the config id is invalid.
441 '''
442 super().__init__(key, default, unit=unit, help=help, parent=parent, allowed_values=allowed_values)
443 self.values: 'dict[ConfigId, T_co]' = {}
444 self.check_config_id = check_config_id
446 # I don't know why this code duplication is necessary,
447 # I have declared the overloads in the parent class already.
448 # But without copy-pasting this code mypy complains
449 # "Signature of __get__ incompatible with supertype Config"
450 @typing.overload
451 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self':
452 pass
454 @typing.overload
455 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> T_co:
456 pass
458 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'T_co|Self':
459 if instance is None:
460 return self
462 return self.values.get(instance.config_id, self.value)
464 def __set__(self: 'MultiConfig[T]', instance: typing.Any, value: T) -> None:
465 config_id = instance.config_id
466 self.values[config_id] = value
467 if config_id not in self.config_ids:
468 self.config_ids.append(config_id)
470 def set_value(self: 'MultiConfig[T]', config_id: 'ConfigId|None', value: T) -> None:
471 '''
472 Check :paramref:`config_id` by calling :meth:`check_config_id` and
473 set the value for the object(s) identified by :paramref:`config_id`.
475 If you know that :paramref:`config_id` is valid you can also change the items of :attr:`values` directly.
476 That is especially useful in test automation with :meth:`pytest.MonkeyPatch.setitem`.
478 If you want to set the default value you can also set :attr:`value` directly.
480 :param config_id: Identifies the object(s) for which :paramref:`value` is intended. :obj:`None` is equivalent to :attr:`default_config_id`.
481 :param value: The value to be assigned for the object(s) identified by :paramref:`config_id`.
482 '''
483 if config_id is None:
484 config_id = self.default_config_id
485 if self.check_config_id and config_id != self.default_config_id:
486 self.check_config_id(self, config_id)
487 if config_id == self.default_config_id:
488 self.value = value
489 else:
490 self.values[config_id] = value
491 if config_id not in self.config_ids:
492 self.config_ids.append(config_id)
494 def format_value(self, config_id: 'ConfigId|None') -> str:
495 '''
496 Convert the value for the specified object(s) to a string.
498 :param config_id: Identifies the value which you want to convert. :obj:`None` is equivalent to :attr:`default_config_id`.
499 '''
500 if config_id is None:
501 config_id = self.default_config_id
502 return self.format_any_value(self.values.get(config_id, self.value))
505class MultiDictConfig(DictConfig[T_KEY, T]):
507 '''
508 A container for several settings which can have different values for different objects.
510 This is essentially a :class:`DictConfig` using :class:`MultiConfig` instead of normal :class:`Config`.
511 However, in order to return different values depending on the ``config_id`` of the owning instance, it implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`_ to return an :class:`InstanceSpecificDictMultiConfig` if it is accessed as an instance attribute.
512 '''
514 def __init__(self,
515 key_prefix: str,
516 default_values: 'dict[T_KEY, T]', *,
517 ignore_keys: 'Container[T_KEY]' = set(),
518 unit: 'str|None' = None,
519 help: 'str|None' = None,
520 allowed_values: 'Sequence[T]|None' = None,
521 check_config_id: 'Callable[[MultiConfig[T], ConfigId], None]|None' = None,
522 ) -> None:
523 '''
524 :param key_prefix: A common prefix which is used by :meth:`format_key` to generate the :attr:`~Config.key` by which the setting is identified in the config file
525 :param default_values: The content of this container. A :class:`Config` instance is created for each of these values (except if the key is contained in :paramref:`ignore_keys`). See :meth:`format_key`.
526 :param ignore_keys: All items which have one of these keys are *not* stored in a :class:`Config` instance, i.e. cannot be set in the config file.
527 :param unit: The unit of all items
528 :param help: A help for all items
529 :param allowed_values: The values which the items can have
530 :param check_config_id: Is passed through to :class:`MultiConfig`
532 :raises ValueError: if a key is not unique
533 '''
534 self.check_config_id = check_config_id
535 super().__init__(
536 key_prefix = key_prefix,
537 default_values = default_values,
538 ignore_keys = ignore_keys,
539 unit = unit,
540 help = help,
541 allowed_values = allowed_values,
542 )
544 @typing.overload
545 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self':
546 pass
548 @typing.overload
549 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'InstanceSpecificDictMultiConfig[T_KEY, T]':
550 pass
552 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'InstanceSpecificDictMultiConfig[T_KEY, T]|Self':
553 if instance is None:
554 return self
556 return InstanceSpecificDictMultiConfig(self, instance.config_id)
558 def __set__(self: 'MultiDictConfig[T_KEY, T]', instance: typing.Any, value: 'InstanceSpecificDictMultiConfig[T_KEY, T]') -> typing.NoReturn:
559 raise NotImplementedError()
561 def new_config(self: 'MultiDictConfig[T_KEY, T]', key: str, default: T, *, unit: 'str|None', help: 'str|dict[T, str]|None') -> MultiConfig[T]:
562 return MultiConfig(key, default, unit=unit, help=help, parent=self, allowed_values=self.allowed_values, check_config_id=self.check_config_id)
564class InstanceSpecificDictMultiConfig(typing.Generic[T_KEY, T]):
566 '''
567 An intermediate instance which is returned when accsessing
568 a :class:`MultiDictConfig` as an instance attribute.
569 Can be indexed like a normal :class:`dict`.
570 '''
572 def __init__(self, mdc: 'MultiDictConfig[T_KEY, T]', config_id: ConfigId) -> None:
573 self.mdc = mdc
574 self.config_id = config_id
576 def __setitem__(self: 'InstanceSpecificDictMultiConfig[T_KEY, T]', key: T_KEY, val: T) -> None:
577 if key in self.mdc.ignore_keys:
578 raise TypeError('cannot set value of ignored key %r' % key)
580 c = self.mdc._values.get(key)
581 if c is None:
582 self.mdc._values[key] = MultiConfig(self.mdc.format_key(key), val, help=self.mdc.help)
583 else:
584 c.__set__(self, val)
586 def __getitem__(self, key: T_KEY) -> T:
587 if key in self.mdc.ignore_keys:
588 return self.mdc._ignored_values[key]
589 else:
590 return self.mdc._values[key].__get__(self)