Coverage for .tox/cov/lib/python3.11/site-packages/confattr/configfile.py: 100%
1262 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-14 08:57 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-14 08:57 +0200
1#!./runmodule.sh
3'''
4This module defines the ConfigFile class
5which can be used to load and save config files.
6'''
8import os
9import shlex
10import platform
11import re
12import enum
13import argparse
14import textwrap
15import inspect
16import io
17import abc
18import typing
19from collections.abc import Iterable, Iterator, Sequence, Callable
21import appdirs
23from .config import Config, DictConfig, MultiConfig, ConfigId
24from .formatters import AbstractFormatter
25from .utils import HelpFormatter, HelpFormatterWrapper, SortedEnum, readable_quote
27if typing.TYPE_CHECKING:
28 from typing_extensions import Unpack
30# T is already used in config.py and I cannot use the same name because both are imported with *
31T2 = typing.TypeVar('T2')
34#: If the name or an alias of :class:`~confattr.configfile.ConfigFileCommand` is this value that command is used by :meth:`ConfigFile.parse_split_line() <confattr.configfile.ConfigFile.parse_split_line>` if an undefined command is encountered.
35DEFAULT_COMMAND = ''
39# ---------- UI notifier ----------
41@enum.unique
42class NotificationLevel(SortedEnum):
43 INFO = 'info'
44 ERROR = 'error'
46UiCallback: 'typing.TypeAlias' = 'Callable[[Message], None]'
48class Message:
50 '''
51 A message which should be displayed to the user.
52 This is passed to the callback of the user interface which has been registered with :meth:`ConfigFile.set_ui_callback() <confattr.configfile.ConfigFile.set_ui_callback>`.
54 If you want full control how to display messages to the user you can access the attributes directly.
55 Otherwise you can simply convert this object to a str, e.g. with ``str(msg)``.
56 I recommend to use different colors for different values of :attr:`~confattr.configfile.Message.notification_level`.
57 '''
59 #: The value of :attr:`~confattr.configfile.Message.file_name` while loading environment variables.
60 ENVIRONMENT_VARIABLES = 'environment variables'
63 __slots__ = ('notification_level', 'message', 'file_name', 'line_number', 'line', 'no_context')
65 #: The importance of this message. I recommend to display messages of different importance levels in different colors.
66 #: :class:`~confattr.configfile.ConfigFile` does not output messages which are less important than the :paramref:`~confattr.configfile.ConfigFile.notification_level` setting which has been passed to it's constructor.
67 notification_level: NotificationLevel
69 #: The string or exception which should be displayed to the user
70 message: 'str|BaseException'
72 #: The name of the config file which has caused this message.
73 #: If this equals :const:`~confattr.configfile.Message.ENVIRONMENT_VARIABLES` it is not a file but the message has occurred while reading the environment variables.
74 #: This is None if :meth:`ConfigFile.parse_line() <confattr.configfile.ConfigFile.parse_line>` is called directly, e.g. when parsing the input from a command line.
75 file_name: 'str|None'
77 #: The number of the line in the config file. This is None if :attr:`~confattr.configfile.Message.file_name` is not a file name.
78 line_number: 'int|None'
80 #: The line where the message occurred. This is an empty str if there is no line, e.g. when loading environment variables.
81 line: str
83 #: If true: don't show line and line number.
84 no_context: bool
87 _last_file_name: 'str|None' = None
89 @classmethod
90 def reset(cls) -> None:
91 '''
92 If you are using :meth:`~confattr.configfile.Message.format_file_name_msg_line` or :meth:`~confattr.configfile.Message.__str__`
93 you must call this method when the widget showing the error messages is cleared.
94 '''
95 cls._last_file_name = None
97 def __init__(self, notification_level: NotificationLevel, message: 'str|BaseException', file_name: 'str|None' = None, line_number: 'int|None' = None, line: 'str' = '', no_context: bool = False) -> None:
98 self.notification_level = notification_level
99 self.message = message
100 self.file_name = file_name
101 self.line_number = line_number
102 self.line = line
103 self.no_context = no_context
105 @property
106 def lvl(self) -> NotificationLevel:
107 '''
108 An abbreviation for :attr:`~confattr.configfile.Message.notification_level`
109 '''
110 return self.notification_level
112 def format_msg_line(self) -> str:
113 '''
114 The return value includes the attributes :attr:`~confattr.configfile.Message.message`, :attr:`~confattr.configfile.Message.line_number` and :attr:`~confattr.configfile.Message.line` if they are set.
115 '''
116 msg = str(self.message)
117 if self.line and not self.no_context:
118 if self.line_number is not None:
119 lnref = 'line %s' % self.line_number
120 else:
121 lnref = 'line'
122 return f'{msg} in {lnref} {self.line!r}'
124 return msg
126 def format_file_name(self) -> str:
127 '''
128 :return: A header including the :attr:`~confattr.configfile.Message.file_name` if the :attr:`~confattr.configfile.Message.file_name` is different from the last time this function has been called or an empty string otherwise
129 '''
130 file_name = '' if self.file_name is None else self.file_name
131 if file_name == self._last_file_name:
132 return ''
134 if file_name:
135 out = f'While loading {file_name}:\n'
136 else:
137 out = ''
139 if self._last_file_name is not None:
140 out = '\n' + out
142 type(self)._last_file_name = file_name
144 return out
147 def format_file_name_msg_line(self) -> str:
148 '''
149 :return: The concatenation of the return values of :meth:`~confattr.configfile.Message.format_file_name` and :meth:`~confattr.configfile.Message.format_msg_line`
150 '''
151 return self.format_file_name() + self.format_msg_line()
154 def __str__(self) -> str:
155 '''
156 :return: The return value of :meth:`~confattr.configfile.Message.format_file_name_msg_line`
157 '''
158 return self.format_file_name_msg_line()
160 def __repr__(self) -> str:
161 return f'{type(self).__name__}(%s)' % ', '.join(f'{a}={self._format_attribute(getattr(self, a))}' for a in self.__slots__)
163 @staticmethod
164 def _format_attribute(obj: object) -> str:
165 if isinstance(obj, enum.Enum):
166 return obj.name
167 return repr(obj)
170class UiNotifier:
172 '''
173 Most likely you will want to load the config file before creating the UI (user interface).
174 But if there are errors in the config file the user will want to know about them.
175 This class takes the messages from :class:`~confattr.configfile.ConfigFile` and stores them until the UI is ready.
176 When you call :meth:`~confattr.configfile.UiNotifier.set_ui_callback` the stored messages will be forwarded and cleared.
178 This object can also filter the messages.
179 :class:`~confattr.configfile.ConfigFile` calls :meth:`~confattr.configfile.UiNotifier.show_info` every time a setting is changed.
180 If you load an entire config file this can be many messages and the user probably does not want to see them all.
181 Therefore this object drops all messages of :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>` by default.
182 Pass :paramref:`~confattr.configfile.UiNotifier.notification_level` to the constructor if you don't want that.
183 '''
185 # ------- public methods -------
187 def __init__(self, config_file: 'ConfigFile', notification_level: 'Config[NotificationLevel]|NotificationLevel' = NotificationLevel.ERROR) -> None:
188 '''
189 :param config_file: Is used to add context information to messages, to which file and to which line a message belongs.
190 :param notification_level: Messages which are less important than this notification level will be ignored. I recommend to pass a :class:`~confattr.config.Config` instance so that users can decide themselves what they want to see.
191 '''
192 self._messages: 'list[Message]' = []
193 self._callback: 'UiCallback|None' = None
194 self._notification_level = notification_level
195 self._config_file = config_file
197 def set_ui_callback(self, callback: UiCallback) -> None:
198 '''
199 Call :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for all messages which have been saved by :meth:`~confattr.configfile.UiNotifier.show` and clear all saved messages afterwards.
200 Save :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for :meth:`~confattr.configfile.UiNotifier.show` to call.
201 '''
202 self._callback = callback
204 for msg in self._messages:
205 callback(msg)
206 self._messages.clear()
209 @property
210 def notification_level(self) -> NotificationLevel:
211 '''
212 Ignore messages that are less important than this level.
213 '''
214 if isinstance(self._notification_level, Config):
215 return self._notification_level.value
216 else:
217 return self._notification_level
219 @notification_level.setter
220 def notification_level(self, val: NotificationLevel) -> None:
221 if isinstance(self._notification_level, Config):
222 self._notification_level.value = val
223 else:
224 self._notification_level = val
227 # ------- called by ConfigFile -------
229 def show_info(self, msg: str, *, ignore_filter: bool = False) -> None:
230 '''
231 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>`.
232 '''
233 self.show(NotificationLevel.INFO, msg, ignore_filter=ignore_filter)
235 def show_error(self, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None:
236 '''
237 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.ERROR <confattr.configfile.NotificationLevel.ERROR>`.
238 '''
239 self.show(NotificationLevel.ERROR, msg, ignore_filter=ignore_filter)
242 # ------- internal methods -------
244 def show(self, notification_level: NotificationLevel, msg: 'str|BaseException', *, ignore_filter: bool = False, no_context: bool = False) -> None:
245 '''
246 If a callback for the user interface has been registered with :meth:`~confattr.configfile.UiNotifier.set_ui_callback` call that callback.
247 Otherwise save the message so that :meth:`~confattr.configfile.UiNotifier.set_ui_callback` can forward the message when :meth:`~confattr.configfile.UiNotifier.set_ui_callback` is called.
249 :param notification_level: The importance of the message
250 :param msg: The message to be displayed on the user interface
251 :param ignore_filter: If true: Show the message even if :paramref:`~confattr.configfile.UiNotifier.show.notification_level` is smaller then the :paramref:`UiNotifier.notification_level <confattr.configfile.UiNotifier.notification_level>`.
252 :param no_context: If true: don't show line and line number.
253 '''
254 if notification_level < self.notification_level and not ignore_filter:
255 return
257 if not self._config_file.context_line_number and not self._config_file.show_line_always:
258 no_context = True
260 message = Message(
261 notification_level = notification_level,
262 message = msg,
263 file_name = self._config_file.context_file_name,
264 line_number = self._config_file.context_line_number,
265 line = self._config_file.context_line,
266 no_context = no_context,
267 )
269 if self._callback:
270 self._callback(message)
271 else:
272 self._messages.append(message)
275# ---------- format help ----------
277class SectionLevel(SortedEnum):
279 #: Is used to separate different commands in :meth:`ConfigFile.write_help() <confattr.configfile.ConfigFile.write_help>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>`
280 SECTION = 'section'
282 #: Is used for subsections in :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>` such as the "data types" section in the help of the set command
283 SUB_SECTION = 'sub-section'
286class FormattedWriter(abc.ABC):
288 @abc.abstractmethod
289 def write_line(self, line: str) -> None:
290 '''
291 Write a single line of documentation.
292 :paramref:`~confattr.configfile.FormattedWriter.write_line.line` may *not* contain a newline.
293 If :paramref:`~confattr.configfile.FormattedWriter.write_line.line` is empty it does not need to be prefixed with a comment character.
294 Empty lines should be dropped if no other lines have been written before.
295 '''
296 pass
298 def write_lines(self, text: str) -> None:
299 '''
300 Write one or more lines of documentation.
301 '''
302 for ln in text.splitlines():
303 self.write_line(ln)
305 @abc.abstractmethod
306 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
307 '''
308 Write a heading.
310 This object should *not* add an indentation depending on the section
311 because if the indentation is increased the line width should be decreased
312 in order to keep the line wrapping consistent.
313 Wrapping lines is handled by :class:`confattr.utils.HelpFormatter`,
314 i.e. before the text is passed to this object.
315 It would be possible to use :class:`argparse.RawTextHelpFormatter` instead
316 and handle line wrapping on a higher level but that would require
317 to understand the help generated by argparse
318 in order to know how far to indent a broken line.
319 One of the trickiest parts would probably be to get the indentation of the usage right.
320 Keep in mind that the term "usage" can differ depending on the language settings of the user.
322 :param lvl: How to format the heading
323 :param heading: The heading
324 '''
325 pass
327 @abc.abstractmethod
328 def write_command(self, cmd: str) -> None:
329 '''
330 Write a config file command.
331 '''
332 pass
335class TextIOWriter(FormattedWriter):
337 def __init__(self, f: 'typing.TextIO|None') -> None:
338 self.f = f
339 self.ignore_empty_lines = True
341 def write_line_raw(self, line: str) -> None:
342 if self.ignore_empty_lines and not line:
343 return
345 print(line, file=self.f)
346 self.ignore_empty_lines = False
349class ConfigFileWriter(TextIOWriter):
351 def __init__(self, f: 'typing.TextIO|None', prefix: str) -> None:
352 super().__init__(f)
353 self.prefix = prefix
355 def write_command(self, cmd: str) -> None:
356 self.write_line_raw(cmd)
358 def write_line(self, line: str) -> None:
359 if line:
360 line = self.prefix + line
362 self.write_line_raw(line)
364 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
365 if lvl is SectionLevel.SECTION:
366 self.write_line('')
367 self.write_line('')
368 self.write_line('=' * len(heading))
369 self.write_line(heading)
370 self.write_line('=' * len(heading))
371 else:
372 self.write_line('')
373 self.write_line(heading)
374 self.write_line('-' * len(heading))
376class HelpWriter(TextIOWriter):
378 def write_line(self, line: str) -> None:
379 self.write_line_raw(line)
381 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
382 self.write_line('')
383 if lvl is SectionLevel.SECTION:
384 self.write_line(heading)
385 self.write_line('=' * len(heading))
386 else:
387 self.write_line(heading)
388 self.write_line('-' * len(heading))
390 def write_command(self, cmd: str) -> None:
391 pass # pragma: no cover
394# ---------- internal exceptions ----------
396class ParseException(Exception):
398 '''
399 This is raised by :class:`~confattr.configfile.ConfigFileCommand` implementations and functions passed to :paramref:`~confattr.configfile.ConfigFile.check_config_id` in order to communicate an error in the config file like invalid syntax or an invalid value.
400 Is caught in :class:`~confattr.configfile.ConfigFile`.
401 '''
403class MultipleParseExceptions(Exception):
405 '''
406 This is raised by :class:`~confattr.configfile.ConfigFileCommand` implementations in order to communicate that multiple errors have occured on the same line.
407 Is caught in :class:`~confattr.configfile.ConfigFile`.
408 '''
410 def __init__(self, exceptions: 'Sequence[ParseException]') -> None:
411 super().__init__()
412 self.exceptions = exceptions
414 def __iter__(self) -> 'Iterator[ParseException]':
415 return iter(self.exceptions)
418# ---------- data types for **kw args ----------
420if hasattr(typing, 'TypedDict'): # python >= 3.8 # pragma: no cover. This is tested but in a different environment which is not known to coverage.
421 class SaveKwargs(typing.TypedDict, total=False):
422 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]'
423 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]] | None'
424 no_multi: bool
425 comments: bool
428# ---------- ConfigFile class ----------
430class ArgPos:
431 '''
432 This is an internal class, the return type of :meth:`ConfigFile.find_arg() <confattr.configfile.ConfigFile.find_arg>`
433 '''
435 #: The index of the argument in :paramref:`~confattr.configfile.ConfigFile.find_arg.ln_split` where the cursor is located and which shall be completed. Please note that this can be one bigger than :paramref:`~confattr.configfile.ConfigFile.find_arg.ln_split` is long if the line ends on a space or a comment and the cursor is behind/in that space/comment. In that case :attr:`~confattr.configfile.ArgPos.in_between` is true.
436 argument_pos: int
438 #: If true: The cursor is between two arguments, before the first argument or after the last argument. :attr:`~confattr.configfile.ArgPos.argument_pos` refers to the next argument, :attr:`argument_pos-1 <confattr.configfile.ArgPos.argument_pos>` to the previous argument. :attr:`~confattr.configfile.ArgPos.i0` is the start of the next argument, :attr:`~confattr.configfile.ArgPos.i1` is the end of the previous argument.
439 in_between: bool
441 #: The index in :paramref:`~confattr.configfile.ConfigFile.find_arg.line` where the argument having the cursor starts (inclusive) or the start of the next argument if :attr:`~confattr.configfile.ArgPos.in_between` is true
442 i0: int
444 #: The index in :paramref:`~confattr.configfile.ConfigFile.find_arg.line` where the current word ends (exclusive) or the end of the previous argument if :attr:`~confattr.configfile.ArgPos.in_between` is true
445 i1: int
448class ConfigFile:
450 '''
451 Read or write a config file.
452 '''
454 COMMENT = '#'
455 COMMENT_PREFIXES = ('"', '#')
456 ENTER_GROUP_PREFIX = '['
457 ENTER_GROUP_SUFFIX = ']'
459 #: How to separete several element in a collection (list, set, dict)
460 ITEM_SEP = ','
462 #: How to separate key and value in a dict
463 KEY_SEP = ':'
466 #: The :class:`~confattr.config.Config` instances to load or save
467 config_instances: 'dict[str, Config[typing.Any]]'
469 #: While loading a config file: The group that is currently being parsed, i.e. an identifier for which object(s) the values shall be set. This is set in :meth:`~confattr.configfile.ConfigFile.enter_group` and reset in :meth:`~confattr.configfile.ConfigFile.load_file`.
470 config_id: 'ConfigId|None'
472 #: Override the config file which is returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
473 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_directory` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.
474 #: If the environment variable ``APPNAME_CONFIG_PATH`` is set this attribute is set to it's value in the constructor (where ``APPNAME`` is the value which is passed as :paramref:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.)
475 config_path: 'str|None' = None
477 #: Override the config directory which is returned by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths`.
478 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_path` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.
479 #: If the environment variable ``APPNAME_CONFIG_DIRECTORY`` is set this attribute is set to it's value in the constructor (where ``APPNAME`` is the value which is passed as :paramref:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.)
480 config_directory: 'str|None' = None
482 #: The name of the config file used by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
483 #: Can be changed with the environment variable ``APPNAME_CONFIG_NAME`` (where ``APPNAME`` is the value which is passed as :paramref:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.).
484 config_name = 'config'
486 #: Contains the names of the environment variables for :attr:`~confattr.configfile.ConfigFile.config_path`, :attr:`~confattr.configfile.ConfigFile.config_directory` and :attr:`~confattr.configfile.ConfigFile.config_name`—in capital letters and prefixed with :attr:`~confattr.configfile.ConfigFile.envprefix`.
487 env_variables: 'list[str]'
489 #: A prefix that is prepended to the name of environment variables in :meth:`~confattr.configfile.ConfigFile.get_env_name`.
490 #: It is set in the constructor by first setting it to an empty str and then passing the value of :paramref:`~confattr.configfile.ConfigFile.appname` to :meth:`~confattr.configfile.ConfigFile.get_env_name` and appending an underscore.
491 envprefix: str
493 #: The name of the file which is currently loaded. If this equals :attr:`Message.ENVIRONMENT_VARIABLES <confattr.configfile.Message.ENVIRONMENT_VARIABLES>` it is no file name but an indicator that environment variables are loaded. This is :obj:`None` if :meth:`~confattr.configfile.ConfigFile.parse_line` is called directly (e.g. the input from a command line is parsed).
494 context_file_name: 'str|None' = None
495 #: The number of the line which is currently parsed. This is :obj:`None` if :attr:`~confattr.configfile.ConfigFile.context_file_name` is not a file name.
496 context_line_number: 'int|None' = None
497 #: The line which is currently parsed.
498 context_line: str = ''
500 #: If true: ``[config-id]`` syntax is allowed in config file, config ids are included in help, config id related options are available for include.
501 #: If false: It is not possible to set different values for different objects (but default values for :class:`~confattr.config.MultiConfig` instances can be set)
502 enable_config_ids: bool
505 #: A mapping from the name to the object for all commands that are available in this config file. If a command has :attr:`~confattr.configfile.ConfigFileCommand.aliases` every alias appears in this mapping, too. Use :attr:`~confattr.configfile.ConfigFile.commands` instead if you want to iterate over all available commands. This is generated in the constructor based on :paramref:`~confattr.configfile.ConfigFile.commands` if it is given or based on the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>` otherwise. Note that you are passing a sequence of *types* as argument but this attribute contains the instantiated *objects*.
506 command_dict: 'dict[str, ConfigFileCommand]'
508 #: A list of all commands that are available in this config file. This is generated in the constructor based on :paramref:`~confattr.configfile.ConfigFile.commands` if it is given or based on the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>` otherwise. Note that you are passing a sequence of *types* as argument but this attribute contains the instantiated *objects*. In contrast to :attr:`~confattr.configfile.ConfigFile.command_dict` this list contains every command only once.
509 commands: 'list[ConfigFileCommand]'
512 #: See :paramref:`~confattr.configfile.ConfigFile.check_config_id`
513 check_config_id: 'Callable[[ConfigId], None]|None'
515 #: If this is true :meth:`ui_notifier.show() <confattr.configfile.UiNotifier.show>` concatenates :attr:`~confattr.configfile.ConfigFile.context_line` to the message even if :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set.
516 show_line_always: bool
519 def __init__(self, *,
520 notification_level: 'Config[NotificationLevel]' = NotificationLevel.ERROR, # type: ignore [assignment] # yes, passing a NotificationLevel directly is possible but I don't want users to do that in order to give the users of their applications the freedom to set this the way they need it
521 appname: str,
522 authorname: 'str|None' = None,
523 config_instances: 'dict[str, Config[typing.Any]]' = Config.instances,
524 commands: 'Sequence[type[ConfigFileCommand]]|None' = None,
525 formatter_class: 'type[argparse.HelpFormatter]' = HelpFormatter,
526 check_config_id: 'Callable[[ConfigId], None]|None' = None,
527 enable_config_ids: 'bool|None' = None,
528 show_line_always: bool = True,
529 ) -> None:
530 '''
531 :param notification_level: A :class:`~confattr.config.Config` which the users of your application can set to choose whether they want to see information which might be interesting for debugging a config file. A :class:`~confattr.configfile.Message` with a priority lower than this value is *not* passed to the callback registered with :meth:`~confattr.configfile.ConfigFile.set_ui_callback`.
532 :param appname: The name of the application, required for generating the path of the config file if you use :meth:`~confattr.configfile.ConfigFile.load` or :meth:`~confattr.configfile.ConfigFile.save` and as prefix of environment variable names
533 :param authorname: The name of the developer of the application, on MS Windows useful for generating the path of the config file if you use :meth:`~confattr.configfile.ConfigFile.load` or :meth:`~confattr.configfile.ConfigFile.save`
534 :param config_instances: The Config instances to load or save, defaults to :attr:`Config.instances <confattr.config.Config.instances>`
535 :param commands: The commands (as subclasses of :class:`~confattr.configfile.ConfigFileCommand` or :class:`~confattr.configfile.ConfigFileArgparseCommand`) allowed in this config file, if this is :obj:`None`: use the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>`
536 :param formatter_class: Is used to clean up doc strings and wrap lines in the help
537 :param check_config_id: Is called every time a configuration group is opened (except for :attr:`Config.default_config_id <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.
538 :param enable_config_ids: see :attr:`~confattr.configfile.ConfigFile.enable_config_ids`. If None: Choose True or False automatically based on :paramref:`~confattr.configfile.ConfigFile.check_config_id` and the existence of :class:`~confattr.config.MultiConfig`/:class:`~confattr.config.MultiDictConfig`
539 :param show_line_always: If false: when calling :meth:`UiNotifier.show() <confattr.configfile.UiNotifier.show>` :attr:`~confattr.configfile.ConfigFile.context_line` and :attr:`~confattr.configfile.ConfigFile.context_line_number` are concatenated to the message if both are set. If :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set it is assumed that the line comes from a command line interface where the user just entered it and it is still visible so there is no need to print it again. If :paramref:`~confattr.configfile.ConfigFile.show_line_always` is true (the default) :attr:`~confattr.configfile.ConfigFile.context_line` is concatenated even if :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set. That is useful when you use :meth:`~confattr.configfile.ConfigFile.parse_line` to parse a command which has been assigned to a keyboard shortcut.
540 '''
541 self.appname = appname
542 self.authorname = authorname
543 self.ui_notifier = UiNotifier(self, notification_level)
544 self.config_instances = config_instances
545 self.config_id: 'ConfigId|None' = None
546 self.formatter_class = formatter_class
547 self.env_variables: 'list[str]' = []
548 self.check_config_id = check_config_id
549 self.show_line_always = show_line_always
551 if enable_config_ids is None:
552 enable_config_ids = self.check_config_id is not None or any(isinstance(cfg, MultiConfig) for cfg in self.config_instances.values())
553 self.enable_config_ids = enable_config_ids
555 self.envprefix = ''
556 self.envprefix = self.get_env_name(appname + '_')
557 envname = self.envprefix + 'CONFIG_PATH'
558 self.env_variables.append(envname)
559 if envname in os.environ:
560 self.config_path = os.environ[envname]
561 envname = self.envprefix + 'CONFIG_DIRECTORY'
562 self.env_variables.append(envname)
563 if envname in os.environ:
564 self.config_directory = os.environ[envname]
565 envname = self.envprefix + 'CONFIG_NAME'
566 self.env_variables.append(envname)
567 if envname in os.environ:
568 self.config_name = os.environ[envname]
570 if commands is None:
571 commands = ConfigFileCommand.get_command_types()
572 self.command_dict = {}
573 self.commands = []
574 for cmd_type in commands:
575 cmd = cmd_type(self)
576 self.commands.append(cmd)
577 for name in cmd.get_names():
578 self.command_dict[name] = cmd
581 def set_ui_callback(self, callback: UiCallback) -> None:
582 '''
583 Register a callback to a user interface in order to show messages to the user like syntax errors or invalid values in the config file.
585 Messages which occur before this method is called are stored and forwarded as soon as the callback is registered.
587 :param ui_callback: A function to display messages to the user
588 '''
589 self.ui_notifier.set_ui_callback(callback)
591 def get_app_dirs(self) -> 'appdirs.AppDirs':
592 '''
593 Create or get a cached `AppDirs <https://github.com/ActiveState/appdirs/blob/master/README.rst#appdirs-for-convenience>`__ instance with multipath support enabled.
595 When creating a new instance, `platformdirs <https://pypi.org/project/platformdirs/>`__, `xdgappdirs <https://pypi.org/project/xdgappdirs/>`__ and `appdirs <https://pypi.org/project/appdirs/>`__ are tried, in that order.
596 The first one installed is used.
597 appdirs, the original of the two forks and the only one of the three with type stubs, is specified in pyproject.toml as a hard dependency so that at least one of the three should always be available.
598 I am not very familiar with the differences but if a user finds that appdirs does not work for them they can choose to use an alternative with ``pipx inject appname xdgappdirs|platformdirs``.
600 These libraries should respect the environment variables ``XDG_CONFIG_HOME`` and ``XDG_CONFIG_DIRS``.
601 '''
602 if not hasattr(self, '_appdirs'):
603 try:
604 import platformdirs # type: ignore [import] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs
605 AppDirs = typing.cast('type[appdirs.AppDirs]', platformdirs.PlatformDirs) # pragma: no cover # This is tested but in a different tox environment
606 except ImportError:
607 try:
608 import xdgappdirs # type: ignore [import] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs
609 AppDirs = typing.cast('type[appdirs.AppDirs]', xdgappdirs.AppDirs) # pragma: no cover # This is tested but in a different tox environment
610 except ImportError:
611 AppDirs = appdirs.AppDirs
613 self._appdirs = AppDirs(self.appname, self.authorname, multipath=True)
615 return self._appdirs
617 # ------- load -------
619 def iter_user_site_config_paths(self) -> 'Iterator[str]':
620 '''
621 Iterate over all directories which are searched for config files, user specific first.
623 The directories are based on :meth:`~confattr.configfile.ConfigFile.get_app_dirs`
624 unless :attr:`~confattr.configfile.ConfigFile.config_directory` has been set.
625 If :attr:`~confattr.configfile.ConfigFile.config_directory` has been set
626 it's value is yielded and nothing else.
627 '''
628 if self.config_directory:
629 yield self.config_directory
630 return
632 appdirs = self.get_app_dirs()
633 yield from appdirs.user_config_dir.split(os.path.pathsep)
634 yield from appdirs.site_config_dir.split(os.path.pathsep)
636 def iter_config_paths(self) -> 'Iterator[str]':
637 '''
638 Iterate over all paths which are checked for config files, user specific first.
640 Use this method if you want to tell the user where the application is looking for it's config file.
641 The first existing file yielded by this method is used by :meth:`~confattr.configfile.ConfigFile.load`.
643 The paths are generated by joining the directories yielded by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths` with
644 :attr:`ConfigFile.config_name <confattr.configfile.ConfigFile.config_name>`.
646 If :attr:`~confattr.configfile.ConfigFile.config_path` has been set this method yields that path instead and no other paths.
647 '''
648 if self.config_path:
649 yield self.config_path
650 return
652 for path in self.iter_user_site_config_paths():
653 yield os.path.join(path, self.config_name)
655 def load(self, *, env: bool = True) -> None:
656 '''
657 Load the first existing config file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
659 If there are several config files a user specific config file is preferred.
660 If a user wants a system wide config file to be loaded, too, they can explicitly include it in their config file.
661 :param env: If true: call :meth:`~confattr.configfile.ConfigFile.load_env` after loading the config file.
662 '''
663 for fn in self.iter_config_paths():
664 if os.path.isfile(fn):
665 self.load_file(fn)
666 break
668 if env:
669 self.load_env()
671 def load_env(self) -> None:
672 '''
673 Load settings from environment variables.
674 The name of the environment variable belonging to a setting is generated with :meth:`~confattr.configfile.ConfigFile.get_env_name`.
676 Environment variables not matching a setting or having an invalid value are reported with :meth:`self.ui_notifier.show_error() <confattr.configfile.UiNotifier.show_error>`.
678 :raises ValueError: if two settings have the same environment variable name (see :meth:`~confattr.configfile.ConfigFile.get_env_name`) or the environment variable name for a setting collides with one of the standard environment variables listed in :attr:`~confattr.configfile.ConfigFile.env_variables`
679 '''
680 old_file_name = self.context_file_name
681 self.context_file_name = Message.ENVIRONMENT_VARIABLES
683 config_instances: 'dict[str, Config[object]]' = {}
684 for key, instance in self.config_instances.items():
685 name = self.get_env_name(key)
686 if name in self.env_variables:
687 raise ValueError(f'setting {instance.key!r} conflicts with environment variable {name!r}')
688 elif name in config_instances:
689 raise ValueError(f'settings {instance.key!r} and {config_instances[name].key!r} result in the same environment variable {name!r}')
690 else:
691 config_instances[name] = instance
693 for name, value in os.environ.items():
694 if not name.startswith(self.envprefix):
695 continue
696 if name in self.env_variables:
697 continue
699 if name in config_instances:
700 instance = config_instances[name]
701 try:
702 instance.set_value(config_id=None, value=self.parse_value(instance, value, raw=True))
703 self.ui_notifier.show_info(f'set {instance.key} to {self.format_value(instance, config_id=None)}')
704 except ValueError as e:
705 self.ui_notifier.show_error(f"{e} while trying to parse environment variable {name}='{value}'")
706 else:
707 self.ui_notifier.show_error(f"unknown environment variable {name}='{value}'")
709 self.context_file_name = old_file_name
712 def get_env_name(self, key: str) -> str:
713 '''
714 Convert the key of a setting to the name of the corresponding environment variable.
716 :return: An all upper case version of :paramref:`~confattr.configfile.ConfigFile.get_env_name.key` with all hyphens, dots and spaces replaced by underscores and :attr:`~confattr.configfile.ConfigFile.envprefix` prepended to the result.
717 '''
718 out = key
719 out = out.upper()
720 for c in ' .-':
721 out = out.replace(c, '_')
722 out = self.envprefix + out
723 return out
725 def load_file(self, fn: str) -> None:
726 '''
727 Load a config file and change the :class:`~confattr.config.Config` objects accordingly.
729 Use :meth:`~confattr.configfile.ConfigFile.set_ui_callback` to get error messages which appeared while loading the config file.
730 You can call :meth:`~confattr.configfile.ConfigFile.set_ui_callback` after this method without loosing any messages.
732 :param fn: The file name of the config file (absolute or relative path)
733 '''
734 self.config_id = None
735 self.load_without_resetting_config_id(fn)
737 def load_without_resetting_config_id(self, fn: str) -> None:
738 old_file_name = self.context_file_name
739 self.context_file_name = fn
741 with open(fn, 'rt') as f:
742 for lnno, ln in enumerate(f, 1):
743 self.context_line_number = lnno
744 self.parse_line(line=ln)
745 self.context_line_number = None
747 self.context_file_name = old_file_name
749 def parse_line(self, line: str) -> bool:
750 '''
751 :param line: The line to be parsed
752 :return: True if line is valid, False if an error has occurred
754 :meth:`~confattr.configfile.ConfigFile.parse_error` is called if something goes wrong (i.e. if the return value is False), e.g. invalid key or invalid value.
755 '''
756 ln = line.strip()
757 if not ln:
758 return True
759 if self.is_comment(ln):
760 return True
761 if self.enable_config_ids and self.enter_group(ln):
762 return True
764 self.context_line = ln
766 try:
767 ln_split = self.split_line(ln)
768 except Exception as e:
769 self.parse_error(str(e))
770 out = False
771 else:
772 out = self.parse_split_line(ln_split)
774 self.context_line = ''
775 return out
777 def split_line(self, line: str) -> 'list[str]':
778 cmd, line = self.split_one_symbol_command(line)
779 line_split = shlex.split(line, comments=True)
780 if cmd:
781 line_split.insert(0, cmd)
782 return line_split
784 def split_line_ignore_errors(self, line: str) -> 'list[str]':
785 out = []
786 cmd, line = self.split_one_symbol_command(line)
787 if cmd:
788 out.append(cmd)
789 lex = shlex.shlex(line, posix=True)
790 lex.whitespace_split = True
791 while True:
792 try:
793 t = lex.get_token()
794 except:
795 out.append(lex.token)
796 return out
797 if t is None:
798 return out
799 out.append(t)
801 def split_one_symbol_command(self, line: str) -> 'tuple[str|None, str]':
802 if line and not line[0].isalnum() and line[0] in self.command_dict:
803 return line[0], line[1:]
805 return None, line
808 def is_comment(self, line: str) -> bool:
809 '''
810 Check if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment.
812 :param line: The current line
813 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment
814 '''
815 for c in self.COMMENT_PREFIXES:
816 if line.startswith(c):
817 return True
818 return False
820 def enter_group(self, line: str) -> bool:
821 '''
822 Check if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group and set :attr:`~confattr.configfile.ConfigFile.config_id` if it does.
823 Call :meth:`~confattr.configfile.ConfigFile.parse_error` if :meth:`~confattr.configfile.ConfigFile.check_config_id` raises a :class:`~confattr.configfile.ParseException`.
825 :param line: The current line
826 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group
827 '''
828 if line.startswith(self.ENTER_GROUP_PREFIX) and line.endswith(self.ENTER_GROUP_SUFFIX):
829 config_id = typing.cast(ConfigId, line[len(self.ENTER_GROUP_PREFIX):-len(self.ENTER_GROUP_SUFFIX)])
830 if self.check_config_id and config_id != Config.default_config_id:
831 try:
832 self.check_config_id(config_id)
833 except ParseException as e:
834 self.parse_error(str(e))
835 self.config_id = config_id
836 if self.config_id not in MultiConfig.config_ids:
837 MultiConfig.config_ids.append(self.config_id)
838 return True
839 return False
841 def parse_split_line(self, ln_split: 'Sequence[str]') -> bool:
842 '''
843 Call the corresponding command in :attr:`~confattr.configfile.ConfigFile.command_dict`.
844 If any :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is raised catch it and call :meth:`~confattr.configfile.ConfigFile.parse_error`.
846 :return: False if a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` has been caught, True if no exception has been caught
847 '''
848 cmd = self.get_command(ln_split)
849 try:
850 cmd.run(ln_split)
851 except ParseException as e:
852 self.parse_error(str(e))
853 return False
854 except MultipleParseExceptions as exceptions:
855 for exc in exceptions:
856 self.parse_error(str(exc))
857 return False
859 return True
861 def get_command(self, ln_split: 'Sequence[str]') -> 'ConfigFileCommand':
862 cmd_name = ln_split[0]
863 if cmd_name in self.command_dict:
864 cmd = self.command_dict[cmd_name]
865 elif DEFAULT_COMMAND in self.command_dict:
866 cmd = self.command_dict[DEFAULT_COMMAND]
867 else:
868 cmd = UnknownCommand(self)
869 return cmd
872 # ------- save -------
874 def get_save_path(self) -> str:
875 '''
876 :return: The first existing and writable file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths` or the first path if none of the files are existing and writable.
877 '''
878 paths = tuple(self.iter_config_paths())
879 for fn in paths:
880 if os.path.isfile(fn) and os.access(fn, os.W_OK):
881 return fn
883 return paths[0]
885 def save(self,
886 if_not_existing: bool = False,
887 **kw: 'Unpack[SaveKwargs]',
888 ) -> str:
889 '''
890 Save the current values of all settings to the file returned by :meth:`~confattr.configfile.ConfigFile.get_save_path`.
891 Directories are created as necessary.
893 :param config_instances: Do not save all settings but only those given. If this is a :class:`list` they are written in the given order. If this is a :class:`set` they are sorted by their keys.
894 :param ignore: Do not write these settings to the file.
895 :param no_multi: Do not write several sections. For :class:`~confattr.config.MultiConfig` instances write the default values only.
896 :param comments: Write comments with allowed values and help.
897 :param if_not_existing: Do not overwrite the file if it is already existing.
898 :return: The path to the file which has been written
899 '''
900 fn = self.get_save_path()
901 if if_not_existing and os.path.isfile(fn):
902 return fn
904 # "If, when attempting to write a file, the destination directory is non-existent an attempt should be made to create it with permission 0700.
905 # If the destination directory exists already the permissions should not be changed."
906 # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
907 os.makedirs(os.path.dirname(fn), exist_ok=True, mode=0o0700)
908 self.save_file(fn, **kw)
909 return fn
911 def save_file(self,
912 fn: str,
913 **kw: 'Unpack[SaveKwargs]'
914 ) -> None:
915 '''
916 Save the current values of all settings to a specific file.
918 :param fn: The name of the file to write to. If this is not an absolute path it is relative to the current working directory.
919 :raises FileNotFoundError: if the directory does not exist
921 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.
922 '''
923 with open(fn, 'wt') as f:
924 self.save_to_open_file(f, **kw)
927 def save_to_open_file(self,
928 f: typing.TextIO,
929 **kw: 'Unpack[SaveKwargs]',
930 ) -> None:
931 '''
932 Save the current values of all settings to a file-like object
933 by creating a :class:`~confattr.configfile.ConfigFileWriter` object and calling :meth:`~confattr.configfile.ConfigFile.save_to_writer`.
935 :param f: The file to write to
937 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.
938 '''
939 writer = ConfigFileWriter(f, prefix=self.COMMENT + ' ')
940 self.save_to_writer(writer, **kw)
942 def save_to_writer(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
943 '''
944 Save the current values of all settings.
946 Ensure that all keyword arguments are passed with :meth:`~confattr.configfile.ConfigFile.set_save_default_arguments`.
947 Iterate over all :class:`~confattr.configfile.ConfigFileCommand` objects in :attr:`~confattr.configfile.ConfigFile.commands` and do for each of them:
949 - set :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading` to :obj:`True` if :python:`getattr(cmd.save, 'implemented', True)` is true for two or more of those commands or to :obj:`False` otherwise
950 - call :meth:`~confattr.configfile.ConfigFileCommand.save`
951 '''
952 self.set_save_default_arguments(kw)
953 commands = self.commands
954 write_headings = len(tuple(cmd for cmd in commands if getattr(cmd.save, 'implemented', True))) >= 2
955 for cmd in commands:
956 cmd.should_write_heading = write_headings
957 cmd.save(writer, **kw)
959 def set_save_default_arguments(self, kw: 'SaveKwargs') -> None:
960 '''
961 Ensure that all arguments are given in :paramref:`~confattr.configfile.ConfigFile.set_save_default_arguments.kw`.
962 '''
963 kw.setdefault('config_instances', set(self.config_instances.values()))
964 kw.setdefault('ignore', None)
965 kw.setdefault('no_multi', not self.enable_config_ids)
966 kw.setdefault('comments', True)
969 def quote(self, val: str) -> str:
970 '''
971 Quote a value if necessary so that it will be interpreted as one argument.
973 The default implementation calls :func:`~confattr.utils.readable_quote`.
974 '''
975 return readable_quote(val)
977 def write_config_id(self, writer: FormattedWriter, config_id: ConfigId) -> None:
978 '''
979 Start a new group in the config file so that all following commands refer to the given :paramref:`~confattr.configfile.ConfigFile.write_config_id.config_id`.
980 '''
981 writer.write_command(self.ENTER_GROUP_PREFIX + config_id + self.ENTER_GROUP_SUFFIX)
983 def get_help_config_id(self) -> str:
984 '''
985 :return: A help how to use :class:`~confattr.config.MultiConfig`. The return value still needs to be cleaned with :func:`inspect.cleandoc`.
986 '''
987 return f'''
988 You can specify the object that a value shall refer to by inserting the line `{self.ENTER_GROUP_PREFIX}config-id{self.ENTER_GROUP_SUFFIX}` above.
989 `config-id` must be replaced by the corresponding identifier for the object.
990 '''
993 # ------- formatting and parsing of values -------
995 def format_value(self, instance: Config[typing.Any], config_id: 'ConfigId|None') -> str:
996 '''
997 :param instance: The config value to be saved
998 :param config_id: Which value to be written in case of a :class:`~confattr.config.MultiConfig`, should be :obj:`None` for a normal :class:`~confattr.config.Config` instance
999 :return: A str representation to be written to the config file
1001 Convert the value of the :class:`~confattr.config.Config` instance into a str with :meth:`~confattr.configfile.ConfigFile.format_any_value`.
1002 '''
1003 return self.format_any_value(instance.type, instance.get_value(config_id))
1005 def format_any_value(self, type: 'AbstractFormatter[T2]', value: 'T2') -> str:
1006 return type.format_value(self, value)
1009 def parse_value(self, instance: 'Config[T2]', value: str, *, raw: bool) -> 'T2':
1010 '''
1011 :param instance: The config instance for which the value should be parsed, this is important for the data type
1012 :param value: The string representation of the value to be parsed
1013 :param raw: if false: expand :paramref:`~confattr.configfile.ConfigFile.parse_value.value` with :meth:`~confattr.configfile.ConfigFile.expand` first, if true: parse :paramref:`~confattr.configfile.ConfigFile.parse_value.value` as it is
1014 Parse a value to the data type of a given setting by calling :meth:`~confattr.configfile.ConfigFile.parse_value_part`
1015 '''
1016 if not raw:
1017 value = self.expand(value)
1018 return self.parse_value_part(instance, instance.type, value)
1020 def parse_value_part(self, config: 'Config[typing.Any]', t: 'AbstractFormatter[T2]', value: str) -> 'T2':
1021 '''
1022 Parse a value to the given data type.
1024 :param config: Needed for the allowed values and the key for error messages
1025 :param t: The data type to which :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` shall be parsed
1026 :param value: The value to be parsed
1027 :raises ValueError: if :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` is invalid
1028 '''
1029 return t.parse_value(self, value)
1032 def expand(self, arg: str) -> str:
1033 return self.expand_config(self.expand_env(arg))
1035 reo_config = re.compile(r'%([^%]*)%')
1036 def expand_config(self, arg: str) -> str:
1037 n = arg.count('%')
1038 if n % 2 == 1:
1039 raise ParseException("uneven number of percent characters, use %% for a literal percent sign or --raw if you don't want expansion")
1040 return self.reo_config.sub(self.expand_config_match, arg)
1042 reo_env = re.compile(r'\$\{([^{}]*)\}')
1043 def expand_env(self, arg: str) -> str:
1044 return self.reo_env.sub(self.expand_env_match, arg)
1046 def expand_config_match(self, m: 're.Match[str]') -> str:
1047 '''
1048 :param m: A match of :attr:`~confattr.configfile.ConfigFile.reo_config`, group 1 is the :attr:`Config.key <confattr.config.Config.key>` possibly including a ``!conversion`` or a ``:format_spec``
1049 :return: The expanded form of the setting or ``'%'`` if group 1 is empty
1050 :raises ParseException: If ``key``, ``!conversion`` or ``:format_spec`` is invalid
1052 This is based on the `Python Format String Syntax <https://docs.python.org/3/library/string.html#format-string-syntax>`__.
1054 ``field_name`` is the :attr:`~confattr.config.Config.key`.
1056 ``!conversion`` is one of:
1058 - ``!``: :meth:`ConfigFile.format_value() <confattr.configfile.ConfigFile.format_value>`
1059 - ``!r``: :func:`repr`
1060 - ``!s``: :class:`str`
1061 - ``!a``: :func:`ascii`
1063 ``:format_spec`` depends on the :attr:`Config.type <confattr.config.Config.type>`, see the `Python Format Specification Mini-Language <https://docs.python.org/3/library/string.html#formatspec>`__.
1064 :meth:`List() <confattr.formatters.List.expand_value>`, :meth:`Set() <confattr.formatters.Set.expand_value>` and :meth:`Dict() <confattr.formatters.Dict.expand_value>` implement :meth:`~confattr.formatters.AbstractFormatter.expand_value` so that you can access specific items.
1065 If :meth:`~confattr.formatters.AbstractFormatter.expand_value` raises an :class:`Exception` it is caught and reraised as a :class:`~confattr.configfile.ParseException`.
1066 '''
1067 key = m.group(1)
1068 if not key:
1069 return '%'
1071 if ':' in key:
1072 key, fmt = key.split(':', 1)
1073 else:
1074 fmt = None
1075 if '!' in key:
1076 key, stringifier = key.split('!', 1)
1077 else:
1078 stringifier = None
1080 if key not in self.config_instances:
1081 raise ParseException(f'invalid key {key!r}')
1082 instance = self.config_instances[key]
1084 if stringifier is None and fmt is None:
1085 return self.format_value(instance, config_id=None)
1086 elif stringifier is None:
1087 assert fmt is not None
1088 try:
1089 return instance.type.expand_value(self, instance.get_value(config_id=None), format_spec=fmt)
1090 except Exception as e:
1091 raise ParseException(e)
1093 val: object
1094 if stringifier == '':
1095 val = self.format_value(instance, config_id=None)
1096 else:
1097 val = instance.get_value(config_id=None)
1098 if stringifier == 'r':
1099 val = repr(val)
1100 elif stringifier == 's':
1101 val = str(val)
1102 elif stringifier == 'a':
1103 val = ascii(val)
1104 else:
1105 raise ParseException('invalid conversion %r' % stringifier)
1107 if fmt is None:
1108 assert isinstance(val, str)
1109 return val
1111 try:
1112 return format(val, fmt)
1113 except ValueError as e:
1114 raise ParseException(e)
1116 def expand_env_match(self, m: 're.Match[str]') -> str:
1117 '''
1118 :param m: A match of :attr:`~confattr.configfile.ConfigFile.reo_env`, group 1 is the name of the environment variable possibly including one of the following expansion features
1119 :return: The expanded form of the environment variable
1121 Supported are the following `parameter expansion features as defined by POSIX <https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_02>`__, except that word is not expanded:
1123 - ``${parameter:-word}``/``${parameter-word}``: Use Default Values. If parameter is unset (or empty), word shall be substituted; otherwise, the value of parameter shall be substituted.
1124 - ``${parameter:=word}``/``${parameter=word}``: Assign Default Values. If parameter is unset (or empty), word shall be assigned to parameter. In all cases, the final value of parameter shall be substituted.
1125 - ``${parameter:?[word]}``/``${parameter?[word]}``: Indicate Error If Unset (or Empty). If parameter is unset (or empty), a :class:`~confattr.configfile.ParseException` shall be raised with word as message or a default error message if word is omitted. Otherwise, the value of parameter shall be substituted.
1126 - ``${parameter:+word}``/``${parameter+word}``: Use Alternative Value. If parameter is unset (or empty), empty shall be substituted; otherwise, the expansion of word shall be substituted.
1128 In the patterns above, if you use a ``:`` it is checked whether parameter is unset or empty.
1129 If ``:`` is not used the check is only true if parameter is unset, empty is treated as a valid value.
1130 '''
1131 env = m.group(1)
1132 for op in '-=?+':
1133 if ':' + op in env:
1134 env, arg = env.split(':' + op, 1)
1135 isset = bool(os.environ.get(env))
1136 elif op in env:
1137 env, arg = env.split(op, 1)
1138 isset = env in os.environ
1139 else:
1140 continue
1142 val = os.environ.get(env, '')
1143 if op == '-':
1144 if isset:
1145 return val
1146 else:
1147 return arg
1148 elif op == '=':
1149 if isset:
1150 return val
1151 else:
1152 os.environ[env] = arg
1153 return arg
1154 elif op == '?':
1155 if isset:
1156 return val
1157 else:
1158 if not arg:
1159 state = 'empty' if env in os.environ else 'unset'
1160 arg = f'environment variable {env} is {state}'
1161 raise ParseException(arg)
1162 elif op == '+':
1163 if isset:
1164 return arg
1165 else:
1166 return ''
1167 else:
1168 assert False
1170 return os.environ.get(env, '')
1173 # ------- help -------
1175 def write_help(self, writer: FormattedWriter) -> None:
1176 import platform
1177 formatter = self.create_formatter()
1178 writer.write_lines('The first existing file of the following paths is loaded:')
1179 for path in self.iter_config_paths():
1180 writer.write_line('- %s' % path)
1182 writer.write_line('')
1183 writer.write_line('This can be influenced with the following environment variables:')
1184 if platform.system() == 'Linux': # pragma: no branch
1185 writer.write_line('- XDG_CONFIG_HOME')
1186 writer.write_line('- XDG_CONFIG_DIRS')
1187 for env in self.env_variables:
1188 writer.write_line(f'- {env}')
1190 writer.write_line('')
1191 writer.write_lines(formatter.format_text(f'''\
1192You can also use environment variables to change the values of the settings listed under `set` command.
1193The corresponding environment variable name is the name of the setting in all upper case letters
1194with dots, hypens and spaces replaced by underscores and prefixed with "{self.envprefix}".'''))
1196 writer.write_lines(formatter.format_text('Lines in the config file which start with a %s are ignored.' % ' or '.join('`%s`' % c for c in self.COMMENT_PREFIXES)))
1198 writer.write_lines('The config file may contain the following commands:')
1199 for cmd in self.commands:
1200 names = '|'.join(cmd.get_names())
1201 writer.write_heading(SectionLevel.SECTION, names)
1202 writer.write_lines(cmd.get_help())
1204 def create_formatter(self) -> HelpFormatterWrapper:
1205 return HelpFormatterWrapper(self.formatter_class)
1207 def get_help(self) -> str:
1208 '''
1209 A convenience wrapper around :meth:`~confattr.configfile.ConfigFile.write_help`
1210 to return the help as a str instead of writing it to a file.
1212 This uses :class:`~confattr.configfile.HelpWriter`.
1213 '''
1214 doc = io.StringIO()
1215 self.write_help(HelpWriter(doc))
1216 # The generated help ends with a \n which is implicitly added by print.
1217 # If I was writing to stdout or a file that would be desired.
1218 # But if I return it as a string and then print it, the print adds another \n which would be too much.
1219 # Therefore I am stripping the trailing \n.
1220 return doc.getvalue().rstrip('\n')
1223 # ------- auto complete -------
1225 def get_completions(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1226 '''
1227 Provide an auto completion for commands that can be executed with :meth:`~confattr.configfile.ConfigFile.parse_line`.
1229 :param line: The entire line that is currently in the text input field
1230 :param cursor_pos: The position of the cursor
1231 :return: start of line, completions, end of line.
1232 *completions* is a list of possible completions for the word where the cursor is located.
1233 If *completions* is an empty list there are no completions available and the user input should not be changed.
1234 If *completions* is not empty it should be displayed by a user interface in a drop down menu.
1235 The *start of line* is everything on the line before the completions.
1236 The *end of line* is everything on the line after the completions.
1237 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1238 *start of line* and *end of line* should be the beginning and end of :paramref:`~confattr.configfile.ConfigFile.get_completions.line` but they may contain minor changes in order to keep quoting feasible.
1239 '''
1240 original_ln = line
1241 stripped_line = line.lstrip()
1242 indentation = line[:len(line) - len(stripped_line)]
1243 cursor_pos -= len(indentation)
1244 line = stripped_line
1245 if self.enable_config_ids and line.startswith(self.ENTER_GROUP_PREFIX):
1246 out = self.get_completions_enter_group(line, cursor_pos)
1247 else:
1248 out = self.get_completions_command(line, cursor_pos)
1250 out = (indentation + out[0], out[1], out[2])
1251 return out
1253 def get_completions_enter_group(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1254 '''
1255 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.
1257 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_enter_group.line`
1258 and will prepend it to the first item of the return value.
1259 '''
1260 start = line
1261 groups = [self.ENTER_GROUP_PREFIX + str(cid) + self.ENTER_GROUP_SUFFIX for cid in MultiConfig.config_ids]
1262 groups = [cid for cid in groups if cid.startswith(start)]
1263 return '', groups, ''
1265 def get_completions_command(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1266 '''
1267 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.
1269 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_command.line`
1270 and will prepend it to the first item of the return value.
1271 '''
1272 if not line:
1273 return self.get_completions_command_name(line, cursor_pos, start_of_line='', end_of_line='')
1275 ln_split = self.split_line_ignore_errors(line)
1276 assert ln_split
1277 a = self.find_arg(line, ln_split, cursor_pos)
1279 if a.in_between:
1280 start_of_line = line[:cursor_pos]
1281 end_of_line = line[cursor_pos:]
1282 else:
1283 start_of_line = line[:a.i0]
1284 end_of_line = line[a.i1:]
1286 if a.argument_pos == 0:
1287 return self.get_completions_command_name(line, cursor_pos, start_of_line=start_of_line, end_of_line=end_of_line)
1288 else:
1289 cmd = self.get_command(ln_split)
1290 return cmd.get_completions(ln_split, a.argument_pos, cursor_pos-a.i0, in_between=a.in_between, start_of_line=start_of_line, end_of_line=end_of_line)
1292 def find_arg(self, line: str, ln_split: 'list[str]', cursor_pos: int) -> ArgPos:
1293 '''
1294 This is an internal method used by :meth:`~confattr.configfile.ConfigFile.get_completions_command`
1295 '''
1296 CHARS_REMOVED_BY_SHLEX = ('"', "'", '\\')
1297 assert cursor_pos <= len(line) # yes, cursor_pos can be == len(str)
1298 out = ArgPos()
1299 out.in_between = True
1301 # init all out attributes just to be save, these should not never be used because line is not empty and not white space only
1302 out.argument_pos = 0
1303 out.i0 = 0
1304 out.i1 = 0
1306 n_ln = len(line)
1307 i_ln = 0
1308 n_arg = len(ln_split)
1309 out.argument_pos = 0
1310 i_in_arg = 0
1311 assert out.argument_pos < n_ln
1312 while True:
1313 if out.in_between:
1314 assert i_in_arg == 0
1315 if i_ln >= n_ln:
1316 assert out.argument_pos >= n_arg - 1
1317 out.i0 = i_ln
1318 return out
1319 elif line[i_ln].isspace():
1320 i_ln += 1
1321 else:
1322 out.i0 = i_ln
1323 if i_ln >= cursor_pos:
1324 return out
1325 if out.argument_pos >= n_arg:
1326 assert line[i_ln] == '#'
1327 out.i0 = len(line)
1328 return out
1329 out.in_between = False
1330 else:
1331 if i_ln >= n_ln:
1332 assert out.argument_pos >= n_arg - 1
1333 out.i1 = i_ln
1334 return out
1335 elif i_in_arg >= len(ln_split[out.argument_pos]):
1336 if line[i_ln].isspace():
1337 out.i1 = i_ln
1338 if i_ln >= cursor_pos:
1339 return out
1340 out.in_between = True
1341 i_ln += 1
1342 out.argument_pos += 1
1343 i_in_arg = 0
1344 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX:
1345 i_ln += 1
1346 else:
1347 # unlike bash shlex treats a comment character inside of an argument as a comment character
1348 assert line[i_ln] == '#'
1349 assert out.argument_pos == n_arg - 1
1350 out.i1 = i_ln
1351 return out
1352 elif line[i_ln] == ln_split[out.argument_pos][i_in_arg]:
1353 i_ln += 1
1354 i_in_arg += 1
1355 if out.argument_pos == 0 and i_ln == 1 and self.split_one_symbol_command(line)[0]:
1356 out.in_between = True
1357 out.argument_pos += 1
1358 out.i0 = i_ln
1359 i_in_arg = 0
1360 else:
1361 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX
1362 i_ln += 1
1365 def get_completions_command_name(self, line: str, cursor_pos: int, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1366 start = line[:cursor_pos]
1367 completions = [cmd for cmd in self.command_dict.keys() if cmd.startswith(start) and len(cmd) > 1]
1368 return start_of_line, completions, end_of_line
1371 def get_completions_for_file_name(self, start: str, *, relative_to: str, include: 'Callable[[str, str], bool]|None' = None, exclude: 'str|None' = None, match: 'Callable[[str, str, str], bool]' = lambda path, name, start: name.startswith(start), start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1372 r'''
1373 :param start: The start of the path to be completed
1374 :param relative_to: If :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` is a relative path it's relative to this directory
1375 :param exclude: A regular expression. The default value :obj:`None` is interpreted differently depending on the :func:`platform.platform`. For ``Windows`` it's ``$none`` so that nothing is excluded. For others it's ``^\.`` so that hidden files and directories are excluded.
1376 :param include: A function which takes the path and file name as arguments and returns whether this file/directory is a valid completion.
1377 :param match: A callable to decide if a completion fits for the given start. It takes three arguments: the parent directory, the file/directory name and the start. If it returns true the file/direcotry is added to the list of possible completions. The default is ``lambda path, name, start: name.startswith(start)``.
1378 :return: All files and directories that start with :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` and do not match :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.exclude`. Directories are appended with :const:`os.path.sep`. :const:`os.path.sep` is appended after quoting so that it can be easily stripped if undesired (e.g. if the user interface cycles through all possible completions instead of completing the longest common prefix).
1379 '''
1380 if exclude is None:
1381 if platform.platform() == 'Windows' or os.path.split(start)[1].startswith('.'):
1382 exclude = '$none'
1383 else:
1384 exclude = r'^\.'
1385 reo = re.compile(exclude)
1387 # I cannot use os.path.split because that would ignore the important difference between having a trailing separator or not
1388 if os.path.sep in start:
1389 directory, start = start.rsplit(os.path.sep, 1)
1390 directory += os.path.sep
1391 quoted_directory = self.quote_path(directory)
1393 start_of_line += quoted_directory
1394 directory = os.path.expanduser(directory)
1395 if not os.path.isabs(directory):
1396 directory = os.path.join(relative_to, directory)
1397 directory = os.path.normpath(directory)
1398 else:
1399 directory = relative_to
1401 try:
1402 names = os.listdir(directory)
1403 except (FileNotFoundError, NotADirectoryError):
1404 return start_of_line, [], end_of_line
1406 out: 'list[str]' = []
1407 for name in names:
1408 if reo.match(name):
1409 continue
1410 if include and not include(directory, name):
1411 continue
1412 if not match(directory, name, start):
1413 continue
1415 quoted_name = self.quote(name)
1416 if os.path.isdir(os.path.join(directory, name)):
1417 quoted_name += os.path.sep
1419 out.append(quoted_name)
1421 return start_of_line, out, end_of_line
1423 def quote_path(self, path: str) -> str:
1424 path_split = path.split(os.path.sep)
1425 i0 = 1 if path_split[0] == '~' else 0
1426 for i in range(i0, len(path_split)):
1427 if path_split[i]:
1428 path_split[i] = self.quote(path_split[i])
1429 return os.path.sep.join(path_split)
1432 def get_completions_for_expand(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1433 applicable, start_of_line, completions, end_of_line = self.get_completions_for_expand_env(start, start_of_line=start_of_line, end_of_line=end_of_line)
1434 if applicable:
1435 return applicable, start_of_line, completions, end_of_line
1437 return self.get_completions_for_expand_config(start, start_of_line=start_of_line, end_of_line=end_of_line)
1439 def get_completions_for_expand_config(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1440 if start.count('%') % 2 == 0:
1441 return False, start_of_line, [], end_of_line
1443 i = start.rindex('%') + 1
1444 start_of_line = start_of_line + start[:i]
1445 start = start[i:]
1446 completions = [key for key in sorted(self.config_instances.keys()) if key.startswith(start)]
1447 return True, start_of_line, completions, end_of_line
1449 def get_completions_for_expand_env(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1450 i = start.rfind('${')
1451 if i < 0:
1452 return False, start_of_line, [], end_of_line
1453 i += 2
1455 if '}' in start[i:]:
1456 return False, start_of_line, [], end_of_line
1458 start_of_line = start_of_line + start[:i]
1459 start = start[i:]
1460 completions = [key for key in sorted(os.environ.keys()) if key.startswith(start)]
1461 return True, start_of_line, completions, end_of_line
1464 # ------- error handling -------
1466 def parse_error(self, msg: str) -> None:
1467 '''
1468 Is called if something went wrong while trying to load a config file.
1470 This method is called when a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is caught.
1471 This method compiles the given information into an error message and calls :meth:`self.ui_notifier.show_error() <confattr.configfile.UiNotifier.show_error>`.
1473 :param msg: The error message
1474 '''
1475 self.ui_notifier.show_error(msg)
1478# ---------- base classes for commands which can be used in config files ----------
1480class ConfigFileCommand(abc.ABC):
1482 '''
1483 An abstract base class for commands which can be used in a config file.
1485 Subclasses must implement the :meth:`~confattr.configfile.ConfigFileCommand.run` method which is called when :class:`~confattr.configfile.ConfigFile` is loading a file.
1486 Subclasses should contain a doc string so that :meth:`~confattr.configfile.ConfigFileCommand.get_help` can provide a description to the user.
1487 Subclasses may set the :attr:`~confattr.configfile.ConfigFileCommand.name` and :attr:`~confattr.configfile.ConfigFileCommand.aliases` attributes to change the output of :meth:`~confattr.configfile.ConfigFileCommand.get_name` and :meth:`~confattr.configfile.ConfigFileCommand.get_names`.
1489 All subclasses are remembered and can be retrieved with :meth:`~confattr.configfile.ConfigFileCommand.get_command_types`.
1490 They are instantiated in the constructor of :class:`~confattr.configfile.ConfigFile`.
1491 '''
1493 #: The name which is used in the config file to call this command. Use an empty string to define a default command which is used if an undefined command is encountered. If this is not set :meth:`~confattr.configfile.ConfigFileCommand.get_name` returns the name of this class in lower case letters and underscores replaced by hyphens.
1494 name: str
1496 #: Alternative names which can be used in the config file.
1497 aliases: 'tuple[str, ...]|list[str]'
1499 #: A description which may be used by an in-app help. If this is not set :meth:`~confattr.configfile.ConfigFileCommand.get_help` uses the doc string instead.
1500 help: str
1502 #: If a config file contains only a single section it makes no sense to write a heading for it. This attribute is set by :meth:`ConfigFile.save_to_writer() <confattr.configfile.ConfigFile.save_to_writer>` if there are several commands which implement the :meth:`~confattr.configfile.ConfigFileCommand.save` method. If you implement :meth:`~confattr.configfile.ConfigFileCommand.save` and this attribute is set then :meth:`~confattr.configfile.ConfigFileCommand.save` should write a section header. If :meth:`~confattr.configfile.ConfigFileCommand.save` writes several sections it should always write the headings regardless of this attribute.
1503 should_write_heading: bool = False
1505 #: The :class:`~confattr.configfile.ConfigFile` that has been passed to the constructor. It determines for example the :paramref:`~confattr.configfile.ConfigFile.notification_level` and the available :paramref:`~confattr.configfile.ConfigFile.commands`.
1506 config_file: ConfigFile
1508 #: The :class:`~confattr.configfile.UiNotifier` of :attr:`~confattr.configfile.ConfigFileCommand.config_file`
1509 ui_notifier: UiNotifier
1512 _subclasses: 'list[type[ConfigFileCommand]]' = []
1513 _used_names: 'set[str]' = set()
1515 @classmethod
1516 def get_command_types(cls) -> 'tuple[type[ConfigFileCommand], ...]':
1517 '''
1518 :return: All subclasses of :class:`~confattr.configfile.ConfigFileCommand` which have not been deleted with :meth:`~confattr.configfile.ConfigFileCommand.delete_command_type`
1519 '''
1520 return tuple(cls._subclasses)
1522 @classmethod
1523 def delete_command_type(cls, cmd_type: 'type[ConfigFileCommand]') -> None:
1524 '''
1525 Delete :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` so that it is not returned anymore by :meth:`~confattr.configfile.ConfigFileCommand.get_command_types` and that it's name can be used by another command.
1526 Do nothing if :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` has already been deleted.
1527 '''
1528 if cmd_type in cls._subclasses:
1529 cls._subclasses.remove(cmd_type)
1530 for name in cmd_type.get_names():
1531 cls._used_names.remove(name)
1533 @classmethod
1534 def __init_subclass__(cls, replace: bool = False, abstract: bool = False) -> None:
1535 '''
1536 :param replace: Set :attr:`~confattr.configfile.ConfigFileCommand.name` and :attr:`~confattr.configfile.ConfigFileCommand.aliases` to the values of the parent class if they are not set explicitly, delete the parent class with :meth:`~confattr.configfile.ConfigFileCommand.delete_command_type` and replace any commands with the same name
1537 :param abstract: This class is a base class for the implementation of other commands and shall *not* be returned by :meth:`~confattr.configfile.ConfigFileCommand.get_command_types`
1538 :raises ValueError: if the name or one of it's aliases is already in use and :paramref:`~confattr.configfile.ConfigFileCommand.__init_subclass__.replace` is not true
1539 '''
1540 if replace:
1541 parent_commands = [parent for parent in cls.__bases__ if issubclass(parent, ConfigFileCommand)]
1543 # set names of this class to that of the parent class(es)
1544 parent = parent_commands[0]
1545 if 'name' not in cls.__dict__:
1546 cls.name = parent.get_name()
1547 if 'aliases' not in cls.__dict__:
1548 cls.aliases = list(parent.get_names())[1:]
1549 for parent in parent_commands[1:]:
1550 cls.aliases.extend(parent.get_names())
1552 # remove parent class from the list of commands to be loaded or saved
1553 for parent in parent_commands:
1554 cls.delete_command_type(parent)
1556 if not abstract:
1557 cls._subclasses.append(cls)
1558 for name in cls.get_names():
1559 if name in cls._used_names and not replace:
1560 raise ValueError('duplicate command name %r' % name)
1561 cls._used_names.add(name)
1563 @classmethod
1564 def get_name(cls) -> str:
1565 '''
1566 :return: The name which is used in config file to call this command.
1568 If :attr:`~confattr.configfile.ConfigFileCommand.name` is set it is returned as it is.
1569 Otherwise a name is generated based on the class name.
1570 '''
1571 if 'name' in cls.__dict__:
1572 return cls.name
1573 return cls.__name__.lower().replace("_", "-")
1575 @classmethod
1576 def get_names(cls) -> 'Iterator[str]':
1577 '''
1578 :return: Several alternative names which can be used in a config file to call this command.
1580 The first one is always the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_name`.
1581 If :attr:`~confattr.configfile.ConfigFileCommand.aliases` is set it's items are yielded afterwards.
1583 If one of the returned items is the empty string this class is the default command
1584 and :meth:`~confattr.configfile.ConfigFileCommand.run` will be called if an undefined command is encountered.
1585 '''
1586 yield cls.get_name()
1587 if 'aliases' in cls.__dict__:
1588 for name in cls.aliases:
1589 yield name
1591 def __init__(self, config_file: ConfigFile) -> None:
1592 self.config_file = config_file
1593 self.ui_notifier = config_file.ui_notifier
1595 @abc.abstractmethod
1596 def run(self, cmd: 'Sequence[str]') -> None:
1597 '''
1598 Process one line which has been read from a config file
1600 :raises ParseException: if there is an error in the line (e.g. invalid syntax)
1601 :raises MultipleParseExceptions: if there are several errors in the same line
1602 '''
1603 raise NotImplementedError()
1606 def create_formatter(self) -> HelpFormatterWrapper:
1607 return self.config_file.create_formatter()
1609 def get_help_attr_or_doc_str(self) -> str:
1610 '''
1611 :return: The :attr:`~confattr.configfile.ConfigFileCommand.help` attribute or the doc string if :attr:`~confattr.configfile.ConfigFileCommand.help` has not been set, cleaned up with :func:`inspect.cleandoc`.
1612 '''
1613 if hasattr(self, 'help'):
1614 doc = self.help
1615 elif self.__doc__:
1616 doc = self.__doc__
1617 else:
1618 doc = ''
1620 return inspect.cleandoc(doc)
1622 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
1623 '''
1624 Add the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_help_attr_or_doc_str` to :paramref:`~confattr.configfile.ConfigFileCommand.add_help_to.formatter`.
1625 '''
1626 formatter.add_text(self.get_help_attr_or_doc_str())
1628 def get_help(self) -> str:
1629 '''
1630 :return: A help text which can be presented to the user.
1632 This is generated by creating a formatter with :meth:`~confattr.configfile.ConfigFileCommand.create_formatter`,
1633 adding the help to it with :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` and
1634 stripping trailing new line characters from the result of :meth:`HelpFormatterWrapper.format_help() <confattr.utils.HelpFormatterWrapper.format_help>`.
1636 Most likely you don't want to override this method but :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` instead.
1637 '''
1638 formatter = self.create_formatter()
1639 self.add_help_to(formatter)
1640 return formatter.format_help().rstrip('\n')
1642 def get_short_description(self) -> str:
1643 '''
1644 :return: The first paragraph of the doc string/help attribute
1645 '''
1646 out = self.get_help_attr_or_doc_str().split('\n\n')
1647 if out[0].startswith('usage: '):
1648 if len(out) > 1:
1649 return out[1]
1650 return ""
1651 return out[0]
1653 def save(self,
1654 writer: FormattedWriter,
1655 **kw: 'Unpack[SaveKwargs]',
1656 ) -> None:
1657 '''
1658 Implement this method if you want calls to this command to be written by :meth:`ConfigFile.save() <confattr.configfile.ConfigFile.save>`.
1660 If you implement this method write a section heading with :meth:`writer.write_heading('Heading') <confattr.configfile.FormattedWriter.write_heading>` if :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading` is true.
1661 If this command writes several sections then write a heading for every section regardless of :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading`.
1663 Write as many calls to this command as necessary to the config file in order to create the current state with :meth:`writer.write_command('...') <confattr.configfile.FormattedWriter.write_command>`.
1664 Write comments or help with :meth:`writer.write_lines('...') <confattr.configfile.FormattedWriter.write_lines>`.
1666 There is the :attr:`~confattr.configfile.ConfigFileCommand.config_file` attribute (which was passed to the constructor) which you can use to:
1668 - quote arguments with :meth:`ConfigFile.quote() <confattr.configfile.ConfigFile.quote>`
1669 - call :meth:`ConfigFile.write_config_id() <confattr.configfile.ConfigFile.write_config_id>`
1671 You probably don't need the comment character :attr:`ConfigFile.COMMENT <confattr.configfile.ConfigFile.COMMENT>` because :paramref:`~confattr.configfile.ConfigFileCommand.save.writer` automatically comments out everything except for :meth:`FormattedWriter.write_command() <confattr.configfile.FormattedWriter.write_command>`.
1673 The default implementation does nothing.
1674 '''
1675 pass
1677 save.implemented = False # type: ignore [attr-defined]
1680 # ------- auto complete -------
1682 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1683 '''
1684 :param cmd: The line split into arguments (including the name of this command as cmd[0])
1685 :param argument_pos: The index of the argument which shall be completed. Please note that this can be one bigger than :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.cmd` is long if the line ends on a space and the cursor is behind that space. In that case :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.in_between` is true.
1686 :param cursor_pos: The index inside of the argument where the cursor is located. This is undefined and should be ignored if :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.in_between` is true. The input from the start of the argument to the cursor should be used to filter the completions. The input after the cursor can be ignored.
1687 :param in_between: If true: The cursor is between two arguments, before the first argument or after the last argument. :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.argument_pos` refers to the next argument, :paramref:`argument_pos-1 <confattr.configfile.ConfigFileCommand.get_completions.argument_pos>` to the previous argument. :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.cursor_pos` is undefined.
1688 :param start_of_line: The first return value. If ``cmd[argument_pos]`` has a pattern like ``key=value`` you can append ``key=`` to this value and return only completions of ``value`` as second return value.
1689 :param end_of_line: The third return value.
1690 :return: start of line, completions, end of line.
1691 *completions* is a list of possible completions for the word where the cursor is located.
1692 If *completions* is an empty list there are no completions available and the user input should not be changed.
1693 This should be displayed by a user interface in a drop down menu.
1694 The *start of line* is everything on the line before the completions.
1695 The *end of line* is everything on the line after the completions.
1696 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1697 '''
1698 completions: 'list[str]' = []
1699 return start_of_line, completions, end_of_line
1702class ArgumentParser(argparse.ArgumentParser):
1704 def error(self, message: str) -> 'typing.NoReturn':
1705 '''
1706 Raise a :class:`~confattr.configfile.ParseException`.
1707 '''
1708 raise ParseException(message)
1710class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True):
1712 '''
1713 An abstract subclass of :class:`~confattr.configfile.ConfigFileCommand` which uses :mod:`argparse` to make parsing and providing help easier.
1715 You must implement the class method :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` to add the arguments to :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.
1716 Instead of :meth:`~confattr.configfile.ConfigFileArgparseCommand.run` you must implement :meth:`~confattr.configfile.ConfigFileArgparseCommand.run_parsed`.
1717 You don't need to add a usage or the possible arguments to the doc string as :mod:`argparse` will do that for you.
1718 You should, however, still give a description what this command does in the doc string.
1720 You may specify :attr:`ConfigFileCommand.name <confattr.configfile.ConfigFileCommand.name>`, :attr:`ConfigFileCommand.aliases <confattr.configfile.ConfigFileCommand.aliases>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>` like for :class:`~confattr.configfile.ConfigFileCommand`.
1721 '''
1723 #: The argument parser which is passed to :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` for adding arguments and which is used in :meth:`~confattr.configfile.ConfigFileArgparseCommand.run`
1724 parser: ArgumentParser
1726 def __init__(self, config_file: ConfigFile) -> None:
1727 super().__init__(config_file)
1728 self._names = set(self.get_names())
1729 self.parser = ArgumentParser(prog=self.get_name(), description=self.get_help_attr_or_doc_str(), add_help=False, formatter_class=self.config_file.formatter_class)
1730 self.init_parser(self.parser)
1732 @abc.abstractmethod
1733 def init_parser(self, parser: ArgumentParser) -> None:
1734 '''
1735 :param parser: The parser to add arguments to. This is the same object like :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.
1737 This is an abstract method which must be implemented by subclasses.
1738 Use :meth:`ArgumentParser.add_argument() <confattr.configfile.ArgumentParser.add_argument>` to add arguments to :paramref:`~confattr.configfile.ConfigFileArgparseCommand.init_parser.parser`.
1739 '''
1740 pass
1742 @staticmethod
1743 def add_enum_argument(parser: 'argparse.ArgumentParser|argparse._MutuallyExclusiveGroup', *name_or_flags: str, type: 'type[enum.Enum]') -> 'argparse.Action':
1744 '''
1745 This method:
1747 - generates a function to convert the user input to an element of the enum
1748 - gives the function the name of the enum in lower case (argparse uses this in error messages)
1749 - generates a help string containing the allowed values
1751 and adds an argument to the given argparse parser with that.
1752 '''
1753 def parse(name: str) -> enum.Enum:
1754 for v in type:
1755 if v.name.lower() == name:
1756 return v
1757 raise TypeError()
1758 parse.__name__ = type.__name__.lower()
1759 choices = ', '.join(v.name.lower() for v in type)
1760 return parser.add_argument(*name_or_flags, type=parse, help="one of " + choices)
1762 def get_help(self) -> str:
1763 '''
1764 Creates a help text which can be presented to the user by calling :meth:`~confattr.configfile.ArgumentParser.format_help` on :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.
1765 The return value of :meth:`~confattr.configfile.ConfigFileArgparseCommand.get_help_attr_or_doc_str` has been passed as :paramref:`~confattr.configfile.ArgumentParser.description` to the constructor of :class:`~confattr.configfile.ArgumentParser`, therefore :attr:`~confattr.configfile.ConfigFileArgparseCommand.help`/the doc string are included as well.
1766 '''
1767 return self.parser.format_help().rstrip('\n')
1769 def run(self, cmd: 'Sequence[str]') -> None:
1770 # if the line was empty this method should not be called but an empty line should be ignored either way
1771 if not cmd:
1772 return # pragma: no cover
1773 # cmd[0] does not need to be in self._names if this is the default command, i.e. if '' in self._names
1774 if cmd[0] in self._names:
1775 cmd = cmd[1:]
1776 args = self.parser.parse_args(cmd)
1777 self.run_parsed(args)
1779 @abc.abstractmethod
1780 def run_parsed(self, args: argparse.Namespace) -> None:
1781 '''
1782 This is an abstract method which must be implemented by subclasses.
1783 '''
1784 pass
1786 # ------- auto complete -------
1788 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1789 if in_between:
1790 start = ''
1791 else:
1792 start = cmd[argument_pos][:cursor_pos]
1794 if self.after_positional_argument_marker(cmd, argument_pos):
1795 pos = self.get_position(cmd, argument_pos)
1796 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
1798 if argument_pos > 0: # pragma: no branch # if argument_pos was 0 this method would not be called, command names would be completed instead
1799 prevarg = self.get_option_name_if_it_takes_an_argument(cmd, argument_pos-1)
1800 if prevarg:
1801 return self.get_completions_for_option_argument(prevarg, start, start_of_line=start_of_line, end_of_line=end_of_line)
1803 if self.is_option_start(start):
1804 if '=' in start:
1805 i = start.index('=')
1806 option_name = start[:i]
1807 i += 1
1808 start_of_line += start[:i]
1809 start = start[i:]
1810 return self.get_completions_for_option_argument(option_name, start, start_of_line=start_of_line, end_of_line=end_of_line)
1811 return self.get_completions_for_option_name(start, start_of_line=start_of_line, end_of_line=end_of_line)
1813 pos = self.get_position(cmd, argument_pos)
1814 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
1816 def get_position(self, cmd: 'Sequence[str]', argument_pos: int) -> int:
1817 '''
1818 :return: the position of a positional argument, not counting options and their arguments
1819 '''
1820 pos = 0
1821 n = len(cmd)
1822 options_allowed = True
1823 # I am starting at 1 because cmd[0] is the name of the command, not an argument
1824 for i in range(1, argument_pos):
1825 if options_allowed and i < n:
1826 if cmd[i] == '--':
1827 options_allowed = False
1828 continue
1829 elif self.is_option_start(cmd[i]):
1830 continue
1831 # > 1 because cmd[0] is the name of the command
1832 elif i > 1 and self.get_option_name_if_it_takes_an_argument(cmd, i-1):
1833 continue
1834 pos += 1
1836 return pos
1838 def is_option_start(self, start: str) -> bool:
1839 return start.startswith('-') or start.startswith('+')
1841 def after_positional_argument_marker(self, cmd: 'Sequence[str]', argument_pos: int) -> bool:
1842 '''
1843 :return: true if this can only be a positional argument. False means it can be both, option or positional argument.
1844 '''
1845 return '--' in cmd and cmd.index('--') < argument_pos
1847 def get_option_name_if_it_takes_an_argument(self, cmd: 'Sequence[str]', argument_pos: int) -> 'str|None':
1848 if argument_pos >= len(cmd):
1849 return None # pragma: no cover # this does not happen because this method is always called for the previous argument
1851 arg = cmd[argument_pos]
1852 if '=' in arg:
1853 # argument of option is already given within arg
1854 return None
1855 if not self.is_option_start(arg):
1856 return None
1857 if arg.startswith('--'):
1858 action = self.get_action_for_option(arg)
1859 if action is None:
1860 return None
1861 if action.nargs != 0:
1862 return arg
1863 return None
1865 # arg is a combination of single character flags like in `tar -xzf file`
1866 for c in arg[1:-1]:
1867 action = self.get_action_for_option('-' + c)
1868 if action is None:
1869 continue
1870 if action.nargs != 0:
1871 # c takes an argument but that is already given within arg
1872 return None
1874 out = '-' + arg[-1]
1875 action = self.get_action_for_option(out)
1876 if action is None:
1877 return None
1878 if action.nargs != 0:
1879 return out
1880 return None
1883 def get_completions_for_option_name(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1884 completions = []
1885 for a in self.parser._get_optional_actions():
1886 for opt in a.option_strings:
1887 if len(opt) <= 2:
1888 # this is trivial to type but not self explanatory
1889 # => not helpful for auto completion
1890 continue
1891 if opt.startswith(start):
1892 completions.append(opt)
1893 return start_of_line, completions, end_of_line
1895 def get_completions_for_option_argument(self, option_name: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1896 return self.get_completions_for_action(self.get_action_for_option(option_name), start, start_of_line=start_of_line, end_of_line=end_of_line)
1898 def get_completions_for_positional_argument(self, position: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1899 return self.get_completions_for_action(self.get_action_for_positional_argument(position), start, start_of_line=start_of_line, end_of_line=end_of_line)
1902 def get_action_for_option(self, option_name: str) -> 'argparse.Action|None':
1903 for a in self.parser._get_optional_actions():
1904 if option_name in a.option_strings:
1905 return a
1906 return None
1908 def get_action_for_positional_argument(self, argument_pos: int) -> 'argparse.Action|None':
1909 actions = self.parser._get_positional_actions()
1910 if argument_pos < len(actions):
1911 return actions[argument_pos]
1912 return None
1914 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1915 if action is None:
1916 completions: 'list[str]' = []
1917 elif not action.choices:
1918 completions = []
1919 else:
1920 completions = [str(val) for val in action.choices]
1921 completions = [val for val in completions if val.startswith(start)]
1922 completions = [self.config_file.quote(val) for val in completions]
1923 return start_of_line, completions, end_of_line
1926# ---------- implementations of commands which can be used in config files ----------
1928class Set(ConfigFileCommand):
1930 r'''
1931 usage: set [--raw] key1=val1 [key2=val2 ...] \\
1932 set [--raw] key [=] val
1934 Change the value of a setting.
1936 In the first form set takes an arbitrary number of arguments, each argument sets one setting.
1937 This has the advantage that several settings can be changed at once.
1938 That is useful if you want to bind a set command to a key and process that command with ConfigFile.parse_line() if the key is pressed.
1940 In the second form set takes two arguments, the key and the value. Optionally a single equals character may be added in between as third argument.
1941 This has the advantage that key and value are separated by one or more spaces which can improve the readability of a config file.
1943 You can use the value of another setting with %other.key% or an environment variable with ${ENV_VAR}.
1944 If you want to insert a literal percent character use two of them: %%.
1945 You can disable expansion of settings and environment variables with the --raw flag.
1946 '''
1948 #: The separator which is used between a key and it's value
1949 KEY_VAL_SEP = '='
1951 FLAGS_RAW = ('-r', '--raw')
1953 raw = False
1955 # ------- load -------
1957 def run(self, cmd: 'Sequence[str]') -> None:
1958 '''
1959 Call :meth:`~confattr.configfile.Set.set_multiple` if the first argument contains :attr:`~confattr.configfile.Set.KEY_VAL_SEP` otherwise :meth:`~confattr.configfile.Set.set_with_spaces`.
1961 :raises ParseException: if something is wrong (no arguments given, invalid syntax, invalid key, invalid value)
1962 '''
1963 if self.is_vim_style(cmd):
1964 self.set_multiple(cmd)
1965 else:
1966 self.set_with_spaces(cmd)
1968 def is_vim_style(self, cmd: 'Sequence[str]') -> bool:
1969 '''
1970 :paramref:`~confattr.configfile.Set.is_vim_style.cmd` has one of two possible styles:
1971 - vim inspired: set takes an arbitrary number of arguments, each argument sets one setting. Is handled by :meth:`~confattr.configfile.Set.set_multiple`.
1972 - ranger inspired: set takes two arguments, the key and the value. Optionally a single equals character may be added in between as third argument. Is handled by :meth:`~confattr.configfile.Set.set_with_spaces`.
1974 :return: true if cmd has a vim inspired style, false if cmd has a ranger inspired style
1975 '''
1976 try:
1977 # cmd[0] is the name of the command, cmd[1] is the first argument
1978 if cmd[1] in self.FLAGS_RAW:
1979 i = 2
1980 else:
1981 i = 1
1982 return self.KEY_VAL_SEP in cmd[i]
1983 except IndexError:
1984 raise ParseException('no settings given')
1986 def set_with_spaces(self, cmd: 'Sequence[str]') -> None:
1987 '''
1988 Process one line of the format ``set key [=] value``
1990 :raises ParseException: if something is wrong (invalid syntax, invalid key, invalid value)
1991 '''
1992 if cmd[1] in self.FLAGS_RAW:
1993 cmd = cmd[2:]
1994 self.raw = True
1995 else:
1996 cmd = cmd[1:]
1997 self.raw = False
1999 n = len(cmd)
2000 if n == 2:
2001 key, value = cmd
2002 self.parse_key_and_set_value(key, value)
2003 elif n == 3:
2004 key, sep, value = cmd
2005 if sep != self.KEY_VAL_SEP:
2006 raise ParseException(f'separator between key and value should be {self.KEY_VAL_SEP}, not {sep!r}')
2007 self.parse_key_and_set_value(key, value)
2008 elif n == 1:
2009 raise ParseException(f'missing value or missing {self.KEY_VAL_SEP}')
2010 else:
2011 assert n >= 4
2012 raise ParseException(f'too many arguments given or missing {self.KEY_VAL_SEP} in first argument')
2014 def set_multiple(self, cmd: 'Sequence[str]') -> None:
2015 '''
2016 Process one line of the format ``set key=value [key2=value2 ...]``
2018 :raises MultipleParseExceptions: if something is wrong (invalid syntax, invalid key, invalid value)
2019 '''
2020 self.raw = False
2021 exceptions = []
2022 for arg in cmd[1:]:
2023 if arg in self.FLAGS_RAW:
2024 self.raw = True
2025 continue
2026 try:
2027 if not self.KEY_VAL_SEP in arg:
2028 raise ParseException(f'missing {self.KEY_VAL_SEP} in {arg!r}')
2029 key, value = arg.split(self.KEY_VAL_SEP, 1)
2030 self.parse_key_and_set_value(key, value)
2031 except ParseException as e:
2032 exceptions.append(e)
2033 if exceptions:
2034 raise MultipleParseExceptions(exceptions)
2036 def parse_key_and_set_value(self, key: str, value: str) -> None:
2037 '''
2038 Find the corresponding :class:`~confattr.config.Config` instance for :paramref:`~confattr.configfile.Set.parse_key_and_set_value.key` and call :meth:`~confattr.configfile.Set.set_value` with the return value of :meth:`config_file.parse_value() <confattr.configfile.ConfigFile.parse_value>`.
2040 :raises ParseException: if key is invalid or if :meth:`config_file.parse_value() <confattr.configfile.ConfigFile.parse_value>` or :meth:`~confattr.configfile.Set.set_value` raises a :class:`ValueError`
2041 '''
2042 if key not in self.config_file.config_instances:
2043 raise ParseException(f'invalid key {key!r}')
2045 instance = self.config_file.config_instances[key]
2046 try:
2047 self.set_value(instance, self.config_file.parse_value(instance, value, raw=self.raw))
2048 except ValueError as e:
2049 raise ParseException(str(e))
2051 def set_value(self, instance: 'Config[T2]', value: 'T2') -> None:
2052 '''
2053 Assign :paramref:`~confattr.configfile.Set.set_value.value` to :paramref`instance` by calling :meth:`Config.set_value() <confattr.config.Config.set_value>` with :attr:`ConfigFile.config_id <confattr.configfile.ConfigFile.config_id>` of :attr:`~confattr.configfile.Set.config_file`.
2054 Afterwards call :meth:`UiNotifier.show_info() <confattr.configfile.UiNotifier.show_info>`.
2055 '''
2056 instance.set_value(self.config_file.config_id, value)
2057 self.ui_notifier.show_info(f'set {instance.key} to {self.config_file.format_value(instance, self.config_file.config_id)}')
2060 # ------- save -------
2062 def iter_config_instances_to_be_saved(self, **kw: 'Unpack[SaveKwargs]') -> 'Iterator[Config[object]]':
2063 '''
2064 :param config_instances: The settings to consider
2065 :param ignore: Skip these settings
2067 Iterate over all given :paramref:`~confattr.configfile.Set.iter_config_instances_to_be_saved.config_instances` and expand all :class:`~confattr.config.DictConfig` instances into the :class:`~confattr.config.Config` instances they consist of.
2068 Sort the resulting list if :paramref:`~confattr.configfile.Set.iter_config_instances_to_be_saved.config_instances` is not a :class:`list` or a :class:`tuple`.
2069 Yield all :class:`~confattr.config.Config` instances which are not (directly or indirectly) contained in :paramref:`~confattr.configfile.Set.iter_config_instances_to_be_saved.ignore` and where :meth:`Config.wants_to_be_exported() <confattr.config.Config.wants_to_be_exported>` returns true.
2070 '''
2071 config_instances = kw['config_instances']
2072 ignore = kw['ignore']
2074 config_keys = []
2075 for c in config_instances:
2076 if isinstance(c, DictConfig):
2077 config_keys.extend(sorted(c.iter_keys()))
2078 else:
2079 config_keys.append(c.key)
2080 if not isinstance(config_instances, (list, tuple)):
2081 config_keys = sorted(config_keys)
2083 if ignore is not None:
2084 tmp = set()
2085 for c in tuple(ignore):
2086 if isinstance(c, DictConfig):
2087 tmp |= set(c._values.values())
2088 else:
2089 tmp.add(c)
2090 ignore = tmp
2092 for key in config_keys:
2093 instance = self.config_file.config_instances[key]
2094 if not instance.wants_to_be_exported():
2095 continue
2097 if ignore is not None and instance in ignore:
2098 continue
2100 yield instance
2103 #: A temporary variable used in :meth:`~confattr.configfile.Set.write_config_help` to prevent repeating the help of several :class:`~confattr.config.Config` instances belonging to the same :class:`~confattr.config.DictConfig`. It is reset in :meth:`~confattr.configfile.Set.save`.
2104 last_name: 'str|None'
2106 def save(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
2107 '''
2108 :param writer: The file to write to
2109 :param bool no_multi: If true: treat :class:`~confattr.config.MultiConfig` instances like normal :class:`~confattr.config.Config` instances and only write their default value. If false: Separate :class:`~confattr.config.MultiConfig` instances and print them once for every :attr:`MultiConfig.config_ids <confattr.config.MultiConfig.config_ids>`.
2110 :param bool comments: If false: don't write help for data types
2112 Iterate over all :class:`~confattr.config.Config` instances with :meth:`~confattr.configfile.Set.iter_config_instances_to_be_saved`,
2113 split them into normal :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig` and write them with :meth:`~confattr.configfile.Set.save_config_instance`.
2114 But before that set :attr:`~confattr.configfile.Set.last_name` to None (which is used by :meth:`~confattr.configfile.Set.write_config_help`)
2115 and write help for data types based on :meth:`~confattr.configfile.Set.get_help_for_data_types`.
2116 '''
2117 no_multi = kw['no_multi']
2118 comments = kw['comments']
2120 config_instances = list(self.iter_config_instances_to_be_saved(**kw))
2121 normal_configs = []
2122 multi_configs = []
2123 if no_multi:
2124 normal_configs = config_instances
2125 else:
2126 for instance in config_instances:
2127 if isinstance(instance, MultiConfig):
2128 multi_configs.append(instance)
2129 else:
2130 normal_configs.append(instance)
2132 self.last_name: 'str|None' = None
2134 if normal_configs:
2135 if multi_configs:
2136 writer.write_heading(SectionLevel.SECTION, 'Application wide settings')
2137 elif self.should_write_heading:
2138 writer.write_heading(SectionLevel.SECTION, 'Settings')
2140 if comments:
2141 type_help = self.get_help_for_data_types(normal_configs)
2142 if type_help:
2143 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
2144 writer.write_lines(type_help)
2146 for instance in normal_configs:
2147 self.save_config_instance(writer, instance, config_id=None, **kw)
2149 if multi_configs:
2150 if normal_configs:
2151 writer.write_heading(SectionLevel.SECTION, 'Settings which can have different values for different objects')
2152 elif self.should_write_heading:
2153 writer.write_heading(SectionLevel.SECTION, 'Settings')
2155 if comments:
2156 type_help = self.get_help_for_data_types(multi_configs)
2157 if type_help:
2158 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
2159 writer.write_lines(type_help)
2161 for instance in multi_configs:
2162 self.save_config_instance(writer, instance, config_id=instance.default_config_id, **kw)
2164 for config_id in MultiConfig.config_ids:
2165 writer.write_line('')
2166 self.config_file.write_config_id(writer, config_id)
2167 for instance in multi_configs:
2168 self.save_config_instance(writer, instance, config_id, **kw)
2170 def save_config_instance(self, writer: FormattedWriter, instance: 'Config[object]', config_id: 'ConfigId|None', **kw: 'Unpack[SaveKwargs]') -> None:
2171 '''
2172 :param writer: The file to write to
2173 :param instance: The config value to be saved
2174 :param config_id: Which value to be written in case of a :class:`~confattr.config.MultiConfig`, should be :obj:`None` for a normal :class:`~confattr.config.Config` instance
2175 :param bool comments: If true: call :meth:`~confattr.configfile.Set.write_config_help`
2177 Convert the :class:`~confattr.config.Config` instance into a value str with :meth:`config_file.format_value() <confattr.configfile.ConfigFile.format_value>`,
2178 wrap it in quotes if necessary with :meth:`config_file.quote() <confattr.configfile.ConfigFile.quote>` and write it to :paramref:`~confattr.configfile.Set.save_config_instance.writer`.
2179 '''
2180 if kw['comments']:
2181 self.write_config_help(writer, instance)
2182 value = self.config_file.format_value(instance, config_id)
2183 value = self.config_file.quote(value)
2184 ln = f'{self.get_name()} {instance.key} = {value}'
2185 writer.write_command(ln)
2187 def write_config_help(self, writer: FormattedWriter, instance: Config[typing.Any], *, group_dict_configs: bool = True) -> None:
2188 '''
2189 :param writer: The output to write to
2190 :param instance: The config value to be saved
2192 Write a comment which explains the meaning and usage of this setting
2193 based on :meth:`instance.type.get_description() <confattr.formatters.AbstractFormatter.get_description>` and :attr:`Config.help <confattr.config.Config.help>`.
2195 Use :attr:`~confattr.configfile.Set.last_name` to write the help only once for all :class:`~confattr.config.Config` instances belonging to the same :class:`~confattr.config.DictConfig` instance.
2196 '''
2197 if group_dict_configs and instance.parent is not None:
2198 name = instance.parent.key_changer(instance.parent.key_prefix)
2199 else:
2200 name = instance.key
2201 if name == self.last_name:
2202 return
2204 formatter = HelpFormatterWrapper(self.config_file.formatter_class)
2205 writer.write_heading(SectionLevel.SUB_SECTION, name)
2206 writer.write_lines(formatter.format_text(instance.type.get_description(self.config_file)).rstrip())
2207 #if instance.unit:
2208 # writer.write_line('unit: %s' % instance.unit)
2209 if isinstance(instance.help, dict):
2210 for key, val in instance.help.items():
2211 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)
2212 val = inspect.cleandoc(val)
2213 writer.write_lines(formatter.format_item(bullet=key_name+': ', text=val).rstrip())
2214 elif isinstance(instance.help, str):
2215 writer.write_lines(formatter.format_text(inspect.cleandoc(instance.help)).rstrip())
2217 self.last_name = name
2220 def get_data_type_name_to_help_map(self, config_instances: 'Iterable[Config[object]]') -> 'dict[str, str]':
2221 '''
2222 :param config_instances: All config values to be saved
2223 :return: A dictionary containing the type names as keys and the help as values
2225 The returned dictionary contains the help for all data types except enumerations
2226 which occur in :paramref:`~confattr.configfile.Set.get_data_type_name_to_help_map.config_instances`.
2227 The help is gathered from the :attr:`~confattr.configfile.Set.help` attribute of the type
2228 or :meth:`Primitive.get_help() <confattr.formatters.Primitive.get_help>`.
2229 The help is cleaned up with :func:`inspect.cleandoc`.
2230 '''
2231 help_text: 'dict[str, str]' = {}
2232 for instance in config_instances:
2233 for t in instance.type.get_primitives():
2234 name = t.get_type_name()
2235 if name in help_text:
2236 continue
2238 h = t.get_help(self.config_file)
2239 if not h:
2240 continue
2241 help_text[name] = inspect.cleandoc(h)
2243 return help_text
2245 def add_help_for_data_types(self, formatter: HelpFormatterWrapper, config_instances: 'Iterable[Config[object]]') -> None:
2246 help_map = self.get_data_type_name_to_help_map(config_instances)
2247 if not help_map:
2248 return
2250 for name in sorted(help_map.keys()):
2251 formatter.add_start_section(name)
2252 formatter.add_text(help_map[name])
2253 formatter.add_end_section()
2255 def get_help_for_data_types(self, config_instances: 'Iterable[Config[object]]') -> str:
2256 formatter = self.create_formatter()
2257 self.add_help_for_data_types(formatter, config_instances)
2258 return formatter.format_help().rstrip('\n')
2260 # ------- help -------
2262 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
2263 super().add_help_to(formatter)
2265 kw: 'SaveKwargs' = {}
2266 self.config_file.set_save_default_arguments(kw)
2267 config_instances = list(self.iter_config_instances_to_be_saved(**kw))
2268 self.last_name = None
2270 formatter.add_start_section('data types')
2271 self.add_help_for_data_types(formatter, config_instances)
2272 formatter.add_end_section()
2274 if self.config_file.enable_config_ids:
2275 normal_configs = []
2276 multi_configs = []
2277 for instance in config_instances:
2278 if isinstance(instance, MultiConfig):
2279 multi_configs.append(instance)
2280 else:
2281 normal_configs.append(instance)
2282 else:
2283 normal_configs = config_instances
2284 multi_configs = []
2286 if normal_configs:
2287 if self.config_file.enable_config_ids:
2288 formatter.add_start_section('application wide settings')
2289 else:
2290 formatter.add_start_section('settings')
2291 for instance in normal_configs:
2292 self.add_config_help(formatter, instance)
2293 formatter.add_end_section()
2295 if multi_configs:
2296 formatter.add_start_section('settings which can have different values for different objects')
2297 formatter.add_text(inspect.cleandoc(self.config_file.get_help_config_id()))
2298 for instance in multi_configs:
2299 self.add_config_help(formatter, instance)
2300 formatter.add_end_section()
2302 def add_config_help(self, formatter: HelpFormatterWrapper, instance: Config[typing.Any]) -> None:
2303 formatter.add_start_section(instance.key)
2304 formatter.add_text(instance.type.get_description(self.config_file))
2305 if isinstance(instance.help, dict):
2306 for key, val in instance.help.items():
2307 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)
2308 val = inspect.cleandoc(val)
2309 formatter.add_item(bullet=key_name+': ', text=val)
2310 elif isinstance(instance.help, str):
2311 formatter.add_text(inspect.cleandoc(instance.help))
2312 formatter.add_end_section()
2314 # ------- auto complete -------
2316 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2317 if argument_pos >= len(cmd):
2318 start = ''
2319 else:
2320 start = cmd[argument_pos][:cursor_pos]
2322 if len(cmd) <= 1:
2323 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2324 elif self.is_vim_style(cmd):
2325 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2326 else:
2327 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2329 def get_completions_for_vim_style_arg(self, cmd: 'Sequence[str]', argument_pos: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2330 if self.KEY_VAL_SEP in start:
2331 key, start = start.split(self.KEY_VAL_SEP, 1)
2332 start_of_line += key + self.KEY_VAL_SEP
2333 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line)
2334 else:
2335 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2337 def get_completions_for_ranger_style_arg(self, cmd: 'Sequence[str]', argument_pos: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2338 if argument_pos == 1:
2339 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2340 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP):
2341 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line)
2342 else:
2343 return start_of_line, [], end_of_line
2345 def get_completions_for_key(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2346 completions = [key for key in self.config_file.config_instances.keys() if key.startswith(start)]
2347 return start_of_line, completions, end_of_line
2349 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2350 applicable, start_of_line, completions, end_of_line = self.config_file.get_completions_for_expand(start, start_of_line=start_of_line, end_of_line=end_of_line)
2351 if applicable:
2352 return start_of_line, completions, end_of_line
2354 instance = self.config_file.config_instances.get(key)
2355 if instance is None:
2356 return start_of_line, [], end_of_line
2358 return instance.type.get_completions(self.config_file, start_of_line, start, end_of_line)
2361class Include(ConfigFileArgparseCommand):
2363 '''
2364 Load another config file.
2366 This is useful if a config file is getting so big that you want to split it up
2367 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy
2368 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line().
2369 '''
2371 help_config_id = '''
2372 By default the loaded config file starts with which ever config id is currently active.
2373 This is useful if you want to use the same values for several config ids:
2374 Write the set commands without a config id to a separate config file and include this file for every config id where these settings shall apply.
2376 After the include the config id is reset to the config id which was active at the beginning of the include
2377 because otherwise it might lead to confusion if the config id is changed in the included config file.
2378 '''
2380 def init_parser(self, parser: ArgumentParser) -> None:
2381 parser.add_argument('path', help='The config file to load. Slashes are replaced with the directory separator appropriate for the current operating system. If the path contains a space it must be wrapped in single or double quotes.')
2382 if self.config_file.enable_config_ids:
2383 assert parser.description is not None
2384 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id)
2385 group = parser.add_mutually_exclusive_group()
2386 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include')
2387 group.add_argument('--no-reset-config-id-after', action='store_true', help='Treat the included lines as if they were written in the same config file instead of the include command')
2389 self.nested_includes: 'list[str]' = []
2391 def run_parsed(self, args: argparse.Namespace) -> None:
2392 fn_imp = args.path
2393 fn_imp = fn_imp.replace('/', os.path.sep)
2394 fn_imp = os.path.expanduser(fn_imp)
2395 if not os.path.isabs(fn_imp):
2396 fn = self.config_file.context_file_name
2397 if fn is None:
2398 fn = self.config_file.get_save_path()
2399 fn_imp = os.path.join(os.path.dirname(os.path.abspath(fn)), fn_imp)
2401 if fn_imp in self.nested_includes:
2402 raise ParseException(f'circular include of file {fn_imp!r}')
2403 if not os.path.isfile(fn_imp):
2404 raise ParseException(f'no such file {fn_imp!r}')
2406 self.nested_includes.append(fn_imp)
2408 if self.config_file.enable_config_ids and args.no_reset_config_id_after:
2409 self.config_file.load_without_resetting_config_id(fn_imp)
2410 elif self.config_file.enable_config_ids and args.reset_config_id_before:
2411 config_id = self.config_file.config_id
2412 self.config_file.load_file(fn_imp)
2413 self.config_file.config_id = config_id
2414 else:
2415 config_id = self.config_file.config_id
2416 self.config_file.load_without_resetting_config_id(fn_imp)
2417 self.config_file.config_id = config_id
2419 assert self.nested_includes[-1] == fn_imp
2420 del self.nested_includes[-1]
2422 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2423 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action
2424 if action is not None and action.dest == 'path':
2425 return self.config_file.get_completions_for_file_name(start, relative_to=os.path.dirname(self.config_file.get_save_path()), start_of_line=start_of_line, end_of_line=end_of_line)
2426 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)
2429class Echo(ConfigFileArgparseCommand):
2431 '''
2432 Display a message.
2434 Settings and environment variables are expanded like in the value of a set command.
2435 '''
2437 def init_parser(self, parser: ArgumentParser) -> None:
2438 parser.add_argument('-l', '--level', default=NotificationLevel.INFO, type=NotificationLevel, metavar='{%s}' % ','.join(l.value for l in NotificationLevel), help="The notification level may influence the formatting but messages printed with echo are always displayed regardless of the notification level.")
2439 parser.add_argument('-r', '--raw', action='store_true', help="Do not expand settings and environment variables.")
2440 parser.add_argument('msg', nargs=argparse.ONE_OR_MORE, help="The message to display")
2442 def run_parsed(self, args: argparse.Namespace) -> None:
2443 msg = ' '.join(self.config_file.expand(m) for m in args.msg)
2444 self.ui_notifier.show(args.level, msg, ignore_filter=True)
2447 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2448 if argument_pos >= len(cmd):
2449 start = ''
2450 else:
2451 start = cmd[argument_pos][:cursor_pos]
2453 applicable, start_of_line, completions, end_of_line = self.config_file.get_completions_for_expand(start, start_of_line=start_of_line, end_of_line=end_of_line)
2454 return start_of_line, completions, end_of_line
2456class Help(ConfigFileArgparseCommand):
2458 '''
2459 Display help.
2460 '''
2462 max_width = 80
2463 max_width_name = 18
2464 min_width_sep = 2
2465 tab_size = 4
2467 def init_parser(self, parser: ArgumentParser) -> None:
2468 parser.add_argument('cmd', nargs='?', help="The command for which you want help")
2470 def run_parsed(self, args: argparse.Namespace) -> None:
2471 if args.cmd:
2472 if args.cmd not in self.config_file.command_dict:
2473 raise ParseException(f"unknown command {args.cmd!r}")
2474 cmd = self.config_file.command_dict[args.cmd]
2475 out = cmd.get_help()
2476 else:
2477 out = "The following commands are defined:\n"
2478 table = []
2479 for cmd in self.config_file.commands:
2480 name = "- %s" % "/".join(cmd.get_names())
2481 descr = cmd.get_short_description()
2482 row = (name, descr)
2483 table.append(row)
2484 out += self.format_table(table)
2486 out += "\n"
2487 out += "\nUse `help <cmd>` to get more information about a command."
2489 self.ui_notifier.show(NotificationLevel.INFO, out, ignore_filter=True, no_context=True)
2491 def format_table(self, table: 'Sequence[tuple[str, str]]') -> str:
2492 max_name_width = max(len(row[0]) for row in table)
2493 col_width_name = min(max_name_width, self.max_width_name)
2494 out: 'list[str]' = []
2495 subsequent_indent = ' ' * (col_width_name + self.min_width_sep)
2496 for name, descr in table:
2497 if not descr:
2498 out.append(name)
2499 continue
2500 if len(name) > col_width_name:
2501 out.append(name)
2502 initial_indent = subsequent_indent
2503 else:
2504 initial_indent = name.ljust(col_width_name + self.min_width_sep)
2505 out.extend(textwrap.wrap(descr, self.max_width,
2506 initial_indent = initial_indent,
2507 subsequent_indent = subsequent_indent,
2508 break_long_words = False,
2509 tabsize = self.tab_size,
2510 ))
2511 return '\n'.join(out)
2513 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2514 if action and action.dest == 'cmd':
2515 start_of_line, completions, end_of_line = self.config_file.get_completions_command_name(start, cursor_pos=len(start), start_of_line=start_of_line, end_of_line=end_of_line)
2516 return start_of_line, completions, end_of_line
2518 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)
2521class UnknownCommand(ConfigFileCommand, abstract=True):
2523 name = DEFAULT_COMMAND
2525 def run(self, cmd: 'Sequence[str]') -> None:
2526 raise ParseException('unknown command %r' % cmd[0])