Coverage for .tox/cov/lib/python3.11/site-packages/confattr/formatters.py: 100%
337 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-30 12:07 +0100
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-30 12:07 +0100
1#!/usr/bin/env python3
3import re
4import copy
5import abc
6import enum
7import typing
8import builtins
9from collections.abc import Iterable, Iterator, Sequence, Mapping, Callable
11if typing.TYPE_CHECKING:
12 from .configfile import ConfigFile
13 from typing_extensions import Self
15try:
16 Collection = typing.Collection
17except: # pragma: no cover
18 from collections.abc import Collection
21TYPES_REQUIRING_UNIT = {int, float}
23VALUE_TRUE = 'true'
24VALUE_FALSE = 'false'
26def format_primitive_value(value: object) -> str:
27 if isinstance(value, enum.Enum):
28 return value.name.lower().replace('_', '-')
29 if isinstance(value, bool):
30 return VALUE_TRUE if value else VALUE_FALSE
31 return str(value)
34# mypy rightfully does not allow AbstractFormatter to be declared as covariant with respect to T because
35# def format_value(self, t: AbstractFormatter[object], val: object):
36# return t.format_value(self, val)
37# ...
38# config_file.format_value(Hex(), "boom")
39# would typecheck ok but crash
40T = typing.TypeVar('T')
42class AbstractFormatter(typing.Generic[T]):
44 '''
45 An abstract base class for classes which define how to parse, format and complete a value.
46 Instances of (subclasses of this class) can be passed to the :paramref:`~confattr.config.Config.type` attribute of settings.
47 '''
49 config_key: 'str|None' = None
51 @abc.abstractmethod
52 def format_value(self, config_file: 'ConfigFile', value: 'T') -> str:
53 raise NotImplementedError()
55 @abc.abstractmethod
56 def expand_value(self, config_file: 'ConfigFile', value: 'T', format_spec: str) -> str:
57 '''
58 :param config_file: has e.g. the :attr:`~confattr.configfile.ConfigFile.ITEM_SEP` attribute
59 :param value: The value to be formatted
60 :param format_spec: A format specifier
61 :return: :paramref:`~confattr.formatters.AbstractFormatter.expand_value.value` formatted according to :paramref:`~confattr.formatters.AbstractFormatter.expand_value.format_spec`
62 :raises ValueError, LookupError: If :paramref:`~confattr.formatters.AbstractFormatter.expand_value.format_spec` is invalid
63 '''
64 raise NotImplementedError()
66 @abc.abstractmethod
67 def parse_value(self, config_file: 'ConfigFile', value: str) -> 'T':
68 '''
69 :param config_file: Is needed e.g. to call :meth:`~confattr.formatters.AbstractFormatter.get_description` in error messages
70 :param value: The value to be parsed
71 :return: The parsed value
72 :raises ValueError: If value cannot be parsed
73 '''
74 raise NotImplementedError()
76 @abc.abstractmethod
77 def get_description(self, config_file: 'ConfigFile') -> str:
78 raise NotImplementedError()
80 @abc.abstractmethod
81 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
82 raise NotImplementedError()
84 @abc.abstractmethod
85 def get_primitives(self) -> 'Sequence[Primitive[typing.Any]]':
86 '''
87 If self is a Primitive data type, return self.
88 If self is a Collection, return self.item_type.
89 '''
90 raise NotImplementedError()
92 def set_config_key(self, config_key: str) -> None:
93 '''
94 In order to generate a useful error message if parsing a value fails the key of the setting is required.
95 This method is called by the constructor of :class:`~confattr.config.Config`.
96 This method must not be called more than once.
98 :raises TypeError: If :attr:`~confattr.formatters.AbstractFormatter.config_key` has already been set.
99 '''
100 if self.config_key:
101 raise TypeError(f"config_key has already been set to {self.config_key!r}, not setting to {config_key!r}")
102 self.config_key = config_key
105class CopyableAbstractFormatter(AbstractFormatter[T]):
107 @abc.abstractmethod
108 def copy(self) -> 'Self':
109 raise NotImplementedError()
112class Primitive(CopyableAbstractFormatter[T]):
114 PATTERN_ONE_OF = "one of {}"
115 PATTERN_ALLOWED_VALUES_UNIT = "{allowed_values} (unit: {unit})"
116 PATTERN_TYPE_UNIT = "{type} in {unit}"
118 #: Help for data types. This is used by :meth:`~confattr.formatters.Primitive.get_help`.
119 help_dict: 'dict[type[typing.Any]|Callable[..., typing.Any], str]' = {
120 str : 'A text. If it contains spaces it must be wrapped in single or double quotes.',
121 int : '''\
122 An integer number in python 3 syntax, as decimal (e.g. 42), hexadecimal (e.g. 0x2a), octal (e.g. 0o52) or binary (e.g. 0b101010).
123 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers.
124 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.''',
125 #bool,
126 float : 'A floating point number in python syntax, e.g. 23, 1.414, -1e3, 3.14_15_93.',
127 }
130 #: If this is set it is used in :meth:`~confattr.formatters.Primitive.get_description` and the list of possible values is moved to the output of :meth:`~confattr.formatters.Primitive.get_help`.
131 type_name: 'str|None'
133 #: The unit of a number
134 unit: 'str|None'
136 #: :class:`str`, :class:`int`, :class:`float`, :class:`bool`, a subclass of :class:`enum.Enum` or any class that follows the pattern of :class:`confattr.types.AbstractType`
137 type: 'type[T]|Callable[..., T]'
139 #: If this is set and a value read from a config file is not contained it is considered invalid. If this is a mapping the keys are the string representations used in the config file.
140 allowed_values: 'Collection[T]|dict[str, T]|None'
142 def __init__(self, type: 'builtins.type[T]|Callable[..., T]', *, allowed_values: 'Collection[T]|dict[str, T]|None' = None, unit: 'str|None' = None, type_name: 'str|None' = None) -> None:
143 '''
144 :param type: :class:`str`, :class:`int`, :class:`float`, :class:`bool`, a subclass of :class:`enum.Enum` or any class which looks like :class:`~confattr.types.AbstractType`
145 :param unit: The unit of an int or float value
146 :param allowed_values: The possible values this setting can have. Values read from a config file or an environment variable are checked against this.
147 :param type_name: A name for this type which is used in the config file.
148 '''
149 if type in TYPES_REQUIRING_UNIT and unit is None and not isinstance(allowed_values, dict):
150 raise TypeError(f"missing argument unit for {self.config_key}, pass an empty string if the number really has no unit")
152 self.type = type
153 self.type_name = type_name
154 self.allowed_values = allowed_values
155 self.unit = unit
157 def copy(self) -> 'Self':
158 out = copy.copy(self)
159 out.config_key = None
160 return out
162 def format_value(self, config_file: 'ConfigFile', value: 'T') -> str:
163 if isinstance(self.allowed_values, dict):
164 for key, val in self.allowed_values.items():
165 if val == value:
166 return key
167 raise ValueError('%r is not an allowed value, should be one of %s' % (value, ', '.join(repr(v) for v in self.allowed_values.values())))
169 if isinstance(value, str):
170 return value.replace('\n', r'\n')
172 return format_primitive_value(value)
174 def expand_value(self, config_file: 'ConfigFile', value: 'T', format_spec: str) -> str:
175 '''
176 This method simply calls the builtin :func:`format`.
177 '''
178 return format(value, format_spec)
180 def parse_value(self, config_file: 'ConfigFile', value: str) -> 'T':
181 if isinstance(self.allowed_values, dict):
182 try:
183 return self.allowed_values[value]
184 except KeyError:
185 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
186 elif self.type is str:
187 value = value.replace(r'\n', '\n')
188 out = typing.cast(T, value)
189 elif self.type is int:
190 out = typing.cast(T, int(value, base=0))
191 elif self.type is float:
192 out = typing.cast(T, float(value))
193 elif self.type is bool:
194 if value == VALUE_TRUE:
195 out = typing.cast(T, True)
196 elif value == VALUE_FALSE:
197 out = typing.cast(T, False)
198 else:
199 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
200 elif isinstance(self.type, type) and issubclass(self.type, enum.Enum):
201 for i in self.type:
202 enum_item = typing.cast(T, i)
203 if self.format_value(config_file, enum_item) == value:
204 out = enum_item
205 break
206 else:
207 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
208 else:
209 try:
210 out = self.type(value) # type: ignore [call-arg]
211 except Exception as e:
212 raise ValueError(f'invalid value for {self.config_key}: {value!r} ({e})')
214 if self.allowed_values is not None and out not in self.allowed_values:
215 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
216 return out
219 def get_description(self, config_file: 'ConfigFile', *, plural: bool = False, article: bool = True) -> str:
220 '''
221 :param config_file: May contain some additional information how to format the allowed values.
222 :param plural: Whether the return value should be a plural form.
223 :param article: Whether the return value is supposed to be formatted with :meth:`~confattr.formatters.Primitive.format_indefinite_singular_article` (if :meth:`~confattr.formatters.Primitive.get_type_name` is used) or :attr:`~confattr.formatters.Primitive.PATTERN_ONE_OF` (if :meth:`~confattr.formatters.Primitive.get_allowed_values` returns an empty sequence). This is assumed to be false if :paramref:`~confattr.formatters.Primitive.get_description.plural` is true.
224 :return: A short description which is displayed in the help/comment for each setting explaining what kind of value is expected.
225 In the easiest case this is just a list of allowed value, e.g. "one of true, false".
226 If :attr:`~confattr.formatters.Primitive.type_name` has been passed to the constructor this is used instead and the list of possible values is moved to the output of :meth:`~confattr.formatters.Primitive.get_help`.
227 If a unit is specified it is included, e.g. "an int in km/h".
229 You can customize the return value of this method by overriding :meth:`~confattr.formatters.Primitive.get_type_name`, :meth:`~confattr.formatters.Primitive.join` or :meth:`~confattr.formatters.Primitive.format_indefinite_singular_article`
230 or by changing the value of :attr:`~confattr.formatters.Primitive.PATTERN_ONE_OF`, :attr:`~confattr.formatters.Primitive.PATTERN_ALLOWED_VALUES_UNIT` or :attr:`~confattr.formatters.Primitive.PATTERN_TYPE_UNIT`.
231 '''
232 if plural:
233 article = False
235 if not self.type_name:
236 out = self.format_allowed_values(config_file, article=article)
237 if out:
238 return out
240 out = self.get_type_name()
241 if self.unit:
242 out = self.PATTERN_TYPE_UNIT.format(type=out, unit=self.unit)
243 if article:
244 out = self.format_indefinite_singular_article(out)
245 return out
247 def format_allowed_values(self, config_file: 'ConfigFile', *, article: bool = True) -> 'str|None':
248 allowed_values = self.get_allowed_values()
249 if not allowed_values:
250 return None
252 out = self.join(self.format_value(config_file, v) for v in allowed_values)
253 if article:
254 out = self.PATTERN_ONE_OF.format(out)
255 if self.unit:
256 out = self.PATTERN_ALLOWED_VALUES_UNIT.format(allowed_values=out, unit=self.unit)
257 return out
259 def get_type_name(self) -> str:
260 '''
261 Return the name of this type (without :attr:`~confattr.formatters.Primitive.unit` or :attr:`~confattr.formatters.Primitive.allowed_values`).
262 This can be used in :meth:`~confattr.formatters.Primitive.get_description` if the type can have more than just a couple of values.
263 If that is the case a help should be provided by :meth:`~confattr.formatters.Primitive.get_help`.
265 :return: :paramref:`~confattr.formatters.Primitive.type_name` if it has been passed to the constructor, the value of an attribute of :attr:`~confattr.formatters.Primitive.type` called ``type_name`` if existing or the lower case name of the class stored in :attr:`~confattr.formatters.Primitive.type` otherwise
266 '''
267 if self.type_name:
268 return self.type_name
269 return getattr(self.type, 'type_name', self.type.__name__.lower())
271 def join(self, names: 'Iterable[str]') -> str:
272 '''
273 Join several values which have already been formatted with :meth:`~confattr.formatters.Primitive.format_value`.
274 '''
275 return ', '.join(names)
277 def format_indefinite_singular_article(self, type_name: str) -> str:
278 '''
279 Getting the article right is not so easy, so a user can specify the correct article with a str attribute called ``type_article``.
280 Alternatively this method can be overridden.
281 This also gives the possibility to omit the article.
282 https://en.wiktionary.org/wiki/Appendix:English_articles#Indefinite_singular_articles
284 This is used in :meth:`~confattr.formatters.Primitive.get_description`.
285 '''
286 if hasattr(self.type, 'type_article'):
287 article = getattr(self.type, 'type_article')
288 if not article:
289 return type_name
290 assert isinstance(article, str)
291 return article + ' ' + type_name
292 if type_name[0].lower() in 'aeio':
293 return 'an ' + type_name
294 return 'a ' + type_name
297 def get_help(self, config_file: 'ConfigFile') -> 'str|None':
298 '''
299 The help for the generic data type, independent of the unit.
300 This is displayed once at the top of the help or the config file (if one or more settings use this type).
302 For example the help for an int might be:
304 An integer number in python 3 syntax, as decimal (e.g. 42), hexadecimal (e.g. 0x2a), octal (e.g. 0o52) or binary (e.g. 0b101010).
305 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers.
306 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.
308 Return None if (and only if) :meth:`~confattr.formatters.Primitive.get_description` returns a simple list of all possible values and not :meth:`~confattr.formatters.Primitive.get_type_name`.
310 :return: The corresponding value in :attr:`~confattr.formatters.Primitive.help_dict`, the value of an attribute called ``help`` on the :attr:`~confattr.formatters.Primitive.type` or None if the return value of :meth:`~confattr.formatters.Primitive.get_allowed_values` is empty.
311 :raises TypeError: If the ``help`` attribute is not a str. If you have no influence over this attribute you can avoid checking it by adding a corresponding value to :attr:`~confattr.formatters.Primitive.help_dict`.
312 :raises NotImplementedError: If there is no help or list of allowed values. If this is raised add a ``help`` attribute to the class or a value for it in :attr:`~confattr.formatters.Primitive.help_dict`.
313 '''
315 if self.type_name:
316 allowed_values = self.format_allowed_values(config_file)
317 if not allowed_values:
318 raise NotImplementedError("used 'type_name' without 'allowed_values', please override 'get_help'")
319 return allowed_values[:1].upper() + allowed_values[1:]
321 if self.type in self.help_dict:
322 return self.help_dict[self.type]
323 elif hasattr(self.type, 'help'):
324 out = getattr(self.type, 'help')
325 if not isinstance(out, str):
326 raise TypeError(f"help attribute of {self.type.__name__!r} has invalid type {type(out).__name__!r}, if you cannot change that attribute please add an entry in Primitive.help_dict")
327 return out
328 elif self.get_allowed_values():
329 return None
330 else:
331 raise NotImplementedError('No help for type %s' % self.get_type_name())
334 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
335 completions = [config_file.quote(config_file.format_any_value(self, val)) for val in self.get_allowed_values()]
336 completions = [v for v in completions if v.startswith(start)]
337 return start_of_line, completions, end_of_line
339 def get_allowed_values(self) -> 'Collection[T]':
340 if isinstance(self.allowed_values, dict):
341 return self.allowed_values.values()
342 if self.allowed_values:
343 return self.allowed_values
344 if self.type is bool:
345 return (typing.cast(T, True), typing.cast(T, False))
346 if isinstance(self.type, type) and issubclass(self.type, enum.Enum):
347 return self.type
348 if hasattr(self.type, 'get_instances'):
349 return self.type.get_instances() # type: ignore [union-attr,no-any-return] # mypy does not understand that I have just checked the existence of get_instances
350 return ()
352 def get_primitives(self) -> 'tuple[Self]':
353 return (self,)
355class Hex(Primitive[int]):
357 def __init__(self, *, allowed_values: 'Collection[int]|None' = None) -> None:
358 super().__init__(int, allowed_values=allowed_values, unit='')
360 def format_value(self, config_file: 'ConfigFile', value: int) -> str:
361 return '%X' % value
363 def parse_value(self, config_file: 'ConfigFile', value: str) -> int:
364 return int(value, base=16)
366 def get_description(self, config_file: 'ConfigFile', *, plural: bool = False, article: bool = True) -> str:
367 out = 'hexadecimal number'
368 if plural:
369 out += 's'
370 elif article:
371 out = 'a ' + out
372 return out
374 def get_help(self, config_file: 'ConfigFile') -> None:
375 return None
378class AbstractCollection(AbstractFormatter[Collection[T]]):
380 def __init__(self, item_type: 'Primitive[T]') -> None:
381 self.item_type = item_type
383 def split_values(self, config_file: 'ConfigFile', values: str) -> 'Iterable[str]':
384 if not values:
385 return []
386 return values.split(config_file.ITEM_SEP)
388 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
389 if config_file.ITEM_SEP in start:
390 first, start = start.rsplit(config_file.ITEM_SEP, 1)
391 start_of_line += first + config_file.ITEM_SEP
392 return self.item_type.get_completions(config_file, start_of_line, start, end_of_line)
394 def get_primitives(self) -> 'tuple[Primitive[T]]':
395 return (self.item_type,)
397 def set_config_key(self, config_key: str) -> None:
398 super().set_config_key(config_key)
399 self.item_type.set_config_key(config_key)
402 # ------- expand ------
404 def expand_value(self, config_file: 'ConfigFile', values: 'Collection[T]', format_spec: str) -> str:
405 '''
406 :paramref:`~confattr.formatters.AbstractCollection.expand_value.format_spec` supports the following features:
408 - Filter out some values, e.g. ``-foo,bar`` expands to all items except for ``foo`` and ``bar``, it is no error if ``foo`` or ``bar`` are not contained
409 - Get the length, ``len`` expands to the number of items
410 - Get extreme values, ``min`` expands to the smallest item and ``max`` expands to the biggest item, raises :class:`TypeError` if the items are not comparable
412 To any of the above you can append another format_spec after a colon to specify how to format the items/the length.
413 '''
414 m = re.match(r'(-(?P<exclude>[^[:]*)|(?P<func>[^[:]*))(:(?P<format_spec>.*))?$', format_spec)
415 if m is None:
416 raise ValueError('Invalid format_spec for collection: %r' % format_spec)
418 format_spec = m.group('format_spec') or ''
419 func = m.group('func')
420 if func == 'len':
421 return self.expand_length(config_file, values, format_spec)
422 elif func:
423 return self.expand_min_max(config_file, values, func, format_spec)
425 exclude = m.group('exclude')
426 if exclude:
427 return self.expand_exclude_items(config_file, values, exclude, format_spec)
429 return self.expand_parsed_items(config_file, values, format_spec)
431 def expand_length(self, config_file: 'ConfigFile', values: 'Collection[T]', int_format_spec: str) -> str:
432 return format(len(values), int_format_spec)
434 def expand_min_max(self, config_file: 'ConfigFile', values: 'Collection[T]', func: str, item_format_spec: str) -> str:
435 if func == 'min':
436 v = min(values) # type: ignore [type-var] # The TypeError is caught in ConfigFile.expand_config_match
437 elif func == 'max':
438 v = max(values) # type: ignore [type-var] # The TypeError is caught in ConfigFile.expand_config_match
439 else:
440 raise ValueError(f'Invalid format_spec for collection: {func!r}')
442 return self.expand_parsed_items(config_file, [v], item_format_spec)
444 def expand_exclude_items(self, config_file: 'ConfigFile', values: 'Collection[T]', items_to_be_excluded: str, item_format_spec: str) -> str:
445 exclude = {self.item_type.parse_value(config_file, item) for item in items_to_be_excluded.split(',')}
446 out = [v for v in values if v not in exclude]
447 return self.expand_parsed_items(config_file, out, item_format_spec)
449 def expand_parsed_items(self, config_file: 'ConfigFile', values: 'Collection[T]', item_format_spec: str) -> str:
450 if not item_format_spec:
451 return self.format_value(config_file, values)
452 return config_file.ITEM_SEP.join(format(v, item_format_spec) for v in values)
454class List(AbstractCollection[T]):
456 def get_description(self, config_file: 'ConfigFile') -> str:
457 return 'a comma separated list of ' + self.item_type.get_description(config_file, plural=True)
459 def format_value(self, config_file: 'ConfigFile', values: 'Collection[T]') -> str:
460 return config_file.ITEM_SEP.join(config_file.format_any_value(self.item_type, i) for i in values)
462 def expand_value(self, config_file: 'ConfigFile', values: 'Sequence[T]', format_spec: str) -> str: # type: ignore [override] # supertype defines the argument type as "Collection[T]", yes because type vars depending on other type vars is not supported yet https://github.com/python/typing/issues/548
463 '''
464 :paramref:`~confattr.formatters.List.expand_value.format_spec` supports all features inherited from :meth:`AbstractCollection.expand_value() <confattr.formatters.AbstractCollection.expand_value>` as well as the following:
466 - Access a single item, e.g. ``[0]`` expands to the first item, ``[-1]`` expands to the last item [1]
467 - Access several items, e.g. ``[0,2,5]`` expands to the items at index 0, 2 and 5, if the list is not that long an :class:`IndexError` is raised
468 - Access a slice of items, e.g. ``[:3]`` expands to the first three items or to as many items as the list is long if the list is not that long [1]
469 - Access a slice of items with a step, e.g. ``[::-1]`` expands to all items in reverse order [1]
471 To any of the above you can append another format_spec after a colon to specify how to format the items.
473 [1] For more information see the `common slicing operations of sequences <https://docs.python.org/3/library/stdtypes.html#common-sequence-operations>`__.
474 '''
475 m = re.match(r'(\[(?P<indices>[^]]+)\])(:(?P<format_spec>.*))?$', format_spec)
476 if m is None:
477 return super().expand_value(config_file, values, format_spec)
479 format_spec = m.group('format_spec') or ''
480 indices = m.group('indices')
481 assert isinstance(indices, str)
482 return self.expand_items(config_file, values, indices, format_spec)
484 def expand_items(self, config_file: 'ConfigFile', values: 'Sequence[T]', indices: str, item_format_spec: str) -> str:
485 out = [v for sl in self.parse_slices(indices) for v in values[sl]]
486 return self.expand_parsed_items(config_file, out, item_format_spec)
488 def parse_slices(self, indices: str) -> 'Iterator[slice]':
489 for s in indices.split(','):
490 yield self.parse_slice(s)
492 def parse_slice(self, s: str) -> 'slice':
493 sl = [int(i) if i else None for i in s.split(':')]
494 if len(sl) == 1 and isinstance(sl[0], int):
495 i = sl[0]
496 return slice(i, i+1)
497 return slice(*sl)
499 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'list[T]':
500 return [self.item_type.parse_value(config_file, i) for i in self.split_values(config_file, values)]
502class Set(AbstractCollection[T]):
504 def get_description(self, config_file: 'ConfigFile') -> str:
505 return 'a comma separated set of ' + self.item_type.get_description(config_file, plural=True)
507 def format_value(self, config_file: 'ConfigFile', values: 'Collection[T]') -> str:
508 try:
509 sorted_values = sorted(values) # type: ignore [type-var] # values may be not comparable but that's what the try/except is there for
510 except TypeError:
511 return config_file.ITEM_SEP.join(sorted(config_file.format_any_value(self.item_type, i) for i in values))
513 return config_file.ITEM_SEP.join(config_file.format_any_value(self.item_type, i) for i in sorted_values)
515 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'set[T]':
516 return {self.item_type.parse_value(config_file, i) for i in self.split_values(config_file, values)}
519T_key = typing.TypeVar('T_key')
520T_val = typing.TypeVar('T_val')
521class Dict(AbstractFormatter['dict[T_key, T_val]']):
523 def __init__(self, key_type: 'Primitive[T_key]', value_type: 'Primitive[T_val]') -> None:
524 self.key_type = key_type
525 self.value_type = value_type
527 def get_description(self, config_file: 'ConfigFile') -> str:
528 return 'a dict of %s:%s' % (self.key_type.get_description(config_file, article=False), self.value_type.get_description(config_file, article=False))
530 def format_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]') -> str:
531 return config_file.ITEM_SEP.join(config_file.format_any_value(self.key_type, key) + config_file.KEY_SEP + config_file.format_any_value(self.value_type, val) for key, val in values.items())
533 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'dict[T_key, T_val]':
534 return dict(self.parse_item(config_file, i) for i in self.split_values(config_file, values))
536 def split_values(self, config_file: 'ConfigFile', values: str) -> 'Iterable[str]':
537 return values.split(config_file.ITEM_SEP)
539 def parse_item(self, config_file: 'ConfigFile', item: str) -> 'tuple[T_key, T_val]':
540 key_name, val_name = item.split(config_file.KEY_SEP, 1)
541 key = self.key_type.parse_value(config_file, key_name)
542 val = self.value_type.parse_value(config_file, val_name)
543 return key, val
545 def get_primitives(self) -> 'tuple[Primitive[T_key], Primitive[T_val]]':
546 return (self.key_type, self.value_type)
548 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
549 if config_file.ITEM_SEP in start:
550 first, start = start.rsplit(config_file.ITEM_SEP, 1)
551 start_of_line += first + config_file.ITEM_SEP
552 if config_file.KEY_SEP in start:
553 first, start = start.rsplit(config_file.KEY_SEP, 1)
554 start_of_line += first + config_file.KEY_SEP
555 return self.value_type.get_completions(config_file, start_of_line, start, end_of_line)
557 return self.key_type.get_completions(config_file, start_of_line, start, end_of_line)
559 def expand_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', format_spec: str) -> str:
560 '''
561 :paramref:`~confattr.formatters.Dict.expand_value.format_spec` supports the following features:
563 - Get a single value, e.g. ``[key1]`` expands to the value corresponding to ``key1``, a :class:`KeyError` is raised if ``key1`` is not contained in the dict
564 - Get a single value or a default value, e.g. ``[key1|default]`` expands to the value corresponding to ``key1`` or to ``default`` if ``key1`` is not contained
565 - Get values with their corresponding keys, e.g. ``{key1,key2}`` expands to ``key1:val1,key2:val2``, if a key is not contained it is skipped
566 - Filter out elements, e.g. ``{^key1}`` expands to all ``key:val`` pairs except for ``key1``
567 - Get the length, ``len`` expands to the number of items
569 To any of the above you can append another format_spec after a colon to specify how to format the items/the length.
570 '''
571 m = re.match(r'(\[(?P<key>[^]|]+)(\|(?P<default>[^]]+))?\]|\{\^(?P<filter>[^}]+)\}|\{(?P<select>[^}]*)\}|(?P<func>[^[{:]+))(:(?P<format_spec>.*))?$', format_spec)
572 if m is None:
573 raise ValueError('Invalid format_spec for dict: %r' % format_spec)
575 item_format_spec = m.group('format_spec') or ''
577 key = m.group('key')
578 if key:
579 default = m.group('default')
580 return self.expand_single_value(config_file, values, key, default, item_format_spec)
582 keys_filter = m.group('filter')
583 if keys_filter:
584 return self.expand_filter(config_file, values, keys_filter, item_format_spec)
586 keys_select = m.group('select')
587 if keys_select:
588 return self.expand_select(config_file, values, keys_select, item_format_spec)
590 func = m.group('func')
591 if func == 'len':
592 return self.expand_length(config_file, values, item_format_spec)
594 raise ValueError('Invalid format_spec for dict: %r' % format_spec)
596 def expand_single_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', key: str, default: 'str|None', item_format_spec: str) -> str:
597 '''
598 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``[key]`` or ``[key|default]``.
599 '''
600 parsed_key = self.key_type.parse_value(config_file, key)
601 try:
602 v = values[parsed_key]
603 except KeyError:
604 if default is not None:
605 return default
606 # The message of a KeyError is the repr of the missing key, nothing more.
607 # Therefore I am raising a new exception with a more descriptive message.
608 # I am not using KeyError because that takes the repr of the argument.
609 raise LookupError(f"key {key!r} is not contained in {self.config_key!r}")
611 if not item_format_spec:
612 return self.value_type.format_value(config_file, v)
613 return format(v, item_format_spec)
615 def expand_filter(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', keys_filter: str, item_format_spec: str) -> str:
616 '''
617 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``{^key1,key2}``.
618 '''
619 parsed_filter_keys = {self.key_type.parse_value(config_file, key) for key in keys_filter.split(',')}
620 values = {k:v for k,v in values.items() if k not in parsed_filter_keys}
621 return self.expand_selected(config_file, values, item_format_spec)
623 def expand_select(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', keys_select: str, item_format_spec: str) -> str:
624 '''
625 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``{key1,key2}``.
626 '''
627 parsed_select_keys = {self.key_type.parse_value(config_file, key) for key in keys_select.split(',')}
628 values = {k:v for k,v in values.items() if k in parsed_select_keys}
629 return self.expand_selected(config_file, values, item_format_spec)
631 def expand_selected(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', item_format_spec: str) -> str:
632 '''
633 Is called by :meth:`~confattr.formatters.Dict.expand_filter` and :meth:`~confattr.formatters.Dict.expand_select` to do the formatting of the filtered/selected values
634 '''
635 if not item_format_spec:
636 return self.format_value(config_file, values)
637 return config_file.ITEM_SEP.join(self.key_type.format_value(config_file, k) + config_file.KEY_SEP + format(v, item_format_spec) for k, v in values.items())
639 def expand_length(self, config_file: 'ConfigFile', values: 'Collection[T]', int_format_spec: str) -> str:
640 '''
641 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` is ``len``.
642 '''
643 return format(len(values), int_format_spec)