Coverage for .tox/cov/lib/python3.11/site-packages/confattr/configfile.py: 100%
1352 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-16 15:15 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-16 15:15 +0100
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 functools
16import inspect
17import io
18import warnings
19import abc
20import typing
21from collections.abc import Iterable, Iterator, Sequence, Callable
23import appdirs
25from .config import Config, DictConfig, MultiConfig, ConfigId
26from .formatters import AbstractFormatter
27from .utils import HelpFormatter, HelpFormatterWrapper, SortedEnum, readable_quote
28from . import state
30if typing.TYPE_CHECKING:
31 from typing_extensions import Unpack
33# T is already used in config.py and I cannot use the same name because both are imported with *
34T2 = typing.TypeVar('T2')
37#: 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.
38DEFAULT_COMMAND = ''
42# ---------- UI notifier ----------
44@functools.total_ordering
45class NotificationLevel:
47 '''
48 Instances of this class indicate how important a message is.
50 I am not using an enum anymore in order to allow users to add custom levels.
51 Like an enum, however, ``NotificationLevel('error')`` returns the existing instance instead of creating a new one.
52 In order to create a new instance use :meth:`~confattr.configfile.NotificationLevel.new`.
53 '''
55 INFO: 'NotificationLevel'
56 ERROR: 'NotificationLevel'
58 _instances: 'list[NotificationLevel]' = []
60 def __new__(cls, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel':
61 '''
62 :return: An existing instance (see :meth:`~confattr.configfile.NotificationLevel.get`) or a new instance if :paramref:`~confattr.configfile.NotificationLevel.new` is true (see :meth:`~confattr.configfile.NotificationLevel.new`)
63 :param value: The name of the notification level
64 :param new: If false: return an existing instance with :meth:`~confattr.configfile.NotificationLevel.get`. If true: create a new instance.
65 :param more_important_than: If :paramref:`~confattr.configfile.NotificationLevel.new` is true either this or :paramref:`~confattr.configfile.NotificationLevel.less_important_than` must be given.
66 :param less_important_than: If :paramref:`~confattr.configfile.NotificationLevel.new` is true either this or :paramref:`~confattr.configfile.NotificationLevel.more_important_than` must be given.
67 '''
68 if new:
69 if more_important_than and less_important_than:
70 raise TypeError("more_important_than and less_important_than are mutually exclusive, you can only pass one of them")
71 elif cls._instances and not (more_important_than or less_important_than):
72 raise TypeError(f"you must specify how important {value!r} is by passing either more_important_than or less_important_than")
74 try:
75 out = cls.get(value)
76 except ValueError:
77 pass
78 else:
79 if more_important_than and out < more_important_than:
80 raise ValueError(f"{out} is already defined and it's less important than {more_important_than}")
81 elif less_important_than and out > less_important_than:
82 raise ValueError(f"{out} is already defined and it's more important than {less_important_than}")
83 warnings.warn(f"{out!r} is already defined, ignoring", stacklevel=3)
84 return out
86 return super().__new__(cls)
88 if more_important_than:
89 raise TypeError('more_important_than must not be passed when new = False')
90 if less_important_than:
91 raise TypeError('less_important_than must not be passed when new = False')
93 return cls.get(value)
95 def __init__(self, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> None:
96 if hasattr(self, '_initialized'):
97 # __init__ is called every time, even if __new__ has returned an old object
98 return
100 assert new
101 self._initialized = True
102 self.value = value
104 if more_important_than:
105 i = self._instances.index(more_important_than) + 1
106 elif less_important_than:
107 i = self._instances.index(less_important_than)
108 elif not self._instances:
109 i = 0
110 else:
111 assert False
113 self._instances.insert(i, self)
115 @classmethod
116 def new(cls, value: str, *, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel':
117 '''
118 :param value: A name for the new notification level
119 :param more_important_than: Specify the importance of the new notification level. Either this or :paramref:`~confattr.configfile.NotificationLevel.new.less_important_than` must be given but not both.
120 :param less_important_than: Specify the importance of the new notification level. Either this or :paramref:`~confattr.configfile.NotificationLevel.new.more_important_than` must be given but not both.
121 '''
122 return cls(value, more_important_than=more_important_than, less_important_than=less_important_than, new=True)
124 @classmethod
125 def get(cls, value: str) -> 'NotificationLevel':
126 '''
127 :return: The instance of this class for the given value
128 :raises ValueError: If there is no instance for the given value
129 '''
130 for lvl in cls._instances:
131 if lvl.value == value:
132 return lvl
134 raise ValueError('')
136 @classmethod
137 def get_instances(cls) -> 'Sequence[NotificationLevel]':
138 '''
139 :return: A sequence of all instances of this class
140 '''
141 return cls._instances
143 def __lt__(self, other: typing.Any) -> bool:
144 if self.__class__ is other.__class__:
145 return self._instances.index(self) < self._instances.index(other)
146 return NotImplemented
148 def __str__(self) -> str:
149 return self.value
151 def __repr__(self) -> str:
152 return "%s(%r)" % (type(self).__name__, self.value)
155NotificationLevel.INFO = NotificationLevel.new('info')
156NotificationLevel.ERROR = NotificationLevel.new('error', more_important_than=NotificationLevel.INFO)
159UiCallback: 'typing.TypeAlias' = 'Callable[[Message], None]'
161class Message:
163 '''
164 A message which should be displayed to the user.
165 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>`.
167 If you want full control how to display messages to the user you can access the attributes directly.
168 Otherwise you can simply convert this object to a str, e.g. with ``str(msg)``.
169 I recommend to use different colors for different values of :attr:`~confattr.configfile.Message.notification_level`.
170 '''
172 #: The value of :attr:`~confattr.configfile.Message.file_name` while loading environment variables.
173 ENVIRONMENT_VARIABLES = 'environment variables'
176 __slots__ = ('notification_level', 'message', 'file_name', 'line_number', 'line', 'no_context')
178 #: The importance of this message. I recommend to display messages of different importance levels in different colors.
179 #: :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.
180 notification_level: NotificationLevel
182 #: The string or exception which should be displayed to the user
183 message: 'str|BaseException'
185 #: The name of the config file which has caused this message.
186 #: If this equals :const:`~confattr.configfile.Message.ENVIRONMENT_VARIABLES` it is not a file but the message has occurred while reading the environment variables.
187 #: 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.
188 file_name: 'str|None'
190 #: The number of the line in the config file. This is None if :attr:`~confattr.configfile.Message.file_name` is not a file name.
191 line_number: 'int|None'
193 #: The line where the message occurred. This is an empty str if there is no line, e.g. when loading environment variables.
194 line: str
196 #: If true: don't show line and line number.
197 no_context: bool
200 _last_file_name: 'str|None' = None
202 @classmethod
203 def reset(cls) -> None:
204 '''
205 If you are using :meth:`~confattr.configfile.Message.format_file_name_msg_line` or :meth:`~confattr.configfile.Message.__str__`
206 you must call this method when the widget showing the error messages is cleared.
207 '''
208 cls._last_file_name = None
210 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:
211 self.notification_level = notification_level
212 self.message = message
213 self.file_name = file_name
214 self.line_number = line_number
215 self.line = line
216 self.no_context = no_context
218 @property
219 def lvl(self) -> NotificationLevel:
220 '''
221 An abbreviation for :attr:`~confattr.configfile.Message.notification_level`
222 '''
223 return self.notification_level
225 def format_msg_line(self) -> str:
226 '''
227 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.
228 '''
229 msg = str(self.message)
230 if self.line and not self.no_context:
231 if self.line_number is not None:
232 lnref = 'line %s' % self.line_number
233 else:
234 lnref = 'line'
235 return f'{msg} in {lnref} {self.line!r}'
237 return msg
239 def format_file_name(self) -> str:
240 '''
241 :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
242 '''
243 file_name = '' if self.file_name is None else self.file_name
244 if file_name == self._last_file_name:
245 return ''
247 if file_name:
248 out = f'While loading {file_name}:\n'
249 else:
250 out = ''
252 if self._last_file_name is not None:
253 out = '\n' + out
255 type(self)._last_file_name = file_name
257 return out
260 def format_file_name_msg_line(self) -> str:
261 '''
262 :return: The concatenation of the return values of :meth:`~confattr.configfile.Message.format_file_name` and :meth:`~confattr.configfile.Message.format_msg_line`
263 '''
264 return self.format_file_name() + self.format_msg_line()
267 def __str__(self) -> str:
268 '''
269 :return: The return value of :meth:`~confattr.configfile.Message.format_file_name_msg_line`
270 '''
271 return self.format_file_name_msg_line()
273 def __repr__(self) -> str:
274 return f'{type(self).__name__}(%s)' % ', '.join(f'{a}={self._format_attribute(getattr(self, a))}' for a in self.__slots__)
276 @staticmethod
277 def _format_attribute(obj: object) -> str:
278 return repr(obj)
281class UiNotifier:
283 '''
284 Most likely you will want to load the config file before creating the UI (user interface).
285 But if there are errors in the config file the user will want to know about them.
286 This class takes the messages from :class:`~confattr.configfile.ConfigFile` and stores them until the UI is ready.
287 When you call :meth:`~confattr.configfile.UiNotifier.set_ui_callback` the stored messages will be forwarded and cleared.
289 This object can also filter the messages.
290 :class:`~confattr.configfile.ConfigFile` calls :meth:`~confattr.configfile.UiNotifier.show_info` every time a setting is changed.
291 If you load an entire config file this can be many messages and the user probably does not want to see them all.
292 Therefore this object drops all messages of :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>` by default.
293 Pass :paramref:`~confattr.configfile.UiNotifier.notification_level` to the constructor if you don't want that.
294 '''
296 # ------- public methods -------
298 def __init__(self, config_file: 'ConfigFile|None' = None, notification_level: 'Config[NotificationLevel]|NotificationLevel' = NotificationLevel.ERROR) -> None:
299 '''
300 :param config_file: Is used to add context information to messages, to which file and to which line a message belongs.
301 :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.
302 '''
303 self._messages: 'list[Message]' = []
304 self._callback: 'UiCallback|None' = None
305 self._notification_level = notification_level
306 self._config_file = config_file
308 def set_ui_callback(self, callback: UiCallback) -> None:
309 '''
310 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.
311 Save :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for :meth:`~confattr.configfile.UiNotifier.show` to call.
312 '''
313 self._callback = callback
315 for msg in self._messages:
316 callback(msg)
317 self._messages.clear()
320 @property
321 def notification_level(self) -> NotificationLevel:
322 '''
323 Ignore messages that are less important than this level.
324 '''
325 if isinstance(self._notification_level, Config):
326 return self._notification_level.value
327 else:
328 return self._notification_level
330 @notification_level.setter
331 def notification_level(self, val: NotificationLevel) -> None:
332 if isinstance(self._notification_level, Config):
333 self._notification_level.value = val
334 else:
335 self._notification_level = val
338 # ------- called by ConfigFile -------
340 def show_info(self, msg: str, *, ignore_filter: bool = False) -> None:
341 '''
342 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>`.
343 '''
344 self.show(NotificationLevel.INFO, msg, ignore_filter=ignore_filter)
346 def show_error(self, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None:
347 '''
348 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.ERROR <confattr.configfile.NotificationLevel.ERROR>`.
349 '''
350 self.show(NotificationLevel.ERROR, msg, ignore_filter=ignore_filter)
353 # ------- internal methods -------
355 def show(self, notification_level: NotificationLevel, msg: 'str|BaseException', *, ignore_filter: bool = False, no_context: bool = False) -> None:
356 '''
357 If a callback for the user interface has been registered with :meth:`~confattr.configfile.UiNotifier.set_ui_callback` call that callback.
358 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.
360 :param notification_level: The importance of the message
361 :param msg: The message to be displayed on the user interface
362 :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>`.
363 :param no_context: If true: don't show line and line number.
364 '''
365 if notification_level < self.notification_level and not ignore_filter:
366 return
368 if self._config_file and not self._config_file.context_line_number and not self._config_file.show_line_always:
369 no_context = True
371 message = Message(
372 notification_level = notification_level,
373 message = msg,
374 file_name = self._config_file.context_file_name if self._config_file else None,
375 line_number = self._config_file.context_line_number if self._config_file else None,
376 line = self._config_file.context_line if self._config_file else '',
377 no_context = no_context,
378 )
380 if self._callback:
381 self._callback(message)
382 else:
383 self._messages.append(message)
386# ---------- format help ----------
388class SectionLevel(SortedEnum):
390 #: Is used to separate different commands in :meth:`ConfigFile.write_help() <confattr.configfile.ConfigFile.write_help>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>`
391 SECTION = 'section'
393 #: 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
394 SUB_SECTION = 'sub-section'
397class FormattedWriter(abc.ABC):
399 @abc.abstractmethod
400 def write_line(self, line: str) -> None:
401 '''
402 Write a single line of documentation.
403 :paramref:`~confattr.configfile.FormattedWriter.write_line.line` may *not* contain a newline.
404 If :paramref:`~confattr.configfile.FormattedWriter.write_line.line` is empty it does not need to be prefixed with a comment character.
405 Empty lines should be dropped if no other lines have been written before.
406 '''
407 pass
409 def write_lines(self, text: str) -> None:
410 '''
411 Write one or more lines of documentation.
412 '''
413 for ln in text.splitlines():
414 self.write_line(ln)
416 @abc.abstractmethod
417 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
418 '''
419 Write a heading.
421 This object should *not* add an indentation depending on the section
422 because if the indentation is increased the line width should be decreased
423 in order to keep the line wrapping consistent.
424 Wrapping lines is handled by :class:`confattr.utils.HelpFormatter`,
425 i.e. before the text is passed to this object.
426 It would be possible to use :class:`argparse.RawTextHelpFormatter` instead
427 and handle line wrapping on a higher level but that would require
428 to understand the help generated by argparse
429 in order to know how far to indent a broken line.
430 One of the trickiest parts would probably be to get the indentation of the usage right.
431 Keep in mind that the term "usage" can differ depending on the language settings of the user.
433 :param lvl: How to format the heading
434 :param heading: The heading
435 '''
436 pass
438 @abc.abstractmethod
439 def write_command(self, cmd: str) -> None:
440 '''
441 Write a config file command.
442 '''
443 pass
446class TextIOWriter(FormattedWriter):
448 def __init__(self, f: 'typing.TextIO|None') -> None:
449 self.f = f
450 self.ignore_empty_lines = True
452 def write_line_raw(self, line: str) -> None:
453 if self.ignore_empty_lines and not line:
454 return
456 print(line, file=self.f)
457 self.ignore_empty_lines = False
460class ConfigFileWriter(TextIOWriter):
462 def __init__(self, f: 'typing.TextIO|None', prefix: str) -> None:
463 super().__init__(f)
464 self.prefix = prefix
466 def write_command(self, cmd: str) -> None:
467 self.write_line_raw(cmd)
469 def write_line(self, line: str) -> None:
470 if line:
471 line = self.prefix + line
473 self.write_line_raw(line)
475 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
476 if lvl is SectionLevel.SECTION:
477 self.write_line('')
478 self.write_line('')
479 self.write_line('=' * len(heading))
480 self.write_line(heading)
481 self.write_line('=' * len(heading))
482 else:
483 self.write_line('')
484 self.write_line(heading)
485 self.write_line('-' * len(heading))
487class HelpWriter(TextIOWriter):
489 def write_line(self, line: str) -> None:
490 self.write_line_raw(line)
492 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
493 self.write_line('')
494 if lvl is SectionLevel.SECTION:
495 self.write_line(heading)
496 self.write_line('=' * len(heading))
497 else:
498 self.write_line(heading)
499 self.write_line('-' * len(heading))
501 def write_command(self, cmd: str) -> None:
502 pass # pragma: no cover
505# ---------- internal exceptions ----------
507class ParseException(Exception):
509 '''
510 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.
511 Is caught in :class:`~confattr.configfile.ConfigFile`.
512 '''
514class MultipleParseExceptions(Exception):
516 '''
517 This is raised by :class:`~confattr.configfile.ConfigFileCommand` implementations in order to communicate that multiple errors have occured on the same line.
518 Is caught in :class:`~confattr.configfile.ConfigFile`.
519 '''
521 def __init__(self, exceptions: 'Sequence[ParseException]') -> None:
522 super().__init__()
523 self.exceptions = exceptions
525 def __iter__(self) -> 'Iterator[ParseException]':
526 return iter(self.exceptions)
529# ---------- data types for **kw args ----------
531if hasattr(typing, 'TypedDict'): # python >= 3.8 # pragma: no cover. This is tested but in a different environment which is not known to coverage.
532 class SaveKwargs(typing.TypedDict, total=False):
533 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]'
534 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]] | None'
535 no_multi: bool
536 comments: bool
537 commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]'
538 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]'
541# ---------- ConfigFile class ----------
543class ArgPos:
544 '''
545 This is an internal class, the return type of :meth:`ConfigFile.find_arg() <confattr.configfile.ConfigFile.find_arg>`
546 '''
548 #: 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.
549 argument_pos: int
551 #: 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.
552 in_between: bool
554 #: 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
555 i0: int
557 #: 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
558 i1: int
561class ConfigFile:
563 '''
564 Read or write a config file.
566 All :class:`~confattr.config.Config` objects must be instantiated before instantiating this class.
567 '''
569 COMMENT = '#'
570 COMMENT_PREFIXES = ('"', '#')
571 ENTER_GROUP_PREFIX = '['
572 ENTER_GROUP_SUFFIX = ']'
574 #: How to separete several element in a collection (list, set, dict)
575 ITEM_SEP = ','
577 #: How to separate key and value in a dict
578 KEY_SEP = ':'
581 #: The :class:`~confattr.config.Config` instances to load or save
582 config_instances: 'dict[str, Config[typing.Any]]'
584 #: 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`.
585 config_id: 'ConfigId|None'
587 #: Override the config file which is returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
588 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_directory` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.
589 #: 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.)
590 config_path: 'str|None' = None
592 #: Override the config directory which is returned by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths`.
593 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_path` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.
594 #: 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.)
595 config_directory: 'str|None' = None
597 #: The name of the config file used by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
598 #: 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.).
599 config_name = 'config'
601 #: 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`.
602 env_variables: 'list[str]'
604 #: A prefix that is prepended to the name of environment variables in :meth:`~confattr.configfile.ConfigFile.get_env_name`.
605 #: 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.
606 envprefix: str
608 #: 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).
609 context_file_name: 'str|None' = None
610 #: 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.
611 context_line_number: 'int|None' = None
612 #: The line which is currently parsed.
613 context_line: str = ''
615 #: If true: ``[config-id]`` syntax is allowed in config file, config ids are included in help, config id related options are available for include.
616 #: 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)
617 enable_config_ids: bool
620 #: 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*.
621 command_dict: 'dict[str, ConfigFileCommand]'
623 #: 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.
624 commands: 'list[ConfigFileCommand]'
627 #: See :paramref:`~confattr.configfile.ConfigFile.check_config_id`
628 check_config_id: 'Callable[[ConfigId], None]|None'
630 #: 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.
631 show_line_always: bool
634 def __init__(self, *,
635 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
636 appname: str,
637 authorname: 'str|None' = None,
638 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]|None' = None,
639 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]|None' = None,
640 commands: 'Iterable[type[ConfigFileCommand]|abc.ABCMeta]|None' = None,
641 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]|None' = None,
642 formatter_class: 'type[argparse.HelpFormatter]' = HelpFormatter,
643 check_config_id: 'Callable[[ConfigId], None]|None' = None,
644 enable_config_ids: 'bool|None' = None,
645 show_line_always: bool = True,
646 ) -> None:
647 '''
648 :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`.
649 :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
650 :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`
651 :param config_instances: The settings supported in this config file. None means all settings which have been defined when this object is created.
652 :param ignore: These settings are *not* supported by this config file even if they are contained in :paramref:`~confattr.configfile.ConfigFile.config_instances`.
653 :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>`. Abstract classes are expanded to all non-abstract subclasses.
654 :param ignore_commands: A sequence of commands (as subclasses of :class:`~confattr.configfile.ConfigFileCommand` or :class:`~confattr.configfile.ConfigFileArgparseCommand`) which are *not* allowed in this config file. May contain abstract classes. All commands which are contained in this sequence or which are a subclass of an item in this sequence are not allowed, regardless of whether they are passed to :paramref:`~confattr.configfile.ConfigFile.commands` or not.
655 :param formatter_class: Is used to clean up doc strings and wrap lines in the help
656 :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.
657 :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`
658 :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.
659 '''
660 self.appname = appname
661 self.authorname = authorname
662 self.ui_notifier = UiNotifier(self, notification_level)
663 if config_instances is None:
664 # I am setting has_config_file_been_instantiated only if no config_instances have been passed
665 # because if the user passes an explicit list of config_instances
666 # then it's clear that Config instances created later on are ignored by this ConfigFile
667 # so no TimingException should be raised if instantiating another Config.
668 state.has_config_file_been_instantiated = True
669 config_instances = Config.iter_instances() # this does not return a list or tuple and that is important for sorting
670 self.config_instances = {i.key: i for i in self.iter_config_instances(config_instances, ignore)}
671 self.config_id: 'ConfigId|None' = None
672 self.formatter_class = formatter_class
673 self.env_variables: 'list[str]' = []
674 self.check_config_id = check_config_id
675 self.show_line_always = show_line_always
677 if enable_config_ids is None:
678 enable_config_ids = self.check_config_id is not None or any(isinstance(cfg, MultiConfig) for cfg in self.config_instances.values())
679 self.enable_config_ids = enable_config_ids
681 self.envprefix = ''
682 self.envprefix = self.get_env_name(appname + '_')
683 envname = self.envprefix + 'CONFIG_PATH'
684 self.env_variables.append(envname)
685 if envname in os.environ:
686 self.config_path = os.environ[envname]
687 envname = self.envprefix + 'CONFIG_DIRECTORY'
688 self.env_variables.append(envname)
689 if envname in os.environ:
690 self.config_directory = os.environ[envname]
691 envname = self.envprefix + 'CONFIG_NAME'
692 self.env_variables.append(envname)
693 if envname in os.environ:
694 self.config_name = os.environ[envname]
696 if commands is None:
697 commands = ConfigFileCommand.get_command_types()
698 else:
699 original_commands = commands
700 def iter_commands() -> 'Iterator[type[ConfigFileCommand]]':
701 for cmd in original_commands:
702 cmd = typing.cast('type[ConfigFileCommand]', cmd)
703 if cmd._abstract:
704 for c in ConfigFileCommand.get_command_types():
705 if issubclass(c, cmd):
706 yield c
707 else:
708 yield cmd
709 commands = iter_commands()
710 self.command_dict = {}
711 self.commands = []
712 for cmd_type in commands:
713 if ignore_commands and any(issubclass(cmd_type, i_c) for i_c in ignore_commands):
714 continue
715 cmd = cmd_type(self)
716 self.commands.append(cmd)
717 for name in cmd.get_names():
718 self.command_dict[name] = cmd
720 def iter_config_instances(self,
721 config_instances: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]',
722 ignore: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]|None',
723 ) -> 'Iterator[Config[object]]':
724 '''
725 :param config_instances: The settings to consider
726 :param ignore: Skip these settings
728 Iterate over all given :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.config_instances` and expand all :class:`~confattr.config.DictConfig` instances into the :class:`~confattr.config.Config` instances they consist of.
729 Sort the resulting list if :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.config_instances` is not a :class:`list` or a :class:`tuple`.
730 Yield all :class:`~confattr.config.Config` instances which are not (directly or indirectly) contained in :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.ignore`.
731 '''
732 should_be_ignored: 'Callable[[Config[typing.Any]], bool]'
733 if ignore is not None:
734 tmp = set()
735 for c in ignore:
736 if isinstance(c, DictConfig):
737 tmp |= set(c._values.values())
738 else:
739 tmp.add(c)
740 should_be_ignored = lambda c: c in tmp
741 else:
742 should_be_ignored = lambda c: False
744 if not isinstance(config_instances, (list, tuple)):
745 config_instances = sorted(config_instances, key=lambda c: c.key_prefix if isinstance(c, DictConfig) else c.key)
746 def expand_configs() -> 'Iterator[Config[typing.Any]]':
747 for c in config_instances:
748 if isinstance(c, DictConfig):
749 yield from c.iter_configs()
750 else:
751 yield c
752 for c in expand_configs():
753 if should_be_ignored(c):
754 continue
756 yield c
758 def set_ui_callback(self, callback: UiCallback) -> None:
759 '''
760 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.
762 Messages which occur before this method is called are stored and forwarded as soon as the callback is registered.
764 :param ui_callback: A function to display messages to the user
765 '''
766 self.ui_notifier.set_ui_callback(callback)
768 def get_app_dirs(self) -> 'appdirs.AppDirs':
769 '''
770 Create or get a cached `AppDirs <https://github.com/ActiveState/appdirs/blob/master/README.rst#appdirs-for-convenience>`__ instance with multipath support enabled.
772 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.
773 The first one installed is used.
774 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.
775 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``.
777 These libraries should respect the environment variables ``XDG_CONFIG_HOME`` and ``XDG_CONFIG_DIRS``.
778 '''
779 if not hasattr(self, '_appdirs'):
780 try:
781 import platformdirs # type: ignore [import-not-found] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs
782 AppDirs = typing.cast('type[appdirs.AppDirs]', platformdirs.PlatformDirs) # pragma: no cover # This is tested but in a different tox environment
783 except ImportError:
784 try:
785 import xdgappdirs # type: ignore [import-not-found] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs
786 AppDirs = typing.cast('type[appdirs.AppDirs]', xdgappdirs.AppDirs) # pragma: no cover # This is tested but in a different tox environment
787 except ImportError:
788 AppDirs = appdirs.AppDirs
790 self._appdirs = AppDirs(self.appname, self.authorname, multipath=True)
792 return self._appdirs
794 # ------- load -------
796 def iter_user_site_config_paths(self) -> 'Iterator[str]':
797 '''
798 Iterate over all directories which are searched for config files, user specific first.
800 The directories are based on :meth:`~confattr.configfile.ConfigFile.get_app_dirs`
801 unless :attr:`~confattr.configfile.ConfigFile.config_directory` has been set.
802 If :attr:`~confattr.configfile.ConfigFile.config_directory` has been set
803 it's value is yielded and nothing else.
804 '''
805 if self.config_directory:
806 yield self.config_directory
807 return
809 appdirs = self.get_app_dirs()
810 yield from appdirs.user_config_dir.split(os.path.pathsep)
811 yield from appdirs.site_config_dir.split(os.path.pathsep)
813 def iter_config_paths(self) -> 'Iterator[str]':
814 '''
815 Iterate over all paths which are checked for config files, user specific first.
817 Use this method if you want to tell the user where the application is looking for it's config file.
818 The first existing file yielded by this method is used by :meth:`~confattr.configfile.ConfigFile.load`.
820 The paths are generated by joining the directories yielded by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths` with
821 :attr:`ConfigFile.config_name <confattr.configfile.ConfigFile.config_name>`.
823 If :attr:`~confattr.configfile.ConfigFile.config_path` has been set this method yields that path instead and no other paths.
824 '''
825 if self.config_path:
826 yield self.config_path
827 return
829 for path in self.iter_user_site_config_paths():
830 yield os.path.join(path, self.config_name)
832 def load(self, *, env: bool = True) -> bool:
833 '''
834 Load the first existing config file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
836 If there are several config files a user specific config file is preferred.
837 If a user wants a system wide config file to be loaded, too, they can explicitly include it in their config file.
838 :param env: If true: call :meth:`~confattr.configfile.ConfigFile.load_env` after loading the config file.
839 :return: False if an error has occurred
840 '''
841 out = True
842 for fn in self.iter_config_paths():
843 if os.path.isfile(fn):
844 out &= self.load_file(fn)
845 break
847 if env:
848 out &= self.load_env()
850 return out
852 def load_env(self) -> bool:
853 '''
854 Load settings from environment variables.
855 The name of the environment variable belonging to a setting is generated with :meth:`~confattr.configfile.ConfigFile.get_env_name`.
857 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>`.
859 :return: False if an error has occurred
860 :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`
861 '''
862 out = True
863 old_file_name = self.context_file_name
864 self.context_file_name = Message.ENVIRONMENT_VARIABLES
866 config_instances: 'dict[str, Config[object]]' = {}
867 for key, instance in self.config_instances.items():
868 name = self.get_env_name(key)
869 if name in self.env_variables:
870 raise ValueError(f'setting {instance.key!r} conflicts with environment variable {name!r}')
871 elif name in config_instances:
872 raise ValueError(f'settings {instance.key!r} and {config_instances[name].key!r} result in the same environment variable {name!r}')
873 else:
874 config_instances[name] = instance
876 for name, value in os.environ.items():
877 if not name.startswith(self.envprefix):
878 continue
879 if name in self.env_variables:
880 continue
882 if name in config_instances:
883 instance = config_instances[name]
884 try:
885 instance.set_value(config_id=None, value=self.parse_value(instance, value, raw=True))
886 self.ui_notifier.show_info(f'set {instance.key} to {self.format_value(instance, config_id=None)}')
887 except ValueError as e:
888 self.ui_notifier.show_error(f"{e} while trying to parse environment variable {name}='{value}'")
889 out = False
890 else:
891 self.ui_notifier.show_error(f"unknown environment variable {name}='{value}'")
892 out = False
894 self.context_file_name = old_file_name
895 return out
898 def get_env_name(self, key: str) -> str:
899 '''
900 Convert the key of a setting to the name of the corresponding environment variable.
902 :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.
903 '''
904 out = key
905 out = out.upper()
906 for c in ' .-':
907 out = out.replace(c, '_')
908 out = self.envprefix + out
909 return out
911 def load_file(self, fn: str) -> bool:
912 '''
913 Load a config file and change the :class:`~confattr.config.Config` objects accordingly.
915 Use :meth:`~confattr.configfile.ConfigFile.set_ui_callback` to get error messages which appeared while loading the config file.
916 You can call :meth:`~confattr.configfile.ConfigFile.set_ui_callback` after this method without loosing any messages.
918 :param fn: The file name of the config file (absolute or relative path)
919 :return: False if an error has occurred
920 '''
921 self.config_id = None
922 return self.load_without_resetting_config_id(fn)
924 def load_without_resetting_config_id(self, fn: str) -> bool:
925 out = True
926 old_file_name = self.context_file_name
927 self.context_file_name = fn
929 with open(fn, 'rt') as f:
930 for lnno, ln in enumerate(f, 1):
931 self.context_line_number = lnno
932 out &= self.parse_line(line=ln)
933 self.context_line_number = None
935 self.context_file_name = old_file_name
936 return out
938 def parse_line(self, line: str) -> bool:
939 '''
940 :param line: The line to be parsed
941 :return: True if line is valid, False if an error has occurred
943 :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.
944 '''
945 ln = line.strip()
946 if not ln:
947 return True
948 if self.is_comment(ln):
949 return True
950 if self.enable_config_ids and self.enter_group(ln):
951 return True
953 self.context_line = ln
955 try:
956 ln_split = self.split_line(ln)
957 except Exception as e:
958 self.parse_error(str(e))
959 out = False
960 else:
961 out = self.parse_split_line(ln_split)
963 self.context_line = ''
964 return out
966 def split_line(self, line: str) -> 'list[str]':
967 cmd, line = self.split_one_symbol_command(line)
968 line_split = shlex.split(line, comments=True)
969 if cmd:
970 line_split.insert(0, cmd)
971 return line_split
973 def split_line_ignore_errors(self, line: str) -> 'list[str]':
974 out = []
975 cmd, line = self.split_one_symbol_command(line)
976 if cmd:
977 out.append(cmd)
978 lex = shlex.shlex(line, posix=True)
979 lex.whitespace_split = True
980 while True:
981 try:
982 t = lex.get_token()
983 except:
984 out.append(lex.token)
985 return out
986 if t is None:
987 return out
988 out.append(t)
990 def split_one_symbol_command(self, line: str) -> 'tuple[str|None, str]':
991 if line and not line[0].isalnum() and line[0] in self.command_dict:
992 return line[0], line[1:]
994 return None, line
997 def is_comment(self, line: str) -> bool:
998 '''
999 Check if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment.
1001 :param line: The current line
1002 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment
1003 '''
1004 for c in self.COMMENT_PREFIXES:
1005 if line.startswith(c):
1006 return True
1007 return False
1009 def enter_group(self, line: str) -> bool:
1010 '''
1011 Check if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group and set :attr:`~confattr.configfile.ConfigFile.config_id` if it does.
1012 Call :meth:`~confattr.configfile.ConfigFile.parse_error` if :meth:`~confattr.configfile.ConfigFile.check_config_id` raises a :class:`~confattr.configfile.ParseException`.
1014 :param line: The current line
1015 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group
1016 '''
1017 if line.startswith(self.ENTER_GROUP_PREFIX) and line.endswith(self.ENTER_GROUP_SUFFIX):
1018 config_id = typing.cast(ConfigId, line[len(self.ENTER_GROUP_PREFIX):-len(self.ENTER_GROUP_SUFFIX)])
1019 if self.check_config_id and config_id != Config.default_config_id:
1020 try:
1021 self.check_config_id(config_id)
1022 except ParseException as e:
1023 self.parse_error(str(e))
1024 self.config_id = config_id
1025 if self.config_id not in MultiConfig.config_ids:
1026 MultiConfig.config_ids.append(self.config_id)
1027 return True
1028 return False
1030 def parse_split_line(self, ln_split: 'Sequence[str]') -> bool:
1031 '''
1032 Call the corresponding command in :attr:`~confattr.configfile.ConfigFile.command_dict`.
1033 If any :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is raised catch it and call :meth:`~confattr.configfile.ConfigFile.parse_error`.
1035 :return: False if a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` has been caught, True if no exception has been caught
1036 '''
1037 cmd = self.get_command(ln_split)
1038 try:
1039 cmd.run(ln_split)
1040 except ParseException as e:
1041 self.parse_error(str(e))
1042 return False
1043 except MultipleParseExceptions as exceptions:
1044 for exc in exceptions:
1045 self.parse_error(str(exc))
1046 return False
1048 return True
1050 def get_command(self, ln_split: 'Sequence[str]') -> 'ConfigFileCommand':
1051 cmd_name = ln_split[0]
1052 if cmd_name in self.command_dict:
1053 cmd = self.command_dict[cmd_name]
1054 elif DEFAULT_COMMAND in self.command_dict:
1055 cmd = self.command_dict[DEFAULT_COMMAND]
1056 else:
1057 cmd = UnknownCommand(self)
1058 return cmd
1061 # ------- save -------
1063 def get_save_path(self) -> str:
1064 '''
1065 :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.
1066 '''
1067 paths = tuple(self.iter_config_paths())
1068 for fn in paths:
1069 if os.path.isfile(fn) and os.access(fn, os.W_OK):
1070 return fn
1072 return paths[0]
1074 def save(self,
1075 if_not_existing: bool = False,
1076 **kw: 'Unpack[SaveKwargs]',
1077 ) -> str:
1078 '''
1079 Save the current values of all settings to the file returned by :meth:`~confattr.configfile.ConfigFile.get_save_path`.
1080 Directories are created as necessary.
1082 :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.
1083 :param ignore: Do not write these settings to the file.
1084 :param no_multi: Do not write several sections. For :class:`~confattr.config.MultiConfig` instances write the default values only.
1085 :param comments: Write comments with allowed values and help.
1086 :param if_not_existing: Do not overwrite the file if it is already existing.
1087 :return: The path to the file which has been written
1088 '''
1089 fn = self.get_save_path()
1090 if if_not_existing and os.path.isfile(fn):
1091 return fn
1093 # "If, when attempting to write a file, the destination directory is non-existent an attempt should be made to create it with permission 0700.
1094 # If the destination directory exists already the permissions should not be changed."
1095 # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
1096 os.makedirs(os.path.dirname(fn), exist_ok=True, mode=0o0700)
1097 self.save_file(fn, **kw)
1098 return fn
1100 def save_file(self,
1101 fn: str,
1102 **kw: 'Unpack[SaveKwargs]'
1103 ) -> None:
1104 '''
1105 Save the current values of all settings to a specific file.
1107 :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.
1108 :raises FileNotFoundError: if the directory does not exist
1110 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.
1111 '''
1112 with open(fn, 'wt') as f:
1113 self.save_to_open_file(f, **kw)
1116 def save_to_open_file(self,
1117 f: typing.TextIO,
1118 **kw: 'Unpack[SaveKwargs]',
1119 ) -> None:
1120 '''
1121 Save the current values of all settings to a file-like object
1122 by creating a :class:`~confattr.configfile.ConfigFileWriter` object and calling :meth:`~confattr.configfile.ConfigFile.save_to_writer`.
1124 :param f: The file to write to
1126 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.
1127 '''
1128 writer = ConfigFileWriter(f, prefix=self.COMMENT + ' ')
1129 self.save_to_writer(writer, **kw)
1131 def save_to_writer(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
1132 '''
1133 Save the current values of all settings.
1135 Ensure that all keyword arguments are passed with :meth:`~confattr.configfile.ConfigFile.set_save_default_arguments`.
1136 Iterate over all :class:`~confattr.configfile.ConfigFileCommand` objects in :attr:`~confattr.configfile.ConfigFile.commands` and do for each of them:
1138 - 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
1139 - call :meth:`~confattr.configfile.ConfigFileCommand.save`
1140 '''
1141 self.set_save_default_arguments(kw)
1142 commands = list(self.commands)
1143 if 'commands' in kw or 'ignore_commands' in kw:
1144 command_types = tuple(kw['commands']) if 'commands' in kw else None
1145 ignore_command_types = tuple(kw['ignore_commands']) if 'ignore_commands' in kw else None
1146 for cmd in tuple(commands):
1147 if (ignore_command_types and isinstance(cmd, ignore_command_types)) \
1148 or (command_types and not isinstance(cmd, command_types)):
1149 commands.remove(cmd)
1150 write_headings = len(tuple(cmd for cmd in commands if getattr(cmd.save, 'implemented', True))) >= 2
1151 for cmd in commands:
1152 cmd.should_write_heading = write_headings
1153 cmd.save(writer, **kw)
1155 def set_save_default_arguments(self, kw: 'SaveKwargs') -> None:
1156 '''
1157 Ensure that all arguments are given in :paramref:`~confattr.configfile.ConfigFile.set_save_default_arguments.kw`.
1158 '''
1159 kw.setdefault('config_instances', list(self.config_instances.values()))
1160 kw.setdefault('ignore', None)
1161 kw.setdefault('no_multi', not self.enable_config_ids)
1162 kw.setdefault('comments', True)
1165 def quote(self, val: str) -> str:
1166 '''
1167 Quote a value if necessary so that it will be interpreted as one argument.
1169 The default implementation calls :func:`~confattr.utils.readable_quote`.
1170 '''
1171 return readable_quote(val)
1173 def write_config_id(self, writer: FormattedWriter, config_id: ConfigId) -> None:
1174 '''
1175 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`.
1176 '''
1177 writer.write_command(self.ENTER_GROUP_PREFIX + config_id + self.ENTER_GROUP_SUFFIX)
1179 def get_help_config_id(self) -> str:
1180 '''
1181 :return: A help how to use :class:`~confattr.config.MultiConfig`. The return value still needs to be cleaned with :func:`inspect.cleandoc`.
1182 '''
1183 return f'''
1184 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.
1185 `config-id` must be replaced by the corresponding identifier for the object.
1186 '''
1189 # ------- formatting and parsing of values -------
1191 def format_value(self, instance: Config[typing.Any], config_id: 'ConfigId|None') -> str:
1192 '''
1193 :param instance: The config value to be saved
1194 :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
1195 :return: A str representation to be written to the config file
1197 Convert the value of the :class:`~confattr.config.Config` instance into a str with :meth:`~confattr.configfile.ConfigFile.format_any_value`.
1198 '''
1199 return self.format_any_value(instance.type, instance.get_value(config_id))
1201 def format_any_value(self, type: 'AbstractFormatter[T2]', value: 'T2') -> str:
1202 return type.format_value(self, value)
1205 def parse_value(self, instance: 'Config[T2]', value: str, *, raw: bool) -> 'T2':
1206 '''
1207 :param instance: The config instance for which the value should be parsed, this is important for the data type
1208 :param value: The string representation of the value to be parsed
1209 :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
1210 Parse a value to the data type of a given setting by calling :meth:`~confattr.configfile.ConfigFile.parse_value_part`
1211 '''
1212 if not raw:
1213 value = self.expand(value)
1214 return self.parse_value_part(instance, instance.type, value)
1216 def parse_value_part(self, config: 'Config[typing.Any]', t: 'AbstractFormatter[T2]', value: str) -> 'T2':
1217 '''
1218 Parse a value to the given data type.
1220 :param config: Needed for the allowed values and the key for error messages
1221 :param t: The data type to which :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` shall be parsed
1222 :param value: The value to be parsed
1223 :raises ValueError: if :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` is invalid
1224 '''
1225 return t.parse_value(self, value)
1228 def expand(self, arg: str) -> str:
1229 return self.expand_config(self.expand_env(arg))
1231 reo_config = re.compile(r'%([^%]*)%')
1232 def expand_config(self, arg: str) -> str:
1233 n = arg.count('%')
1234 if n % 2 == 1:
1235 raise ParseException("uneven number of percent characters, use %% for a literal percent sign or --raw if you don't want expansion")
1236 return self.reo_config.sub(self.expand_config_match, arg)
1238 reo_env = re.compile(r'\$\{([^{}]*)\}')
1239 def expand_env(self, arg: str) -> str:
1240 return self.reo_env.sub(self.expand_env_match, arg)
1242 def expand_config_match(self, m: 're.Match[str]') -> str:
1243 '''
1244 :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``
1245 :return: The expanded form of the setting or ``'%'`` if group 1 is empty
1246 :raises ParseException: If ``key``, ``!conversion`` or ``:format_spec`` is invalid
1248 This is based on the `Python Format String Syntax <https://docs.python.org/3/library/string.html#format-string-syntax>`__.
1250 ``field_name`` is the :attr:`~confattr.config.Config.key`.
1252 ``!conversion`` is one of:
1254 - ``!``: :meth:`ConfigFile.format_value() <confattr.configfile.ConfigFile.format_value>`
1255 - ``!r``: :func:`repr`
1256 - ``!s``: :class:`str`
1257 - ``!a``: :func:`ascii`
1259 ``: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>`__.
1260 :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.
1261 If :meth:`~confattr.formatters.AbstractFormatter.expand_value` raises an :class:`Exception` it is caught and reraised as a :class:`~confattr.configfile.ParseException`.
1262 '''
1263 key = m.group(1)
1264 if not key:
1265 return '%'
1267 if ':' in key:
1268 key, fmt = key.split(':', 1)
1269 else:
1270 fmt = None
1271 if '!' in key:
1272 key, stringifier = key.split('!', 1)
1273 else:
1274 stringifier = None
1276 if key not in self.config_instances:
1277 raise ParseException(f'invalid key {key!r}')
1278 instance = self.config_instances[key]
1280 if stringifier is None and fmt is None:
1281 return self.format_value(instance, config_id=None)
1282 elif stringifier is None:
1283 assert fmt is not None
1284 try:
1285 return instance.type.expand_value(self, instance.get_value(config_id=None), format_spec=fmt)
1286 except Exception as e:
1287 raise ParseException(e)
1289 val: object
1290 if stringifier == '':
1291 val = self.format_value(instance, config_id=None)
1292 else:
1293 val = instance.get_value(config_id=None)
1294 if stringifier == 'r':
1295 val = repr(val)
1296 elif stringifier == 's':
1297 val = str(val)
1298 elif stringifier == 'a':
1299 val = ascii(val)
1300 else:
1301 raise ParseException('invalid conversion %r' % stringifier)
1303 if fmt is None:
1304 assert isinstance(val, str)
1305 return val
1307 try:
1308 return format(val, fmt)
1309 except ValueError as e:
1310 raise ParseException(e)
1312 def expand_env_match(self, m: 're.Match[str]') -> str:
1313 '''
1314 :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
1315 :return: The expanded form of the environment variable
1317 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:
1319 - ``${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.
1320 - ``${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.
1321 - ``${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.
1322 - ``${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.
1324 In the patterns above, if you use a ``:`` it is checked whether parameter is unset or empty.
1325 If ``:`` is not used the check is only true if parameter is unset, empty is treated as a valid value.
1326 '''
1327 env = m.group(1)
1328 for op in '-=?+':
1329 if ':' + op in env:
1330 env, arg = env.split(':' + op, 1)
1331 isset = bool(os.environ.get(env))
1332 elif op in env:
1333 env, arg = env.split(op, 1)
1334 isset = env in os.environ
1335 else:
1336 continue
1338 val = os.environ.get(env, '')
1339 if op == '-':
1340 if isset:
1341 return val
1342 else:
1343 return arg
1344 elif op == '=':
1345 if isset:
1346 return val
1347 else:
1348 os.environ[env] = arg
1349 return arg
1350 elif op == '?':
1351 if isset:
1352 return val
1353 else:
1354 if not arg:
1355 state = 'empty' if env in os.environ else 'unset'
1356 arg = f'environment variable {env} is {state}'
1357 raise ParseException(arg)
1358 elif op == '+':
1359 if isset:
1360 return arg
1361 else:
1362 return ''
1363 else:
1364 assert False
1366 return os.environ.get(env, '')
1369 # ------- help -------
1371 def write_help(self, writer: FormattedWriter) -> None:
1372 import platform
1373 formatter = self.create_formatter()
1374 writer.write_lines('The first existing file of the following paths is loaded:')
1375 for path in self.iter_config_paths():
1376 writer.write_line('- %s' % path)
1378 writer.write_line('')
1379 writer.write_line('This can be influenced with the following environment variables:')
1380 if platform.system() == 'Linux': # pragma: no branch
1381 writer.write_line('- XDG_CONFIG_HOME')
1382 writer.write_line('- XDG_CONFIG_DIRS')
1383 for env in self.env_variables:
1384 writer.write_line(f'- {env}')
1386 writer.write_line('')
1387 writer.write_lines(formatter.format_text(f'''\
1388You can also use environment variables to change the values of the settings listed under `set` command.
1389The corresponding environment variable name is the name of the setting in all upper case letters
1390with dots, hypens and spaces replaced by underscores and prefixed with "{self.envprefix}".'''))
1392 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)))
1394 writer.write_lines('The config file may contain the following commands:')
1395 for cmd in self.commands:
1396 names = '|'.join(cmd.get_names())
1397 writer.write_heading(SectionLevel.SECTION, names)
1398 writer.write_lines(cmd.get_help())
1400 def create_formatter(self) -> HelpFormatterWrapper:
1401 return HelpFormatterWrapper(self.formatter_class)
1403 def get_help(self) -> str:
1404 '''
1405 A convenience wrapper around :meth:`~confattr.configfile.ConfigFile.write_help`
1406 to return the help as a str instead of writing it to a file.
1408 This uses :class:`~confattr.configfile.HelpWriter`.
1409 '''
1410 doc = io.StringIO()
1411 self.write_help(HelpWriter(doc))
1412 # The generated help ends with a \n which is implicitly added by print.
1413 # If I was writing to stdout or a file that would be desired.
1414 # But if I return it as a string and then print it, the print adds another \n which would be too much.
1415 # Therefore I am stripping the trailing \n.
1416 return doc.getvalue().rstrip('\n')
1419 # ------- auto complete -------
1421 def get_completions(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1422 '''
1423 Provide an auto completion for commands that can be executed with :meth:`~confattr.configfile.ConfigFile.parse_line`.
1425 :param line: The entire line that is currently in the text input field
1426 :param cursor_pos: The position of the cursor
1427 :return: start of line, completions, end of line.
1428 *completions* is a list of possible completions for the word where the cursor is located.
1429 If *completions* is an empty list there are no completions available and the user input should not be changed.
1430 If *completions* is not empty it should be displayed by a user interface in a drop down menu.
1431 The *start of line* is everything on the line before the completions.
1432 The *end of line* is everything on the line after the completions.
1433 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1434 *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.
1435 '''
1436 original_ln = line
1437 stripped_line = line.lstrip()
1438 indentation = line[:len(line) - len(stripped_line)]
1439 cursor_pos -= len(indentation)
1440 line = stripped_line
1441 if self.enable_config_ids and line.startswith(self.ENTER_GROUP_PREFIX):
1442 out = self.get_completions_enter_group(line, cursor_pos)
1443 else:
1444 out = self.get_completions_command(line, cursor_pos)
1446 out = (indentation + out[0], out[1], out[2])
1447 return out
1449 def get_completions_enter_group(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1450 '''
1451 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.
1453 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_enter_group.line`
1454 and will prepend it to the first item of the return value.
1455 '''
1456 start = line
1457 groups = [self.ENTER_GROUP_PREFIX + str(cid) + self.ENTER_GROUP_SUFFIX for cid in MultiConfig.config_ids]
1458 groups = [cid for cid in groups if cid.startswith(start)]
1459 return '', groups, ''
1461 def get_completions_command(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1462 '''
1463 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.
1465 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_command.line`
1466 and will prepend it to the first item of the return value.
1467 '''
1468 if not line:
1469 return self.get_completions_command_name(line, cursor_pos, start_of_line='', end_of_line='')
1471 ln_split = self.split_line_ignore_errors(line)
1472 assert ln_split
1473 a = self.find_arg(line, ln_split, cursor_pos)
1475 if a.in_between:
1476 start_of_line = line[:cursor_pos]
1477 end_of_line = line[cursor_pos:]
1478 else:
1479 start_of_line = line[:a.i0]
1480 end_of_line = line[a.i1:]
1482 if a.argument_pos == 0:
1483 return self.get_completions_command_name(line, cursor_pos, start_of_line=start_of_line, end_of_line=end_of_line)
1484 else:
1485 cmd = self.get_command(ln_split)
1486 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)
1488 def find_arg(self, line: str, ln_split: 'list[str]', cursor_pos: int) -> ArgPos:
1489 '''
1490 This is an internal method used by :meth:`~confattr.configfile.ConfigFile.get_completions_command`
1491 '''
1492 CHARS_REMOVED_BY_SHLEX = ('"', "'", '\\')
1493 assert cursor_pos <= len(line) # yes, cursor_pos can be == len(str)
1494 out = ArgPos()
1495 out.in_between = True
1497 # init all out attributes just to be save, these should not never be used because line is not empty and not white space only
1498 out.argument_pos = 0
1499 out.i0 = 0
1500 out.i1 = 0
1502 n_ln = len(line)
1503 i_ln = 0
1504 n_arg = len(ln_split)
1505 out.argument_pos = 0
1506 i_in_arg = 0
1507 assert out.argument_pos < n_ln
1508 while True:
1509 if out.in_between:
1510 assert i_in_arg == 0
1511 if i_ln >= n_ln:
1512 assert out.argument_pos >= n_arg - 1
1513 out.i0 = i_ln
1514 return out
1515 elif line[i_ln].isspace():
1516 i_ln += 1
1517 else:
1518 out.i0 = i_ln
1519 if i_ln >= cursor_pos:
1520 return out
1521 if out.argument_pos >= n_arg:
1522 assert line[i_ln] == '#'
1523 out.i0 = len(line)
1524 return out
1525 out.in_between = False
1526 else:
1527 if i_ln >= n_ln:
1528 assert out.argument_pos >= n_arg - 1
1529 out.i1 = i_ln
1530 return out
1531 elif i_in_arg >= len(ln_split[out.argument_pos]):
1532 if line[i_ln].isspace():
1533 out.i1 = i_ln
1534 if i_ln >= cursor_pos:
1535 return out
1536 out.in_between = True
1537 i_ln += 1
1538 out.argument_pos += 1
1539 i_in_arg = 0
1540 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX:
1541 i_ln += 1
1542 else:
1543 # unlike bash shlex treats a comment character inside of an argument as a comment character
1544 assert line[i_ln] == '#'
1545 assert out.argument_pos == n_arg - 1
1546 out.i1 = i_ln
1547 return out
1548 elif line[i_ln] == ln_split[out.argument_pos][i_in_arg]:
1549 i_ln += 1
1550 i_in_arg += 1
1551 if out.argument_pos == 0 and i_ln == 1 and self.split_one_symbol_command(line)[0]:
1552 out.in_between = True
1553 out.argument_pos += 1
1554 out.i0 = i_ln
1555 i_in_arg = 0
1556 else:
1557 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX
1558 i_ln += 1
1561 def get_completions_command_name(self, line: str, cursor_pos: int, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1562 start = line[:cursor_pos]
1563 completions = [cmd for cmd in self.command_dict.keys() if cmd.startswith(start) and len(cmd) > 1]
1564 return start_of_line, completions, end_of_line
1567 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]':
1568 r'''
1569 :param start: The start of the path to be completed
1570 :param relative_to: If :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` is a relative path it's relative to this directory
1571 :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.
1572 :param include: A function which takes the path and file name as arguments and returns whether this file/directory is a valid completion.
1573 :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)``.
1574 :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).
1575 '''
1576 if exclude is None:
1577 if platform.platform() == 'Windows' or os.path.split(start)[1].startswith('.'):
1578 exclude = '$none'
1579 else:
1580 exclude = r'^\.'
1581 reo = re.compile(exclude)
1583 # I cannot use os.path.split because that would ignore the important difference between having a trailing separator or not
1584 if os.path.sep in start:
1585 directory, start = start.rsplit(os.path.sep, 1)
1586 directory += os.path.sep
1587 quoted_directory = self.quote_path(directory)
1589 start_of_line += quoted_directory
1590 directory = os.path.expanduser(directory)
1591 if not os.path.isabs(directory):
1592 directory = os.path.join(relative_to, directory)
1593 directory = os.path.normpath(directory)
1594 else:
1595 directory = relative_to
1597 try:
1598 names = os.listdir(directory)
1599 except (FileNotFoundError, NotADirectoryError):
1600 return start_of_line, [], end_of_line
1602 out: 'list[str]' = []
1603 for name in names:
1604 if reo.match(name):
1605 continue
1606 if include and not include(directory, name):
1607 continue
1608 if not match(directory, name, start):
1609 continue
1611 quoted_name = self.quote(name)
1612 if os.path.isdir(os.path.join(directory, name)):
1613 quoted_name += os.path.sep
1615 out.append(quoted_name)
1617 return start_of_line, out, end_of_line
1619 def quote_path(self, path: str) -> str:
1620 path_split = path.split(os.path.sep)
1621 i0 = 1 if path_split[0] == '~' else 0
1622 for i in range(i0, len(path_split)):
1623 if path_split[i]:
1624 path_split[i] = self.quote(path_split[i])
1625 return os.path.sep.join(path_split)
1628 def get_completions_for_expand(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1629 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)
1630 if applicable:
1631 return applicable, start_of_line, completions, end_of_line
1633 return self.get_completions_for_expand_config(start, start_of_line=start_of_line, end_of_line=end_of_line)
1635 def get_completions_for_expand_config(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1636 if start.count('%') % 2 == 0:
1637 return False, start_of_line, [], end_of_line
1639 i = start.rindex('%') + 1
1640 start_of_line = start_of_line + start[:i]
1641 start = start[i:]
1642 completions = [key for key in sorted(self.config_instances.keys()) if key.startswith(start)]
1643 return True, start_of_line, completions, end_of_line
1645 def get_completions_for_expand_env(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1646 i = start.rfind('${')
1647 if i < 0:
1648 return False, start_of_line, [], end_of_line
1649 i += 2
1651 if '}' in start[i:]:
1652 return False, start_of_line, [], end_of_line
1654 start_of_line = start_of_line + start[:i]
1655 start = start[i:]
1656 completions = [key for key in sorted(os.environ.keys()) if key.startswith(start)]
1657 return True, start_of_line, completions, end_of_line
1660 # ------- error handling -------
1662 def parse_error(self, msg: str) -> None:
1663 '''
1664 Is called if something went wrong while trying to load a config file.
1666 This method is called when a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is caught.
1667 This method compiles the given information into an error message and calls :meth:`self.ui_notifier.show_error() <confattr.configfile.UiNotifier.show_error>`.
1669 :param msg: The error message
1670 '''
1671 self.ui_notifier.show_error(msg)
1674# ---------- base classes for commands which can be used in config files ----------
1676class ConfigFileCommand(abc.ABC):
1678 '''
1679 An abstract base class for commands which can be used in a config file.
1681 Subclasses must implement the :meth:`~confattr.configfile.ConfigFileCommand.run` method which is called when :class:`~confattr.configfile.ConfigFile` is loading a file.
1682 Subclasses should contain a doc string so that :meth:`~confattr.configfile.ConfigFileCommand.get_help` can provide a description to the user.
1683 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`.
1685 All subclasses are remembered and can be retrieved with :meth:`~confattr.configfile.ConfigFileCommand.get_command_types`.
1686 They are instantiated in the constructor of :class:`~confattr.configfile.ConfigFile`.
1687 '''
1689 #: 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.
1690 name: str
1692 #: Alternative names which can be used in the config file.
1693 aliases: 'tuple[str, ...]|list[str]'
1695 #: 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.
1696 help: str
1698 #: 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.
1699 should_write_heading: bool = False
1701 #: 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`.
1702 config_file: ConfigFile
1704 #: The :class:`~confattr.configfile.UiNotifier` of :attr:`~confattr.configfile.ConfigFileCommand.config_file`
1705 ui_notifier: UiNotifier
1707 _abstract: bool
1710 _subclasses: 'list[type[ConfigFileCommand]]' = []
1711 _used_names: 'set[str]' = set()
1713 @classmethod
1714 def get_command_types(cls) -> 'tuple[type[ConfigFileCommand], ...]':
1715 '''
1716 :return: All subclasses of :class:`~confattr.configfile.ConfigFileCommand` which have not been deleted with :meth:`~confattr.configfile.ConfigFileCommand.delete_command_type`
1717 '''
1718 return tuple(cls._subclasses)
1720 @classmethod
1721 def delete_command_type(cls, cmd_type: 'type[ConfigFileCommand]') -> None:
1722 '''
1723 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.
1724 Do nothing if :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` has already been deleted.
1725 '''
1726 if cmd_type in cls._subclasses:
1727 cls._subclasses.remove(cmd_type)
1728 for name in cmd_type.get_names():
1729 cls._used_names.remove(name)
1731 @classmethod
1732 def __init_subclass__(cls, replace: bool = False, abstract: bool = False) -> None:
1733 '''
1734 :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
1735 :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`
1736 :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
1737 '''
1738 cls._abstract = abstract
1739 if replace:
1740 parent_commands = [parent for parent in cls.__bases__ if issubclass(parent, ConfigFileCommand)]
1742 # set names of this class to that of the parent class(es)
1743 parent = parent_commands[0]
1744 if 'name' not in cls.__dict__:
1745 cls.name = parent.get_name()
1746 if 'aliases' not in cls.__dict__:
1747 cls.aliases = list(parent.get_names())[1:]
1748 for parent in parent_commands[1:]:
1749 cls.aliases.extend(parent.get_names())
1751 # remove parent class from the list of commands to be loaded or saved
1752 for parent in parent_commands:
1753 cls.delete_command_type(parent)
1755 if not abstract:
1756 cls._subclasses.append(cls)
1757 for name in cls.get_names():
1758 if name in cls._used_names and not replace:
1759 raise ValueError('duplicate command name %r' % name)
1760 cls._used_names.add(name)
1762 @classmethod
1763 def get_name(cls) -> str:
1764 '''
1765 :return: The name which is used in config file to call this command.
1767 If :attr:`~confattr.configfile.ConfigFileCommand.name` is set it is returned as it is.
1768 Otherwise a name is generated based on the class name.
1769 '''
1770 if 'name' in cls.__dict__:
1771 return cls.name
1772 return cls.__name__.lower().replace("_", "-")
1774 @classmethod
1775 def get_names(cls) -> 'Iterator[str]':
1776 '''
1777 :return: Several alternative names which can be used in a config file to call this command.
1779 The first one is always the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_name`.
1780 If :attr:`~confattr.configfile.ConfigFileCommand.aliases` is set it's items are yielded afterwards.
1782 If one of the returned items is the empty string this class is the default command
1783 and :meth:`~confattr.configfile.ConfigFileCommand.run` will be called if an undefined command is encountered.
1784 '''
1785 yield cls.get_name()
1786 if 'aliases' in cls.__dict__:
1787 for name in cls.aliases:
1788 yield name
1790 def __init__(self, config_file: ConfigFile) -> None:
1791 self.config_file = config_file
1792 self.ui_notifier = config_file.ui_notifier
1794 @abc.abstractmethod
1795 def run(self, cmd: 'Sequence[str]') -> None:
1796 '''
1797 Process one line which has been read from a config file
1799 :raises ParseException: if there is an error in the line (e.g. invalid syntax)
1800 :raises MultipleParseExceptions: if there are several errors in the same line
1801 '''
1802 raise NotImplementedError()
1805 def create_formatter(self) -> HelpFormatterWrapper:
1806 return self.config_file.create_formatter()
1808 def get_help_attr_or_doc_str(self) -> str:
1809 '''
1810 :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`.
1811 '''
1812 if hasattr(self, 'help'):
1813 doc = self.help
1814 elif self.__doc__:
1815 doc = self.__doc__
1816 else:
1817 doc = ''
1819 return inspect.cleandoc(doc)
1821 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
1822 '''
1823 Add the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_help_attr_or_doc_str` to :paramref:`~confattr.configfile.ConfigFileCommand.add_help_to.formatter`.
1824 '''
1825 formatter.add_text(self.get_help_attr_or_doc_str())
1827 def get_help(self) -> str:
1828 '''
1829 :return: A help text which can be presented to the user.
1831 This is generated by creating a formatter with :meth:`~confattr.configfile.ConfigFileCommand.create_formatter`,
1832 adding the help to it with :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` and
1833 stripping trailing new line characters from the result of :meth:`HelpFormatterWrapper.format_help() <confattr.utils.HelpFormatterWrapper.format_help>`.
1835 Most likely you don't want to override this method but :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` instead.
1836 '''
1837 formatter = self.create_formatter()
1838 self.add_help_to(formatter)
1839 return formatter.format_help().rstrip('\n')
1841 def get_short_description(self) -> str:
1842 '''
1843 :return: The first paragraph of the doc string/help attribute
1844 '''
1845 out = self.get_help_attr_or_doc_str().split('\n\n')
1846 if out[0].startswith('usage: '):
1847 if len(out) > 1:
1848 return out[1]
1849 return ""
1850 return out[0]
1852 def save(self,
1853 writer: FormattedWriter,
1854 **kw: 'Unpack[SaveKwargs]',
1855 ) -> None:
1856 '''
1857 Implement this method if you want calls to this command to be written by :meth:`ConfigFile.save() <confattr.configfile.ConfigFile.save>`.
1859 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.
1860 If this command writes several sections then write a heading for every section regardless of :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading`.
1862 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>`.
1863 Write comments or help with :meth:`writer.write_lines('...') <confattr.configfile.FormattedWriter.write_lines>`.
1865 There is the :attr:`~confattr.configfile.ConfigFileCommand.config_file` attribute (which was passed to the constructor) which you can use to:
1867 - quote arguments with :meth:`ConfigFile.quote() <confattr.configfile.ConfigFile.quote>`
1868 - call :meth:`ConfigFile.write_config_id() <confattr.configfile.ConfigFile.write_config_id>`
1870 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>`.
1872 The default implementation does nothing.
1873 '''
1874 pass
1876 save.implemented = False # type: ignore [attr-defined]
1879 # ------- auto complete -------
1881 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]':
1882 '''
1883 :param cmd: The line split into arguments (including the name of this command as cmd[0])
1884 :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.
1885 :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.
1886 :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.
1887 :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.
1888 :param end_of_line: The third return value.
1889 :return: start of line, completions, end of line.
1890 *completions* is a list of possible completions for the word where the cursor is located.
1891 If *completions* is an empty list there are no completions available and the user input should not be changed.
1892 This should be displayed by a user interface in a drop down menu.
1893 The *start of line* is everything on the line before the completions.
1894 The *end of line* is everything on the line after the completions.
1895 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1896 '''
1897 completions: 'list[str]' = []
1898 return start_of_line, completions, end_of_line
1901class ArgumentParser(argparse.ArgumentParser):
1903 def error(self, message: str) -> 'typing.NoReturn':
1904 '''
1905 Raise a :class:`~confattr.configfile.ParseException`.
1906 '''
1907 raise ParseException(message)
1909class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True):
1911 '''
1912 An abstract subclass of :class:`~confattr.configfile.ConfigFileCommand` which uses :mod:`argparse` to make parsing and providing help easier.
1914 You must implement the class method :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` to add the arguments to :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.
1915 Instead of :meth:`~confattr.configfile.ConfigFileArgparseCommand.run` you must implement :meth:`~confattr.configfile.ConfigFileArgparseCommand.run_parsed`.
1916 You don't need to add a usage or the possible arguments to the doc string as :mod:`argparse` will do that for you.
1917 You should, however, still give a description what this command does in the doc string.
1919 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`.
1920 '''
1922 #: 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`
1923 parser: ArgumentParser
1925 def __init__(self, config_file: ConfigFile) -> None:
1926 super().__init__(config_file)
1927 self._names = set(self.get_names())
1928 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)
1929 self.init_parser(self.parser)
1931 @abc.abstractmethod
1932 def init_parser(self, parser: ArgumentParser) -> None:
1933 '''
1934 :param parser: The parser to add arguments to. This is the same object like :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.
1936 This is an abstract method which must be implemented by subclasses.
1937 Use :meth:`ArgumentParser.add_argument() <confattr.configfile.ArgumentParser.add_argument>` to add arguments to :paramref:`~confattr.configfile.ConfigFileArgparseCommand.init_parser.parser`.
1938 '''
1939 pass
1941 @staticmethod
1942 def add_enum_argument(parser: 'argparse.ArgumentParser|argparse._MutuallyExclusiveGroup', *name_or_flags: str, type: 'type[enum.Enum]') -> 'argparse.Action':
1943 '''
1944 This method:
1946 - generates a function to convert the user input to an element of the enum
1947 - gives the function the name of the enum in lower case (argparse uses this in error messages)
1948 - generates a help string containing the allowed values
1950 and adds an argument to the given argparse parser with that.
1951 '''
1952 def parse(name: str) -> enum.Enum:
1953 for v in type:
1954 if v.name.lower() == name:
1955 return v
1956 raise TypeError()
1957 parse.__name__ = type.__name__.lower()
1958 choices = ', '.join(v.name.lower() for v in type)
1959 return parser.add_argument(*name_or_flags, type=parse, help="one of " + choices)
1961 def get_help(self) -> str:
1962 '''
1963 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`.
1964 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.
1965 '''
1966 return self.parser.format_help().rstrip('\n')
1968 def run(self, cmd: 'Sequence[str]') -> None:
1969 # if the line was empty this method should not be called but an empty line should be ignored either way
1970 if not cmd:
1971 return # pragma: no cover
1972 # cmd[0] does not need to be in self._names if this is the default command, i.e. if '' in self._names
1973 if cmd[0] in self._names:
1974 cmd = cmd[1:]
1975 args = self.parser.parse_args(cmd)
1976 self.run_parsed(args)
1978 @abc.abstractmethod
1979 def run_parsed(self, args: argparse.Namespace) -> None:
1980 '''
1981 This is an abstract method which must be implemented by subclasses.
1982 '''
1983 pass
1985 # ------- auto complete -------
1987 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]':
1988 if in_between:
1989 start = ''
1990 else:
1991 start = cmd[argument_pos][:cursor_pos]
1993 if self.after_positional_argument_marker(cmd, argument_pos):
1994 pos = self.get_position(cmd, argument_pos)
1995 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
1997 if argument_pos > 0: # pragma: no branch # if argument_pos was 0 this method would not be called, command names would be completed instead
1998 prevarg = self.get_option_name_if_it_takes_an_argument(cmd, argument_pos-1)
1999 if prevarg:
2000 return self.get_completions_for_option_argument(prevarg, start, start_of_line=start_of_line, end_of_line=end_of_line)
2002 if self.is_option_start(start):
2003 if '=' in start:
2004 i = start.index('=')
2005 option_name = start[:i]
2006 i += 1
2007 start_of_line += start[:i]
2008 start = start[i:]
2009 return self.get_completions_for_option_argument(option_name, start, start_of_line=start_of_line, end_of_line=end_of_line)
2010 return self.get_completions_for_option_name(start, start_of_line=start_of_line, end_of_line=end_of_line)
2012 pos = self.get_position(cmd, argument_pos)
2013 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2015 def get_position(self, cmd: 'Sequence[str]', argument_pos: int) -> int:
2016 '''
2017 :return: the position of a positional argument, not counting options and their arguments
2018 '''
2019 pos = 0
2020 n = len(cmd)
2021 options_allowed = True
2022 # I am starting at 1 because cmd[0] is the name of the command, not an argument
2023 for i in range(1, argument_pos):
2024 if options_allowed and i < n:
2025 if cmd[i] == '--':
2026 options_allowed = False
2027 continue
2028 elif self.is_option_start(cmd[i]):
2029 continue
2030 # > 1 because cmd[0] is the name of the command
2031 elif i > 1 and self.get_option_name_if_it_takes_an_argument(cmd, i-1):
2032 continue
2033 pos += 1
2035 return pos
2037 def is_option_start(self, start: str) -> bool:
2038 return start.startswith('-') or start.startswith('+')
2040 def after_positional_argument_marker(self, cmd: 'Sequence[str]', argument_pos: int) -> bool:
2041 '''
2042 :return: true if this can only be a positional argument. False means it can be both, option or positional argument.
2043 '''
2044 return '--' in cmd and cmd.index('--') < argument_pos
2046 def get_option_name_if_it_takes_an_argument(self, cmd: 'Sequence[str]', argument_pos: int) -> 'str|None':
2047 if argument_pos >= len(cmd):
2048 return None # pragma: no cover # this does not happen because this method is always called for the previous argument
2050 arg = cmd[argument_pos]
2051 if '=' in arg:
2052 # argument of option is already given within arg
2053 return None
2054 if not self.is_option_start(arg):
2055 return None
2056 if arg.startswith('--'):
2057 action = self.get_action_for_option(arg)
2058 if action is None:
2059 return None
2060 if action.nargs != 0:
2061 return arg
2062 return None
2064 # arg is a combination of single character flags like in `tar -xzf file`
2065 for c in arg[1:-1]:
2066 action = self.get_action_for_option('-' + c)
2067 if action is None:
2068 continue
2069 if action.nargs != 0:
2070 # c takes an argument but that is already given within arg
2071 return None
2073 out = '-' + arg[-1]
2074 action = self.get_action_for_option(out)
2075 if action is None:
2076 return None
2077 if action.nargs != 0:
2078 return out
2079 return None
2082 def get_completions_for_option_name(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2083 completions = []
2084 for a in self.parser._get_optional_actions():
2085 for opt in a.option_strings:
2086 if len(opt) <= 2:
2087 # this is trivial to type but not self explanatory
2088 # => not helpful for auto completion
2089 continue
2090 if opt.startswith(start):
2091 completions.append(opt)
2092 return start_of_line, completions, end_of_line
2094 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]':
2095 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)
2097 def get_completions_for_positional_argument(self, position: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2098 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)
2101 def get_action_for_option(self, option_name: str) -> 'argparse.Action|None':
2102 for a in self.parser._get_optional_actions():
2103 if option_name in a.option_strings:
2104 return a
2105 return None
2107 def get_action_for_positional_argument(self, argument_pos: int) -> 'argparse.Action|None':
2108 actions = self.parser._get_positional_actions()
2109 if argument_pos < len(actions):
2110 return actions[argument_pos]
2111 return None
2113 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]':
2114 if action is None:
2115 completions: 'list[str]' = []
2116 elif not action.choices:
2117 completions = []
2118 else:
2119 completions = [str(val) for val in action.choices]
2120 completions = [val for val in completions if val.startswith(start)]
2121 completions = [self.config_file.quote(val) for val in completions]
2122 return start_of_line, completions, end_of_line
2125# ---------- implementations of commands which can be used in config files ----------
2127class Set(ConfigFileCommand):
2129 r'''
2130 usage: set [--raw] key1=val1 [key2=val2 ...] \\
2131 set [--raw] key [=] val
2133 Change the value of a setting.
2135 In the first form set takes an arbitrary number of arguments, each argument sets one setting.
2136 This has the advantage that several settings can be changed at once.
2137 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.
2139 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.
2140 This has the advantage that key and value are separated by one or more spaces which can improve the readability of a config file.
2142 You can use the value of another setting with %other.key% or an environment variable with ${ENV_VAR}.
2143 If you want to insert a literal percent character use two of them: %%.
2144 You can disable expansion of settings and environment variables with the --raw flag.
2145 '''
2147 #: The separator which is used between a key and it's value
2148 KEY_VAL_SEP = '='
2150 FLAGS_RAW = ('-r', '--raw')
2152 raw = False
2154 # ------- load -------
2156 def run(self, cmd: 'Sequence[str]') -> None:
2157 '''
2158 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`.
2160 :raises ParseException: if something is wrong (no arguments given, invalid syntax, invalid key, invalid value)
2161 '''
2162 if self.is_vim_style(cmd):
2163 self.set_multiple(cmd)
2164 else:
2165 self.set_with_spaces(cmd)
2167 def is_vim_style(self, cmd: 'Sequence[str]') -> bool:
2168 '''
2169 :paramref:`~confattr.configfile.Set.is_vim_style.cmd` has one of two possible styles:
2170 - vim inspired: set takes an arbitrary number of arguments, each argument sets one setting. Is handled by :meth:`~confattr.configfile.Set.set_multiple`.
2171 - 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`.
2173 :return: true if cmd has a vim inspired style, false if cmd has a ranger inspired style
2174 '''
2175 try:
2176 # cmd[0] is the name of the command, cmd[1] is the first argument
2177 if cmd[1] in self.FLAGS_RAW:
2178 i = 2
2179 else:
2180 i = 1
2181 return self.KEY_VAL_SEP in cmd[i]
2182 except IndexError:
2183 raise ParseException('no settings given')
2185 def set_with_spaces(self, cmd: 'Sequence[str]') -> None:
2186 '''
2187 Process one line of the format ``set key [=] value``
2189 :raises ParseException: if something is wrong (invalid syntax, invalid key, invalid value)
2190 '''
2191 if cmd[1] in self.FLAGS_RAW:
2192 cmd = cmd[2:]
2193 self.raw = True
2194 else:
2195 cmd = cmd[1:]
2196 self.raw = False
2198 n = len(cmd)
2199 if n == 2:
2200 key, value = cmd
2201 self.parse_key_and_set_value(key, value)
2202 elif n == 3:
2203 key, sep, value = cmd
2204 if sep != self.KEY_VAL_SEP:
2205 raise ParseException(f'separator between key and value should be {self.KEY_VAL_SEP}, not {sep!r}')
2206 self.parse_key_and_set_value(key, value)
2207 elif n == 1:
2208 raise ParseException(f'missing value or missing {self.KEY_VAL_SEP}')
2209 else:
2210 assert n >= 4
2211 raise ParseException(f'too many arguments given or missing {self.KEY_VAL_SEP} in first argument')
2213 def set_multiple(self, cmd: 'Sequence[str]') -> None:
2214 '''
2215 Process one line of the format ``set key=value [key2=value2 ...]``
2217 :raises MultipleParseExceptions: if something is wrong (invalid syntax, invalid key, invalid value)
2218 '''
2219 self.raw = False
2220 exceptions = []
2221 for arg in cmd[1:]:
2222 if arg in self.FLAGS_RAW:
2223 self.raw = True
2224 continue
2225 try:
2226 if not self.KEY_VAL_SEP in arg:
2227 raise ParseException(f'missing {self.KEY_VAL_SEP} in {arg!r}')
2228 key, value = arg.split(self.KEY_VAL_SEP, 1)
2229 self.parse_key_and_set_value(key, value)
2230 except ParseException as e:
2231 exceptions.append(e)
2232 if exceptions:
2233 raise MultipleParseExceptions(exceptions)
2235 def parse_key_and_set_value(self, key: str, value: str) -> None:
2236 '''
2237 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>`.
2239 :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`
2240 '''
2241 if key not in self.config_file.config_instances:
2242 raise ParseException(f'invalid key {key!r}')
2244 instance = self.config_file.config_instances[key]
2245 try:
2246 self.set_value(instance, self.config_file.parse_value(instance, value, raw=self.raw))
2247 except ValueError as e:
2248 raise ParseException(str(e))
2250 def set_value(self, instance: 'Config[T2]', value: 'T2') -> None:
2251 '''
2252 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`.
2253 Afterwards call :meth:`UiNotifier.show_info() <confattr.configfile.UiNotifier.show_info>`.
2254 '''
2255 instance.set_value(self.config_file.config_id, value)
2256 self.ui_notifier.show_info(f'set {instance.key} to {self.config_file.format_value(instance, self.config_file.config_id)}')
2259 # ------- save -------
2261 def iter_config_instances_to_be_saved(self,
2262 config_instances: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]',
2263 ignore: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]|None' = None,
2264 ) -> 'Iterator[Config[object]]':
2265 '''
2266 Iterate over all :class:`~confattr.config.Config` instances yielded from :meth:`ConfigFile.iter_config_instances() <confattr.configfile.ConfigFile.iter_config_instances>` and yield all instances where :meth:`Config.wants_to_be_exported() <confattr.config.Config.wants_to_be_exported>` returns true.
2267 '''
2268 for config in self.config_file.iter_config_instances(config_instances, ignore):
2269 if config.wants_to_be_exported():
2270 yield config
2272 #: 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`.
2273 last_name: 'str|None'
2275 def save(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
2276 '''
2277 :param writer: The file to write to
2278 :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>`.
2279 :param bool comments: If false: don't write help for data types
2281 Iterate over all :class:`~confattr.config.Config` instances with :meth:`~confattr.configfile.Set.iter_config_instances_to_be_saved`,
2282 split them into normal :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig` and write them with :meth:`~confattr.configfile.Set.save_config_instance`.
2283 But before that set :attr:`~confattr.configfile.Set.last_name` to None (which is used by :meth:`~confattr.configfile.Set.write_config_help`)
2284 and write help for data types based on :meth:`~confattr.configfile.Set.get_help_for_data_types`.
2285 '''
2286 no_multi = kw['no_multi']
2287 comments = kw['comments']
2289 config_instances = list(self.iter_config_instances_to_be_saved(config_instances=kw['config_instances'], ignore=kw['ignore']))
2290 normal_configs = []
2291 multi_configs = []
2292 if no_multi:
2293 normal_configs = config_instances
2294 else:
2295 for instance in config_instances:
2296 if isinstance(instance, MultiConfig):
2297 multi_configs.append(instance)
2298 else:
2299 normal_configs.append(instance)
2301 self.last_name: 'str|None' = None
2303 if normal_configs:
2304 if multi_configs:
2305 writer.write_heading(SectionLevel.SECTION, 'Application wide settings')
2306 elif self.should_write_heading:
2307 writer.write_heading(SectionLevel.SECTION, 'Settings')
2309 if comments:
2310 type_help = self.get_help_for_data_types(normal_configs)
2311 if type_help:
2312 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
2313 writer.write_lines(type_help)
2315 for instance in normal_configs:
2316 self.save_config_instance(writer, instance, config_id=None, **kw)
2318 if multi_configs:
2319 if normal_configs:
2320 writer.write_heading(SectionLevel.SECTION, 'Settings which can have different values for different objects')
2321 elif self.should_write_heading:
2322 writer.write_heading(SectionLevel.SECTION, 'Settings')
2324 if comments:
2325 type_help = self.get_help_for_data_types(multi_configs)
2326 if type_help:
2327 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
2328 writer.write_lines(type_help)
2330 for instance in multi_configs:
2331 self.save_config_instance(writer, instance, config_id=instance.default_config_id, **kw)
2333 for config_id in MultiConfig.config_ids:
2334 writer.write_line('')
2335 self.config_file.write_config_id(writer, config_id)
2336 for instance in multi_configs:
2337 self.save_config_instance(writer, instance, config_id, **kw)
2339 def save_config_instance(self, writer: FormattedWriter, instance: 'Config[object]', config_id: 'ConfigId|None', **kw: 'Unpack[SaveKwargs]') -> None:
2340 '''
2341 :param writer: The file to write to
2342 :param instance: The config value to be saved
2343 :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
2344 :param bool comments: If true: call :meth:`~confattr.configfile.Set.write_config_help`
2346 Convert the :class:`~confattr.config.Config` instance into a value str with :meth:`config_file.format_value() <confattr.configfile.ConfigFile.format_value>`,
2347 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`.
2348 '''
2349 if kw['comments']:
2350 self.write_config_help(writer, instance)
2351 value = self.config_file.format_value(instance, config_id)
2352 value = self.config_file.quote(value)
2353 if '%' in value or '${' in value:
2354 raw = ' --raw'
2355 else:
2356 raw = ''
2357 ln = f'{self.get_name()}{raw} {instance.key} = {value}'
2358 writer.write_command(ln)
2360 def write_config_help(self, writer: FormattedWriter, instance: Config[typing.Any], *, group_dict_configs: bool = True) -> None:
2361 '''
2362 :param writer: The output to write to
2363 :param instance: The config value to be saved
2365 Write a comment which explains the meaning and usage of this setting
2366 based on :meth:`instance.type.get_description() <confattr.formatters.AbstractFormatter.get_description>` and :attr:`Config.help <confattr.config.Config.help>`.
2368 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.
2369 '''
2370 if group_dict_configs and instance.parent is not None:
2371 name = instance.parent.key_changer(instance.parent.key_prefix)
2372 else:
2373 name = instance.key
2374 if name == self.last_name:
2375 return
2377 formatter = HelpFormatterWrapper(self.config_file.formatter_class)
2378 writer.write_heading(SectionLevel.SUB_SECTION, name)
2379 writer.write_lines(formatter.format_text(instance.type.get_description(self.config_file)).rstrip())
2380 #if instance.unit:
2381 # writer.write_line('unit: %s' % instance.unit)
2382 if isinstance(instance.help, dict):
2383 for key, val in instance.help.items():
2384 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)
2385 val = inspect.cleandoc(val)
2386 writer.write_lines(formatter.format_item(bullet=key_name+': ', text=val).rstrip())
2387 elif isinstance(instance.help, str):
2388 writer.write_lines(formatter.format_text(inspect.cleandoc(instance.help)).rstrip())
2390 self.last_name = name
2393 def get_data_type_name_to_help_map(self, config_instances: 'Iterable[Config[object]]') -> 'dict[str, str]':
2394 '''
2395 :param config_instances: All config values to be saved
2396 :return: A dictionary containing the type names as keys and the help as values
2398 The returned dictionary contains the help for all data types except enumerations
2399 which occur in :paramref:`~confattr.configfile.Set.get_data_type_name_to_help_map.config_instances`.
2400 The help is gathered from the :attr:`~confattr.configfile.Set.help` attribute of the type
2401 or :meth:`Primitive.get_help() <confattr.formatters.Primitive.get_help>`.
2402 The help is cleaned up with :func:`inspect.cleandoc`.
2403 '''
2404 help_text: 'dict[str, str]' = {}
2405 for instance in config_instances:
2406 for t in instance.type.get_primitives():
2407 name = t.get_type_name()
2408 if name in help_text:
2409 continue
2411 h = t.get_help(self.config_file)
2412 if not h:
2413 continue
2414 help_text[name] = inspect.cleandoc(h)
2416 return help_text
2418 def add_help_for_data_types(self, formatter: HelpFormatterWrapper, config_instances: 'Iterable[Config[object]]') -> None:
2419 help_map = self.get_data_type_name_to_help_map(config_instances)
2420 if not help_map:
2421 return
2423 for name in sorted(help_map.keys()):
2424 formatter.add_start_section(name)
2425 formatter.add_text(help_map[name])
2426 formatter.add_end_section()
2428 def get_help_for_data_types(self, config_instances: 'Iterable[Config[object]]') -> str:
2429 formatter = self.create_formatter()
2430 self.add_help_for_data_types(formatter, config_instances)
2431 return formatter.format_help().rstrip('\n')
2433 # ------- help -------
2435 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
2436 super().add_help_to(formatter)
2438 config_instances = list(self.iter_config_instances_to_be_saved(config_instances=self.config_file.config_instances.values()))
2439 self.last_name = None
2441 formatter.add_start_section('data types')
2442 self.add_help_for_data_types(formatter, config_instances)
2443 formatter.add_end_section()
2445 if self.config_file.enable_config_ids:
2446 normal_configs = []
2447 multi_configs = []
2448 for instance in config_instances:
2449 if isinstance(instance, MultiConfig):
2450 multi_configs.append(instance)
2451 else:
2452 normal_configs.append(instance)
2453 else:
2454 normal_configs = config_instances
2455 multi_configs = []
2457 if normal_configs:
2458 if self.config_file.enable_config_ids:
2459 formatter.add_start_section('application wide settings')
2460 else:
2461 formatter.add_start_section('settings')
2462 for instance in normal_configs:
2463 self.add_config_help(formatter, instance)
2464 formatter.add_end_section()
2466 if multi_configs:
2467 formatter.add_start_section('settings which can have different values for different objects')
2468 formatter.add_text(inspect.cleandoc(self.config_file.get_help_config_id()))
2469 for instance in multi_configs:
2470 self.add_config_help(formatter, instance)
2471 formatter.add_end_section()
2473 def add_config_help(self, formatter: HelpFormatterWrapper, instance: Config[typing.Any]) -> None:
2474 formatter.add_start_section(instance.key)
2475 formatter.add_text(instance.type.get_description(self.config_file))
2476 if isinstance(instance.help, dict):
2477 for key, val in instance.help.items():
2478 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)
2479 val = inspect.cleandoc(val)
2480 formatter.add_item(bullet=key_name+': ', text=val)
2481 elif isinstance(instance.help, str):
2482 formatter.add_text(inspect.cleandoc(instance.help))
2483 formatter.add_end_section()
2485 # ------- auto complete -------
2487 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]':
2488 if argument_pos >= len(cmd):
2489 start = ''
2490 else:
2491 start = cmd[argument_pos][:cursor_pos]
2493 if len(cmd) <= 1:
2494 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2495 elif self.is_vim_style(cmd):
2496 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2497 else:
2498 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2500 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]':
2501 if self.KEY_VAL_SEP in start:
2502 key, start = start.split(self.KEY_VAL_SEP, 1)
2503 start_of_line += key + self.KEY_VAL_SEP
2504 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line)
2505 else:
2506 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2508 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]':
2509 if argument_pos == 1:
2510 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2511 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP):
2512 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line)
2513 else:
2514 return start_of_line, [], end_of_line
2516 def get_completions_for_key(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2517 completions = [key for key in self.config_file.config_instances.keys() if key.startswith(start)]
2518 return start_of_line, completions, end_of_line
2520 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2521 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)
2522 if applicable:
2523 return start_of_line, completions, end_of_line
2525 instance = self.config_file.config_instances.get(key)
2526 if instance is None:
2527 return start_of_line, [], end_of_line
2529 return instance.type.get_completions(self.config_file, start_of_line, start, end_of_line)
2532class Include(ConfigFileArgparseCommand):
2534 '''
2535 Load another config file.
2537 This is useful if a config file is getting so big that you want to split it up
2538 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy
2539 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line().
2540 '''
2542 help_config_id = '''
2543 By default the loaded config file starts with which ever config id is currently active.
2544 This is useful if you want to use the same values for several config ids:
2545 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.
2547 After the include the config id is reset to the config id which was active at the beginning of the include
2548 because otherwise it might lead to confusion if the config id is changed in the included config file.
2549 '''
2551 def init_parser(self, parser: ArgumentParser) -> None:
2552 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.')
2553 if self.config_file.enable_config_ids:
2554 assert parser.description is not None
2555 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id)
2556 group = parser.add_mutually_exclusive_group()
2557 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include')
2558 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')
2560 self.nested_includes: 'list[str]' = []
2562 def run_parsed(self, args: argparse.Namespace) -> None:
2563 fn_imp = args.path
2564 fn_imp = fn_imp.replace('/', os.path.sep)
2565 fn_imp = os.path.expanduser(fn_imp)
2566 if not os.path.isabs(fn_imp):
2567 fn = self.config_file.context_file_name
2568 if fn is None:
2569 fn = self.config_file.get_save_path()
2570 fn_imp = os.path.join(os.path.dirname(os.path.abspath(fn)), fn_imp)
2572 if fn_imp in self.nested_includes:
2573 raise ParseException(f'circular include of file {fn_imp!r}')
2574 if not os.path.isfile(fn_imp):
2575 raise ParseException(f'no such file {fn_imp!r}')
2577 self.nested_includes.append(fn_imp)
2579 if self.config_file.enable_config_ids and args.no_reset_config_id_after:
2580 self.config_file.load_without_resetting_config_id(fn_imp)
2581 elif self.config_file.enable_config_ids and args.reset_config_id_before:
2582 config_id = self.config_file.config_id
2583 self.config_file.load_file(fn_imp)
2584 self.config_file.config_id = config_id
2585 else:
2586 config_id = self.config_file.config_id
2587 self.config_file.load_without_resetting_config_id(fn_imp)
2588 self.config_file.config_id = config_id
2590 assert self.nested_includes[-1] == fn_imp
2591 del self.nested_includes[-1]
2593 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]':
2594 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action
2595 if action is not None and action.dest == 'path':
2596 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)
2597 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)
2600class Echo(ConfigFileArgparseCommand):
2602 '''
2603 Display a message.
2605 Settings and environment variables are expanded like in the value of a set command.
2606 '''
2608 def init_parser(self, parser: ArgumentParser) -> None:
2609 parser.add_argument('-l', '--level', default=NotificationLevel.INFO, type=NotificationLevel, metavar='{%s}' % ','.join(l.value for l in NotificationLevel.get_instances()), help="The notification level may influence the formatting but messages printed with echo are always displayed regardless of the notification level.")
2610 parser.add_argument('-r', '--raw', action='store_true', help="Do not expand settings and environment variables.")
2611 parser.add_argument('msg', nargs=argparse.ONE_OR_MORE, help="The message to display")
2613 def run_parsed(self, args: argparse.Namespace) -> None:
2614 msg = ' '.join(self.config_file.expand(m) for m in args.msg)
2615 self.ui_notifier.show(args.level, msg, ignore_filter=True)
2618 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]':
2619 if argument_pos >= len(cmd):
2620 start = ''
2621 else:
2622 start = cmd[argument_pos][:cursor_pos]
2624 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)
2625 return start_of_line, completions, end_of_line
2627class Help(ConfigFileArgparseCommand):
2629 '''
2630 Display help.
2631 '''
2633 max_width = 80
2634 max_width_name = 18
2635 min_width_sep = 2
2636 tab_size = 4
2638 def init_parser(self, parser: ArgumentParser) -> None:
2639 parser.add_argument('cmd', nargs='?', help="The command for which you want help")
2641 def run_parsed(self, args: argparse.Namespace) -> None:
2642 if args.cmd:
2643 if args.cmd not in self.config_file.command_dict:
2644 raise ParseException(f"unknown command {args.cmd!r}")
2645 cmd = self.config_file.command_dict[args.cmd]
2646 out = cmd.get_help()
2647 else:
2648 out = "The following commands are defined:\n"
2649 table = []
2650 for cmd in self.config_file.commands:
2651 name = "- %s" % "/".join(cmd.get_names())
2652 descr = cmd.get_short_description()
2653 row = (name, descr)
2654 table.append(row)
2655 out += self.format_table(table)
2657 out += "\n"
2658 out += "\nUse `help <cmd>` to get more information about a command."
2660 self.ui_notifier.show(NotificationLevel.INFO, out, ignore_filter=True, no_context=True)
2662 def format_table(self, table: 'Sequence[tuple[str, str]]') -> str:
2663 max_name_width = max(len(row[0]) for row in table)
2664 col_width_name = min(max_name_width, self.max_width_name)
2665 out: 'list[str]' = []
2666 subsequent_indent = ' ' * (col_width_name + self.min_width_sep)
2667 for name, descr in table:
2668 if not descr:
2669 out.append(name)
2670 continue
2671 if len(name) > col_width_name:
2672 out.append(name)
2673 initial_indent = subsequent_indent
2674 else:
2675 initial_indent = name.ljust(col_width_name + self.min_width_sep)
2676 out.extend(textwrap.wrap(descr, self.max_width,
2677 initial_indent = initial_indent,
2678 subsequent_indent = subsequent_indent,
2679 break_long_words = False,
2680 tabsize = self.tab_size,
2681 ))
2682 return '\n'.join(out)
2684 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]':
2685 if action and action.dest == 'cmd':
2686 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)
2687 return start_of_line, completions, end_of_line
2689 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)
2692class UnknownCommand(ConfigFileCommand, abstract=True):
2694 name = DEFAULT_COMMAND
2696 def run(self, cmd: 'Sequence[str]') -> None:
2697 raise ParseException('unknown command %r' % cmd[0])