Coverage for .tox/cov/lib/python3.11/site-packages/confattr/config.py: 100%
189 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-10 20:18 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-10 20:18 +0200
1#!./runmodule.sh
3import builtins
4import enum
5import typing
6from collections.abc import Iterable, Iterator, Container, Sequence, Callable
8if typing.TYPE_CHECKING:
9 from typing_extensions import Self
11from .formatters import AbstractFormatter, CopyableAbstractFormatter, Primitive, List, Set, Dict, format_primitive_value
14#: An identifier to specify which value of a :class:`~confattr.config.MultiConfig` or :class:`~confattr.config.MultiDictConfig` should be used for a certain object.
15ConfigId = typing.NewType('ConfigId', str)
17T_KEY = typing.TypeVar('T_KEY')
18T = typing.TypeVar('T')
21class Config(typing.Generic[T]):
23 '''
24 Each instance of this class represents a setting which can be changed in a config file.
26 This class implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`__ to return :attr:`~confattr.config.Config.value` if an instance of this class is accessed as an instance attribute.
27 If you want to get this object you need to access it as a class attribute.
28 '''
30 #: A mapping of all :class:`~confattr.config.Config` instances. The key in the mapping is the :attr:`~confattr.config.Config.key` attribute. The value is the :class:`~confattr.config.Config` instance. New :class:`~confattr.config.Config` instances add themselves automatically in their constructor.
31 instances: 'dict[str, Config[typing.Any]]' = {}
33 default_config_id = ConfigId('general')
35 #: The value of this setting.
36 value: 'T'
38 #: Information about data type, unit and allowed values for :attr:`~confattr.config.Config.value` and methods how to parse, format and complete it.
39 type: 'AbstractFormatter[T]'
41 #: A description of this setting or a description for each allowed value.
42 help: 'str|dict[T, str]|None'
45 def __init__(self,
46 key: str,
47 default: T, *,
48 type: 'AbstractFormatter[T]|None' = None,
49 unit: 'str|None' = None,
50 allowed_values: 'Sequence[T]|dict[str, T]|None' = None,
51 help: 'str|dict[T, str]|None' = None,
52 parent: 'DictConfig[typing.Any, T]|None' = None,
53 ):
54 '''
55 :param key: The name of this setting in the config file
56 :param default: The default value of this setting
57 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`~confattr.config.Config.default`. But if :paramref:`~confattr.config.Config.default` is an empty list the item type cannot be determined automatically so that this argument must be passed explicitly. This also gives the possibility to format a standard type differently e.g. as :class:`~confattr.formatters.Hex`. It is not permissible to reuse the same object for different settings, otherwise :meth:`AbstractFormatter.set_config_key() <confattr.formatters.AbstractFormatter.set_config_key>` will throw an exception.
58 :param unit: The unit of an int or float value (only if type is None)
59 :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:`~confattr.config.Config.default` value is *not* checked. (Only if type is None.)
60 :param help: A description of this setting
61 :param parent: Applies only if this is part of a :class:`~confattr.config.DictConfig`
63 :obj:`~confattr.config.T` can be one of:
64 * :class:`str`
65 * :class:`int`
66 * :class:`float`
67 * :class:`bool`
68 * 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)
69 * a class where :meth:`~object.__str__` returns a string representation which can be passed to the constructor to create an equal object. \
70 A help which is written to the config file must be provided as a str in the class attribute :attr:`~confattr.types.AbstractType.help` or by adding it to :attr:`Primitive.help_dict <confattr.formatters.Primitive.help_dict>`. \
71 If that class has a str attribute :attr:`~confattr.types.AbstractType.type_name` this is used instead of the class name inside of config file.
72 * 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.)
74 :raises ValueError: if key is not unique
75 :raises TypeError: if :paramref:`~confattr.config.Config.default` is an empty list/set because the first element is used to infer the data type to which a value given in a config file is converted
76 :raises TypeError: if this setting is a number or a list of numbers and :paramref:`~confattr.config.Config.unit` is not given
77 '''
78 if type is None:
79 if isinstance(default, list):
80 if not default:
81 raise TypeError('I cannot infer the item type from an empty list. Please pass an argument to the type parameter.')
82 item_type: 'builtins.type[T]' = builtins.type(default[0])
83 type = typing.cast('AbstractFormatter[T]', List(item_type=Primitive(item_type, allowed_values=allowed_values, unit=unit)))
84 elif isinstance(default, set):
85 if not default:
86 raise TypeError('I cannot infer the item type from an empty set. Please pass an argument to the type parameter.')
87 item_type = builtins.type(next(iter(default)))
88 type = typing.cast('AbstractFormatter[T]', Set(item_type=Primitive(item_type, allowed_values=allowed_values, unit=unit)))
89 elif isinstance(default, dict):
90 if not default:
91 raise TypeError('I cannot infer the key and value types from an empty dict. Please pass an argument to the type parameter.')
92 some_key, some_value = next(iter(default.items()))
93 key_type = Primitive(builtins.type(some_key))
94 val_type = Primitive(builtins.type(some_value), allowed_values=allowed_values, unit=unit)
95 type = typing.cast('AbstractFormatter[T]', Dict(key_type, val_type))
96 else:
97 type = Primitive(builtins.type(default), allowed_values=allowed_values, unit=unit)
98 else:
99 if unit is not None:
100 raise TypeError("The keyword argument 'unit' is not supported if 'type' is given. Pass it to the type instead.")
101 if allowed_values is not None:
102 raise TypeError("The keyword argument 'allowed_values' is not supported if 'type' is given. Pass it to the type instead.")
104 type.set_config_key(key)
106 self._key = key
107 self.value = default
108 self.type = type
109 self.help = help
110 self.parent = parent
112 cls = builtins.type(self)
113 if key in cls.instances:
114 raise ValueError(f'duplicate config key {key!r}')
115 cls.instances[key] = self
117 @property
118 def key(self) -> str:
119 '''The name of this setting which is used in the config file. This must be unique.'''
120 return self._key
122 @key.setter
123 def key(self, key: str) -> None:
124 if key in self.instances:
125 raise ValueError(f'duplicate config key {key!r}')
126 del self.instances[self._key]
127 self._key = key
128 self.type.config_key = key
129 self.instances[key] = self
132 @typing.overload
133 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self':
134 pass
136 @typing.overload
137 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> T:
138 pass
140 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'T|Self':
141 if instance is None:
142 return self
144 return self.value
146 def __set__(self: 'Config[T]', instance: typing.Any, value: T) -> None:
147 self.value = value
149 def __repr__(self) -> str:
150 return '%s(%s, ...)' % (type(self).__name__, ', '.join(repr(a) for a in (self.key, self.value)))
152 def set_value(self: 'Config[T]', config_id: 'ConfigId|None', value: T) -> None:
153 '''
154 This method is just to provide a common interface for :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig`.
155 If you know that you are dealing with a normal :class:`~confattr.config.Config` you can set :attr:`~confattr.config.Config.value` directly.
156 '''
157 if config_id is None:
158 config_id = self.default_config_id
159 if config_id != self.default_config_id:
160 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}')
161 self.value = value
163 def wants_to_be_exported(self) -> bool:
164 return True
166 def get_value(self, config_id: 'ConfigId|None') -> T:
167 '''
168 :return: :attr:`~confattr.config.Config.value`
170 This getter is only to have a common interface for :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig`
171 '''
172 return self.value
175class DictConfig(typing.Generic[T_KEY, T]):
177 '''
178 A container for several settings which belong together.
179 It can be indexed like a normal :class:`dict` but internally the items are stored in :class:`~confattr.config.Config` instances.
181 In contrast to a :class:`~confattr.config.Config` instance it does *not* make a difference whether an instance of this class is accessed as a type or instance attribute.
182 '''
184 def __init__(self,
185 key_prefix: str,
186 default_values: 'dict[T_KEY, T]', *,
187 type: 'CopyableAbstractFormatter[T]|None' = None,
188 ignore_keys: 'Container[T_KEY]' = set(),
189 unit: 'str|None' = None,
190 allowed_values: 'Sequence[T]|dict[str, T]|None' = None,
191 help: 'str|None' = None,
192 ) -> None:
193 '''
194 :param key_prefix: A common prefix which is used by :meth:`~confattr.config.DictConfig.format_key` to generate the :attr:`~confattr.config.Config.key` by which the setting is identified in the config file
195 :param default_values: The content of this container. A :class:`~confattr.config.Config` instance is created for each of these values (except if the key is contained in :paramref:`~confattr.config.DictConfig.ignore_keys`). See :meth:`~confattr.config.DictConfig.format_key`.
196 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`default_values`. But if you want more control you can implement your own class and pass it to this parameter.
197 :param ignore_keys: All items which have one of these keys are *not* stored in a :class:`~confattr.config.Config` instance, i.e. cannot be set in the config file.
198 :param unit: The unit of all items (only if type is None)
199 :param allowed_values: The possible values these settings can have. Values read from a config file or an environment variable are checked against this. The :paramref:`~confattr.config.DictConfig.default_values` are *not* checked. (Only if type is None.)
200 :param help: A help for all items
202 :raises ValueError: if a key is not unique
203 '''
204 self._values: 'dict[T_KEY, Config[T]]' = {}
205 self._ignored_values: 'dict[T_KEY, T]' = {}
206 self.allowed_values = allowed_values
208 self.key_prefix = key_prefix
209 self.type = type
210 self.unit = unit
211 self.help = help
212 self.ignore_keys = ignore_keys
214 for key, val in default_values.items():
215 self[key] = val
217 def format_key(self, key: T_KEY) -> str:
218 '''
219 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.
221 :return: :paramref:`~confattr.config.DictConfig.key_prefix` + dot + :paramref:`~confattr.config.DictConfig.format_key.key`
222 '''
223 key_str = format_primitive_value(key)
224 return '%s.%s' % (self.key_prefix, key_str)
226 def __setitem__(self: 'DictConfig[T_KEY, T]', key: T_KEY, val: T) -> None:
227 if key in self.ignore_keys:
228 self._ignored_values[key] = val
229 return
231 c = self._values.get(key)
232 if c is None:
233 self._values[key] = self.new_config(self.format_key(key), val, unit=self.unit, help=self.help)
234 else:
235 c.value = val
237 def new_config(self: 'DictConfig[T_KEY, T]', key: str, default: T, *, unit: 'str|None', help: 'str|dict[T, str]|None') -> Config[T]:
238 '''
239 Create a new :class:`~confattr.config.Config` instance to be used internally
240 '''
241 return Config(key, default, type=self.type.copy() if self.type else None, unit=unit, help=help, parent=self, allowed_values=self.allowed_values)
243 def __getitem__(self, key: T_KEY) -> T:
244 if key in self.ignore_keys:
245 return self._ignored_values[key]
246 else:
247 return self._values[key].value
249 def get(self, key: T_KEY, default: 'T|None' = None) -> 'T|None':
250 try:
251 return self[key]
252 except KeyError:
253 return default
255 def __repr__(self) -> str:
256 values = {key:val.value for key,val in self._values.items()}
257 values.update({key:val for key,val in self._ignored_values.items()})
258 return '%s(%r, ignore_keys=%r, ...)' % (type(self).__name__, values, self.ignore_keys)
260 def __contains__(self, key: T_KEY) -> bool:
261 if key in self.ignore_keys:
262 return key in self._ignored_values
263 else:
264 return key in self._values
266 def __iter__(self) -> 'Iterator[T_KEY]':
267 yield from self._values
268 yield from self._ignored_values
270 def iter_keys(self) -> 'Iterator[str]':
271 '''
272 Iterate over the keys by which the settings can be identified in the config file
273 '''
274 for cfg in self._values.values():
275 yield cfg.key
278# ========== settings which can have different values for different groups ==========
280class MultiConfig(Config[T]):
282 '''
283 A setting which can have different values for different objects.
285 This class implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`__ to return one of the values in :attr:`~confattr.config.MultiConfig.values` depending on a ``config_id`` attribute of the owning object if an instance of this class is accessed as an instance attribute.
286 If there is no value for the ``config_id`` in :attr:`~confattr.config.MultiConfig.values` :attr:`~confattr.config.MultiConfig.value` is returned instead.
287 If the owning instance does not have a ``config_id`` attribute an :class:`AttributeError` is raised.
289 In the config file a group can be opened with ``[config-id]``.
290 Then all following ``set`` commands set the value for the specified config id.
291 '''
293 #: 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:`~confattr.config.MultiConfig.reset`.
294 config_ids: 'list[ConfigId]' = []
296 #: Stores the values for specific objects.
297 values: 'dict[ConfigId, T]'
299 #: Stores the default value which is used if no value for the object is defined in :attr:`~confattr.config.MultiConfig.values`.
300 value: 'T'
302 #: The callable which has been passed to the constructor as :paramref:`~confattr.config.MultiConfig.check_config_id`
303 check_config_id: 'Callable[[MultiConfig[T], ConfigId], None]|None'
305 @classmethod
306 def reset(cls) -> None:
307 '''
308 Clear :attr:`~confattr.config.MultiConfig.config_ids` and clear :attr:`~confattr.config.MultiConfig.values` for all instances in :attr:`Config.instances <confattr.config.Config.instances>`
309 '''
310 cls.config_ids.clear()
311 for cfg in Config.instances.values():
312 if isinstance(cfg, MultiConfig):
313 cfg.values.clear()
315 def __init__(self,
316 key: str,
317 default: T, *,
318 type: 'AbstractFormatter[T]|None' = None,
319 unit: 'str|None' = None,
320 allowed_values: 'Sequence[T]|dict[str, T]|None' = None,
321 help: 'str|dict[T, str]|None' = None,
322 parent: 'MultiDictConfig[typing.Any, T]|None' = None,
323 check_config_id: 'Callable[[MultiConfig[T], ConfigId], None]|None' = None,
324 ) -> None:
325 '''
326 :param key: The name of this setting in the config file
327 :param default: The default value of this setting
328 :param help: A description of this setting
329 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`~confattr.config.MultiConfig.default`. But if :paramref:`~confattr.config.MultiConfig.default` is an empty list the item type cannot be determined automatically so that this argument must be passed explicitly. This also gives the possibility to format a standard type differently e.g. as :class:`~confattr.formatters.Hex`. It is not permissible to reuse the same object for different settings, otherwise :meth:`AbstractFormatter.set_config_key() <confattr.formatters.AbstractFormatter.set_config_key>` will throw an exception.
330 :param unit: The unit of an int or float value (only if type is None)
331 :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:`~confattr.config.MultiConfig.default` value is *not* checked. (Only if type is None.)
332 :param parent: Applies only if this is part of a :class:`~confattr.config.MultiDictConfig`
333 :param check_config_id: Is called every time a value is set in the config file (except if the config id is :attr:`~confattr.config.Config.default_config_id`—that is always allowed). The callback should raise a :class:`~confattr.configfile.ParseException` if the config id is invalid.
334 '''
335 super().__init__(key, default, type=type, unit=unit, help=help, parent=parent, allowed_values=allowed_values)
336 self.values: 'dict[ConfigId, T]' = {}
337 self.check_config_id = check_config_id
339 # I don't know why this code duplication is necessary,
340 # I have declared the overloads in the parent class already.
341 # But without copy-pasting this code mypy complains
342 # "Signature of __get__ incompatible with supertype Config"
343 @typing.overload
344 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self':
345 pass
347 @typing.overload
348 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> T:
349 pass
351 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'T|Self':
352 if instance is None:
353 return self
355 return self.values.get(instance.config_id, self.value)
357 def __set__(self: 'MultiConfig[T]', instance: typing.Any, value: T) -> None:
358 config_id = instance.config_id
359 self.values[config_id] = value
360 if config_id not in self.config_ids:
361 self.config_ids.append(config_id)
363 def set_value(self: 'MultiConfig[T]', config_id: 'ConfigId|None', value: T) -> None:
364 '''
365 Check :paramref:`~confattr.config.MultiConfig.set_value.config_id` by calling :meth:`~confattr.config.MultiConfig.check_config_id` and
366 set the value for the object(s) identified by :paramref:`~confattr.config.MultiConfig.set_value.config_id`.
368 If you know that :paramref:`~confattr.config.MultiConfig.set_value.config_id` is valid you can also change the items of :attr:`~confattr.config.MultiConfig.values` directly.
369 That is especially useful in test automation with :meth:`pytest.MonkeyPatch.setitem`.
371 If you want to set the default value you can also set :attr:`~confattr.config.MultiConfig.value` directly.
373 :param config_id: Identifies the object(s) for which :paramref:`~confattr.config.MultiConfig.set_value.value` is intended. :obj:`None` is equivalent to :attr:`~confattr.config.MultiConfig.default_config_id`.
374 :param value: The value to be assigned for the object(s) identified by :paramref:`~confattr.config.MultiConfig.set_value.config_id`.
375 '''
376 if config_id is None:
377 config_id = self.default_config_id
378 if self.check_config_id and config_id != self.default_config_id:
379 self.check_config_id(self, config_id)
380 if config_id == self.default_config_id:
381 self.value = value
382 else:
383 self.values[config_id] = value
384 if config_id not in self.config_ids:
385 self.config_ids.append(config_id)
387 def get_value(self, config_id: 'ConfigId|None') -> T:
388 '''
389 :return: The corresponding value from :attr:`~confattr.config.MultiConfig.values` if :paramref:`~confattr.config.MultiConfig.get_value.config_id` is contained or :attr:`~confattr.config.MultiConfig.value` otherwise
390 '''
391 if config_id is None:
392 config_id = self.default_config_id
393 return self.values.get(config_id, self.value)
396class MultiDictConfig(DictConfig[T_KEY, T]):
398 '''
399 A container for several settings which can have different values for different objects.
401 This is essentially a :class:`~confattr.config.DictConfig` using :class:`~confattr.config.MultiConfig` instead of normal :class:`~confattr.config.Config`.
402 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:`~confattr.config.InstanceSpecificDictMultiConfig` if it is accessed as an instance attribute.
403 '''
405 def __init__(self,
406 key_prefix: str,
407 default_values: 'dict[T_KEY, T]', *,
408 type: 'CopyableAbstractFormatter[T]|None' = None,
409 ignore_keys: 'Container[T_KEY]' = set(),
410 unit: 'str|None' = None,
411 allowed_values: 'Sequence[T]|dict[str, T]|None' = None,
412 help: 'str|None' = None,
413 check_config_id: 'Callable[[MultiConfig[T], ConfigId], None]|None' = None,
414 ) -> None:
415 '''
416 :param key_prefix: A common prefix which is used by :meth:`~confattr.config.MultiDictConfig.format_key` to generate the :attr:`~confattr.config.Config.key` by which the setting is identified in the config file
417 :param default_values: The content of this container. A :class:`~confattr.config.Config` instance is created for each of these values (except if the key is contained in :paramref:`~confattr.config.MultiDictConfig.ignore_keys`). See :meth:`~confattr.config.MultiDictConfig.format_key`.
418 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`default_values`. But if you want more control you can implement your own class and pass it to this parameter.
419 :param ignore_keys: All items which have one of these keys are *not* stored in a :class:`~confattr.config.Config` instance, i.e. cannot be set in the config file.
420 :param unit: The unit of all items (only if type is None)
421 :param allowed_values: The possible values these settings can have. Values read from a config file or an environment variable are checked against this. The :paramref:`~confattr.config.MultiDictConfig.default_values` are *not* checked. (Only if type is None.)
422 :param help: A help for all items
423 :param check_config_id: Is passed through to :class:`~confattr.config.MultiConfig`
425 :raises ValueError: if a key is not unique
426 '''
427 self.check_config_id = check_config_id
428 super().__init__(
429 key_prefix = key_prefix,
430 default_values = default_values,
431 type = type,
432 ignore_keys = ignore_keys,
433 unit = unit,
434 help = help,
435 allowed_values = allowed_values,
436 )
438 @typing.overload
439 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self':
440 pass
442 @typing.overload
443 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'InstanceSpecificDictMultiConfig[T_KEY, T]':
444 pass
446 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'InstanceSpecificDictMultiConfig[T_KEY, T]|Self':
447 if instance is None:
448 return self
450 return InstanceSpecificDictMultiConfig(self, instance.config_id)
452 def __set__(self: 'MultiDictConfig[T_KEY, T]', instance: typing.Any, value: 'InstanceSpecificDictMultiConfig[T_KEY, T]') -> typing.NoReturn:
453 raise NotImplementedError()
455 def new_config(self: 'MultiDictConfig[T_KEY, T]', key: str, default: T, *, unit: 'str|None', help: 'str|dict[T, str]|None') -> MultiConfig[T]:
456 return MultiConfig(key, default, type=self.type.copy() if self.type else None, unit=unit, help=help, parent=self, allowed_values=self.allowed_values, check_config_id=self.check_config_id)
458class InstanceSpecificDictMultiConfig(typing.Generic[T_KEY, T]):
460 '''
461 An intermediate instance which is returned when accsessing
462 a :class:`~confattr.config.MultiDictConfig` as an instance attribute.
463 Can be indexed like a normal :class:`dict`.
464 '''
466 def __init__(self, mdc: 'MultiDictConfig[T_KEY, T]', config_id: ConfigId) -> None:
467 self.mdc = mdc
468 self.config_id = config_id
470 def __setitem__(self: 'InstanceSpecificDictMultiConfig[T_KEY, T]', key: T_KEY, val: T) -> None:
471 if key in self.mdc.ignore_keys:
472 raise TypeError('cannot set value of ignored key %r' % key)
474 c = self.mdc._values.get(key)
475 if c is None:
476 self.mdc._values[key] = MultiConfig(self.mdc.format_key(key), val, help=self.mdc.help)
477 else:
478 c.__set__(self, val)
480 def __getitem__(self, key: T_KEY) -> T:
481 if key in self.mdc.ignore_keys:
482 return self.mdc._ignored_values[key]
483 else:
484 return self.mdc._values[key].__get__(self)