Coverage for .tox/cov/lib/python3.10/site-packages/confattr/configfile.py: 100%
1037 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-22 14:03 +0200
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-22 14:03 +0200
1#!./runmodule.sh
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 inspect
15import io
16import abc
17import typing
18from collections.abc import Iterable, Iterator, Sequence, Callable
20import appdirs
22from .config import Config, DictConfig, MultiConfig, ConfigId
23from .utils import HelpFormatter, HelpFormatterWrapper, SortedEnum, readable_quote
25if typing.TYPE_CHECKING:
26 from typing_extensions import Unpack
28 # T is already used in config.py and I cannot use the same name because both are imported with *
29 T2 = typing.TypeVar('T2')
32#: If the name or an alias of :class:`ConfigFileCommand` is this value that command is used by :meth:`ConfigFile.parse_split_line` if an undefined command is encountered.
33DEFAULT_COMMAND = ''
37# ---------- UI notifier ----------
39@enum.unique
40class NotificationLevel(SortedEnum):
41 INFO = 'info'
42 ERROR = 'error'
44UiCallback: 'typing.TypeAlias' = 'Callable[[Message], None]'
46class Message:
48 '''
49 A message which should be displayed to the user.
50 This is passed to the callback of the user interface which has been registered with :meth:`ConfigFile.set_ui_callback`.
52 If you want full control how to display messages to the user you can access the attributes directly.
53 Otherwise you can simply convert this object to a str, e.g. with ``str(msg)``.
54 I recommend to use different colors for different values of :attr:`notification_level`.
55 '''
57 #: The value of :attr:`file_name` while loading environment variables.
58 ENVIRONMENT_VARIABLES = 'environment variables'
61 __slots__ = ('notification_level', 'message', 'file_name', 'line_number', 'line')
63 #: The importance of this message. I recommend to display messages of different importance levels in different colors.
64 #: :class:`ConfigFile` does not output messages which are less important than the :paramref:`~ConfigFile.notification_level` setting which has been passed to it's constructor.
65 notification_level: NotificationLevel
67 #: The string or exception which should be displayed to the user
68 message: 'str|BaseException'
70 #: The name of the config file which has caused this message.
71 #: If this equals :const:`ENVIRONMENT_VARIABLES` it is not a file but the message has occurred while reading the environment variables.
72 #: This is None if :meth:`ConfigFile.parse_line` is called directly, e.g. when parsing the input from a command line.
73 file_name: 'str|None'
75 #: The number of the line in the config file. This is None if :attr:`file_name` is not a file name.
76 line_number: 'int|None'
78 #: The line where the message occurred. This is an empty str if there is no line, e.g. when loading environment variables.
79 line: str
81 _last_file_name: 'str|None' = None
83 @classmethod
84 def reset(cls) -> None:
85 '''
86 If you are using :meth:`format_file_name_msg_line` or :meth:`__str__`
87 you must call this method when the widget showing the error messages is cleared.
88 '''
89 cls._last_file_name = None
91 def __init__(self, notification_level: NotificationLevel, message: 'str|BaseException', file_name: 'str|None' = None, line_number: 'int|None' = None, line: 'str' = '') -> None:
92 self.notification_level = notification_level
93 self.message = message
94 self.file_name = file_name
95 self.line_number = line_number
96 self.line = line
98 @property
99 def lvl(self) -> NotificationLevel:
100 '''
101 An abbreviation for :attr:`notification_level`
102 '''
103 return self.notification_level
105 def format_msg_line(self) -> str:
106 '''
107 The return value includes the attributes :attr:`message`, :attr:`line_number` and :attr:`line` if they are set.
108 '''
109 msg = str(self.message)
110 if self.line:
111 if self.line_number is not None:
112 lnref = 'line %s' % self.line_number
113 else:
114 lnref = 'line'
115 return f'{msg} in {lnref} {self.line!r}'
117 return msg
119 def format_file_name(self) -> str:
120 '''
121 :return: A header including the :attr:`file_name` if the :attr:`file_name` is different from the last time this function has been called or an empty string otherwise
122 '''
123 file_name = '' if self.file_name is None else self.file_name
124 if file_name == self._last_file_name:
125 return ''
127 if file_name:
128 out = f'While loading {file_name}:\n'
129 else:
130 out = ''
132 if self._last_file_name is not None:
133 out = '\n' + out
135 type(self)._last_file_name = file_name
137 return out
140 def format_file_name_msg_line(self) -> str:
141 '''
142 :return: The concatenation of the return values of :meth:`format_file_name` and :meth:`format_msg_line`
143 '''
144 return self.format_file_name() + self.format_msg_line()
147 def __str__(self) -> str:
148 '''
149 :return: The return value of :meth:`format_file_name_msg_line`
150 '''
151 return self.format_file_name_msg_line()
153 def __repr__(self) -> str:
154 return f'{type(self).__name__}(%s)' % ', '.join(f'{a}={self._format_attribute(getattr(self, a))}' for a in self.__slots__)
156 @staticmethod
157 def _format_attribute(obj: object) -> str:
158 if isinstance(obj, enum.Enum):
159 return obj.name
160 return repr(obj)
163class UiNotifier:
165 '''
166 Most likely you will want to load the config file before creating the UI (user interface).
167 But if there are errors in the config file the user will want to know about them.
168 This class takes the messages from :class:`ConfigFile` and stores them until the UI is ready.
169 When you call :meth:`set_ui_callback` the stored messages will be forwarded and cleared.
171 This object can also filter the messages.
172 :class:`ConfigFile` calls :meth:`show_info` every time a setting is changed.
173 If you load an entire config file this can be many messages and the user probably does not want to see them all.
174 Therefore this object drops all messages of :const:`NotificationLevel.INFO` by default.
175 Pass :obj:`notification_level` to the constructor if you don't want that.
176 '''
178 # ------- public methods -------
180 def __init__(self, config_file: 'ConfigFile', notification_level: 'Config[NotificationLevel]|NotificationLevel' = NotificationLevel.ERROR) -> None:
181 self._messages: 'list[Message]' = []
182 self._callback: 'UiCallback|None' = None
183 self._notification_level = notification_level
184 self._config_file = config_file
186 def set_ui_callback(self, callback: UiCallback) -> None:
187 '''
188 Call :paramref:`callback` for all messages which have been saved by :meth:`show` and clear all saved messages afterwards.
189 Save :paramref:`callback` for :meth:`show` to call.
190 '''
191 self._callback = callback
193 for msg in self._messages:
194 callback(msg)
195 self._messages.clear()
198 @property
199 def notification_level(self) -> NotificationLevel:
200 '''
201 Ignore messages that are less important than this level.
202 '''
203 if isinstance(self._notification_level, Config):
204 return self._notification_level.value
205 else:
206 return self._notification_level
208 @notification_level.setter
209 def notification_level(self, val: NotificationLevel) -> None:
210 if isinstance(self._notification_level, Config):
211 self._notification_level.value = val
212 else:
213 self._notification_level = val
216 # ------- called by ConfigFile -------
218 def show_info(self, msg: str, *, ignore_filter: bool = False) -> None:
219 '''
220 Call :meth:`show` with :obj:`NotificationLevel.INFO`.
221 '''
222 self.show(NotificationLevel.INFO, msg, ignore_filter=ignore_filter)
224 def show_error(self, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None:
225 '''
226 Call :meth:`show` with :obj:`NotificationLevel.ERROR`.
227 '''
228 self.show(NotificationLevel.ERROR, msg, ignore_filter=ignore_filter)
231 # ------- internal methods -------
233 def show(self, notification_level: NotificationLevel, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None:
234 '''
235 If a callback for the user interface has been registered with :meth:`set_ui_callback` call that callback.
236 Otherwise save the message so that :meth:`set_ui_callback` can forward the message when :meth:`set_ui_callback` is called.
238 :param notification_level: The importance of the message
239 :param msg: The message to be displayed on the user interface
240 :param ignore_filter: If true: Show the message even if :paramref:`notification_level` is smaller then the :paramref:`UiNotifier.notification_level`.
241 '''
242 if notification_level < self.notification_level and not ignore_filter:
243 return
245 message = Message(
246 notification_level = notification_level,
247 message = msg,
248 file_name = self._config_file.context_file_name,
249 line_number = self._config_file.context_line_number,
250 line = self._config_file.context_line,
251 )
253 if self._callback:
254 self._callback(message)
255 else:
256 self._messages.append(message)
259# ---------- format help ----------
261class SectionLevel(SortedEnum):
263 #: Is used to separate different commands in :meth:`ConfigFile.write_help` and :meth:`ConfigFileCommand.save`
264 SECTION = 'section'
266 #: Is used for subsections in :meth:`ConfigFileCommand.save` such as the "data types" section in the help of the set command
267 SUB_SECTION = 'sub-section'
270class FormattedWriter(abc.ABC):
272 @abc.abstractmethod
273 def write_line(self, line: str) -> None:
274 '''
275 Write a single line of documentation.
276 :paramref:`line` may *not* contain a newline.
277 If :paramref:`line` is empty it does not need to be prefixed with a comment character.
278 Empty lines should be dropped if no other lines have been written before.
279 '''
280 pass
282 def write_lines(self, text: str) -> None:
283 '''
284 Write one or more lines of documentation.
285 '''
286 for ln in text.splitlines():
287 self.write_line(ln)
289 @abc.abstractmethod
290 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
291 '''
292 Write a heading.
294 This object should *not* add an indentation depending on the section
295 because if the indentation is increased the line width should be decreased
296 in order to keep the line wrapping consistent.
297 Wrapping lines is handled by :class:`confattr.utils.HelpFormatter`,
298 i.e. before the text is passed to this object.
299 It would be possible to use :class:`argparse.RawTextHelpFormatter` instead
300 and handle line wrapping on a higher level but that would require
301 to understand the help generated by argparse
302 in order to know how far to indent a broken line.
303 One of the trickiest parts would probably be to get the indentation of the usage right.
304 Keep in mind that the term "usage" can differ depending on the language settings of the user.
306 :param lvl: How to format the heading
307 :param heading: The heading
308 '''
309 pass
311 @abc.abstractmethod
312 def write_command(self, cmd: str) -> None:
313 '''
314 Write a config file command.
315 '''
316 pass
319class TextIOWriter(FormattedWriter):
321 def __init__(self, f: 'typing.TextIO|None') -> None:
322 self.f = f
323 self.ignore_empty_lines = True
325 def write_line_raw(self, line: str) -> None:
326 if self.ignore_empty_lines and not line:
327 return
329 print(line, file=self.f)
330 self.ignore_empty_lines = False
333class ConfigFileWriter(TextIOWriter):
335 def __init__(self, f: 'typing.TextIO|None', prefix: str) -> None:
336 super().__init__(f)
337 self.prefix = prefix
339 def write_command(self, cmd: str) -> None:
340 self.write_line_raw(cmd)
342 def write_line(self, line: str) -> None:
343 if line:
344 line = self.prefix + line
346 self.write_line_raw(line)
348 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
349 if lvl is SectionLevel.SECTION:
350 self.write_line('')
351 self.write_line('')
352 self.write_line('=' * len(heading))
353 self.write_line(heading)
354 self.write_line('=' * len(heading))
355 else:
356 self.write_line('')
357 self.write_line(heading)
358 self.write_line('-' * len(heading))
360class HelpWriter(TextIOWriter):
362 def write_line(self, line: str) -> None:
363 self.write_line_raw(line)
365 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
366 self.write_line('')
367 if lvl is SectionLevel.SECTION:
368 self.write_line(heading)
369 self.write_line('=' * len(heading))
370 else:
371 self.write_line(heading)
372 self.write_line('-' * len(heading))
374 def write_command(self, cmd: str) -> None:
375 pass # pragma: no cover
378# ---------- internal exceptions ----------
380class ParseException(Exception):
382 '''
383 This is raised by :class:`ConfigFileCommand` implementations and functions passed to :paramref:`~ConfigFile.check_config_id` in order to communicate an error in the config file like invalid syntax or an invalid value.
384 Is caught in :class:`ConfigFile`.
385 '''
387class MultipleParseExceptions(Exception):
389 '''
390 This is raised by :class:`ConfigFileCommand` implementations in order to communicate that multiple errors have occured on the same line.
391 Is caught in :class:`ConfigFile`.
392 '''
394 def __init__(self, exceptions: 'Sequence[ParseException]') -> None:
395 super().__init__()
396 self.exceptions = exceptions
398 def __iter__(self) -> 'Iterator[ParseException]':
399 return iter(self.exceptions)
402# ---------- data types for **kw args ----------
404if hasattr(typing, 'TypedDict'): # python >= 3.8 # pragma: no cover. This is tested but in a different environment which is not known to coverage.
405 class SaveKwargs(typing.TypedDict, total=False):
406 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]'
407 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]] | None'
408 no_multi: bool
409 comments: bool
412# ---------- ConfigFile class ----------
414class ArgPos:
415 '''
416 This is an internal class, the return type of :meth:`ConfigFile.find_arg`
417 '''
419 #: The index of the argument in :paramref:`~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:`~ConfigFile.find_arg.ln_split` is long if the line ends on a space and the cursor is behind that space. In that case :attr:`in_between` is true.
420 argument_pos: int
422 #: If true: The cursor is between two arguments, before the first argument or after the last argument. :attr:`argument_pos` refers to the next argument, :attr:`argument_pos-1 <argument_pos>` to the previous argument. :attr:`i0` is the start of the next argument, :attr:`i1` is the end of the previous argument.
423 in_between: bool
425 #: The index in :paramref:`~ConfigFile.find_arg.line` where the argument having the cursor starts (inclusive) or the start of the next argument if :attr:`in_between` is true
426 i0: int
428 #: The index in :paramref:`~ConfigFile.find_arg.line` where the current word ends (exclusive) or the end of the previous argument if :attr:`in_between` is true
429 i1: int
432class ConfigFile:
434 '''
435 Read or write a config file.
436 '''
438 COMMENT = '#'
439 COMMENT_PREFIXES = ('"', '#')
440 ENTER_GROUP_PREFIX = '['
441 ENTER_GROUP_SUFFIX = ']'
443 #: The :class:`Config` instances to load or save
444 config_instances: 'dict[str, Config[typing.Any]]'
446 #: 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:`enter_group` and reset in :meth:`load_file`.
447 config_id: 'ConfigId|None'
449 #: Override the config file which is returned by :meth:`iter_config_paths`.
450 #: You should set either this attribute or :attr:`config_directory` in your tests with :meth:`monkeypatch.setattr <pytest.MonkeyPatch.setattr>`.
451 #: 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:`appname <ConfigFile.appname>` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.)
452 config_path: 'str|None' = None
454 #: Override the config directory which is returned by :meth:`iter_user_site_config_paths`.
455 #: You should set either this attribute or :attr:`config_path` in your tests with :meth:`monkeypatch.setattr <pytest.MonkeyPatch.setattr>`.
456 #: 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:`appname <ConfigFile.appname>` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.)
457 config_directory: 'str|None' = None
459 #: The name of the config file used by :meth:`iter_config_paths`.
460 #: Can be changed with the environment variable ``APPNAME_CONFIG_NAME`` (where ``APPNAME`` is the value which is passed as :paramref:`appname <ConfigFile.appname>` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.).
461 config_name = 'config'
463 #: Contains the names of the environment variables for :attr:`config_path`, :attr:`config_directory` and :attr:`config_name`—in capital letters and prefixed with :attr:`envprefix`.
464 env_variables: 'list[str]'
466 #: A prefix that is prepended to the name of environment variables in :meth:`get_env_name`.
467 #: It is set in the constructor by first setting it to an empty str and then passing the value of :paramref:`appname <ConfigFile.appname>` to :meth:`get_env_name` and appending an underscore.
468 envprefix: str
470 #: The name of the file which is currently loaded. If this equals :attr:`Message.ENVIRONMENT_VARIABLES` it is no file name but an indicator that environment variables are loaded. This is :obj:`None` if :meth:`parse_line` is called directly (e.g. the input from a command line is parsed).
471 context_file_name: 'str|None' = None
472 #: The number of the line which is currently parsed. This is :obj:`None` if :attr:`context_file_name` is not a file name.
473 context_line_number: 'int|None' = None
474 #: The line which is currently parsed.
475 context_line: str = ''
477 #: If true: ``[config-id]`` syntax is allowed in config file, config ids are included in help, config id related options are available for include.
478 #: If false: It is not possible to set different values for different objects (but default values for :class:`MultiConfig` instances can be set)
479 enable_config_ids: bool
482 def __init__(self, *,
483 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
484 appname: str,
485 authorname: 'str|None' = None,
486 config_instances: 'dict[str, Config[typing.Any]]' = Config.instances,
487 commands: 'Sequence[type[ConfigFileCommand]]|None' = None,
488 formatter_class: 'type[argparse.HelpFormatter]' = HelpFormatter,
489 check_config_id: 'Callable[[ConfigId], None]|None' = None,
490 enable_config_ids: 'bool|None' = None,
491 ) -> None:
492 '''
493 :param notification_level: A :class:`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:`Message` with a priority lower than this value is *not* passed to the callback registered with :meth:`set_ui_callback`.
494 :param appname: The name of the application, required for generating the path of the config file if you use :meth:`load` or :meth:`save` and as prefix of environment variable names
495 :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:`load` or :meth:`save`
496 :param config_instances: The Config instances to load or save, defaults to :attr:`Config.instances`
497 :param commands: The commands (as subclasses of :class:`ConfigFileCommand` or :class:`ConfigFileArgparseCommand`) allowed in this config file, if this is :const:`None`: use the return value of :meth:`ConfigFileCommand.get_command_types`
498 :param formatter_class: Is used to clean up doc strings and wrap lines in the help
499 :param check_config_id: Is called every time a configuration group is opened (except for :attr:`Config.default_config_id`—that is always allowed). The callback should raise a :class:`ParseException` if the config id is invalid.
500 :param enable_config_ids: see :attr:`enable_config_ids`. If None: Choose True or False automatically based on :paramref:`check_config_id` and the existence of :class:`MultiConfig`/:class:`MultiDictConfig`
501 '''
502 self.appname = appname
503 self.authorname = authorname
504 self.ui_notifier = UiNotifier(self, notification_level)
505 self.config_instances = config_instances
506 self.config_id: 'ConfigId|None' = None
507 self.formatter_class = formatter_class
508 self.env_variables: 'list[str]' = []
509 self.check_config_id = check_config_id
511 if enable_config_ids is None:
512 enable_config_ids = self.check_config_id is not None or any(isinstance(cfg, MultiConfig) for cfg in self.config_instances.values())
513 self.enable_config_ids = enable_config_ids
515 if not appname:
516 # Avoid an exception if appname is None.
517 # Although mypy does not allow passing None directly
518 # passing __package__ is (and should be) allowed.
519 # And __package__ is None if the module is not part of a package.
520 appname = ''
521 self.envprefix = ''
522 self.envprefix = self.get_env_name(appname + '_')
523 envname = self.envprefix + 'CONFIG_PATH'
524 self.env_variables.append(envname)
525 if envname in os.environ:
526 self.config_path = os.environ[envname]
527 envname = self.envprefix + 'CONFIG_DIRECTORY'
528 self.env_variables.append(envname)
529 if envname in os.environ:
530 self.config_directory = os.environ[envname]
531 envname = self.envprefix + 'CONFIG_NAME'
532 self.env_variables.append(envname)
533 if envname in os.environ:
534 self.config_name = os.environ[envname]
536 if commands is None:
537 commands = ConfigFileCommand.get_command_types()
538 self.command_dict = {}
539 self.commands = []
540 for cmd_type in commands:
541 cmd = cmd_type(self)
542 self.commands.append(cmd)
543 for name in cmd.get_names():
544 self.command_dict[name] = cmd
547 def set_ui_callback(self, callback: UiCallback) -> None:
548 '''
549 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.
551 Messages which occur before this method is called are stored and forwarded as soon as the callback is registered.
553 :param ui_callback: A function to display messages to the user
554 '''
555 self.ui_notifier.set_ui_callback(callback)
557 def get_app_dirs(self) -> 'appdirs.AppDirs':
558 '''
559 Create or get a cached `AppDirs <https://github.com/ActiveState/appdirs/blob/master/README.rst#appdirs-for-convenience>`_ instance with multipath support enabled.
561 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.
562 The first one installed is used.
563 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.
564 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``.
566 These libraries should respect the environment variables ``XDG_CONFIG_HOME`` and ``XDG_CONFIG_DIRS``.
567 '''
568 if not hasattr(self, '_appdirs'):
569 try:
570 import platformdirs # type: ignore [import] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs
571 AppDirs = typing.cast('type[appdirs.AppDirs]', platformdirs.PlatformDirs) # pragma: no cover # This is tested but in a different tox environment
572 except ImportError:
573 try:
574 import xdgappdirs # type: ignore [import] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs
575 AppDirs = typing.cast('type[appdirs.AppDirs]', xdgappdirs.AppDirs) # pragma: no cover # This is tested but in a different tox environment
576 except ImportError:
577 AppDirs = appdirs.AppDirs
579 self._appdirs = AppDirs(self.appname, self.authorname, multipath=True)
581 return self._appdirs
583 # ------- load -------
585 def iter_user_site_config_paths(self) -> 'Iterator[str]':
586 '''
587 Iterate over all directories which are searched for config files, user specific first.
589 The directories are based on :meth:`get_app_dirs`
590 unless :attr:`config_directory` has been set.
591 If :attr:`config_directory` has been set
592 it's value is yielded and nothing else.
593 '''
594 if self.config_directory:
595 yield self.config_directory
596 return
598 appdirs = self.get_app_dirs()
599 yield from appdirs.user_config_dir.split(os.path.pathsep)
600 yield from appdirs.site_config_dir.split(os.path.pathsep)
602 def iter_config_paths(self) -> 'Iterator[str]':
603 '''
604 Iterate over all paths which are checked for config files, user specific first.
606 Use this method if you want to tell the user where the application is looking for it's config file.
607 The first existing file yielded by this method is used by :meth:`load`.
609 The paths are generated by joining the directories yielded by :meth:`iter_user_site_config_paths` with
610 :attr:`ConfigFile.config_name`.
612 If :attr:`config_path` has been set this method yields that path instead and no other paths.
613 '''
614 if self.config_path:
615 yield self.config_path
616 return
618 for path in self.iter_user_site_config_paths():
619 yield os.path.join(path, self.config_name)
621 def load(self, *, env: bool = True) -> None:
622 '''
623 Load the first existing config file returned by :meth:`iter_config_paths`.
625 If there are several config files a user specific config file is preferred.
626 If a user wants a system wide config file to be loaded, too, they can explicitly include it in their config file.
627 :param env: If true: call :meth:`load_env` after loading the config file.
628 '''
629 for fn in self.iter_config_paths():
630 if os.path.isfile(fn):
631 self.load_file(fn)
632 break
634 if env:
635 self.load_env()
637 def load_env(self) -> None:
638 '''
639 Load settings from environment variables.
640 The name of the environment variable belonging to a setting is generated with :meth:`get_env_name`.
642 Environment variables not matching a setting or having an invalid value are reported with :meth:`self.ui_notifier.show_error() <UiNotifier.show_error>`.
644 :raises ValueError: if two settings have the same environment variable name (see :meth:`get_env_name`) or the environment variable name for a setting collides with one of the standard environment variables listed in :attr:`env_variables`
645 '''
646 old_file_name = self.context_file_name
647 self.context_file_name = Message.ENVIRONMENT_VARIABLES
649 config_instances: 'dict[str, Config[object]]' = {}
650 for key, instance in self.config_instances.items():
651 name = self.get_env_name(key)
652 if name in self.env_variables:
653 raise ValueError(f'setting {instance.key!r} conflicts with environment variable {name!r}')
654 elif name in config_instances:
655 raise ValueError(f'settings {instance.key!r} and {config_instances[name].key!r} result in the same environment variable {name!r}')
656 else:
657 config_instances[name] = instance
659 for name, value in os.environ.items():
660 if not name.startswith(self.envprefix):
661 continue
662 if name in self.env_variables:
663 continue
665 if name in config_instances:
666 instance = config_instances[name]
667 try:
668 instance.set_value(config_id=None, value=instance.parse_value(value))
669 self.ui_notifier.show_info(f'set {instance.key} to {instance.format_value(config_id=None)}')
670 except ValueError as e:
671 self.ui_notifier.show_error(f"{e} while trying to parse environment variable {name}='{value}'")
672 else:
673 self.ui_notifier.show_error(f"unknown environment variable {name}='{value}'")
675 self.context_file_name = old_file_name
678 def get_env_name(self, key: str) -> str:
679 '''
680 Convert the key of a setting to the name of the corresponding environment variable.
682 :return: An all upper case version of :paramref:`key` with all hyphens, dots and spaces replaced by underscores and :attr:`envprefix` prepended to the result.
683 '''
684 out = key
685 out = out.upper()
686 for c in ' .-':
687 out = out.replace(c, '_')
688 out = self.envprefix + out
689 return out
691 def load_file(self, fn: str) -> None:
692 '''
693 Load a config file and change the :class:`Config` objects accordingly.
695 Use :meth:`set_ui_callback` to get error messages which appeared while loading the config file.
696 You can call :meth:`set_ui_callback` after this method without loosing any messages.
698 :param fn: The file name of the config file (absolute or relative path)
699 '''
700 self.config_id = None
701 self.load_without_resetting_config_id(fn)
703 def load_without_resetting_config_id(self, fn: str) -> None:
704 old_file_name = self.context_file_name
705 self.context_file_name = fn
707 with open(fn, 'rt') as f:
708 for lnno, ln in enumerate(f, 1):
709 self.context_line_number = lnno
710 self.parse_line(line=ln)
711 self.context_line_number = None
713 self.context_file_name = old_file_name
715 def parse_line(self, line: str) -> bool:
716 '''
717 :param line: The line to be parsed
718 :return: True if line is valid, False if an error has occurred
720 :meth:`parse_error` is called if something goes wrong (i.e. if the return value is False), e.g. invalid key or invalid value.
721 '''
722 ln = line.strip()
723 if not ln:
724 return True
725 if self.is_comment(ln):
726 return True
727 if self.enable_config_ids and self.enter_group(ln):
728 return True
730 self.context_line = ln
732 try:
733 ln_split = self.split_line(ln)
734 except Exception as e:
735 self.parse_error(str(e))
736 out = False
737 else:
738 out = self.parse_split_line(ln_split)
740 self.context_line = ''
741 return out
743 def split_line(self, line: str) -> 'list[str]':
744 return shlex.split(line, comments=True)
746 def split_line_ignore_errors(self, line: str) -> 'list[str]':
747 out = []
748 lex = shlex.shlex(line, posix=True)
749 lex.whitespace_split = True
750 while True:
751 try:
752 t = lex.get_token()
753 except:
754 out.append(lex.token)
755 return out
756 if t is None:
757 return out # type: ignore [unreachable] # yes, with posix=True lex.get_token returns None at the end
758 out.append(t)
760 def is_comment(self, line: str) -> bool:
761 '''
762 Check if :paramref:`line` is a comment.
764 :param line: The current line
765 :return: :const:`True` if :paramref:`line` is a comment
766 '''
767 for c in self.COMMENT_PREFIXES:
768 if line.startswith(c):
769 return True
770 return False
772 def enter_group(self, line: str) -> bool:
773 '''
774 Check if :paramref:`line` starts a new group and set :attr:`config_id` if it does.
775 Call :meth:`parse_error` if :attr:`check_config_id` raises a :class:`ParseException`.
777 :param line: The current line
778 :return: :const:`True` if :paramref:`line` starts a new group
779 '''
780 if line.startswith(self.ENTER_GROUP_PREFIX) and line.endswith(self.ENTER_GROUP_SUFFIX):
781 config_id = typing.cast(ConfigId, line[len(self.ENTER_GROUP_PREFIX):-len(self.ENTER_GROUP_SUFFIX)])
782 if self.check_config_id and config_id != Config.default_config_id:
783 try:
784 self.check_config_id(config_id)
785 except ParseException as e:
786 self.parse_error(str(e))
787 self.config_id = config_id
788 if self.config_id not in MultiConfig.config_ids:
789 MultiConfig.config_ids.append(self.config_id)
790 return True
791 return False
793 def parse_split_line(self, ln_split: 'Sequence[str]') -> bool:
794 '''
795 Call the corresponding command in :attr:`command_dict`.
796 If any :class:`ParseException` or :class:`MultipleParseExceptions` is raised catch it and call :meth:`parse_error`.
798 :return: False if a :class:`ParseException` or :class:`MultipleParseExceptions` has been caught, True if no exception has been caught
799 '''
800 cmd = self.get_command(ln_split)
801 try:
802 cmd.run(ln_split)
803 except ParseException as e:
804 self.parse_error(str(e))
805 return False
806 except MultipleParseExceptions as exceptions:
807 for exc in exceptions:
808 self.parse_error(str(exc))
809 return False
811 return True
813 def get_command(self, ln_split: 'Sequence[str]') -> 'ConfigFileCommand':
814 cmd_name = ln_split[0]
815 if cmd_name in self.command_dict:
816 cmd = self.command_dict[cmd_name]
817 elif DEFAULT_COMMAND in self.command_dict:
818 cmd = self.command_dict[DEFAULT_COMMAND]
819 else:
820 cmd = UnknownCommand(self)
821 return cmd
824 # ------- save -------
826 def get_save_path(self) -> str:
827 '''
828 :return: The first existing and writable file returned by :meth:`iter_config_paths` or the first path if none of the files are existing and writable.
829 '''
830 paths = tuple(self.iter_config_paths())
831 for fn in paths:
832 if os.path.isfile(fn) and os.access(fn, os.W_OK):
833 return fn
835 return paths[0]
837 def save(self,
838 **kw: 'Unpack[SaveKwargs]',
839 ) -> str:
840 '''
841 Save the current values of all settings to the file returned by :meth:`get_save_path`.
842 Directories are created as necessary.
844 :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.
845 :param ignore: Do not write these settings to the file.
846 :param no_multi: Do not write several sections. For :class:`MultiConfig` instances write the default values only.
847 :param comments: Write comments with allowed values and help.
848 :return: The path to the file which has been written
849 '''
850 fn = self.get_save_path()
851 # "If, when attempting to write a file, the destination directory is non-existent an attempt should be made to create it with permission 0700.
852 # If the destination directory exists already the permissions should not be changed."
853 # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
854 os.makedirs(os.path.dirname(fn), exist_ok=True, mode=0o0700)
855 self.save_file(fn, **kw)
856 return fn
858 def save_file(self,
859 fn: str,
860 **kw: 'Unpack[SaveKwargs]'
861 ) -> None:
862 '''
863 Save the current values of all settings to a specific file.
865 :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.
866 :raises FileNotFoundError: if the directory does not exist
868 For an explanation of the other parameters see :meth:`save`.
869 '''
870 with open(fn, 'wt') as f:
871 self.save_to_open_file(f, **kw)
874 def save_to_open_file(self,
875 f: typing.TextIO,
876 **kw: 'Unpack[SaveKwargs]',
877 ) -> None:
878 '''
879 Save the current values of all settings to a file-like object
880 by creating a :class:`ConfigFileWriter` object and calling :meth:`save_to_writer`.
882 :param f: The file to write to
884 For an explanation of the other parameters see :meth:`save`.
885 '''
886 writer = ConfigFileWriter(f, prefix=self.COMMENT + ' ')
887 self.save_to_writer(writer, **kw)
889 def save_to_writer(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
890 '''
891 Save the current values of all settings.
893 Ensure that all keyword arguments are passed with :meth:`set_save_default_arguments`.
894 Iterate over all :class:`ConfigFileCommand` objects in :attr:`self.commands` and do for each of them:
896 - set :attr:`~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
897 - call :meth:`~ConfigFileCommand.save`
898 '''
899 self.set_save_default_arguments(kw)
900 commands = self.commands
901 write_headings = len(tuple(cmd for cmd in commands if getattr(cmd.save, 'implemented', True))) >= 2
902 for cmd in commands:
903 cmd.should_write_heading = write_headings
904 cmd.save(writer, **kw)
906 def set_save_default_arguments(self, kw: 'SaveKwargs') -> None:
907 '''
908 Ensure that all arguments are given in :paramref:`kw`.
909 '''
910 kw.setdefault('config_instances', set(self.config_instances.values()))
911 kw.setdefault('ignore', None)
912 kw.setdefault('no_multi', not self.enable_config_ids)
913 kw.setdefault('comments', True)
916 def quote(self, val: str) -> str:
917 '''
918 Quote a value if necessary so that it will be interpreted as one argument.
920 The default implementation calls :func:`readable_quote`.
921 '''
922 return readable_quote(val)
924 def write_config_id(self, writer: FormattedWriter, config_id: ConfigId) -> None:
925 '''
926 Start a new group in the config file so that all following commands refer to the given :paramref:`config_id`.
927 '''
928 writer.write_command(self.ENTER_GROUP_PREFIX + config_id + self.ENTER_GROUP_SUFFIX)
930 def get_help_config_id(self) -> str:
931 '''
932 :return: A help how to use :class:`MultiConfig`. The return value still needs to be cleaned with :meth:`inspect.cleandoc`.
933 '''
934 return f'''
935 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.
936 `config-id` must be replaced by the corresponding identifier for the object.
937 '''
940 # ------- help -------
942 def write_help(self, writer: FormattedWriter) -> None:
943 import platform
944 formatter = self.create_formatter()
945 writer.write_lines('The first existing file of the following paths is loaded:')
946 for path in self.iter_config_paths():
947 writer.write_line('- %s' % path)
949 writer.write_line('')
950 writer.write_line('This can be influenced with the following environment variables:')
951 if platform.system() == 'Linux': # pragma: no branch
952 writer.write_line('- XDG_CONFIG_HOME')
953 writer.write_line('- XDG_CONFIG_DIRS')
954 for env in self.env_variables:
955 writer.write_line(f'- {env}')
957 writer.write_line('')
958 writer.write_lines(formatter.format_text(f'''\
959You can also use environment variables to change the values of the settings listed under `set` command.
960The corresponding environment variable name is the name of the setting in all upper case letters
961with dots, hypens and spaces replaced by underscores and prefixed with "{self.envprefix}".'''))
963 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)))
965 writer.write_lines('The config file may contain the following commands:')
966 for cmd in self.commands:
967 names = '|'.join(cmd.get_names())
968 writer.write_heading(SectionLevel.SECTION, names)
969 writer.write_lines(cmd.get_help())
971 def create_formatter(self) -> HelpFormatterWrapper:
972 return HelpFormatterWrapper(self.formatter_class)
974 def get_help(self) -> str:
975 '''
976 A convenience wrapper around :meth:`write_help`
977 to return the help as a str instead of writing it to a file.
979 This uses :class:`HelpWriter`.
980 '''
981 doc = io.StringIO()
982 self.write_help(HelpWriter(doc))
983 # The generated help ends with a \n which is implicitly added by print.
984 # If I was writing to stdout or a file that would be desired.
985 # But if I return it as a string and then print it, the print adds another \n which would be too much.
986 # Therefore I am stripping the trailing \n.
987 return doc.getvalue().rstrip('\n')
990 # ------- auto complete -------
992 def get_completions(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
993 '''
994 Provide an auto completion for commands that can be executed with :meth:`parse_line`.
996 :param line: The entire line that is currently in the text input field
997 :param cursor_pos: The position of the cursor
998 :return: start of line, completions, end of line.
999 *completions* is a list of possible completions for the word where the cursor is located.
1000 If *completions* is an empty list there are no completions available and the user input should not be changed.
1001 If *completions* is not empty it should be displayed by a user interface in a drop down menu.
1002 The *start of line* is everything on the line before the completions.
1003 The *end of line* is everything on the line after the completions.
1004 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1005 *start of line* and *end of line* should be the beginning and end of :paramref:`line` but they may contain minor changes in order to keep quoting feasible.
1006 '''
1007 original_ln = line
1008 stripped_line = line.lstrip()
1009 indentation = line[:len(line) - len(stripped_line)]
1010 cursor_pos -= len(indentation)
1011 line = stripped_line
1012 if self.enable_config_ids and line.startswith(self.ENTER_GROUP_PREFIX):
1013 out = self.get_completions_enter_group(line, cursor_pos)
1014 else:
1015 out = self.get_completions_command(line, cursor_pos)
1017 out = (indentation + out[0], out[1], out[2])
1018 return out
1020 def get_completions_enter_group(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1021 '''
1022 For a description of parameters and return type see :meth:`get_completions`.
1024 :meth:`get_completions` has stripped any indentation from :paramref:`line`
1025 and will prepend it to the first item of the return value.
1026 '''
1027 start = line
1028 groups = [self.ENTER_GROUP_PREFIX + str(cid) + self.ENTER_GROUP_SUFFIX for cid in MultiConfig.config_ids]
1029 groups = [cid for cid in groups if cid.startswith(start)]
1030 return '', groups, ''
1032 def get_completions_command(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1033 '''
1034 For a description of parameters and return type see :meth:`get_completions`.
1036 :meth:`get_completions` has stripped any indentation from :paramref:`line`
1037 and will prepend it to the first item of the return value.
1038 '''
1039 if not line:
1040 return self.get_completions_command_name(line, cursor_pos, start_of_line='', end_of_line='')
1042 ln_split = self.split_line_ignore_errors(line)
1043 assert ln_split
1044 a = self.find_arg(line, ln_split, cursor_pos)
1046 if a.in_between:
1047 start_of_line = line[:cursor_pos]
1048 end_of_line = line[cursor_pos:]
1049 else:
1050 start_of_line = line[:a.i0]
1051 end_of_line = line[a.i1:]
1053 if a.argument_pos == 0:
1054 return self.get_completions_command_name(line, cursor_pos, start_of_line=start_of_line, end_of_line=end_of_line)
1055 else:
1056 cmd = self.get_command(ln_split)
1057 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)
1059 def find_arg(self, line: str, ln_split: 'list[str]', cursor_pos: int) -> ArgPos:
1060 '''
1061 This is an internal method used by :meth:`get_completions_command`
1062 '''
1063 CHARS_REMOVED_BY_SHLEX = ('"', "'", '\\')
1064 assert cursor_pos <= len(line) # yes, cursor_pos can be == len(str)
1065 out = ArgPos()
1066 out.in_between = True
1068 # init all out attributes just to be save, these should not never be used because line is not empty and not white space only
1069 out.argument_pos = 0
1070 out.i0 = 0
1071 out.i1 = 0
1073 n_ln = len(line)
1074 i_ln = 0
1075 n_arg = len(ln_split)
1076 out.argument_pos = 0
1077 i_in_arg = 0
1078 assert out.argument_pos < n_ln
1079 while True:
1080 if out.in_between:
1081 assert i_in_arg == 0
1082 if i_ln >= n_ln:
1083 assert out.argument_pos >= n_arg - 1
1084 out.i0 = i_ln
1085 return out
1086 elif line[i_ln].isspace():
1087 i_ln += 1
1088 else:
1089 out.i0 = i_ln
1090 if i_ln >= cursor_pos:
1091 return out
1092 out.in_between = False
1093 else:
1094 if i_ln >= n_ln:
1095 assert out.argument_pos >= n_arg - 1
1096 out.i1 = i_ln
1097 return out
1098 elif out.argument_pos >= n_arg:
1099 # This is a comment
1100 out.i1 = n_ln
1101 return out
1102 elif i_in_arg >= len(ln_split[out.argument_pos]):
1103 if line[i_ln].isspace():
1104 out.i1 = i_ln
1105 if i_ln >= cursor_pos:
1106 return out
1107 out.in_between = True
1108 i_ln += 1
1109 out.argument_pos += 1
1110 i_in_arg = 0
1111 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX:
1112 i_ln += 1
1113 else:
1114 # unlike bash shlex treats a comment character inside of an argument as a comment character
1115 assert line[i_ln] == '#'
1116 assert out.argument_pos == n_arg - 1
1117 out.i1 = i_ln
1118 return out
1119 elif line[i_ln] == ln_split[out.argument_pos][i_in_arg]:
1120 i_ln += 1
1121 i_in_arg += 1
1122 else:
1123 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX
1124 i_ln += 1
1127 def get_completions_command_name(self, line: str, cursor_pos: int, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1128 start = line[:cursor_pos]
1129 completions = [cmd for cmd in self.command_dict.keys() if cmd.startswith(start)]
1130 return start_of_line, completions, end_of_line
1133 # ------- error handling -------
1135 def parse_error(self, msg: str) -> None:
1136 '''
1137 Is called if something went wrong while trying to load a config file.
1139 This method is called when a :class:`ParseException` or :class:`MultipleParseExceptions` is caught.
1140 This method compiles the given information into an error message and calls :meth:`self.ui_notifier.show_error() <UiNotifier.show_error>`.
1142 :param msg: The error message
1143 '''
1144 self.ui_notifier.show_error(msg)
1147# ---------- base classes for commands which can be used in config files ----------
1149class ConfigFileCommand(abc.ABC):
1151 '''
1152 An abstract base class for commands which can be used in a config file.
1154 Subclasses must implement the :meth:`run` method which is called when :class:`ConfigFile` is loading a file.
1155 Subclasses should contain a doc string so that :meth:`get_help` can provide a description to the user.
1156 Subclasses may set the :attr:`name` and :attr:`aliases` attributes to change the output of :meth:`get_name` and :meth:`get_names`.
1158 All subclasses are remembered and can be retrieved with :meth:`get_command_types`.
1159 They are instantiated in the constructor of :class:`ConfigFile`.
1160 '''
1162 #: 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:`get_name` returns the name of this class in lower case letters and underscores replaced by hyphens.
1163 name: str
1165 #: Alternative names which can be used in the config file.
1166 aliases: 'tuple[str, ...]|list[str]'
1168 #: A description which may be used by an in-app help. If this is not set :meth:`get_help` uses the doc string instead.
1169 help: str
1171 #: 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` if there are several commands which implement the :meth:`save` method. If you implement :meth:`save` and this attribute is set then :meth:`save` should write a section header. If :meth:`save` writes several sections it should always write the headings regardless of this attribute.
1172 should_write_heading: bool = False
1174 #: The :class:`ConfigFile` that has been passed to the constructor. It determines for example the :paramref:`~ConfigFile.notification_level` and the available :paramref:`~ConfigFile.commands`.
1175 config_file: ConfigFile
1177 #: The :class:`UiNotifier` of :attr:`config_file`
1178 ui_notifier: UiNotifier
1181 _subclasses: 'list[type[ConfigFileCommand]]' = []
1182 _used_names: 'set[str]' = set()
1184 @classmethod
1185 def get_command_types(cls) -> 'tuple[type[ConfigFileCommand], ...]':
1186 '''
1187 :return: All subclasses of :class:`ConfigFileCommand` which have not been deleted with :meth:`delete_command_type`
1188 '''
1189 return tuple(cls._subclasses)
1191 @classmethod
1192 def delete_command_type(cls, cmd_type: 'type[ConfigFileCommand]') -> None:
1193 '''
1194 Delete :paramref:`cmd_type` so that it is not returned anymore by :meth:`get_command_types` and that it's name can be used by another command.
1195 Do nothing if :paramref:`cmd_type` has already been deleted.
1196 '''
1197 if cmd_type in cls._subclasses:
1198 cls._subclasses.remove(cmd_type)
1199 for name in cmd_type.get_names():
1200 cls._used_names.remove(name)
1202 @classmethod
1203 def __init_subclass__(cls, replace: bool = False, abstract: bool = False) -> None:
1204 '''
1205 Add the new subclass to :attr:`subclass`.
1207 :param replace: Set :attr:`name` and :attr:`aliases` to the values of the parent class if they are not set explicitly, delete the parent class with :meth:`delete_command_type` and replace any commands with the same name
1208 :param abstract: This class is a base class for the implementation of other commands and shall *not* be returned by :meth:`get_command_types`
1209 :raises ValueError: if the name or one of it's aliases is already in use and :paramref:`replace` is not true
1210 '''
1211 if replace:
1212 parent_commands = [parent for parent in cls.__bases__ if issubclass(parent, ConfigFileCommand)]
1214 # set names of this class to that of the parent class(es)
1215 parent = parent_commands[0]
1216 if 'name' not in cls.__dict__:
1217 cls.name = parent.get_name()
1218 if 'aliases' not in cls.__dict__:
1219 cls.aliases = list(parent.get_names())[1:]
1220 for parent in parent_commands[1:]:
1221 cls.aliases.extend(parent.get_names())
1223 # remove parent class from the list of commands to be loaded or saved
1224 for parent in parent_commands:
1225 cls.delete_command_type(parent)
1227 if not abstract:
1228 cls._subclasses.append(cls)
1229 for name in cls.get_names():
1230 if name in cls._used_names and not replace:
1231 raise ValueError('duplicate command name %r' % name)
1232 cls._used_names.add(name)
1234 @classmethod
1235 def get_name(cls) -> str:
1236 '''
1237 :return: The name which is used in config file to call this command.
1239 If :attr:`name` is set it is returned as it is.
1240 Otherwise a name is generated based on the class name.
1241 '''
1242 if 'name' in cls.__dict__:
1243 return cls.name
1244 return cls.__name__.lower().replace("_", "-")
1246 @classmethod
1247 def get_names(cls) -> 'Iterator[str]':
1248 '''
1249 :return: Several alternative names which can be used in a config file to call this command.
1251 The first one is always the return value of :meth:`get_name`.
1252 If :attr:`aliases` is set it's items are yielded afterwards.
1254 If one of the returned items is the empty string this class is the default command
1255 and :meth:`run` will be called if an undefined command is encountered.
1256 '''
1257 yield cls.get_name()
1258 if 'aliases' in cls.__dict__:
1259 for name in cls.aliases:
1260 yield name
1262 def __init__(self, config_file: ConfigFile) -> None:
1263 self.config_file = config_file
1264 self.ui_notifier = config_file.ui_notifier
1266 @abc.abstractmethod
1267 def run(self, cmd: 'Sequence[str]') -> None:
1268 '''
1269 Process one line which has been read from a config file
1271 :raises ParseException: if there is an error in the line (e.g. invalid syntax)
1272 :raises MultipleParseExceptions: if there are several errors in the same line
1273 '''
1274 raise NotImplementedError()
1276 def create_formatter(self) -> HelpFormatterWrapper:
1277 return self.config_file.create_formatter()
1279 def get_help_attr_or_doc_str(self) -> str:
1280 '''
1281 :return: The :attr:`help` attribute or the doc string if :attr:`help` has not been set, cleaned up with :meth:`inspect.cleandoc`.
1282 '''
1283 if hasattr(self, 'help'):
1284 doc = self.help
1285 elif self.__doc__:
1286 doc = self.__doc__
1287 else:
1288 doc = ''
1290 return inspect.cleandoc(doc)
1292 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
1293 '''
1294 Add the return value of :meth:`get_help_attr_or_doc_str` to :paramref:`formatter`.
1295 '''
1296 formatter.add_text(self.get_help_attr_or_doc_str())
1298 def get_help(self) -> str:
1299 '''
1300 :return: A help text which can be presented to the user.
1302 This is generated by creating a formatter with :meth:`create_formatter`,
1303 adding the help to it with :meth:`add_help_to` and
1304 stripping trailing new line characters from the result of :meth:`HelpFormatterWrapper.format_help`.
1306 Most likely you don't want to override this method but :meth:`add_help_to` instead.
1307 '''
1308 formatter = self.create_formatter()
1309 self.add_help_to(formatter)
1310 return formatter.format_help().rstrip('\n')
1312 def save(self,
1313 writer: FormattedWriter,
1314 **kw: 'Unpack[SaveKwargs]',
1315 ) -> None:
1316 '''
1317 Implement this method if you want calls to this command to be written by :meth:`ConfigFile.save`.
1319 If you implement this method write a section heading with :meth:`writer.write_heading('Heading') <FormattedWriter.write_heading>` if :attr:`should_write_heading` is true.
1320 If this command writes several sections then write a heading for every section regardless of :attr:`should_write_heading`.
1322 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('...') <FormattedWriter.write_command>`.
1323 Write comments or help with :meth:`writer.write_lines('...') <FormattedWriter.write_lines>`.
1325 There is the :attr:`config_file` attribute (which was passed to the constructor) which you can use to:
1327 - quote arguments with :meth:`ConfigFile.quote`
1328 - call :attr:`ConfigFile.write_config_id`
1330 You probably don't need the comment character :attr:`ConfigFile.COMMENT` because :paramref:`writer` automatically comments out everything except for :meth:`FormattedWriter.write_command`.
1332 The default implementation does nothing.
1333 '''
1334 pass
1336 save.implemented = False # type: ignore [attr-defined]
1339 # ------- auto complete -------
1341 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]':
1342 '''
1343 :param cmd: The line split into arguments (including the name of this command as cmd[0])
1344 :param argument_pos: The index of the argument which shall be completed. Please note that this can be one bigger than :paramref:`cmd` is long if the line ends on a space and the cursor is behind that space. In that case :paramref:`in_between` is true.
1345 :param cursor_pos: The index inside of the argument where the cursor is located. This is undefined and should be ignored if :paramref:`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.
1346 :param in_between: If true: The cursor is between two arguments, before the first argument or after the last argument. :paramref:`argument_pos` refers to the next argument, :paramref:`argument_pos-1 <argument_pos>` to the previous argument. :paramref:`cursor_pos` is undefined.
1347 :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.
1348 :param end_of_line: The third return value.
1349 :return: start of line, completions, end of line.
1350 *completions* is a list of possible completions for the word where the cursor is located.
1351 If *completions* is an empty list there are no completions available and the user input should not be changed.
1352 This should be displayed by a user interface in a drop down menu.
1353 The *start of line* is everything on the line before the completions.
1354 The *end of line* is everything on the line after the completions.
1355 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1356 *start of line* and *end of line* should be the beginning and end of :paramref:`line` but they may contain minor changes in order to keep quoting feasible.
1357 '''
1358 completions: 'list[str]' = []
1359 return start_of_line, completions, end_of_line
1361 def get_completions_for_file_name(self, start: str, *, relative_to: str, exclude: 'str|None' = None, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1362 r'''
1363 :param start: The start of the path to be completed
1364 :param relative_to: If :paramref:`start` is a relative path it's relative to this directory
1365 :param exclude: A regular expression. The default value :obj:`None` is interpreted differently depending on the :meth:`platform.platform`. For ``Windows`` it's ``$none`` so that nothing is excluded. For others it's ``^\.`` so that hidden files and directories are excluded.
1366 :return: All files and directories that start with :paramref:`start` and do not match :paramref:`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).
1367 '''
1368 if exclude is None:
1369 if platform.platform() == 'Windows' or os.path.split(start)[1].startswith('.'):
1370 exclude = '$none'
1371 else:
1372 exclude = r'^\.'
1373 reo = re.compile(exclude)
1375 # I cannot use os.path.split because that would ignore the important difference between having a trailing separator or not
1376 if os.path.sep in start:
1377 directory, start = start.rsplit(os.path.sep, 1)
1378 directory += os.path.sep
1379 quoted_directory = self.quote_path(directory)
1381 start_of_line += quoted_directory
1382 directory = os.path.expanduser(directory)
1383 if not os.path.isabs(directory):
1384 directory = os.path.join(relative_to, directory)
1385 directory = os.path.normpath(directory)
1386 else:
1387 directory = relative_to
1389 try:
1390 names = os.listdir(directory)
1391 except (FileNotFoundError, NotADirectoryError):
1392 return start_of_line, [], end_of_line
1394 out: 'list[str]' = []
1395 for name in names:
1396 if reo.match(name):
1397 continue
1398 if not name.startswith(start):
1399 continue
1401 quoted_name = self.config_file.quote(name)
1402 if os.path.isdir(os.path.join(directory, name)):
1403 quoted_name += os.path.sep
1405 out.append(quoted_name)
1407 return start_of_line, out, end_of_line
1409 def quote_path(self, path: str) -> str:
1410 path_split = path.split(os.path.sep)
1411 i0 = 1 if path_split[0] == '~' else 0
1412 for i in range(i0, len(path_split)):
1413 if path_split[i]:
1414 path_split[i] = self.config_file.quote(path_split[i])
1415 return os.path.sep.join(path_split)
1418class ArgumentParser(argparse.ArgumentParser):
1420 def error(self, message: str) -> 'typing.NoReturn':
1421 '''
1422 Raise a :class:`ParseException`.
1423 '''
1424 raise ParseException(message)
1426class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True):
1428 '''
1429 An abstract subclass of :class:`ConfigFileCommand` which uses :mod:`argparse` to make parsing and providing help easier.
1431 You must implement the class method :meth:`init_parser` to add the arguments to :attr:`parser`.
1432 Instead of :meth:`run` you must implement :meth:`run_parsed`.
1433 You don't need to add a usage or the possible arguments to the doc string as :mod:`argparse` will do that for you.
1434 You should, however, still give a description what this command does in the doc string.
1436 You may specify :attr:`ConfigFileCommand.name`, :attr:`ConfigFileCommand.aliases` and :meth:`ConfigFileCommand.save` like for :class:`ConfigFileCommand`.
1437 '''
1439 def __init__(self, config_file: ConfigFile) -> None:
1440 super().__init__(config_file)
1441 self._names = set(self.get_names())
1442 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)
1443 self.init_parser(self.parser)
1445 @abc.abstractmethod
1446 def init_parser(self, parser: ArgumentParser) -> None:
1447 '''
1448 :param parser: The parser to add arguments to. This is the same object like :attr:`parser`.
1450 This is an abstract method which must be implemented by subclasses.
1451 Use :meth:`ArgumentParser.add_argument` to add arguments to :paramref:`parser`.
1452 '''
1453 pass
1455 def get_help(self) -> str:
1456 '''
1457 Creates a help text which can be presented to the user by calling :meth:`parser.format_help`.
1458 The return value of :meth:`ConfigFileCommand.write_help` has been passed as :paramref:`description` to the constructor of :class:`ArgumentParser`, therefore :attr:`help`/the doc string are included as well.
1459 '''
1460 return self.parser.format_help().rstrip('\n')
1462 def run(self, cmd: 'Sequence[str]') -> None:
1463 # if the line was empty this method should not be called but an empty line should be ignored either way
1464 if not cmd:
1465 return # pragma: no cover
1466 # cmd[0] does not need to be in self._names if this is the default command, i.e. if '' in self._names
1467 if cmd[0] in self._names:
1468 cmd = cmd[1:]
1469 args = self.parser.parse_args(cmd)
1470 self.run_parsed(args)
1472 @abc.abstractmethod
1473 def run_parsed(self, args: argparse.Namespace) -> None:
1474 '''
1475 This is an abstract method which must be implemented by subclasses.
1476 '''
1477 pass
1479 # ------- auto complete -------
1481 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]':
1482 if in_between:
1483 start = ''
1484 else:
1485 start = cmd[argument_pos][:cursor_pos]
1487 if self.after_positional_argument_marker(cmd, argument_pos):
1488 pos = self.get_position(cmd, argument_pos)
1489 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
1491 if argument_pos > 0: # pragma: no branch # if argument_pos was 0 this method would not be called, command names would be completed instead
1492 prevarg = self.get_option_name_if_it_takes_an_argument(cmd, argument_pos-1)
1493 if prevarg:
1494 return self.get_completions_for_option_argument(prevarg, start, start_of_line=start_of_line, end_of_line=end_of_line)
1496 if self.is_option_start(start):
1497 if '=' in start:
1498 i = start.index('=')
1499 option_name = start[:i]
1500 i += 1
1501 start_of_line += start[:i]
1502 start = start[i:]
1503 return self.get_completions_for_option_argument(option_name, start, start_of_line=start_of_line, end_of_line=end_of_line)
1504 return self.get_completions_for_option_name(start, start_of_line=start_of_line, end_of_line=end_of_line)
1506 pos = self.get_position(cmd, argument_pos)
1507 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
1509 def get_position(self, cmd: 'Sequence[str]', argument_pos: int) -> int:
1510 '''
1511 :return: the position of a positional argument, not counting options and their arguments
1512 '''
1513 pos = 0
1514 n = len(cmd)
1515 options_allowed = True
1516 # I am starting at 1 because cmd[0] is the name of the command, not an argument
1517 for i in range(1, argument_pos):
1518 if options_allowed and i < n:
1519 if cmd[i] == '--':
1520 options_allowed = False
1521 continue
1522 elif self.is_option_start(cmd[i]):
1523 continue
1524 # > 1 because cmd[0] is the name of the command
1525 elif i > 1 and self.get_option_name_if_it_takes_an_argument(cmd, i-1):
1526 continue
1527 pos += 1
1529 return pos
1531 def is_option_start(self, start: str) -> bool:
1532 return start.startswith('-') or start.startswith('+')
1534 def after_positional_argument_marker(self, cmd: 'Sequence[str]', argument_pos: int) -> bool:
1535 '''
1536 :return: true if this can only be a positional argument. False means it can be both, option or positional argument.
1537 '''
1538 return '--' in cmd and cmd.index('--') < argument_pos
1540 def get_option_name_if_it_takes_an_argument(self, cmd: 'Sequence[str]', argument_pos: int) -> 'str|None':
1541 if argument_pos >= len(cmd):
1542 return None # pragma: no cover # this does not happen because this method is always called for the previous argument
1544 arg = cmd[argument_pos]
1545 if '=' in arg:
1546 # argument of option is already given within arg
1547 return None
1548 if not self.is_option_start(arg):
1549 return None
1550 if arg.startswith('--'):
1551 action = self.get_action_for_option(arg)
1552 if action is None:
1553 return None
1554 if action.nargs != 0:
1555 return arg
1556 return None
1558 # arg is a combination of single character flags like in `tar -xzf file`
1559 for c in arg[1:-1]:
1560 action = self.get_action_for_option('-' + c)
1561 if action is None:
1562 continue
1563 if action.nargs != 0:
1564 # c takes an argument but that is already given within arg
1565 return None
1567 out = '-' + arg[-1]
1568 action = self.get_action_for_option(out)
1569 if action is None:
1570 return None
1571 if action.nargs != 0:
1572 return out
1573 return None
1576 def get_completions_for_option_name(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1577 completions = []
1578 for a in self.parser._get_optional_actions():
1579 for opt in a.option_strings:
1580 if len(opt) <= 2:
1581 # this is trivial to type but not self explanatory
1582 # => not helpful for auto completion
1583 continue
1584 if opt.startswith(start):
1585 completions.append(opt)
1586 return start_of_line, completions, end_of_line
1588 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]':
1589 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)
1591 def get_completions_for_positional_argument(self, position: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1592 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)
1595 def get_action_for_option(self, option_name: str) -> 'argparse.Action|None':
1596 for a in self.parser._get_optional_actions():
1597 if option_name in a.option_strings:
1598 return a
1599 return None
1601 def get_action_for_positional_argument(self, argument_pos: int) -> 'argparse.Action|None':
1602 actions = self.parser._get_positional_actions()
1603 if argument_pos < len(actions):
1604 return actions[argument_pos]
1605 return None
1607 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]':
1608 if action is None:
1609 completions: 'list[str]' = []
1610 elif not action.choices:
1611 completions = []
1612 else:
1613 completions = [str(val) for val in action.choices]
1614 completions = [val for val in completions if val.startswith(start)]
1615 completions = [self.config_file.quote(val) for val in completions]
1616 return start_of_line, completions, end_of_line
1619# ---------- implementations of commands which can be used in config files ----------
1621class Set(ConfigFileCommand):
1623 r'''
1624 usage: set key1=val1 [key2=val2 ...] \\
1625 set key [=] val
1627 Change the value of a setting.
1629 In the first form set takes an arbitrary number of arguments, each argument sets one setting.
1630 This has the advantage that several settings can be changed at once.
1631 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.
1633 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.
1634 This has the advantage that key and value are separated by one or more spaces which can improve the readability of a config file.
1635 '''
1637 #: The separator which is used between a key and it's value
1638 KEY_VAL_SEP = '='
1640 #: Help for data types. This is used by :meth:`get_help_for_data_types`. Change this with :meth:`set_help_for_type`.
1641 help_for_types = {
1642 str : 'A text. If it contains spaces it must be wrapped in single or double quotes.',
1643 int : '''\
1644 An integer number in python 3 syntax, as decimal (e.g. 42), hexadecimal (e.g. 0x2a), octal (e.g. 0o52) or binary (e.g. 0b101010).
1645 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers.
1646 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.''',
1647 #bool,
1648 float : 'A floating point number in python syntax, e.g. 23, 1.414, -1e3, 3.14_15_93.',
1649 }
1652 # ------- load -------
1654 def run(self, cmd: 'Sequence[str]') -> None:
1655 '''
1656 Call :meth:`set_multiple` if the first argument contains :attr:`KEY_VAL_SEP` otherwise :meth:`set_with_spaces`.
1658 :raises ParseException: if something is wrong (no arguments given, invalid syntax, invalid key, invalid value)
1659 '''
1660 if len(cmd) < 2:
1661 raise ParseException('no settings given')
1663 if self.is_vim_style(cmd):
1664 self.set_multiple(cmd)
1665 else:
1666 self.set_with_spaces(cmd)
1668 def is_vim_style(self, cmd: 'Sequence[str]') -> bool:
1669 '''
1670 :paramref:`cmd` has one of two possible styles:
1671 - vim inspired: set takes an arbitrary number of arguments, each argument sets one setting. Is handled by :meth:`set_multiple`.
1672 - 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:`set_with_spaces`.
1674 :return: true if cmd has a vim inspired style, false if cmd has a ranger inspired style
1675 '''
1676 return self.KEY_VAL_SEP in cmd[1] # cmd[0] is the name of the command, cmd[1] is the first argument
1678 def set_with_spaces(self, cmd: 'Sequence[str]') -> None:
1679 '''
1680 Process one line of the format ``set key [=] value``
1682 :raises ParseException: if something is wrong (invalid syntax, invalid key, invalid value)
1683 '''
1684 n = len(cmd)
1685 if n == 3:
1686 cmdname, key, value = cmd
1687 self.parse_key_and_set_value(key, value)
1688 elif n == 4:
1689 cmdname, key, sep, value = cmd
1690 if sep != self.KEY_VAL_SEP:
1691 raise ParseException(f'separator between key and value should be {self.KEY_VAL_SEP}, not {sep!r}')
1692 self.parse_key_and_set_value(key, value)
1693 elif n == 2:
1694 raise ParseException(f'missing value or missing {self.KEY_VAL_SEP}')
1695 else:
1696 assert n >= 5
1697 raise ParseException(f'too many arguments given or missing {self.KEY_VAL_SEP} in first argument')
1699 def set_multiple(self, cmd: 'Sequence[str]') -> None:
1700 '''
1701 Process one line of the format ``set key=value [key2=value2 ...]``
1703 :raises MultipleParseExceptions: if something is wrong (invalid syntax, invalid key, invalid value)
1704 '''
1705 exceptions = []
1706 for arg in cmd[1:]:
1707 try:
1708 if not self.KEY_VAL_SEP in arg:
1709 raise ParseException(f'missing {self.KEY_VAL_SEP} in {arg!r}')
1710 key, value = arg.split(self.KEY_VAL_SEP, 1)
1711 self.parse_key_and_set_value(key, value)
1712 except ParseException as e:
1713 exceptions.append(e)
1714 if exceptions:
1715 raise MultipleParseExceptions(exceptions)
1717 def parse_key_and_set_value(self, key: str, value: str) -> None:
1718 '''
1719 Find the corresponding :class:`Config` instance for :paramref:`key` and call :meth:`set_value` with the return value of :meth:`parse_value`.
1721 :raises ParseException: if key is invalid or if :meth:`parse_value` or :meth:`set_value` raises a :class:`ValueError`
1722 '''
1723 if key not in self.config_file.config_instances:
1724 raise ParseException(f'invalid key {key!r}')
1726 instance = self.config_file.config_instances[key]
1727 try:
1728 self.set_value(instance, self.parse_value(instance, value))
1729 except ValueError as e:
1730 raise ParseException(str(e))
1732 def parse_value(self, instance: 'Config[T2]', value: str) -> 'T2':
1733 '''
1734 Parse a value to the data type of a given setting by calling :meth:`instance.parse_value(value) <Config.parse_value>`
1735 '''
1736 return instance.parse_value(value)
1738 def set_value(self, instance: 'Config[T2]', value: 'T2') -> None:
1739 '''
1740 Assign :paramref:`value` to :paramref`instance` by calling :meth:`Config.set_value` with :attr:`ConfigFile.config_id` of :attr:`config_file`.
1741 Afterwards call :meth:`UiNotifier.show_info`.
1742 '''
1743 instance.set_value(self.config_file.config_id, value)
1744 self.ui_notifier.show_info(f'set {instance.key} to {instance.format_value(self.config_file.config_id)}')
1747 # ------- save -------
1749 def iter_config_instances_to_be_saved(self, **kw: 'Unpack[SaveKwargs]') -> 'Iterator[Config[object]]':
1750 '''
1751 :param config_instances: The settings to consider
1752 :param ignore: Skip these settings
1754 Iterate over all given :paramref:`config_instances` and expand all :class:`DictConfig` instances into the :class:`Config` instances they consist of.
1755 Sort the resulting list if :paramref:`config_instances` is not a :class:`list` or a :class:`tuple`.
1756 Yield all :class:`Config` instances which are not (directly or indirectly) contained in :paramref:`ignore` and where :meth:`Config.wants_to_be_exported` returns true.
1757 '''
1758 config_instances = kw['config_instances']
1759 ignore = kw['ignore']
1761 config_keys = []
1762 for c in config_instances:
1763 if isinstance(c, DictConfig):
1764 config_keys.extend(sorted(c.iter_keys()))
1765 else:
1766 config_keys.append(c.key)
1767 if not isinstance(config_instances, (list, tuple)):
1768 config_keys = sorted(config_keys)
1770 if ignore is not None:
1771 tmp = set()
1772 for c in tuple(ignore):
1773 if isinstance(c, DictConfig):
1774 tmp |= set(c._values.values())
1775 else:
1776 tmp.add(c)
1777 ignore = tmp
1779 for key in config_keys:
1780 instance = self.config_file.config_instances[key]
1781 if not instance.wants_to_be_exported():
1782 continue
1784 if ignore is not None and instance in ignore:
1785 continue
1787 yield instance
1789 def save(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
1790 '''
1791 :param writer: The file to write to
1792 :param bool no_multi: If true: treat :class:`MultiConfig` instances like normal :class:`Config` instances and only write their default value. If false: Separate :class:`MultiConfig` instances and print them once for every :attr:`MultiConfig.config_ids`.
1793 :param bool comments: If false: don't write help for data types
1795 Iterate over all :class:`Config` instances with :meth:`iter_config_instances_to_be_saved`,
1796 split them into normal :class:`Config` and :class:`MultiConfig` and write them with :meth:`save_config_instance`.
1797 But before that set :attr:`last_name` to None (which is used by :meth:`write_config_help`)
1798 and write help for data types based on :meth:`get_help_for_data_types`.
1799 '''
1800 no_multi = kw['no_multi']
1801 comments = kw['comments']
1803 config_instances = list(self.iter_config_instances_to_be_saved(**kw))
1804 normal_configs = []
1805 multi_configs = []
1806 if no_multi:
1807 normal_configs = config_instances
1808 else:
1809 for instance in config_instances:
1810 if isinstance(instance, MultiConfig):
1811 multi_configs.append(instance)
1812 else:
1813 normal_configs.append(instance)
1815 self.last_name: 'str|None' = None
1817 if normal_configs:
1818 if multi_configs:
1819 writer.write_heading(SectionLevel.SECTION, 'Application wide settings')
1820 elif self.should_write_heading:
1821 writer.write_heading(SectionLevel.SECTION, 'Settings')
1823 if comments:
1824 type_help = self.get_help_for_data_types(normal_configs)
1825 if type_help:
1826 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
1827 writer.write_lines(type_help)
1829 for instance in normal_configs:
1830 self.save_config_instance(writer, instance, config_id=None, **kw)
1832 if multi_configs:
1833 if normal_configs:
1834 writer.write_heading(SectionLevel.SECTION, 'Settings which can have different values for different objects')
1835 elif self.should_write_heading:
1836 writer.write_heading(SectionLevel.SECTION, 'Settings')
1838 if comments:
1839 type_help = self.get_help_for_data_types(multi_configs)
1840 if type_help:
1841 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
1842 writer.write_lines(type_help)
1844 for instance in multi_configs:
1845 self.save_config_instance(writer, instance, config_id=instance.default_config_id, **kw)
1847 for config_id in MultiConfig.config_ids:
1848 writer.write_line('')
1849 self.config_file.write_config_id(writer, config_id)
1850 for instance in multi_configs:
1851 self.save_config_instance(writer, instance, config_id, **kw)
1853 def save_config_instance(self, writer: FormattedWriter, instance: 'Config[object]', config_id: 'ConfigId|None', **kw: 'Unpack[SaveKwargs]') -> None:
1854 '''
1855 :param writer: The file to write to
1856 :param instance: The config value to be saved
1857 :param config_id: Which value to be written in case of a :class:`MultiConfig`, should be :const:`None` for a normal :class:`Config` instance
1858 :param bool comments: If true: call :meth:`write_config_help`
1860 Convert the :class:`Config` instance into a value str with :meth:`format_value`,
1861 wrap it in quotes if necessary with :meth:`config_file.quote` and write it to :paramref:`writer`.
1862 '''
1863 if kw['comments']:
1864 self.write_config_help(writer, instance)
1865 value = self.format_value(instance, config_id)
1866 value = self.config_file.quote(value)
1867 ln = f'{self.get_name()} {instance.key} = {value}'
1868 writer.write_command(ln)
1870 def format_value(self, instance: Config[typing.Any], config_id: 'ConfigId|None') -> str:
1871 '''
1872 :param instance: The config value to be saved
1873 :param config_id: Which value to be written in case of a :class:`MultiConfig`, should be :const:`None` for a normal :class:`Config` instance
1874 :return: A str representation to be written to the config file
1876 Convert the value of the :class:`Config` instance into a str with :meth:`Config.format_value`.
1877 '''
1878 return instance.format_value(config_id)
1880 def write_config_help(self, writer: FormattedWriter, instance: Config[typing.Any], *, group_dict_configs: bool = True) -> None:
1881 '''
1882 :param writer: The output to write to
1883 :param instance: The config value to be saved
1885 Write a comment which explains the meaning and usage of this setting
1886 based on :meth:`Config.format_allowed_values_or_type` and :attr:`Config.help`.
1888 Use :attr:`last_name` to write the help only once for all :class:`Config` instances belonging to the same :class:`DictConfig` instance.
1889 '''
1890 if group_dict_configs and instance.parent is not None:
1891 name = instance.parent.key_prefix
1892 else:
1893 name = instance.key
1894 if name == self.last_name:
1895 return
1897 formatter = HelpFormatterWrapper(self.config_file.formatter_class)
1898 writer.write_heading(SectionLevel.SUB_SECTION, name)
1899 writer.write_lines(formatter.format_text(instance.format_allowed_values_or_type()).rstrip())
1900 #if instance.unit:
1901 # writer.write_line('unit: %s' % instance.unit)
1902 if isinstance(instance.help, dict):
1903 for key, val in instance.help.items():
1904 key_name = instance.format_any_value(key)
1905 val = inspect.cleandoc(val)
1906 writer.write_lines(formatter.format_item(bullet=key_name+': ', text=val).rstrip())
1907 elif isinstance(instance.help, str):
1908 writer.write_lines(formatter.format_text(inspect.cleandoc(instance.help)).rstrip())
1910 self.last_name = name
1913 @classmethod
1914 def set_help_for_type(cls, t: 'type[object]', help_text: str) -> None:
1915 '''
1916 :meth:`get_help_for_data_types` is used by :meth:`save` and :meth:`get_help`.
1917 Usually it uses the :attr:`help` attribute of the class.
1918 But if the class does not have a :attr:`help` attribute or if you want a different help text
1919 you can set the help with this method.
1921 :param t: The type for which you want to specify a help
1922 :param help_text: The help for :paramref:`t`. It is cleaned up in :meth:`get_data_type_name_to_help_map` with :func:`inspect.cleandoc`.
1923 '''
1924 cls.help_for_types[t] = help_text
1926 def get_data_type_name_to_help_map(self, config_instances: 'Iterable[Config[object]]') -> 'dict[str, str]':
1927 '''
1928 :param config_instances: All config values to be saved
1929 :return: A dictionary containing the type names as keys and the help as values
1931 The returned dictionary contains the help for all data types except enumerations
1932 which occur in :paramref:`config_instances`.
1933 The help is gathered from the :attr:`help` attribute of the type
1934 or the str registered with :meth:`set_help_for_type`.
1935 The help is cleaned up with :func:`inspect.cleandoc`.
1936 '''
1937 help_text: 'dict[str, str]' = {}
1938 for instance in config_instances:
1939 t = instance.type if instance.type != list else instance.item_type
1940 name = getattr(t, 'type_name', t.__name__)
1941 if name in help_text:
1942 continue
1944 if t in self.help_for_types:
1945 h = self.help_for_types[t]
1946 elif hasattr(t, 'help'):
1947 h = t.help
1948 elif issubclass(t, enum.Enum) or t is bool:
1949 # an enum does not need a help if the values have self explanatory names
1950 # bool is treated like an enum
1951 continue
1952 else:
1953 raise AttributeError('No help given for {typename} ({classname}). Please specify it as help attribute or with set_help_for_type.'.format(typename=name, classname=t.__name__))
1955 help_text[name] = inspect.cleandoc(h)
1957 return help_text
1959 def add_help_for_data_types(self, formatter: HelpFormatterWrapper, config_instances: 'Iterable[Config[object]]') -> None:
1960 help_map = self.get_data_type_name_to_help_map(config_instances)
1961 if not help_map:
1962 return
1964 for name in sorted(help_map.keys()):
1965 formatter.add_start_section(name)
1966 formatter.add_text(help_map[name])
1967 formatter.add_end_section()
1969 def get_help_for_data_types(self, config_instances: 'Iterable[Config[object]]') -> str:
1970 formatter = self.create_formatter()
1971 self.add_help_for_data_types(formatter, config_instances)
1972 return formatter.format_help().rstrip('\n')
1974 # ------- help -------
1976 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
1977 super().add_help_to(formatter)
1979 kw: 'SaveKwargs' = {}
1980 self.config_file.set_save_default_arguments(kw)
1981 config_instances = list(self.iter_config_instances_to_be_saved(**kw))
1982 self.last_name = None
1984 formatter.add_start_section('data types')
1985 self.add_help_for_data_types(formatter, config_instances)
1986 formatter.add_end_section()
1988 if self.config_file.enable_config_ids:
1989 normal_configs = []
1990 multi_configs = []
1991 for instance in config_instances:
1992 if isinstance(instance, MultiConfig):
1993 multi_configs.append(instance)
1994 else:
1995 normal_configs.append(instance)
1996 else:
1997 normal_configs = config_instances
1998 multi_configs = []
2000 if normal_configs:
2001 if self.config_file.enable_config_ids:
2002 formatter.add_start_section('application wide settings')
2003 else:
2004 formatter.add_start_section('settings')
2005 for instance in normal_configs:
2006 self.add_config_help(formatter, instance)
2007 formatter.add_end_section()
2009 if multi_configs:
2010 formatter.add_start_section('settings which can have different values for different objects')
2011 formatter.add_text(inspect.cleandoc(self.config_file.get_help_config_id()))
2012 for instance in multi_configs:
2013 self.add_config_help(formatter, instance)
2014 formatter.add_end_section()
2016 def add_config_help(self, formatter: HelpFormatterWrapper, instance: Config[typing.Any]) -> None:
2017 formatter.add_start_section(instance.key)
2018 formatter.add_text(instance.format_allowed_values_or_type())
2019 #if instance.unit:
2020 # formatter.add_item(bullet='unit: ', text=instance.unit)
2021 if isinstance(instance.help, dict):
2022 for key, val in instance.help.items():
2023 key_name = instance.format_any_value(key)
2024 val = inspect.cleandoc(val)
2025 formatter.add_item(bullet=key_name+': ', text=val)
2026 elif isinstance(instance.help, str):
2027 formatter.add_text(inspect.cleandoc(instance.help))
2028 formatter.add_end_section()
2030 # ------- auto complete -------
2032 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]':
2033 if argument_pos >= len(cmd):
2034 start = ''
2035 else:
2036 start = cmd[argument_pos][:cursor_pos]
2038 if len(cmd) <= 1:
2039 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2040 elif self.is_vim_style(cmd):
2041 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2042 else:
2043 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2045 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]':
2046 if self.KEY_VAL_SEP in start:
2047 key, start = start.split(self.KEY_VAL_SEP, 1)
2048 start_of_line += key + self.KEY_VAL_SEP
2049 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line)
2050 else:
2051 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2053 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]':
2054 if argument_pos == 1:
2055 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2056 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP):
2057 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line)
2058 else:
2059 return start_of_line, [], end_of_line
2061 def get_completions_for_key(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2062 completions = [key for key in self.config_file.config_instances.keys() if key.startswith(start)]
2063 return start_of_line, completions, end_of_line
2065 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2066 instance = self.config_file.config_instances.get(key)
2067 if instance is None:
2068 return start_of_line, [], end_of_line
2070 t: 'None|type[object]' = None
2071 if instance.type is list:
2072 first, start = start.rsplit(instance.LIST_SEP)
2073 start_of_line += first + instance.LIST_SEP
2074 t = instance.item_type
2076 completions = [self.config_file.quote(val) for val in instance.get_stringified_allowed_values(t) if val.startswith(start)]
2077 return start_of_line, completions, end_of_line
2080class Include(ConfigFileArgparseCommand):
2082 '''
2083 Load another config file.
2085 This is useful if a config file is getting so big that you want to split it up
2086 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy
2087 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line().
2088 '''
2090 help_config_id = '''
2091 By default the loaded config file starts with which ever config id is currently active.
2092 This is useful if you want to use the same values for several config ids:
2093 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.
2095 After the include the config id is reset to the config id which was active at the beginning of the include
2096 because otherwise it might lead to confusion if the config id is changed in the included config file.
2097 '''
2099 def init_parser(self, parser: ArgumentParser) -> None:
2100 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.')
2101 if self.config_file.enable_config_ids:
2102 assert parser.description is not None
2103 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id)
2104 group = parser.add_mutually_exclusive_group()
2105 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include')
2106 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')
2108 self.nested_includes: 'list[str]' = []
2110 def run_parsed(self, args: argparse.Namespace) -> None:
2111 fn_imp = args.path
2112 fn_imp = fn_imp.replace('/', os.path.sep)
2113 fn_imp = os.path.expanduser(fn_imp)
2114 if not os.path.isabs(fn_imp):
2115 fn = self.config_file.context_file_name
2116 if fn is None:
2117 fn = self.config_file.get_save_path()
2118 fn_imp = os.path.join(os.path.dirname(os.path.abspath(fn)), fn_imp)
2120 if fn_imp in self.nested_includes:
2121 raise ParseException(f'circular include of file {fn_imp!r}')
2122 if not os.path.isfile(fn_imp):
2123 raise ParseException(f'no such file {fn_imp!r}')
2125 self.nested_includes.append(fn_imp)
2127 if self.config_file.enable_config_ids and args.no_reset_config_id_after:
2128 self.config_file.load_without_resetting_config_id(fn_imp)
2129 elif self.config_file.enable_config_ids and args.reset_config_id_before:
2130 config_id = self.config_file.config_id
2131 self.config_file.load_file(fn_imp)
2132 self.config_file.config_id = config_id
2133 else:
2134 config_id = self.config_file.config_id
2135 self.config_file.load_without_resetting_config_id(fn_imp)
2136 self.config_file.config_id = config_id
2138 assert self.nested_includes[-1] == fn_imp
2139 del self.nested_includes[-1]
2141 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]':
2142 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action
2143 if action is not None and action.dest == 'path':
2144 return self.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)
2145 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)
2148class UnknownCommand(ConfigFileCommand, abstract=True):
2150 name = DEFAULT_COMMAND
2152 def run(self, cmd: 'Sequence[str]') -> None:
2153 raise ParseException('unknown command %r' % cmd[0])