Coverage for .tox/cov/lib/python3.11/site-packages/confattr/formatters.py: 100%
333 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#!/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 config_key: 'str|None' = None
46 @abc.abstractmethod
47 def format_value(self, config_file: 'ConfigFile', value: 'T') -> str:
48 raise NotImplementedError()
50 @abc.abstractmethod
51 def expand_value(self, config_file: 'ConfigFile', value: 'T', format_spec: str) -> str:
52 '''
53 :param config_file: has e.g. the :attr:`~confattr.configfile.ConfigFile.ITEM_SEP` attribute
54 :param value: The value to be formatted
55 :param format_spec: A format specifier
56 :return: :paramref:`~confattr.formatters.AbstractFormatter.expand_value.value` formatted according to :paramref:`~confattr.formatters.AbstractFormatter.expand_value.format_spec`
57 :raises ValueError, LookupError: If :paramref:`~confattr.formatters.AbstractFormatter.expand_value.format_spec` is invalid
58 '''
59 raise NotImplementedError()
61 @abc.abstractmethod
62 def parse_value(self, config_file: 'ConfigFile', value: str) -> 'T':
63 raise NotImplementedError()
65 @abc.abstractmethod
66 def get_description(self, config_file: 'ConfigFile') -> str:
67 raise NotImplementedError()
69 @abc.abstractmethod
70 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
71 raise NotImplementedError()
73 @abc.abstractmethod
74 def get_primitives(self) -> 'Sequence[Primitive[typing.Any]]':
75 '''
76 If self is a Primitive data type, return self.
77 If self is a Collection, return self.item_type.
78 '''
79 raise NotImplementedError()
81 def set_config_key(self, config_key: str) -> None:
82 '''
83 In order to generate a useful error message if parsing a value fails the key of the setting is required.
84 This method is called by the constructor of :class:`~confattr.config.Config`.
85 This method must not be called more than once.
87 :raises TypeError: If :attr:`~confattr.formatters.AbstractFormatter.config_key` has already been set.
88 '''
89 if self.config_key:
90 raise TypeError(f"config_key has already been set to {self.config_key!r}, not setting to {config_key!r}")
91 self.config_key = config_key
94class CopyableAbstractFormatter(AbstractFormatter[T]):
96 @abc.abstractmethod
97 def copy(self) -> 'Self':
98 raise NotImplementedError()
101class Primitive(CopyableAbstractFormatter[T]):
103 PATTERN_ONE_OF = "one of {}"
104 PATTERN_ALLOWED_VALUES_UNIT = "{allowed_values} (unit: {unit})"
105 PATTERN_TYPE_UNIT = "{type} in {unit}"
107 #: Help for data types. This is used by :meth:`~confattr.formatters.Primitive.get_help`.
108 help_dict: 'dict[type[typing.Any]|Callable[..., typing.Any], str]' = {
109 str : 'A text. If it contains spaces it must be wrapped in single or double quotes.',
110 int : '''\
111 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).
112 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers.
113 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.''',
114 #bool,
115 float : 'A floating point number in python syntax, e.g. 23, 1.414, -1e3, 3.14_15_93.',
116 }
119 #: 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`.
120 type_name: 'str|None'
122 #: The unit of a number
123 unit: 'str|None'
125 #: :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`
126 type: 'type[T]|Callable[..., T]'
128 #: 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.
129 allowed_values: 'Collection[T]|dict[str, T]|None'
131 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:
132 if type in TYPES_REQUIRING_UNIT and unit is None and not isinstance(allowed_values, dict):
133 raise TypeError(f"missing argument unit for {self.config_key}, pass an empty string if the number really has no unit")
135 self.type = type
136 self.type_name = type_name
137 self.allowed_values = allowed_values
138 self.unit = unit
140 def copy(self) -> 'Self':
141 out = copy.copy(self)
142 out.config_key = None
143 return out
145 def format_value(self, config_file: 'ConfigFile', value: 'T') -> str:
146 if isinstance(self.allowed_values, dict):
147 for key, val in self.allowed_values.items():
148 if val == value:
149 return key
150 raise ValueError('%r is not an allowed value, should be one of %s' % (value, ', '.join(repr(v) for v in self.allowed_values.values())))
152 if isinstance(value, str):
153 return value.replace('\n', r'\n')
155 return format_primitive_value(value)
157 def expand_value(self, config_file: 'ConfigFile', value: 'T', format_spec: str) -> str:
158 '''
159 This method simply calls the builtin :func:`format`.
160 '''
161 return format(value, format_spec)
163 def parse_value(self, config_file: 'ConfigFile', value: str) -> 'T':
164 if isinstance(self.allowed_values, dict):
165 try:
166 return self.allowed_values[value]
167 except KeyError:
168 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
169 elif self.type is str:
170 value = value.replace(r'\n', '\n')
171 out = typing.cast(T, value)
172 elif self.type is int:
173 out = typing.cast(T, int(value, base=0))
174 elif self.type is float:
175 out = typing.cast(T, float(value))
176 elif self.type is bool:
177 if value == VALUE_TRUE:
178 out = typing.cast(T, True)
179 elif value == VALUE_FALSE:
180 out = typing.cast(T, False)
181 else:
182 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
183 elif isinstance(self.type, type) and issubclass(self.type, enum.Enum):
184 for i in self.type:
185 enum_item = typing.cast(T, i)
186 if self.format_value(config_file, enum_item) == value:
187 out = enum_item
188 break
189 else:
190 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
191 else:
192 try:
193 out = self.type(value) # type: ignore [call-arg]
194 except Exception as e:
195 raise ValueError(f'invalid value for {self.config_key}: {value!r} ({e})')
197 if self.allowed_values is not None and out not in self.allowed_values:
198 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')
199 return out
202 def get_description(self, config_file: 'ConfigFile', *, plural: bool = False, article: bool = True) -> str:
203 '''
204 :param config_file: May contain some additional information how to format the allowed values.
205 :param plural: Whether the return value should be a plural form.
206 :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.
207 :return: A short description which is displayed in the help/comment for each setting explaining what kind of value is expected.
208 In the easiest case this is just a list of allowed value, e.g. "one of true, false".
209 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`.
210 If a unit is specified it is included, e.g. "an int in km/h".
212 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`
213 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`.
214 '''
215 if plural:
216 article = False
218 if not self.type_name:
219 out = self.format_allowed_values(config_file, article=article)
220 if out:
221 return out
223 out = self.get_type_name()
224 if self.unit:
225 out = self.PATTERN_TYPE_UNIT.format(type=out, unit=self.unit)
226 if article:
227 out = self.format_indefinite_singular_article(out)
228 return out
230 def format_allowed_values(self, config_file: 'ConfigFile', *, article: bool = True) -> 'str|None':
231 allowed_values = self.get_allowed_values()
232 if not allowed_values:
233 return None
235 out = self.join(self.format_value(config_file, v) for v in allowed_values)
236 if article:
237 out = self.PATTERN_ONE_OF.format(out)
238 if self.unit:
239 out = self.PATTERN_ALLOWED_VALUES_UNIT.format(allowed_values=out, unit=self.unit)
240 return out
242 def get_type_name(self) -> str:
243 '''
244 Return the name of this type (without :attr:`~confattr.formatters.Primitive.unit` or :attr:`~confattr.formatters.Primitive.allowed_values`).
245 This can be used in :meth:`~confattr.formatters.Primitive.get_description` if the type can have more than just a couple of values.
246 If that is the case a help should be provided by :meth:`~confattr.formatters.Primitive.get_help`.
248 :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
249 '''
250 if self.type_name:
251 return self.type_name
252 return getattr(self.type, 'type_name', self.type.__name__.lower())
254 def join(self, names: 'Iterable[str]') -> str:
255 '''
256 Join several values which have already been formatted with :meth:`~confattr.formatters.Primitive.format_value`.
257 '''
258 return ', '.join(names)
260 def format_indefinite_singular_article(self, type_name: str) -> str:
261 '''
262 Getting the article right is not so easy, so a user can specify the correct article with a str attribute called ``type_article``.
263 Alternatively this method can be overridden.
264 This also gives the possibility to omit the article.
265 https://en.wiktionary.org/wiki/Appendix:English_articles#Indefinite_singular_articles
267 This is used in :meth:`~confattr.formatters.Primitive.get_description`.
268 '''
269 if hasattr(self.type, 'type_article'):
270 article = getattr(self.type, 'type_article')
271 if not article:
272 return type_name
273 assert isinstance(article, str)
274 return article + ' ' + type_name
275 if type_name[0].lower() in 'aeio':
276 return 'an ' + type_name
277 return 'a ' + type_name
280 def get_help(self, config_file: 'ConfigFile') -> 'str|None':
281 '''
282 The help for the generic data type, independent of the unit.
283 This is displayed once at the top of the help or the config file (if one or more settings use this type).
285 For example the help for an int might be:
287 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).
288 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers.
289 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.
291 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`.
293 :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.
294 :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`.
295 :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`.
296 '''
298 if self.type_name:
299 allowed_values = self.format_allowed_values(config_file)
300 if not allowed_values:
301 raise NotImplementedError("used 'type_name' without 'allowed_values', please override 'get_help'")
302 return allowed_values[:1].upper() + allowed_values[1:]
304 if self.type in self.help_dict:
305 return self.help_dict[self.type]
306 elif hasattr(self.type, 'help'):
307 out = getattr(self.type, 'help')
308 if not isinstance(out, str):
309 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")
310 return out
311 elif self.get_allowed_values():
312 return None
313 else:
314 raise NotImplementedError('No help for type %s' % self.get_type_name())
317 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
318 completions = [config_file.quote(config_file.format_any_value(self, val)) for val in self.get_allowed_values()]
319 completions = [v for v in completions if v.startswith(start)]
320 return start_of_line, completions, end_of_line
322 def get_allowed_values(self) -> 'Collection[T]':
323 if isinstance(self.allowed_values, dict):
324 return self.allowed_values.values()
325 if self.allowed_values:
326 return self.allowed_values
327 if self.type is bool:
328 return (typing.cast(T, True), typing.cast(T, False))
329 if isinstance(self.type, type) and issubclass(self.type, enum.Enum):
330 return self.type
331 return ()
333 def get_primitives(self) -> 'tuple[Self]':
334 return (self,)
336class Hex(Primitive[int]):
338 def __init__(self, *, allowed_values: 'Collection[int]|None' = None) -> None:
339 super().__init__(int, allowed_values=allowed_values, unit='')
341 def format_value(self, config_file: 'ConfigFile', value: int) -> str:
342 return '%X' % value
344 def parse_value(self, config_file: 'ConfigFile', value: str) -> int:
345 return int(value, base=16)
347 def get_description(self, config_file: 'ConfigFile', *, plural: bool = False, article: bool = True) -> str:
348 out = 'hexadecimal number'
349 if plural:
350 out += 's'
351 elif article:
352 out = 'a ' + out
353 return out
355 def get_help(self, config_file: 'ConfigFile') -> None:
356 return None
359class AbstractCollection(AbstractFormatter[Collection[T]]):
361 def __init__(self, item_type: 'Primitive[T]') -> None:
362 self.item_type = item_type
364 def split_values(self, config_file: 'ConfigFile', values: str) -> 'Iterable[str]':
365 return values.split(config_file.ITEM_SEP)
367 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
368 if config_file.ITEM_SEP in start:
369 first, start = start.rsplit(config_file.ITEM_SEP, 1)
370 start_of_line += first + config_file.ITEM_SEP
371 return self.item_type.get_completions(config_file, start_of_line, start, end_of_line)
373 def get_primitives(self) -> 'tuple[Primitive[T]]':
374 return (self.item_type,)
376 def set_config_key(self, config_key: str) -> None:
377 super().set_config_key(config_key)
378 self.item_type.set_config_key(config_key)
381 # ------- expand ------
383 def expand_value(self, config_file: 'ConfigFile', values: 'Collection[T]', format_spec: str) -> str:
384 '''
385 :paramref:`~confattr.formatters.AbstractCollection.expand_value.format_spec` supports the following features:
387 - 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
388 - Get the length, ``len`` expands to the number of items
389 - 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
391 To any of the above you can append another format_spec after a colon to specify how to format the items/the length.
392 '''
393 m = re.match(r'(-(?P<exclude>[^[:]*)|(?P<func>[^[:]*))(:(?P<format_spec>.*))?$', format_spec)
394 if m is None:
395 raise ValueError('Invalid format_spec for collection: %r' % format_spec)
397 format_spec = m.group('format_spec') or ''
398 func = m.group('func')
399 if func == 'len':
400 return self.expand_length(config_file, values, format_spec)
401 elif func:
402 return self.expand_min_max(config_file, values, func, format_spec)
404 exclude = m.group('exclude')
405 if exclude:
406 return self.expand_exclude_items(config_file, values, exclude, format_spec)
408 return self.expand_parsed_items(config_file, values, format_spec)
410 def expand_length(self, config_file: 'ConfigFile', values: 'Collection[T]', int_format_spec: str) -> str:
411 return format(len(values), int_format_spec)
413 def expand_min_max(self, config_file: 'ConfigFile', values: 'Collection[T]', func: str, item_format_spec: str) -> str:
414 if func == 'min':
415 v = min(values) # type: ignore [type-var] # The TypeError is caught in ConfigFile.expand_config_match
416 elif func == 'max':
417 v = max(values) # type: ignore [type-var] # The TypeError is caught in ConfigFile.expand_config_match
418 else:
419 raise ValueError(f'Invalid format_spec for collection: {func!r}')
421 return self.expand_parsed_items(config_file, [v], item_format_spec)
423 def expand_exclude_items(self, config_file: 'ConfigFile', values: 'Collection[T]', items_to_be_excluded: str, item_format_spec: str) -> str:
424 exclude = {self.item_type.parse_value(config_file, item) for item in items_to_be_excluded.split(',')}
425 out = [v for v in values if v not in exclude]
426 return self.expand_parsed_items(config_file, out, item_format_spec)
428 def expand_parsed_items(self, config_file: 'ConfigFile', values: 'Collection[T]', item_format_spec: str) -> str:
429 if not item_format_spec:
430 return self.format_value(config_file, values)
431 return config_file.ITEM_SEP.join(format(v, item_format_spec) for v in values)
433class List(AbstractCollection[T]):
435 def get_description(self, config_file: 'ConfigFile') -> str:
436 return 'a comma separated list of ' + self.item_type.get_description(config_file, plural=True)
438 def format_value(self, config_file: 'ConfigFile', values: 'Collection[T]') -> str:
439 return config_file.ITEM_SEP.join(config_file.format_any_value(self.item_type, i) for i in values)
441 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
442 '''
443 :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:
445 - Access a single item, e.g. ``[0]`` expands to the first item, ``[-1]`` expands to the last item [1]
446 - 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
447 - 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]
448 - Access a slice of items with a step, e.g. ``[::-1]`` expands to all items in reverse order [1]
450 To any of the above you can append another format_spec after a colon to specify how to format the items.
452 [1] For more information see the `common slicing operations of sequences <https://docs.python.org/3/library/stdtypes.html#common-sequence-operations>`__.
453 '''
454 m = re.match(r'(\[(?P<indices>[^]]+)\])(:(?P<format_spec>.*))?$', format_spec)
455 if m is None:
456 return super().expand_value(config_file, values, format_spec)
458 format_spec = m.group('format_spec') or ''
459 indices = m.group('indices')
460 assert isinstance(indices, str)
461 return self.expand_items(config_file, values, indices, format_spec)
463 def expand_items(self, config_file: 'ConfigFile', values: 'Sequence[T]', indices: str, item_format_spec: str) -> str:
464 out = [v for sl in self.parse_slices(indices) for v in values[sl]]
465 return self.expand_parsed_items(config_file, out, item_format_spec)
467 def parse_slices(self, indices: str) -> 'Iterator[slice]':
468 for s in indices.split(','):
469 yield self.parse_slice(s)
471 def parse_slice(self, s: str) -> 'slice':
472 sl = [int(i) if i else None for i in s.split(':')]
473 if len(sl) == 1 and isinstance(sl[0], int):
474 i = sl[0]
475 return slice(i, i+1)
476 return slice(*sl)
478 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'list[T]':
479 return [self.item_type.parse_value(config_file, i) for i in self.split_values(config_file, values)]
481class Set(AbstractCollection[T]):
483 def get_description(self, config_file: 'ConfigFile') -> str:
484 return 'a comma separated set of ' + self.item_type.get_description(config_file, plural=True)
486 def format_value(self, config_file: 'ConfigFile', values: 'Collection[T]') -> str:
487 try:
488 sorted_values = sorted(values) # type: ignore [type-var] # values may be not comparable but that's what the try/except is there for
489 except TypeError:
490 return config_file.ITEM_SEP.join(sorted(config_file.format_any_value(self.item_type, i) for i in values))
492 return config_file.ITEM_SEP.join(config_file.format_any_value(self.item_type, i) for i in sorted_values)
494 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'set[T]':
495 return {self.item_type.parse_value(config_file, i) for i in self.split_values(config_file, values)}
498T_key = typing.TypeVar('T_key')
499T_val = typing.TypeVar('T_val')
500class Dict(AbstractFormatter['dict[T_key, T_val]']):
502 def __init__(self, key_type: 'Primitive[T_key]', value_type: 'Primitive[T_val]') -> None:
503 self.key_type = key_type
504 self.value_type = value_type
506 def get_description(self, config_file: 'ConfigFile') -> str:
507 return 'a dict of %s:%s' % (self.key_type.get_description(config_file, article=False), self.value_type.get_description(config_file, article=False))
509 def format_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]') -> str:
510 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())
512 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'dict[T_key, T_val]':
513 return dict(self.parse_item(config_file, i) for i in self.split_values(config_file, values))
515 def split_values(self, config_file: 'ConfigFile', values: str) -> 'Iterable[str]':
516 return values.split(config_file.ITEM_SEP)
518 def parse_item(self, config_file: 'ConfigFile', item: str) -> 'tuple[T_key, T_val]':
519 key_name, val_name = item.split(config_file.KEY_SEP, 1)
520 key = self.key_type.parse_value(config_file, key_name)
521 val = self.value_type.parse_value(config_file, val_name)
522 return key, val
524 def get_primitives(self) -> 'tuple[Primitive[T_key], Primitive[T_val]]':
525 return (self.key_type, self.value_type)
527 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':
528 if config_file.ITEM_SEP in start:
529 first, start = start.rsplit(config_file.ITEM_SEP, 1)
530 start_of_line += first + config_file.ITEM_SEP
531 if config_file.KEY_SEP in start:
532 first, start = start.rsplit(config_file.KEY_SEP, 1)
533 start_of_line += first + config_file.KEY_SEP
534 return self.value_type.get_completions(config_file, start_of_line, start, end_of_line)
536 return self.key_type.get_completions(config_file, start_of_line, start, end_of_line)
538 def expand_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', format_spec: str) -> str:
539 '''
540 :paramref:`~confattr.formatters.Dict.expand_value.format_spec` supports the following features:
542 - 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
543 - 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
544 - 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
545 - Filter out elements, e.g. ``{^key1}`` expands to all ``key:val`` pairs except for ``key1``
546 - Get the length, ``len`` expands to the number of items
548 To any of the above you can append another format_spec after a colon to specify how to format the items/the length.
549 '''
550 m = re.match(r'(\[(?P<key>[^]|]+)(\|(?P<default>[^]]+))?\]|\{\^(?P<filter>[^}]+)\}|\{(?P<select>[^}]*)\}|(?P<func>[^[{:]+))(:(?P<format_spec>.*))?$', format_spec)
551 if m is None:
552 raise ValueError('Invalid format_spec for dict: %r' % format_spec)
554 item_format_spec = m.group('format_spec') or ''
556 key = m.group('key')
557 if key:
558 default = m.group('default')
559 return self.expand_single_value(config_file, values, key, default, item_format_spec)
561 keys_filter = m.group('filter')
562 if keys_filter:
563 return self.expand_filter(config_file, values, keys_filter, item_format_spec)
565 keys_select = m.group('select')
566 if keys_select:
567 return self.expand_select(config_file, values, keys_select, item_format_spec)
569 func = m.group('func')
570 if func == 'len':
571 return self.expand_length(config_file, values, item_format_spec)
573 raise ValueError('Invalid format_spec for dict: %r' % format_spec)
575 def expand_single_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', key: str, default: 'str|None', item_format_spec: str) -> str:
576 '''
577 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]``.
578 '''
579 parsed_key = self.key_type.parse_value(config_file, key)
580 try:
581 v = values[parsed_key]
582 except KeyError:
583 if default is not None:
584 return default
585 # The message of a KeyError is the repr of the missing key, nothing more.
586 # Therefore I am raising a new exception with a more descriptive message.
587 # I am not using KeyError because that takes the repr of the argument.
588 raise LookupError(f"key {key!r} is not contained in {self.config_key!r}")
590 if not item_format_spec:
591 return self.value_type.format_value(config_file, v)
592 return format(v, item_format_spec)
594 def expand_filter(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', keys_filter: str, item_format_spec: str) -> str:
595 '''
596 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``{^key1,key2}``.
597 '''
598 parsed_filter_keys = {self.key_type.parse_value(config_file, key) for key in keys_filter.split(',')}
599 values = {k:v for k,v in values.items() if k not in parsed_filter_keys}
600 return self.expand_selected(config_file, values, item_format_spec)
602 def expand_select(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', keys_select: str, item_format_spec: str) -> str:
603 '''
604 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``{key1,key2}``.
605 '''
606 parsed_select_keys = {self.key_type.parse_value(config_file, key) for key in keys_select.split(',')}
607 values = {k:v for k,v in values.items() if k in parsed_select_keys}
608 return self.expand_selected(config_file, values, item_format_spec)
610 def expand_selected(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', item_format_spec: str) -> str:
611 '''
612 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
613 '''
614 if not item_format_spec:
615 return self.format_value(config_file, values)
616 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())
618 def expand_length(self, config_file: 'ConfigFile', values: 'Collection[T]', int_format_spec: str) -> str:
619 '''
620 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` is ``len``.
621 '''
622 return format(len(values), int_format_spec)