Coverage for .tox/cov/lib/python3.12/site-packages/confattr/configfile.py: 100%
1374 statements
« prev ^ index » next coverage.py v7.5.3, created at 2024-05-29 08:17 +0200
« prev ^ index » next coverage.py v7.5.3, created at 2024-05-29 08:17 +0200
1#!./runmodule.sh
3'''
4This module defines the ConfigFile class
5which can be used to load and save config files.
6'''
8import os
9import shlex
10import platform
11import re
12import enum
13import argparse
14import textwrap
15import 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 = ''
41if hasattr(typing, 'Protocol'):
42 class PathType(typing.Protocol):
44 def __init__(self, path: str) -> None:
45 ...
47 def expand(self) -> str:
48 ...
51# ---------- UI notifier ----------
53@functools.total_ordering
54class NotificationLevel:
56 '''
57 Instances of this class indicate how important a message is.
59 I am not using an enum anymore in order to allow users to add custom levels.
60 Like an enum, however, ``NotificationLevel('error')`` returns the existing instance instead of creating a new one.
61 In order to create a new instance use :meth:`~confattr.configfile.NotificationLevel.new`.
62 '''
64 INFO: 'NotificationLevel'
65 ERROR: 'NotificationLevel'
67 _instances: 'list[NotificationLevel]' = []
69 def __new__(cls, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel':
70 '''
71 :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`)
72 :param value: The name of the notification level
73 :param new: If false: return an existing instance with :meth:`~confattr.configfile.NotificationLevel.get`. If true: create a new instance.
74 :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.
75 :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.
76 '''
77 if new:
78 if more_important_than and less_important_than:
79 raise TypeError("more_important_than and less_important_than are mutually exclusive, you can only pass one of them")
80 elif cls._instances and not (more_important_than or less_important_than):
81 raise TypeError(f"you must specify how important {value!r} is by passing either more_important_than or less_important_than")
83 try:
84 out = cls.get(value)
85 except ValueError:
86 pass
87 else:
88 if more_important_than and out < more_important_than:
89 raise ValueError(f"{out} is already defined and it's less important than {more_important_than}")
90 elif less_important_than and out > less_important_than:
91 raise ValueError(f"{out} is already defined and it's more important than {less_important_than}")
92 warnings.warn(f"{out!r} is already defined, ignoring", stacklevel=3)
93 return out
95 return super().__new__(cls)
97 if more_important_than:
98 raise TypeError('more_important_than must not be passed when new = False')
99 if less_important_than:
100 raise TypeError('less_important_than must not be passed when new = False')
102 return cls.get(value)
104 def __init__(self, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> None:
105 if hasattr(self, '_initialized'):
106 # __init__ is called every time, even if __new__ has returned an old object
107 return
109 assert new
110 self._initialized = True
111 self.value = value
113 if more_important_than:
114 i = self._instances.index(more_important_than) + 1
115 elif less_important_than:
116 i = self._instances.index(less_important_than)
117 elif not self._instances:
118 i = 0
119 else:
120 assert False
122 self._instances.insert(i, self)
124 @classmethod
125 def new(cls, value: str, *, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel':
126 '''
127 :param value: A name for the new notification level
128 :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.
129 :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.
130 '''
131 return cls(value, more_important_than=more_important_than, less_important_than=less_important_than, new=True)
133 @classmethod
134 def get(cls, value: str) -> 'NotificationLevel':
135 '''
136 :return: The instance of this class for the given value
137 :raises ValueError: If there is no instance for the given value
138 '''
139 for lvl in cls._instances:
140 if lvl.value == value:
141 return lvl
143 raise ValueError('')
145 @classmethod
146 def get_instances(cls) -> 'Sequence[NotificationLevel]':
147 '''
148 :return: A sequence of all instances of this class
149 '''
150 return cls._instances
152 def __lt__(self, other: typing.Any) -> bool:
153 if self.__class__ is other.__class__:
154 return self._instances.index(self) < self._instances.index(other)
155 return NotImplemented
157 def __str__(self) -> str:
158 return self.value
160 def __repr__(self) -> str:
161 return "%s(%r)" % (type(self).__name__, self.value)
164NotificationLevel.INFO = NotificationLevel.new('info')
165NotificationLevel.ERROR = NotificationLevel.new('error', more_important_than=NotificationLevel.INFO)
168UiCallback: 'typing.TypeAlias' = 'Callable[[Message], None]'
170class Message:
172 '''
173 A message which should be displayed to the user.
174 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>`.
176 If you want full control how to display messages to the user you can access the attributes directly.
177 Otherwise you can simply convert this object to a str, e.g. with ``str(msg)``.
178 I recommend to use different colors for different values of :attr:`~confattr.configfile.Message.notification_level`.
179 '''
181 #: The value of :attr:`~confattr.configfile.Message.file_name` while loading environment variables.
182 ENVIRONMENT_VARIABLES = 'environment variables'
185 __slots__ = ('notification_level', 'message', 'file_name', 'line_number', 'line', 'no_context')
187 #: The importance of this message. I recommend to display messages of different importance levels in different colors.
188 #: :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.
189 notification_level: NotificationLevel
191 #: The string or exception which should be displayed to the user
192 message: 'str|BaseException'
194 #: The name of the config file which has caused this message.
195 #: If this equals :const:`~confattr.configfile.Message.ENVIRONMENT_VARIABLES` it is not a file but the message has occurred while reading the environment variables.
196 #: 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.
197 file_name: 'str|None'
199 #: The number of the line in the config file. This is None if :attr:`~confattr.configfile.Message.file_name` is not a file name.
200 line_number: 'int|None'
202 #: The line where the message occurred. This is an empty str if there is no line, e.g. when loading environment variables.
203 line: str
205 #: If true: don't show line and line number.
206 no_context: bool
209 _last_file_name: 'str|None' = None
211 @classmethod
212 def reset(cls) -> None:
213 '''
214 If you are using :meth:`~confattr.configfile.Message.format_file_name_msg_line` or :meth:`~confattr.configfile.Message.__str__`
215 you must call this method when the widget showing the error messages is cleared.
216 '''
217 cls._last_file_name = None
219 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:
220 self.notification_level = notification_level
221 self.message = message
222 self.file_name = file_name
223 self.line_number = line_number
224 self.line = line
225 self.no_context = no_context
227 @property
228 def lvl(self) -> NotificationLevel:
229 '''
230 An abbreviation for :attr:`~confattr.configfile.Message.notification_level`
231 '''
232 return self.notification_level
234 def format_msg_line(self) -> str:
235 '''
236 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.
237 '''
238 msg = str(self.message)
239 if self.line and not self.no_context:
240 if self.line_number is not None:
241 lnref = 'line %s' % self.line_number
242 else:
243 lnref = 'line'
244 return f'{msg} in {lnref} {self.line!r}'
246 return msg
248 def format_file_name(self) -> str:
249 '''
250 :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
251 '''
252 file_name = '' if self.file_name is None else self.file_name
253 if file_name == self._last_file_name:
254 return ''
256 if file_name:
257 out = f'While loading {file_name}:\n'
258 else:
259 out = ''
261 if self._last_file_name is not None:
262 out = '\n' + out
264 type(self)._last_file_name = file_name
266 return out
269 def format_file_name_msg_line(self) -> str:
270 '''
271 :return: The concatenation of the return values of :meth:`~confattr.configfile.Message.format_file_name` and :meth:`~confattr.configfile.Message.format_msg_line`
272 '''
273 return self.format_file_name() + self.format_msg_line()
276 def __str__(self) -> str:
277 '''
278 :return: The return value of :meth:`~confattr.configfile.Message.format_file_name_msg_line`
279 '''
280 return self.format_file_name_msg_line()
282 def __repr__(self) -> str:
283 return f'{type(self).__name__}(%s)' % ', '.join(f'{a}={self._format_attribute(getattr(self, a))}' for a in self.__slots__)
285 @staticmethod
286 def _format_attribute(obj: object) -> str:
287 return repr(obj)
290class UiNotifier:
292 '''
293 Most likely you will want to load the config file before creating the UI (user interface).
294 But if there are errors in the config file the user will want to know about them.
295 This class takes the messages from :class:`~confattr.configfile.ConfigFile` and stores them until the UI is ready.
296 When you call :meth:`~confattr.configfile.UiNotifier.set_ui_callback` the stored messages will be forwarded and cleared.
298 This object can also filter the messages.
299 :class:`~confattr.configfile.ConfigFile` calls :meth:`~confattr.configfile.UiNotifier.show_info` every time a setting is changed.
300 If you load an entire config file this can be many messages and the user probably does not want to see them all.
301 Therefore this object drops all messages of :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>` by default.
302 Pass :paramref:`~confattr.configfile.UiNotifier.notification_level` to the constructor if you don't want that.
303 '''
305 # ------- public methods -------
307 def __init__(self, config_file: 'ConfigFile|None' = None, notification_level: 'Config[NotificationLevel]|NotificationLevel' = NotificationLevel.ERROR) -> None:
308 '''
309 :param config_file: Is used to add context information to messages, to which file and to which line a message belongs.
310 :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.
311 '''
312 self._messages: 'list[Message]' = []
313 self._callback: 'UiCallback|None' = None
314 self._notification_level = notification_level
315 self._config_file = config_file
317 def set_ui_callback(self, callback: UiCallback) -> None:
318 '''
319 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.
320 Save :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for :meth:`~confattr.configfile.UiNotifier.show` to call.
321 '''
322 self._callback = callback
324 for msg in self._messages:
325 callback(msg)
326 self._messages.clear()
329 @property
330 def notification_level(self) -> NotificationLevel:
331 '''
332 Ignore messages that are less important than this level.
333 '''
334 if isinstance(self._notification_level, Config):
335 return self._notification_level.value
336 else:
337 return self._notification_level
339 @notification_level.setter
340 def notification_level(self, val: NotificationLevel) -> None:
341 if isinstance(self._notification_level, Config):
342 self._notification_level.value = val
343 else:
344 self._notification_level = val
347 # ------- called by ConfigFile -------
349 def show_info(self, msg: str, *, ignore_filter: bool = False) -> None:
350 '''
351 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>`.
352 '''
353 self.show(NotificationLevel.INFO, msg, ignore_filter=ignore_filter)
355 def show_error(self, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None:
356 '''
357 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.ERROR <confattr.configfile.NotificationLevel.ERROR>`.
358 '''
359 self.show(NotificationLevel.ERROR, msg, ignore_filter=ignore_filter)
362 # ------- internal methods -------
364 def show(self, notification_level: NotificationLevel, msg: 'str|BaseException', *, ignore_filter: bool = False, no_context: bool = False) -> None:
365 '''
366 If a callback for the user interface has been registered with :meth:`~confattr.configfile.UiNotifier.set_ui_callback` call that callback.
367 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.
369 :param notification_level: The importance of the message
370 :param msg: The message to be displayed on the user interface
371 :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>`.
372 :param no_context: If true: don't show line and line number.
373 '''
374 if notification_level < self.notification_level and not ignore_filter:
375 return
377 if self._config_file and not self._config_file.context_line_number and not self._config_file.show_line_always:
378 no_context = True
380 message = Message(
381 notification_level = notification_level,
382 message = msg,
383 file_name = self._config_file.context_file_name if self._config_file else None,
384 line_number = self._config_file.context_line_number if self._config_file else None,
385 line = self._config_file.context_line if self._config_file else '',
386 no_context = no_context,
387 )
389 if self._callback:
390 self._callback(message)
391 else:
392 self._messages.append(message)
395# ---------- format help ----------
397class SectionLevel(SortedEnum):
399 #: Is used to separate different commands in :meth:`ConfigFile.write_help() <confattr.configfile.ConfigFile.write_help>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>`
400 SECTION = 'section'
402 #: 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
403 SUB_SECTION = 'sub-section'
406class FormattedWriter(abc.ABC):
408 @abc.abstractmethod
409 def write_line(self, line: str) -> None:
410 '''
411 Write a single line of documentation.
412 :paramref:`~confattr.configfile.FormattedWriter.write_line.line` may *not* contain a newline.
413 If :paramref:`~confattr.configfile.FormattedWriter.write_line.line` is empty it does not need to be prefixed with a comment character.
414 Empty lines should be dropped if no other lines have been written before.
415 '''
416 pass
418 def write_lines(self, text: str) -> None:
419 '''
420 Write one or more lines of documentation.
421 '''
422 for ln in text.splitlines():
423 self.write_line(ln)
425 @abc.abstractmethod
426 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
427 '''
428 Write a heading.
430 This object should *not* add an indentation depending on the section
431 because if the indentation is increased the line width should be decreased
432 in order to keep the line wrapping consistent.
433 Wrapping lines is handled by :class:`confattr.utils.HelpFormatter`,
434 i.e. before the text is passed to this object.
435 It would be possible to use :class:`argparse.RawTextHelpFormatter` instead
436 and handle line wrapping on a higher level but that would require
437 to understand the help generated by argparse
438 in order to know how far to indent a broken line.
439 One of the trickiest parts would probably be to get the indentation of the usage right.
440 Keep in mind that the term "usage" can differ depending on the language settings of the user.
442 :param lvl: How to format the heading
443 :param heading: The heading
444 '''
445 pass
447 @abc.abstractmethod
448 def write_command(self, cmd: str) -> None:
449 '''
450 Write a config file command.
451 '''
452 pass
455class TextIOWriter(FormattedWriter):
457 def __init__(self, f: 'typing.TextIO|None') -> None:
458 self.f = f
459 self.ignore_empty_lines = True
461 def write_line_raw(self, line: str) -> None:
462 if self.ignore_empty_lines and not line:
463 return
465 print(line, file=self.f)
466 self.ignore_empty_lines = False
469class ConfigFileWriter(TextIOWriter):
471 def __init__(self, f: 'typing.TextIO|None', prefix: str) -> None:
472 super().__init__(f)
473 self.prefix = prefix
475 def write_command(self, cmd: str) -> None:
476 self.write_line_raw(cmd)
478 def write_line(self, line: str) -> None:
479 if line:
480 line = self.prefix + line
482 self.write_line_raw(line)
484 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
485 if lvl is SectionLevel.SECTION:
486 self.write_line('')
487 self.write_line('')
488 self.write_line('=' * len(heading))
489 self.write_line(heading)
490 self.write_line('=' * len(heading))
491 else:
492 self.write_line('')
493 self.write_line(heading)
494 self.write_line('-' * len(heading))
496class HelpWriter(TextIOWriter):
498 def write_line(self, line: str) -> None:
499 self.write_line_raw(line)
501 def write_heading(self, lvl: SectionLevel, heading: str) -> None:
502 self.write_line('')
503 if lvl is SectionLevel.SECTION:
504 self.write_line(heading)
505 self.write_line('=' * len(heading))
506 else:
507 self.write_line(heading)
508 self.write_line('-' * len(heading))
510 def write_command(self, cmd: str) -> None:
511 pass # pragma: no cover
514# ---------- internal exceptions ----------
516class ParseException(Exception):
518 '''
519 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.
520 Is caught in :class:`~confattr.configfile.ConfigFile`.
521 '''
523class MultipleParseExceptions(Exception):
525 '''
526 This is raised by :class:`~confattr.configfile.ConfigFileCommand` implementations in order to communicate that multiple errors have occured on the same line.
527 Is caught in :class:`~confattr.configfile.ConfigFile`.
528 '''
530 def __init__(self, exceptions: 'Sequence[ParseException]') -> None:
531 super().__init__()
532 self.exceptions = exceptions
534 def __iter__(self) -> 'Iterator[ParseException]':
535 return iter(self.exceptions)
538# ---------- data types for **kw args ----------
540if hasattr(typing, 'TypedDict'): # python >= 3.8 # pragma: no cover. This is tested but in a different environment which is not known to coverage.
541 class SaveKwargs(typing.TypedDict, total=False):
542 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]'
543 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]] | None'
544 no_multi: bool
545 comments: bool
546 commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]'
547 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]'
550# ---------- ConfigFile class ----------
552class ArgPos:
553 '''
554 This is an internal class, the return type of :meth:`ConfigFile.find_arg() <confattr.configfile.ConfigFile.find_arg>`
555 '''
557 #: 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.
558 argument_pos: int
560 #: 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.
561 in_between: bool
563 #: 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
564 i0: int
566 #: 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
567 i1: int
570class ConfigFile:
572 '''
573 Read or write a config file.
575 All :class:`~confattr.config.Config` objects must be instantiated before instantiating this class.
576 '''
578 COMMENT = '#'
579 COMMENT_PREFIXES = ('"', '#')
580 ENTER_GROUP_PREFIX = '['
581 ENTER_GROUP_SUFFIX = ']'
583 #: How to separete several element in a collection (list, set, dict)
584 ITEM_SEP = ','
586 #: How to separate key and value in a dict
587 KEY_SEP = ':'
590 #: The :class:`~confattr.config.Config` instances to load or save
591 config_instances: 'dict[str, Config[typing.Any]]'
593 #: 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`.
594 config_id: 'ConfigId|None'
596 #: Override the config file which is returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
597 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_directory` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.
598 #: 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.)
599 config_path: 'str|None' = None
601 #: Override the config directory which is returned by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths`.
602 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_path` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.
603 #: 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.)
604 config_directory: 'str|None' = None
606 #: The name of the config file used by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
607 #: 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.).
608 config_name = 'config'
610 #: 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`.
611 env_variables: 'list[str]'
613 #: A prefix that is prepended to the name of environment variables in :meth:`~confattr.configfile.ConfigFile.get_env_name`.
614 #: 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.
615 envprefix: str
617 #: 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).
618 context_file_name: 'str|None' = None
619 #: 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.
620 context_line_number: 'int|None' = None
621 #: The line which is currently parsed.
622 context_line: str = ''
624 #: If true: ``[config-id]`` syntax is allowed in config file, config ids are included in help, config id related options are available for include.
625 #: 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)
626 enable_config_ids: bool
629 #: 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*.
630 command_dict: 'dict[str, ConfigFileCommand]'
632 #: 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.
633 commands: 'list[ConfigFileCommand]'
636 #: See :paramref:`~confattr.configfile.ConfigFile.check_config_id`
637 check_config_id: 'Callable[[ConfigId], None]|None'
639 #: 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.
640 show_line_always: bool
643 def __init__(self, *,
644 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
645 appname: str,
646 authorname: 'str|None' = None,
647 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]|None' = None,
648 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]|None' = None,
649 commands: 'Iterable[type[ConfigFileCommand]|abc.ABCMeta]|None' = None,
650 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]|None' = None,
651 formatter_class: 'type[argparse.HelpFormatter]' = HelpFormatter,
652 check_config_id: 'Callable[[ConfigId], None]|None' = None,
653 enable_config_ids: 'bool|None' = None,
654 show_line_always: bool = True,
655 ) -> None:
656 '''
657 :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`.
658 :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
659 :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`
660 :param config_instances: The settings supported in this config file. None means all settings which have been defined when this object is created.
661 :param ignore: These settings are *not* supported by this config file even if they are contained in :paramref:`~confattr.configfile.ConfigFile.config_instances`.
662 :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.
663 :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.
664 :param formatter_class: Is used to clean up doc strings and wrap lines in the help
665 :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.
666 :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`
667 :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.
668 '''
669 self.appname = appname
670 self.authorname = authorname
671 self.ui_notifier = UiNotifier(self, notification_level)
672 state.has_any_config_file_been_instantiated = True
673 if config_instances is None:
674 # I am setting has_config_file_been_instantiated only if no config_instances have been passed
675 # because if the user passes an explicit list of config_instances
676 # then it's clear that Config instances created later on are ignored by this ConfigFile
677 # so no TimingException should be raised if instantiating another Config.
678 state.has_config_file_been_instantiated = True
679 config_instances = Config.iter_instances()
680 sort: 'bool|None' = True
681 else:
682 sort = None
683 self.config_instances = {i.key: i for i in self.iter_config_instances(config_instances, ignore, sort=sort)}
684 self.config_id: 'ConfigId|None' = None
685 self.formatter_class = formatter_class
686 self.env_variables: 'list[str]' = []
687 self.check_config_id = check_config_id
688 self.show_line_always = show_line_always
690 if enable_config_ids is None:
691 enable_config_ids = self.check_config_id is not None or any(isinstance(cfg, MultiConfig) for cfg in self.config_instances.values())
692 self.enable_config_ids = enable_config_ids
694 self.envprefix = ''
695 self.envprefix = self.get_env_name(appname + '_')
696 envname = self.envprefix + 'CONFIG_PATH'
697 self.env_variables.append(envname)
698 if envname in os.environ:
699 self.config_path = os.environ[envname]
700 envname = self.envprefix + 'CONFIG_DIRECTORY'
701 self.env_variables.append(envname)
702 if envname in os.environ:
703 self.config_directory = os.environ[envname]
704 envname = self.envprefix + 'CONFIG_NAME'
705 self.env_variables.append(envname)
706 if envname in os.environ:
707 self.config_name = os.environ[envname]
709 if commands is None:
710 commands = ConfigFileCommand.get_command_types()
711 else:
712 original_commands = commands
713 def iter_commands() -> 'Iterator[type[ConfigFileCommand]]':
714 for cmd in original_commands:
715 cmd = typing.cast('type[ConfigFileCommand]', cmd)
716 if cmd._abstract:
717 for c in ConfigFileCommand.get_command_types():
718 if issubclass(c, cmd):
719 yield c
720 else:
721 yield cmd
722 commands = iter_commands()
723 self.command_dict = {}
724 self.commands = []
725 for cmd_type in commands:
726 if ignore_commands and any(issubclass(cmd_type, i_c) for i_c in ignore_commands):
727 continue
728 cmd = cmd_type(self)
729 self.commands.append(cmd)
730 for name in cmd.get_names():
731 self.command_dict[name] = cmd
733 def iter_config_instances(self,
734 config_instances: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]',
735 ignore: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]|None',
736 *,
737 sort: 'bool|None',
738 ) -> 'Iterator[Config[object]]':
739 '''
740 :param config_instances: The settings to consider
741 :param ignore: Skip these settings
742 :param sort: If :obj:`None`: sort :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.config_instances` if it is a :class:`set`
744 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.
745 Yield all :class:`~confattr.config.Config` instances which are not (directly or indirectly) contained in :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.ignore`.
746 '''
747 should_be_ignored: 'Callable[[Config[typing.Any]], bool]'
748 if ignore is not None:
749 tmp = set()
750 for c in ignore:
751 if isinstance(c, DictConfig):
752 tmp |= set(c._values.values())
753 else:
754 tmp.add(c)
755 should_be_ignored = lambda c: c in tmp
756 else:
757 should_be_ignored = lambda c: False
759 if sort is None:
760 sort = isinstance(config_instances, set)
761 if sort:
762 config_instances = sorted(config_instances, key=lambda c: c.key_prefix if isinstance(c, DictConfig) else c.key)
763 def expand_configs() -> 'Iterator[Config[typing.Any]]':
764 for c in config_instances:
765 if isinstance(c, DictConfig):
766 yield from c.iter_configs()
767 else:
768 yield c
769 for c in expand_configs():
770 if should_be_ignored(c):
771 continue
773 yield c
775 def set_ui_callback(self, callback: UiCallback) -> None:
776 '''
777 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.
779 Messages which occur before this method is called are stored and forwarded as soon as the callback is registered.
781 :param ui_callback: A function to display messages to the user
782 '''
783 self.ui_notifier.set_ui_callback(callback)
785 def get_app_dirs(self) -> 'appdirs.AppDirs':
786 '''
787 Create or get a cached `AppDirs <https://github.com/ActiveState/appdirs/blob/master/README.rst#appdirs-for-convenience>`__ instance with multipath support enabled.
789 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.
790 The first one installed is used.
791 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.
792 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``.
794 These libraries should respect the environment variables ``XDG_CONFIG_HOME`` and ``XDG_CONFIG_DIRS``.
795 '''
796 if not hasattr(self, '_appdirs'):
797 try:
798 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
799 AppDirs = typing.cast('type[appdirs.AppDirs]', platformdirs.PlatformDirs) # pragma: no cover # This is tested but in a different tox environment
800 except ImportError:
801 try:
802 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
803 AppDirs = typing.cast('type[appdirs.AppDirs]', xdgappdirs.AppDirs) # pragma: no cover # This is tested but in a different tox environment
804 except ImportError:
805 AppDirs = appdirs.AppDirs
807 self._appdirs = AppDirs(self.appname, self.authorname, multipath=True)
809 return self._appdirs
811 # ------- load -------
813 def iter_user_site_config_paths(self) -> 'Iterator[str]':
814 '''
815 Iterate over all directories which are searched for config files, user specific first.
817 The directories are based on :meth:`~confattr.configfile.ConfigFile.get_app_dirs`
818 unless :attr:`~confattr.configfile.ConfigFile.config_directory` has been set.
819 If :attr:`~confattr.configfile.ConfigFile.config_directory` has been set
820 it's value is yielded and nothing else.
821 '''
822 if self.config_directory:
823 yield self.config_directory
824 return
826 appdirs = self.get_app_dirs()
827 yield from appdirs.user_config_dir.split(os.path.pathsep)
828 yield from appdirs.site_config_dir.split(os.path.pathsep)
830 def iter_config_paths(self) -> 'Iterator[str]':
831 '''
832 Iterate over all paths which are checked for config files, user specific first.
834 Use this method if you want to tell the user where the application is looking for it's config file.
835 The first existing file yielded by this method is used by :meth:`~confattr.configfile.ConfigFile.load`.
837 The paths are generated by joining the directories yielded by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths` with
838 :attr:`ConfigFile.config_name <confattr.configfile.ConfigFile.config_name>`.
840 If :attr:`~confattr.configfile.ConfigFile.config_path` has been set this method yields that path instead and no other paths.
841 '''
842 if self.config_path:
843 yield self.config_path
844 return
846 for path in self.iter_user_site_config_paths():
847 yield os.path.join(path, self.config_name)
849 def load(self, *, env: bool = True) -> bool:
850 '''
851 Load the first existing config file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.
853 If there are several config files a user specific config file is preferred.
854 If a user wants a system wide config file to be loaded, too, they can explicitly include it in their config file.
855 :param env: If true: call :meth:`~confattr.configfile.ConfigFile.load_env` after loading the config file.
856 :return: False if an error has occurred
857 '''
858 out = True
859 for fn in self.iter_config_paths():
860 if os.path.isfile(fn):
861 out &= self.load_file(fn)
862 break
864 if env:
865 out &= self.load_env()
867 return out
869 def load_env(self) -> bool:
870 '''
871 Load settings from environment variables.
872 The name of the environment variable belonging to a setting is generated with :meth:`~confattr.configfile.ConfigFile.get_env_name`.
874 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>`.
876 :return: False if an error has occurred
877 :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`
878 '''
879 out = True
880 old_file_name = self.context_file_name
881 self.context_file_name = Message.ENVIRONMENT_VARIABLES
883 config_instances: 'dict[str, Config[object]]' = {}
884 for key, instance in self.config_instances.items():
885 name = self.get_env_name(key)
886 if name in self.env_variables:
887 raise ValueError(f'setting {instance.key!r} conflicts with environment variable {name!r}')
888 elif name in config_instances:
889 raise ValueError(f'settings {instance.key!r} and {config_instances[name].key!r} result in the same environment variable {name!r}')
890 else:
891 config_instances[name] = instance
893 for name, value in os.environ.items():
894 if not name.startswith(self.envprefix):
895 continue
896 if name in self.env_variables:
897 continue
899 if name in config_instances:
900 instance = config_instances[name]
901 try:
902 instance.set_value(config_id=None, value=self.parse_value(instance, value, raw=True))
903 self.ui_notifier.show_info(f'set {instance.key} to {self.format_value(instance, config_id=None)}')
904 except ValueError as e:
905 self.ui_notifier.show_error(f"{e} while trying to parse environment variable {name}='{value}'")
906 out = False
907 else:
908 self.ui_notifier.show_error(f"unknown environment variable {name}='{value}'")
909 out = False
911 self.context_file_name = old_file_name
912 return out
915 def get_env_name(self, key: str) -> str:
916 '''
917 Convert the key of a setting to the name of the corresponding environment variable.
919 :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.
920 '''
921 out = key
922 out = out.upper()
923 for c in ' .-':
924 out = out.replace(c, '_')
925 out = self.envprefix + out
926 return out
928 def load_file(self, fn: str) -> bool:
929 '''
930 Load a config file and change the :class:`~confattr.config.Config` objects accordingly.
932 Use :meth:`~confattr.configfile.ConfigFile.set_ui_callback` to get error messages which appeared while loading the config file.
933 You can call :meth:`~confattr.configfile.ConfigFile.set_ui_callback` after this method without loosing any messages.
935 :param fn: The file name of the config file (absolute or relative path)
936 :return: False if an error has occurred
937 '''
938 self.config_id = None
939 return self.load_without_resetting_config_id(fn)
941 def load_without_resetting_config_id(self, fn: str) -> bool:
942 out = True
943 old_file_name = self.context_file_name
944 self.context_file_name = fn
946 with open(fn, 'rt') as f:
947 for lnno, ln in enumerate(f, 1):
948 self.context_line_number = lnno
949 out &= self.parse_line(line=ln)
950 self.context_line_number = None
952 self.context_file_name = old_file_name
953 return out
955 def parse_line(self, line: str) -> bool:
956 '''
957 :param line: The line to be parsed
958 :return: True if line is valid, False if an error has occurred
960 :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.
961 '''
962 ln = line.strip()
963 if not ln:
964 return True
965 if self.is_comment(ln):
966 return True
967 if self.enable_config_ids and self.enter_group(ln):
968 return True
970 self.context_line = ln
972 try:
973 ln_split = self.split_line(ln)
974 except Exception as e:
975 self.parse_error(str(e))
976 out = False
977 else:
978 out = self.parse_split_line(ln_split)
980 self.context_line = ''
981 return out
983 def split_line(self, line: str) -> 'list[str]':
984 cmd, line = self.split_one_symbol_command(line)
985 line_split = shlex.split(line, comments=True)
986 if cmd:
987 line_split.insert(0, cmd)
988 return line_split
990 def split_line_ignore_errors(self, line: str) -> 'list[str]':
991 out = []
992 cmd, line = self.split_one_symbol_command(line)
993 if cmd:
994 out.append(cmd)
995 lex = shlex.shlex(line, posix=True)
996 lex.whitespace_split = True
997 while True:
998 try:
999 t = lex.get_token()
1000 except:
1001 out.append(lex.token)
1002 return out
1003 if t is None:
1004 return out
1005 out.append(t)
1007 def split_one_symbol_command(self, line: str) -> 'tuple[str|None, str]':
1008 if line and not line[0].isalnum() and line[0] in self.command_dict:
1009 return line[0], line[1:]
1011 return None, line
1014 def is_comment(self, line: str) -> bool:
1015 '''
1016 Check if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment.
1018 :param line: The current line
1019 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment
1020 '''
1021 for c in self.COMMENT_PREFIXES:
1022 if line.startswith(c):
1023 return True
1024 return False
1026 def enter_group(self, line: str) -> bool:
1027 '''
1028 Check if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group and set :attr:`~confattr.configfile.ConfigFile.config_id` if it does.
1029 Call :meth:`~confattr.configfile.ConfigFile.parse_error` if :meth:`~confattr.configfile.ConfigFile.check_config_id` raises a :class:`~confattr.configfile.ParseException`.
1031 :param line: The current line
1032 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group
1033 '''
1034 if line.startswith(self.ENTER_GROUP_PREFIX) and line.endswith(self.ENTER_GROUP_SUFFIX):
1035 config_id = typing.cast(ConfigId, line[len(self.ENTER_GROUP_PREFIX):-len(self.ENTER_GROUP_SUFFIX)])
1036 if self.check_config_id and config_id != Config.default_config_id:
1037 try:
1038 self.check_config_id(config_id)
1039 except ParseException as e:
1040 self.parse_error(str(e))
1041 self.config_id = config_id
1042 if self.config_id not in MultiConfig.config_ids:
1043 MultiConfig.config_ids.append(self.config_id)
1044 return True
1045 return False
1047 def parse_split_line(self, ln_split: 'Sequence[str]') -> bool:
1048 '''
1049 Call the corresponding command in :attr:`~confattr.configfile.ConfigFile.command_dict`.
1050 If any :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is raised catch it and call :meth:`~confattr.configfile.ConfigFile.parse_error`.
1052 :return: False if a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` has been caught, True if no exception has been caught
1053 '''
1054 cmd = self.get_command(ln_split)
1055 try:
1056 cmd.run(ln_split)
1057 except ParseException as e:
1058 self.parse_error(str(e))
1059 return False
1060 except MultipleParseExceptions as exceptions:
1061 for exc in exceptions:
1062 self.parse_error(str(exc))
1063 return False
1065 return True
1067 def get_command(self, ln_split: 'Sequence[str]') -> 'ConfigFileCommand':
1068 cmd_name = ln_split[0]
1069 if cmd_name in self.command_dict:
1070 cmd = self.command_dict[cmd_name]
1071 elif DEFAULT_COMMAND in self.command_dict:
1072 cmd = self.command_dict[DEFAULT_COMMAND]
1073 else:
1074 cmd = UnknownCommand(self)
1075 return cmd
1078 # ------- save -------
1080 def get_save_path(self) -> str:
1081 '''
1082 :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.
1083 '''
1084 paths = tuple(self.iter_config_paths())
1085 for fn in paths:
1086 if os.path.isfile(fn) and os.access(fn, os.W_OK):
1087 return fn
1089 return paths[0]
1091 def save(self,
1092 if_not_existing: bool = False,
1093 **kw: 'Unpack[SaveKwargs]',
1094 ) -> str:
1095 '''
1096 Save the current values of all settings to the file returned by :meth:`~confattr.configfile.ConfigFile.get_save_path`.
1097 Directories are created as necessary.
1099 :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.
1100 :param ignore: Do not write these settings to the file.
1101 :param no_multi: Do not write several sections. For :class:`~confattr.config.MultiConfig` instances write the default values only.
1102 :param comments: Write comments with allowed values and help.
1103 :param if_not_existing: Do not overwrite the file if it is already existing.
1104 :return: The path to the file which has been written
1105 '''
1106 fn = self.get_save_path()
1107 if if_not_existing and os.path.isfile(fn):
1108 return fn
1110 self.save_file(fn, **kw)
1111 return fn
1113 def save_file(self,
1114 fn: str,
1115 **kw: 'Unpack[SaveKwargs]'
1116 ) -> None:
1117 '''
1118 Save the current values of all settings to a specific file.
1119 Directories are created as necessary, with `mode 0700 <https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation>`__ as specified by the `XDG Base Directory Specification standard <https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html>`__.
1121 :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.
1122 :raises FileNotFoundError: if the directory does not exist
1124 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.
1125 '''
1126 # because os.path.dirname is not able to handle a file name without path
1127 fn = os.path.abspath(fn)
1129 # "If, when attempting to write a file, the destination directory is non-existent an attempt should be made to create it with permission 0700.
1130 # If the destination directory exists already the permissions should not be changed."
1131 # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
1132 os.makedirs(os.path.dirname(fn), exist_ok=True, mode=0o0700)
1134 with open(fn, 'wt') as f:
1135 self.save_to_open_file(f, **kw)
1138 def save_to_open_file(self,
1139 f: typing.TextIO,
1140 **kw: 'Unpack[SaveKwargs]',
1141 ) -> None:
1142 '''
1143 Save the current values of all settings to a file-like object
1144 by creating a :class:`~confattr.configfile.ConfigFileWriter` object and calling :meth:`~confattr.configfile.ConfigFile.save_to_writer`.
1146 :param f: The file to write to
1148 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.
1149 '''
1150 writer = ConfigFileWriter(f, prefix=self.COMMENT + ' ')
1151 self.save_to_writer(writer, **kw)
1153 def save_to_writer(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
1154 '''
1155 Save the current values of all settings.
1157 Ensure that all keyword arguments are passed with :meth:`~confattr.configfile.ConfigFile.set_save_default_arguments`.
1158 Iterate over all :class:`~confattr.configfile.ConfigFileCommand` objects in :attr:`~confattr.configfile.ConfigFile.commands` and do for each of them:
1160 - 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
1161 - call :meth:`~confattr.configfile.ConfigFileCommand.save`
1162 '''
1163 self.set_save_default_arguments(kw)
1164 commands = list(self.commands)
1165 if 'commands' in kw or 'ignore_commands' in kw:
1166 command_types = tuple(kw['commands']) if 'commands' in kw else None
1167 ignore_command_types = tuple(kw['ignore_commands']) if 'ignore_commands' in kw else None
1168 for cmd in tuple(commands):
1169 if (ignore_command_types and isinstance(cmd, ignore_command_types)) \
1170 or (command_types and not isinstance(cmd, command_types)):
1171 commands.remove(cmd)
1172 write_headings = len(tuple(cmd for cmd in commands if getattr(cmd.save, 'implemented', True))) >= 2
1173 for cmd in commands:
1174 cmd.should_write_heading = write_headings
1175 cmd.save(writer, **kw)
1177 def set_save_default_arguments(self, kw: 'SaveKwargs') -> None:
1178 '''
1179 Ensure that all arguments are given in :paramref:`~confattr.configfile.ConfigFile.set_save_default_arguments.kw`.
1180 '''
1181 kw.setdefault('config_instances', self.config_instances.values())
1182 kw.setdefault('ignore', None)
1183 kw.setdefault('no_multi', not self.enable_config_ids)
1184 kw.setdefault('comments', True)
1187 def quote(self, val: str) -> str:
1188 '''
1189 Quote a value if necessary so that it will be interpreted as one argument.
1191 The default implementation calls :func:`~confattr.utils.readable_quote`.
1192 '''
1193 return readable_quote(val)
1195 def write_config_id(self, writer: FormattedWriter, config_id: ConfigId) -> None:
1196 '''
1197 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`.
1198 '''
1199 writer.write_command(self.ENTER_GROUP_PREFIX + config_id + self.ENTER_GROUP_SUFFIX)
1201 def get_help_config_id(self) -> str:
1202 '''
1203 :return: A help how to use :class:`~confattr.config.MultiConfig`. The return value still needs to be cleaned with :func:`inspect.cleandoc`.
1204 '''
1205 return f'''
1206 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.
1207 `config-id` must be replaced by the corresponding identifier for the object.
1208 '''
1211 # ------- formatting and parsing of values -------
1213 def format_value(self, instance: Config[typing.Any], config_id: 'ConfigId|None') -> str:
1214 '''
1215 :param instance: The config value to be saved
1216 :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
1217 :return: A str representation to be written to the config file
1219 Convert the value of the :class:`~confattr.config.Config` instance into a str with :meth:`~confattr.configfile.ConfigFile.format_any_value`.
1220 '''
1221 return self.format_any_value(instance.type, instance.get_value(config_id))
1223 def format_any_value(self, type: 'AbstractFormatter[T2]', value: 'T2') -> str:
1224 return type.format_value(self, value)
1227 def parse_value(self, instance: 'Config[T2]', value: str, *, raw: bool) -> 'T2':
1228 '''
1229 :param instance: The config instance for which the value should be parsed, this is important for the data type
1230 :param value: The string representation of the value to be parsed
1231 :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
1232 Parse a value to the data type of a given setting by calling :meth:`~confattr.configfile.ConfigFile.parse_value_part`
1233 '''
1234 if not raw:
1235 value = self.expand(value)
1236 return self.parse_value_part(instance, instance.type, value)
1238 def parse_value_part(self, config: 'Config[typing.Any]', t: 'AbstractFormatter[T2]', value: str) -> 'T2':
1239 '''
1240 Parse a value to the given data type.
1242 :param config: Needed for the allowed values and the key for error messages
1243 :param t: The data type to which :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` shall be parsed
1244 :param value: The value to be parsed
1245 :raises ValueError: if :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` is invalid
1246 '''
1247 return t.parse_value(self, value)
1250 def expand(self, arg: str) -> str:
1251 return self.expand_config(self.expand_env(arg))
1253 reo_config = re.compile(r'%([^%]*)%')
1254 def expand_config(self, arg: str) -> str:
1255 n = arg.count('%')
1256 if n % 2 == 1:
1257 raise ParseException("uneven number of percent characters, use %% for a literal percent sign or --raw if you don't want expansion")
1258 return self.reo_config.sub(self.expand_config_match, arg)
1260 reo_env = re.compile(r'\$\{([^{}]*)\}')
1261 def expand_env(self, arg: str) -> str:
1262 return self.reo_env.sub(self.expand_env_match, arg)
1264 def expand_config_match(self, m: 're.Match[str]') -> str:
1265 '''
1266 :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``
1267 :return: The expanded form of the setting or ``'%'`` if group 1 is empty
1268 :raises ParseException: If ``key``, ``!conversion`` or ``:format_spec`` is invalid
1270 This is based on the `Python Format String Syntax <https://docs.python.org/3/library/string.html#format-string-syntax>`__.
1272 ``field_name`` is the :attr:`~confattr.config.Config.key`.
1274 ``!conversion`` is one of:
1276 - ``!``: :meth:`ConfigFile.format_value() <confattr.configfile.ConfigFile.format_value>`
1277 - ``!r``: :func:`repr`
1278 - ``!s``: :class:`str`
1279 - ``!a``: :func:`ascii`
1281 ``: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>`__.
1282 :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.
1283 If :meth:`~confattr.formatters.AbstractFormatter.expand_value` raises an :class:`Exception` it is caught and reraised as a :class:`~confattr.configfile.ParseException`.
1284 '''
1285 key = m.group(1)
1286 if not key:
1287 return '%'
1289 if ':' in key:
1290 key, fmt = key.split(':', 1)
1291 else:
1292 fmt = None
1293 if '!' in key:
1294 key, stringifier = key.split('!', 1)
1295 else:
1296 stringifier = None
1298 if key not in self.config_instances:
1299 raise ParseException(f'invalid key {key!r}')
1300 instance = self.config_instances[key]
1302 if stringifier is None and fmt is None:
1303 return self.format_value(instance, config_id=None)
1304 elif stringifier is None:
1305 assert fmt is not None
1306 try:
1307 return instance.type.expand_value(self, instance.get_value(config_id=None), format_spec=fmt)
1308 except Exception as e:
1309 raise ParseException(e)
1311 val: object
1312 if stringifier == '':
1313 val = self.format_value(instance, config_id=None)
1314 else:
1315 val = instance.get_value(config_id=None)
1316 if stringifier == 'r':
1317 val = repr(val)
1318 elif stringifier == 's':
1319 val = str(val)
1320 elif stringifier == 'a':
1321 val = ascii(val)
1322 else:
1323 raise ParseException('invalid conversion %r' % stringifier)
1325 if fmt is None:
1326 assert isinstance(val, str)
1327 return val
1329 try:
1330 return format(val, fmt)
1331 except ValueError as e:
1332 raise ParseException(e)
1334 def expand_env_match(self, m: 're.Match[str]') -> str:
1335 '''
1336 :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
1337 :return: The expanded form of the environment variable
1339 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:
1341 - ``${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.
1342 - ``${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.
1343 - ``${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.
1344 - ``${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.
1346 In the patterns above, if you use a ``:`` it is checked whether parameter is unset or empty.
1347 If ``:`` is not used the check is only true if parameter is unset, empty is treated as a valid value.
1348 '''
1349 env = m.group(1)
1350 for op in '-=?+':
1351 if ':' + op in env:
1352 env, arg = env.split(':' + op, 1)
1353 isset = bool(os.environ.get(env))
1354 elif op in env:
1355 env, arg = env.split(op, 1)
1356 isset = env in os.environ
1357 else:
1358 continue
1360 val = os.environ.get(env, '')
1361 if op == '-':
1362 if isset:
1363 return val
1364 else:
1365 return arg
1366 elif op == '=':
1367 if isset:
1368 return val
1369 else:
1370 os.environ[env] = arg
1371 return arg
1372 elif op == '?':
1373 if isset:
1374 return val
1375 else:
1376 if not arg:
1377 state = 'empty' if env in os.environ else 'unset'
1378 arg = f'environment variable {env} is {state}'
1379 raise ParseException(arg)
1380 elif op == '+':
1381 if isset:
1382 return arg
1383 else:
1384 return ''
1385 else:
1386 assert False
1388 return os.environ.get(env, '')
1391 # ------- help -------
1393 def write_help(self, writer: FormattedWriter) -> None:
1394 import platform
1395 formatter = self.create_formatter()
1396 writer.write_lines('The first existing file of the following paths is loaded:')
1397 for path in self.iter_config_paths():
1398 writer.write_line('- %s' % path)
1400 writer.write_line('')
1401 writer.write_line('This can be influenced with the following environment variables:')
1402 if platform.system() == 'Linux': # pragma: no branch
1403 writer.write_line('- XDG_CONFIG_HOME')
1404 writer.write_line('- XDG_CONFIG_DIRS')
1405 for env in self.env_variables:
1406 writer.write_line(f'- {env}')
1408 writer.write_line('')
1409 writer.write_lines(formatter.format_text(f'''\
1410 \
1411You can also use environment variables to change the values of the settings listed under `set` command.
1412The corresponding environment variable name is the name of the setting in all upper case letters
1413with dots, hypens and spaces replaced by underscores and prefixed with "{self.envprefix}".'''))
1415 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)))
1417 writer.write_lines('The config file may contain the following commands:')
1418 for cmd in self.commands:
1419 names = '|'.join(cmd.get_names())
1420 writer.write_heading(SectionLevel.SECTION, names)
1421 writer.write_lines(cmd.get_help())
1423 def create_formatter(self) -> HelpFormatterWrapper:
1424 return HelpFormatterWrapper(self.formatter_class)
1426 def get_help(self) -> str:
1427 '''
1428 A convenience wrapper around :meth:`~confattr.configfile.ConfigFile.write_help`
1429 to return the help as a str instead of writing it to a file.
1431 This uses :class:`~confattr.configfile.HelpWriter`.
1432 '''
1433 doc = io.StringIO()
1434 self.write_help(HelpWriter(doc))
1435 # The generated help ends with a \n which is implicitly added by print.
1436 # If I was writing to stdout or a file that would be desired.
1437 # But if I return it as a string and then print it, the print adds another \n which would be too much.
1438 # Therefore I am stripping the trailing \n.
1439 return doc.getvalue().rstrip('\n')
1442 # ------- auto complete -------
1444 def get_completions(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1445 '''
1446 Provide an auto completion for commands that can be executed with :meth:`~confattr.configfile.ConfigFile.parse_line`.
1448 :param line: The entire line that is currently in the text input field
1449 :param cursor_pos: The position of the cursor
1450 :return: start of line, completions, end of line.
1451 *completions* is a list of possible completions for the word where the cursor is located.
1452 If *completions* is an empty list there are no completions available and the user input should not be changed.
1453 If *completions* is not empty it should be displayed by a user interface in a drop down menu.
1454 The *start of line* is everything on the line before the completions.
1455 The *end of line* is everything on the line after the completions.
1456 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1457 *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.
1458 '''
1459 original_ln = line
1460 stripped_line = line.lstrip()
1461 indentation = line[:len(line) - len(stripped_line)]
1462 cursor_pos -= len(indentation)
1463 line = stripped_line
1464 if self.enable_config_ids and line.startswith(self.ENTER_GROUP_PREFIX):
1465 out = self.get_completions_enter_group(line, cursor_pos)
1466 else:
1467 out = self.get_completions_command(line, cursor_pos)
1469 out = (indentation + out[0], out[1], out[2])
1470 return out
1472 def get_completions_enter_group(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1473 '''
1474 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.
1476 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_enter_group.line`
1477 and will prepend it to the first item of the return value.
1478 '''
1479 start = line
1480 groups = [self.ENTER_GROUP_PREFIX + str(cid) + self.ENTER_GROUP_SUFFIX for cid in MultiConfig.config_ids]
1481 groups = [cid for cid in groups if cid.startswith(start)]
1482 return '', groups, ''
1484 def get_completions_command(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':
1485 '''
1486 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.
1488 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_command.line`
1489 and will prepend it to the first item of the return value.
1490 '''
1491 if not line:
1492 return self.get_completions_command_name(line, cursor_pos, start_of_line='', end_of_line='')
1494 ln_split = self.split_line_ignore_errors(line)
1495 assert ln_split
1496 a = self.find_arg(line, ln_split, cursor_pos)
1498 if a.in_between:
1499 start_of_line = line[:cursor_pos]
1500 end_of_line = line[cursor_pos:]
1501 else:
1502 start_of_line = line[:a.i0]
1503 end_of_line = line[a.i1:]
1505 if a.argument_pos == 0:
1506 return self.get_completions_command_name(line, cursor_pos, start_of_line=start_of_line, end_of_line=end_of_line)
1507 else:
1508 cmd = self.get_command(ln_split)
1509 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)
1511 def find_arg(self, line: str, ln_split: 'list[str]', cursor_pos: int) -> ArgPos:
1512 '''
1513 This is an internal method used by :meth:`~confattr.configfile.ConfigFile.get_completions_command`
1514 '''
1515 CHARS_REMOVED_BY_SHLEX = ('"', "'", '\\')
1516 assert cursor_pos <= len(line) # yes, cursor_pos can be == len(str)
1517 out = ArgPos()
1518 out.in_between = True
1520 # init all out attributes just to be save, these should not never be used because line is not empty and not white space only
1521 out.argument_pos = 0
1522 out.i0 = 0
1523 out.i1 = 0
1525 n_ln = len(line)
1526 i_ln = 0
1527 n_arg = len(ln_split)
1528 out.argument_pos = 0
1529 i_in_arg = 0
1530 assert out.argument_pos < n_ln
1531 while True:
1532 if out.in_between:
1533 assert i_in_arg == 0
1534 if i_ln >= n_ln:
1535 assert out.argument_pos >= n_arg - 1
1536 out.i0 = i_ln
1537 return out
1538 elif line[i_ln].isspace():
1539 i_ln += 1
1540 else:
1541 out.i0 = i_ln
1542 if i_ln >= cursor_pos:
1543 return out
1544 if out.argument_pos >= n_arg:
1545 assert line[i_ln] == '#'
1546 out.i0 = len(line)
1547 return out
1548 out.in_between = False
1549 else:
1550 if i_ln >= n_ln:
1551 assert out.argument_pos >= n_arg - 1
1552 out.i1 = i_ln
1553 return out
1554 elif i_in_arg >= len(ln_split[out.argument_pos]):
1555 if line[i_ln].isspace():
1556 out.i1 = i_ln
1557 if i_ln >= cursor_pos:
1558 return out
1559 out.in_between = True
1560 i_ln += 1
1561 out.argument_pos += 1
1562 i_in_arg = 0
1563 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX:
1564 i_ln += 1
1565 else:
1566 # unlike bash shlex treats a comment character inside of an argument as a comment character
1567 assert line[i_ln] == '#'
1568 assert out.argument_pos == n_arg - 1
1569 out.i1 = i_ln
1570 return out
1571 elif line[i_ln] == ln_split[out.argument_pos][i_in_arg]:
1572 i_ln += 1
1573 i_in_arg += 1
1574 if out.argument_pos == 0 and i_ln == 1 and self.split_one_symbol_command(line)[0]:
1575 out.in_between = True
1576 out.argument_pos += 1
1577 out.i0 = i_ln
1578 i_in_arg = 0
1579 else:
1580 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX
1581 i_ln += 1
1584 def get_completions_command_name(self, line: str, cursor_pos: int, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
1585 start = line[:cursor_pos]
1586 completions = [cmd for cmd in self.command_dict.keys() if cmd.startswith(start) and len(cmd) > 1]
1587 return start_of_line, completions, end_of_line
1590 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]':
1591 r'''
1592 :param start: The start of the path to be completed
1593 :param relative_to: If :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` is a relative path it's relative to this directory
1594 :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.
1595 :param include: A function which takes the path and file name as arguments and returns whether this file/directory is a valid completion.
1596 :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)``.
1597 :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).
1598 '''
1599 if exclude is None:
1600 if platform.platform() == 'Windows' or os.path.split(start)[1].startswith('.'):
1601 exclude = '$none'
1602 else:
1603 exclude = r'^\.'
1604 reo = re.compile(exclude)
1606 # I cannot use os.path.split because that would ignore the important difference between having a trailing separator or not
1607 if os.path.sep in start:
1608 directory, start = start.rsplit(os.path.sep, 1)
1609 directory += os.path.sep
1610 quoted_directory = self.quote_path(directory)
1612 start_of_line += quoted_directory
1613 directory = os.path.expanduser(directory)
1614 if not os.path.isabs(directory):
1615 directory = os.path.join(relative_to, directory)
1616 directory = os.path.normpath(directory)
1617 else:
1618 directory = relative_to
1620 try:
1621 names = os.listdir(directory)
1622 except (FileNotFoundError, NotADirectoryError):
1623 return start_of_line, [], end_of_line
1625 out: 'list[str]' = []
1626 for name in names:
1627 if reo.match(name):
1628 continue
1629 if include and not include(directory, name):
1630 continue
1631 if not match(directory, name, start):
1632 continue
1634 quoted_name = self.quote(name)
1635 if os.path.isdir(os.path.join(directory, name)):
1636 quoted_name += os.path.sep
1638 out.append(quoted_name)
1640 return start_of_line, out, end_of_line
1642 def quote_path(self, path: str) -> str:
1643 path_split = path.split(os.path.sep)
1644 i0 = 1 if path_split[0] == '~' else 0
1645 for i in range(i0, len(path_split)):
1646 if path_split[i]:
1647 path_split[i] = self.quote(path_split[i])
1648 return os.path.sep.join(path_split)
1651 def get_completions_for_expand(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1652 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)
1653 if applicable:
1654 return applicable, start_of_line, completions, end_of_line
1656 return self.get_completions_for_expand_config(start, start_of_line=start_of_line, end_of_line=end_of_line)
1658 def get_completions_for_expand_config(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1659 if start.count('%') % 2 == 0:
1660 return False, start_of_line, [], end_of_line
1662 i = start.rindex('%') + 1
1663 start_of_line = start_of_line + start[:i]
1664 start = start[i:]
1665 completions = [key for key in sorted(self.config_instances.keys()) if key.startswith(start)]
1666 return True, start_of_line, completions, end_of_line
1668 def get_completions_for_expand_env(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':
1669 i = start.rfind('${')
1670 if i < 0:
1671 return False, start_of_line, [], end_of_line
1672 i += 2
1674 if '}' in start[i:]:
1675 return False, start_of_line, [], end_of_line
1677 start_of_line = start_of_line + start[:i]
1678 start = start[i:]
1679 completions = [key for key in sorted(os.environ.keys()) if key.startswith(start)]
1680 return True, start_of_line, completions, end_of_line
1683 # ------- error handling -------
1685 def parse_error(self, msg: str) -> None:
1686 '''
1687 Is called if something went wrong while trying to load a config file.
1689 This method is called when a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is caught.
1690 This method compiles the given information into an error message and calls :meth:`self.ui_notifier.show_error() <confattr.configfile.UiNotifier.show_error>`.
1692 :param msg: The error message
1693 '''
1694 self.ui_notifier.show_error(msg)
1697# ---------- base classes for commands which can be used in config files ----------
1699class ConfigFileCommand(abc.ABC):
1701 '''
1702 An abstract base class for commands which can be used in a config file.
1704 Subclasses must implement the :meth:`~confattr.configfile.ConfigFileCommand.run` method which is called when :class:`~confattr.configfile.ConfigFile` is loading a file.
1705 Subclasses should contain a doc string so that :meth:`~confattr.configfile.ConfigFileCommand.get_help` can provide a description to the user.
1706 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`.
1708 All subclasses are remembered and can be retrieved with :meth:`~confattr.configfile.ConfigFileCommand.get_command_types`.
1709 They are instantiated in the constructor of :class:`~confattr.configfile.ConfigFile`.
1710 '''
1712 #: 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.
1713 name: str
1715 #: Alternative names which can be used in the config file.
1716 aliases: 'tuple[str, ...]|list[str]'
1718 #: 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.
1719 help: str
1721 #: 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.
1722 should_write_heading: bool = False
1724 #: 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`.
1725 config_file: ConfigFile
1727 #: The :class:`~confattr.configfile.UiNotifier` of :attr:`~confattr.configfile.ConfigFileCommand.config_file`
1728 ui_notifier: UiNotifier
1730 _abstract: bool
1733 _subclasses: 'list[type[ConfigFileCommand]]' = []
1734 _used_names: 'set[str]' = set()
1736 @classmethod
1737 def get_command_types(cls) -> 'tuple[type[ConfigFileCommand], ...]':
1738 '''
1739 :return: All subclasses of :class:`~confattr.configfile.ConfigFileCommand` which have not been deleted with :meth:`~confattr.configfile.ConfigFileCommand.delete_command_type`
1740 '''
1741 return tuple(cls._subclasses)
1743 @classmethod
1744 def delete_command_type(cls, cmd_type: 'type[ConfigFileCommand]') -> None:
1745 '''
1746 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.
1747 Do nothing if :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` has already been deleted.
1748 '''
1749 if cmd_type in cls._subclasses:
1750 cls._subclasses.remove(cmd_type)
1751 for name in cmd_type.get_names():
1752 cls._used_names.remove(name)
1754 @classmethod
1755 def __init_subclass__(cls, replace: bool = False, abstract: bool = False) -> None:
1756 '''
1757 :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
1758 :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`
1759 :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
1760 '''
1761 cls._abstract = abstract
1762 if replace:
1763 parent_commands = [parent for parent in cls.__bases__ if issubclass(parent, ConfigFileCommand)]
1765 # set names of this class to that of the parent class(es)
1766 parent = parent_commands[0]
1767 if 'name' not in cls.__dict__:
1768 cls.name = parent.get_name()
1769 if 'aliases' not in cls.__dict__:
1770 cls.aliases = list(parent.get_names())[1:]
1771 for parent in parent_commands[1:]:
1772 cls.aliases.extend(parent.get_names())
1774 # remove parent class from the list of commands to be loaded or saved
1775 for parent in parent_commands:
1776 cls.delete_command_type(parent)
1778 if not abstract:
1779 cls._subclasses.append(cls)
1780 for name in cls.get_names():
1781 if name in cls._used_names and not replace:
1782 raise ValueError('duplicate command name %r' % name)
1783 cls._used_names.add(name)
1785 @classmethod
1786 def get_name(cls) -> str:
1787 '''
1788 :return: The name which is used in config file to call this command.
1790 If :attr:`~confattr.configfile.ConfigFileCommand.name` is set it is returned as it is.
1791 Otherwise a name is generated based on the class name.
1792 '''
1793 if 'name' in cls.__dict__:
1794 return cls.name
1795 return cls.__name__.lower().replace("_", "-")
1797 @classmethod
1798 def get_names(cls) -> 'Iterator[str]':
1799 '''
1800 :return: Several alternative names which can be used in a config file to call this command.
1802 The first one is always the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_name`.
1803 If :attr:`~confattr.configfile.ConfigFileCommand.aliases` is set it's items are yielded afterwards.
1805 If one of the returned items is the empty string this class is the default command
1806 and :meth:`~confattr.configfile.ConfigFileCommand.run` will be called if an undefined command is encountered.
1807 '''
1808 yield cls.get_name()
1809 if 'aliases' in cls.__dict__:
1810 for name in cls.aliases:
1811 yield name
1813 def __init__(self, config_file: ConfigFile) -> None:
1814 self.config_file = config_file
1815 self.ui_notifier = config_file.ui_notifier
1817 @abc.abstractmethod
1818 def run(self, cmd: 'Sequence[str]') -> None:
1819 '''
1820 Process one line which has been read from a config file
1822 :raises ParseException: if there is an error in the line (e.g. invalid syntax)
1823 :raises MultipleParseExceptions: if there are several errors in the same line
1824 '''
1825 raise NotImplementedError()
1828 def create_formatter(self) -> HelpFormatterWrapper:
1829 return self.config_file.create_formatter()
1831 def get_help_attr_or_doc_str(self) -> str:
1832 '''
1833 :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`.
1834 '''
1835 if hasattr(self, 'help'):
1836 doc = self.help
1837 elif self.__doc__:
1838 doc = self.__doc__
1839 else:
1840 doc = ''
1842 return inspect.cleandoc(doc)
1844 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
1845 '''
1846 Add the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_help_attr_or_doc_str` to :paramref:`~confattr.configfile.ConfigFileCommand.add_help_to.formatter`.
1847 '''
1848 formatter.add_text(self.get_help_attr_or_doc_str())
1850 def get_help(self) -> str:
1851 '''
1852 :return: A help text which can be presented to the user.
1854 This is generated by creating a formatter with :meth:`~confattr.configfile.ConfigFileCommand.create_formatter`,
1855 adding the help to it with :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` and
1856 stripping trailing new line characters from the result of :meth:`HelpFormatterWrapper.format_help() <confattr.utils.HelpFormatterWrapper.format_help>`.
1858 Most likely you don't want to override this method but :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` instead.
1859 '''
1860 formatter = self.create_formatter()
1861 self.add_help_to(formatter)
1862 return formatter.format_help().rstrip('\n')
1864 def get_short_description(self) -> str:
1865 '''
1866 :return: The first paragraph of the doc string/help attribute
1867 '''
1868 out = self.get_help_attr_or_doc_str().split('\n\n')
1869 if out[0].startswith('usage: '):
1870 if len(out) > 1:
1871 return out[1]
1872 return ""
1873 return out[0]
1875 def save(self,
1876 writer: FormattedWriter,
1877 **kw: 'Unpack[SaveKwargs]',
1878 ) -> None:
1879 '''
1880 Implement this method if you want calls to this command to be written by :meth:`ConfigFile.save() <confattr.configfile.ConfigFile.save>`.
1882 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.
1883 If this command writes several sections then write a heading for every section regardless of :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading`.
1885 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>`.
1886 Write comments or help with :meth:`writer.write_lines('...') <confattr.configfile.FormattedWriter.write_lines>`.
1888 There is the :attr:`~confattr.configfile.ConfigFileCommand.config_file` attribute (which was passed to the constructor) which you can use to:
1890 - quote arguments with :meth:`ConfigFile.quote() <confattr.configfile.ConfigFile.quote>`
1891 - call :meth:`ConfigFile.write_config_id() <confattr.configfile.ConfigFile.write_config_id>`
1893 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>`.
1895 The default implementation does nothing.
1896 '''
1897 pass
1899 save.implemented = False # type: ignore [attr-defined]
1902 # ------- auto complete -------
1904 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]':
1905 '''
1906 :param cmd: The line split into arguments (including the name of this command as cmd[0])
1907 :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.
1908 :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.
1909 :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.
1910 :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.
1911 :param end_of_line: The third return value.
1912 :return: start of line, completions, end of line.
1913 *completions* is a list of possible completions for the word where the cursor is located.
1914 If *completions* is an empty list there are no completions available and the user input should not be changed.
1915 This should be displayed by a user interface in a drop down menu.
1916 The *start of line* is everything on the line before the completions.
1917 The *end of line* is everything on the line after the completions.
1918 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.
1919 '''
1920 completions: 'list[str]' = []
1921 return start_of_line, completions, end_of_line
1924class ArgumentParser(argparse.ArgumentParser):
1926 def error(self, message: str) -> 'typing.NoReturn':
1927 '''
1928 Raise a :class:`~confattr.configfile.ParseException`.
1929 '''
1930 raise ParseException(message)
1932class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True):
1934 '''
1935 An abstract subclass of :class:`~confattr.configfile.ConfigFileCommand` which uses :mod:`argparse` to make parsing and providing help easier.
1937 You must implement the class method :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` to add the arguments to :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.
1938 Instead of :meth:`~confattr.configfile.ConfigFileArgparseCommand.run` you must implement :meth:`~confattr.configfile.ConfigFileArgparseCommand.run_parsed`.
1939 You don't need to add a usage or the possible arguments to the doc string as :mod:`argparse` will do that for you.
1940 You should, however, still give a description what this command does in the doc string.
1942 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`.
1943 '''
1945 #: 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`
1946 parser: ArgumentParser
1948 def __init__(self, config_file: ConfigFile) -> None:
1949 super().__init__(config_file)
1950 self._names = set(self.get_names())
1951 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)
1952 self.init_parser(self.parser)
1954 @abc.abstractmethod
1955 def init_parser(self, parser: ArgumentParser) -> None:
1956 '''
1957 :param parser: The parser to add arguments to. This is the same object like :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.
1959 This is an abstract method which must be implemented by subclasses.
1960 Use :meth:`ArgumentParser.add_argument() <confattr.configfile.ArgumentParser.add_argument>` to add arguments to :paramref:`~confattr.configfile.ConfigFileArgparseCommand.init_parser.parser`.
1961 '''
1962 pass
1964 @staticmethod
1965 def add_enum_argument(parser: 'argparse.ArgumentParser|argparse._MutuallyExclusiveGroup', *name_or_flags: str, type: 'type[enum.Enum]') -> 'argparse.Action':
1966 '''
1967 This method:
1969 - generates a function to convert the user input to an element of the enum
1970 - gives the function the name of the enum in lower case (argparse uses this in error messages)
1971 - generates a help string containing the allowed values
1973 and adds an argument to the given argparse parser with that.
1974 '''
1975 def parse(name: str) -> enum.Enum:
1976 for v in type:
1977 if v.name.lower() == name:
1978 return v
1979 raise TypeError()
1980 parse.__name__ = type.__name__.lower()
1981 choices = ', '.join(v.name.lower() for v in type)
1982 return parser.add_argument(*name_or_flags, type=parse, help="one of " + choices)
1984 def get_help(self) -> str:
1985 '''
1986 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`.
1987 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.
1988 '''
1989 return self.parser.format_help().rstrip('\n')
1991 def run(self, cmd: 'Sequence[str]') -> None:
1992 # if the line was empty this method should not be called but an empty line should be ignored either way
1993 if not cmd:
1994 return # pragma: no cover
1995 # cmd[0] does not need to be in self._names if this is the default command, i.e. if '' in self._names
1996 if cmd[0] in self._names:
1997 cmd = cmd[1:]
1998 args = self.parser.parse_args(cmd)
1999 self.run_parsed(args)
2001 @abc.abstractmethod
2002 def run_parsed(self, args: argparse.Namespace) -> None:
2003 '''
2004 This is an abstract method which must be implemented by subclasses.
2005 '''
2006 pass
2008 # ------- auto complete -------
2010 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]':
2011 if in_between:
2012 start = ''
2013 else:
2014 start = cmd[argument_pos][:cursor_pos]
2016 if self.after_positional_argument_marker(cmd, argument_pos):
2017 pos = self.get_position(cmd, argument_pos)
2018 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2020 if argument_pos > 0: # pragma: no branch # if argument_pos was 0 this method would not be called, command names would be completed instead
2021 prevarg = self.get_option_name_if_it_takes_an_argument(cmd, argument_pos-1)
2022 if prevarg:
2023 return self.get_completions_for_option_argument(prevarg, start, start_of_line=start_of_line, end_of_line=end_of_line)
2025 if self.is_option_start(start):
2026 if '=' in start:
2027 i = start.index('=')
2028 option_name = start[:i]
2029 i += 1
2030 start_of_line += start[:i]
2031 start = start[i:]
2032 return self.get_completions_for_option_argument(option_name, start, start_of_line=start_of_line, end_of_line=end_of_line)
2033 return self.get_completions_for_option_name(start, start_of_line=start_of_line, end_of_line=end_of_line)
2035 pos = self.get_position(cmd, argument_pos)
2036 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2038 def get_position(self, cmd: 'Sequence[str]', argument_pos: int) -> int:
2039 '''
2040 :return: the position of a positional argument, not counting options and their arguments
2041 '''
2042 pos = 0
2043 n = len(cmd)
2044 options_allowed = True
2045 # I am starting at 1 because cmd[0] is the name of the command, not an argument
2046 for i in range(1, argument_pos):
2047 if options_allowed and i < n:
2048 if cmd[i] == '--':
2049 options_allowed = False
2050 continue
2051 elif self.is_option_start(cmd[i]):
2052 continue
2053 # > 1 because cmd[0] is the name of the command
2054 elif i > 1 and self.get_option_name_if_it_takes_an_argument(cmd, i-1):
2055 continue
2056 pos += 1
2058 return pos
2060 def is_option_start(self, start: str) -> bool:
2061 return start.startswith('-') or start.startswith('+')
2063 def after_positional_argument_marker(self, cmd: 'Sequence[str]', argument_pos: int) -> bool:
2064 '''
2065 :return: true if this can only be a positional argument. False means it can be both, option or positional argument.
2066 '''
2067 return '--' in cmd and cmd.index('--') < argument_pos
2069 def get_option_name_if_it_takes_an_argument(self, cmd: 'Sequence[str]', argument_pos: int) -> 'str|None':
2070 if argument_pos >= len(cmd):
2071 return None # pragma: no cover # this does not happen because this method is always called for the previous argument
2073 arg = cmd[argument_pos]
2074 if '=' in arg:
2075 # argument of option is already given within arg
2076 return None
2077 if not self.is_option_start(arg):
2078 return None
2079 if arg.startswith('--'):
2080 action = self.get_action_for_option(arg)
2081 if action is None:
2082 return None
2083 if action.nargs != 0:
2084 return arg
2085 return None
2087 # arg is a combination of single character flags like in `tar -xzf file`
2088 for c in arg[1:-1]:
2089 action = self.get_action_for_option('-' + c)
2090 if action is None:
2091 continue
2092 if action.nargs != 0:
2093 # c takes an argument but that is already given within arg
2094 return None
2096 out = '-' + arg[-1]
2097 action = self.get_action_for_option(out)
2098 if action is None:
2099 return None
2100 if action.nargs != 0:
2101 return out
2102 return None
2105 def get_completions_for_option_name(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2106 completions = []
2107 for a in self.parser._get_optional_actions():
2108 for opt in a.option_strings:
2109 if len(opt) <= 2:
2110 # this is trivial to type but not self explanatory
2111 # => not helpful for auto completion
2112 continue
2113 if opt.startswith(start):
2114 completions.append(opt)
2115 return start_of_line, completions, end_of_line
2117 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]':
2118 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)
2120 def get_completions_for_positional_argument(self, position: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2121 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)
2124 def get_action_for_option(self, option_name: str) -> 'argparse.Action|None':
2125 for a in self.parser._get_optional_actions():
2126 if option_name in a.option_strings:
2127 return a
2128 return None
2130 def get_action_for_positional_argument(self, argument_pos: int) -> 'argparse.Action|None':
2131 actions = self.parser._get_positional_actions()
2132 if argument_pos < len(actions):
2133 return actions[argument_pos]
2134 return None
2136 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]':
2137 if action is None:
2138 completions: 'list[str]' = []
2139 elif not action.choices:
2140 completions = []
2141 else:
2142 completions = [str(val) for val in action.choices]
2143 completions = [val for val in completions if val.startswith(start)]
2144 completions = [self.config_file.quote(val) for val in completions]
2145 return start_of_line, completions, end_of_line
2148# ---------- implementations of commands which can be used in config files ----------
2150class Set(ConfigFileCommand):
2152 r'''
2153 usage: set [--raw] key1=val1 [key2=val2 ...] \\
2154 set [--raw] key [=] val
2156 Change the value of a setting.
2158 In the first form set takes an arbitrary number of arguments, each argument sets one setting.
2159 This has the advantage that several settings can be changed at once.
2160 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.
2162 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.
2163 This has the advantage that key and value are separated by one or more spaces which can improve the readability of a config file.
2165 You can use the value of another setting with %other.key% or an environment variable with ${ENV_VAR}.
2166 If you want to insert a literal percent character use two of them: %%.
2167 You can disable expansion of settings and environment variables with the --raw flag.
2168 '''
2170 #: The separator which is used between a key and it's value
2171 KEY_VAL_SEP = '='
2173 FLAGS_RAW = ('-r', '--raw')
2175 raw = False
2177 # ------- load -------
2179 def run(self, cmd: 'Sequence[str]') -> None:
2180 '''
2181 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`.
2183 :raises ParseException: if something is wrong (no arguments given, invalid syntax, invalid key, invalid value)
2184 '''
2185 if self.is_vim_style(cmd):
2186 self.set_multiple(cmd)
2187 else:
2188 self.set_with_spaces(cmd)
2190 def is_vim_style(self, cmd: 'Sequence[str]') -> bool:
2191 '''
2192 :paramref:`~confattr.configfile.Set.is_vim_style.cmd` has one of two possible styles:
2193 - vim inspired: set takes an arbitrary number of arguments, each argument sets one setting. Is handled by :meth:`~confattr.configfile.Set.set_multiple`.
2194 - 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`.
2196 :return: true if cmd has a vim inspired style, false if cmd has a ranger inspired style
2197 '''
2198 try:
2199 # cmd[0] is the name of the command, cmd[1] is the first argument
2200 if cmd[1] in self.FLAGS_RAW:
2201 i = 2
2202 else:
2203 i = 1
2204 return self.KEY_VAL_SEP in cmd[i]
2205 except IndexError:
2206 raise ParseException('no settings given')
2208 def set_with_spaces(self, cmd: 'Sequence[str]') -> None:
2209 '''
2210 Process one line of the format ``set key [=] value``
2212 :raises ParseException: if something is wrong (invalid syntax, invalid key, invalid value)
2213 '''
2214 if cmd[1] in self.FLAGS_RAW:
2215 cmd = cmd[2:]
2216 self.raw = True
2217 else:
2218 cmd = cmd[1:]
2219 self.raw = False
2221 n = len(cmd)
2222 if n == 2:
2223 key, value = cmd
2224 self.parse_key_and_set_value(key, value)
2225 elif n == 3:
2226 key, sep, value = cmd
2227 if sep != self.KEY_VAL_SEP:
2228 raise ParseException(f'separator between key and value should be {self.KEY_VAL_SEP}, not {sep!r}')
2229 self.parse_key_and_set_value(key, value)
2230 elif n == 1:
2231 raise ParseException(f'missing value or missing {self.KEY_VAL_SEP}')
2232 else:
2233 assert n >= 4
2234 raise ParseException(f'too many arguments given or missing {self.KEY_VAL_SEP} in first argument')
2236 def set_multiple(self, cmd: 'Sequence[str]') -> None:
2237 '''
2238 Process one line of the format ``set key=value [key2=value2 ...]``
2240 :raises MultipleParseExceptions: if something is wrong (invalid syntax, invalid key, invalid value)
2241 '''
2242 self.raw = False
2243 exceptions = []
2244 for arg in cmd[1:]:
2245 if arg in self.FLAGS_RAW:
2246 self.raw = True
2247 continue
2248 try:
2249 if not self.KEY_VAL_SEP in arg:
2250 raise ParseException(f'missing {self.KEY_VAL_SEP} in {arg!r}')
2251 key, value = arg.split(self.KEY_VAL_SEP, 1)
2252 self.parse_key_and_set_value(key, value)
2253 except ParseException as e:
2254 exceptions.append(e)
2255 if exceptions:
2256 raise MultipleParseExceptions(exceptions)
2258 def parse_key_and_set_value(self, key: str, value: str) -> None:
2259 '''
2260 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>`.
2262 :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`
2263 '''
2264 if key not in self.config_file.config_instances:
2265 raise ParseException(f'invalid key {key!r}')
2267 instance = self.config_file.config_instances[key]
2268 try:
2269 self.set_value(instance, self.config_file.parse_value(instance, value, raw=self.raw))
2270 except ValueError as e:
2271 raise ParseException(str(e))
2273 def set_value(self, instance: 'Config[T2]', value: 'T2') -> None:
2274 '''
2275 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`.
2276 Afterwards call :meth:`UiNotifier.show_info() <confattr.configfile.UiNotifier.show_info>`.
2277 '''
2278 instance.set_value(self.config_file.config_id, value)
2279 self.ui_notifier.show_info(f'set {instance.key} to {self.config_file.format_value(instance, self.config_file.config_id)}')
2282 # ------- save -------
2284 def iter_config_instances_to_be_saved(self,
2285 config_instances: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]',
2286 ignore: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]|None' = None,
2287 *,
2288 sort: 'bool|None' = None,
2289 ) -> 'Iterator[Config[object]]':
2290 '''
2291 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.
2292 '''
2293 for config in self.config_file.iter_config_instances(config_instances, ignore, sort=sort):
2294 if config.wants_to_be_exported():
2295 yield config
2297 #: 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`.
2298 last_name: 'str|None'
2300 def save(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:
2301 '''
2302 :param writer: The file to write to
2303 :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>`.
2304 :param bool comments: If false: don't write help for data types
2306 Iterate over all :class:`~confattr.config.Config` instances with :meth:`~confattr.configfile.Set.iter_config_instances_to_be_saved`,
2307 split them into normal :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig` and write them with :meth:`~confattr.configfile.Set.save_config_instance`.
2308 But before that set :attr:`~confattr.configfile.Set.last_name` to None (which is used by :meth:`~confattr.configfile.Set.write_config_help`)
2309 and write help for data types based on :meth:`~confattr.configfile.Set.get_help_for_data_types`.
2310 '''
2311 no_multi = kw['no_multi']
2312 comments = kw['comments']
2314 config_instances = list(self.iter_config_instances_to_be_saved(config_instances=kw['config_instances'], ignore=kw['ignore']))
2315 normal_configs = []
2316 multi_configs = []
2317 if no_multi:
2318 normal_configs = config_instances
2319 else:
2320 for instance in config_instances:
2321 if isinstance(instance, MultiConfig):
2322 multi_configs.append(instance)
2323 else:
2324 normal_configs.append(instance)
2326 self.last_name: 'str|None' = None
2328 if normal_configs:
2329 if multi_configs:
2330 writer.write_heading(SectionLevel.SECTION, 'Application wide settings')
2331 elif self.should_write_heading:
2332 writer.write_heading(SectionLevel.SECTION, 'Settings')
2334 if comments:
2335 type_help = self.get_help_for_data_types(normal_configs)
2336 if type_help:
2337 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
2338 writer.write_lines(type_help)
2340 for instance in normal_configs:
2341 self.save_config_instance(writer, instance, config_id=None, **kw)
2343 if multi_configs:
2344 if normal_configs:
2345 writer.write_heading(SectionLevel.SECTION, 'Settings which can have different values for different objects')
2346 elif self.should_write_heading:
2347 writer.write_heading(SectionLevel.SECTION, 'Settings')
2349 if comments:
2350 type_help = self.get_help_for_data_types(multi_configs)
2351 if type_help:
2352 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')
2353 writer.write_lines(type_help)
2355 for instance in multi_configs:
2356 self.save_config_instance(writer, instance, config_id=instance.default_config_id, **kw)
2358 for config_id in MultiConfig.config_ids:
2359 writer.write_line('')
2360 self.config_file.write_config_id(writer, config_id)
2361 for instance in multi_configs:
2362 self.save_config_instance(writer, instance, config_id, **kw)
2364 def save_config_instance(self, writer: FormattedWriter, instance: 'Config[object]', config_id: 'ConfigId|None', **kw: 'Unpack[SaveKwargs]') -> None:
2365 '''
2366 :param writer: The file to write to
2367 :param instance: The config value to be saved
2368 :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
2369 :param bool comments: If true: call :meth:`~confattr.configfile.Set.write_config_help`
2371 Convert the :class:`~confattr.config.Config` instance into a value str with :meth:`config_file.format_value() <confattr.configfile.ConfigFile.format_value>`,
2372 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`.
2373 '''
2374 if kw['comments']:
2375 self.write_config_help(writer, instance)
2376 if instance.is_value_valid():
2377 is_valid = True
2378 value = self.config_file.format_value(instance, config_id)
2379 value = self.config_file.quote(value)
2380 else:
2381 is_valid = False
2382 value = ""
2383 if '%' in value or '${' in value:
2384 raw = ' --raw'
2385 else:
2386 raw = ''
2387 ln = f'{self.get_name()}{raw} {instance.key} = {value}'
2388 if is_valid:
2389 writer.write_command(ln)
2390 else:
2391 writer.write_line(ln)
2393 def write_config_help(self, writer: FormattedWriter, instance: Config[typing.Any], *, group_dict_configs: bool = True) -> None:
2394 '''
2395 :param writer: The output to write to
2396 :param instance: The config value to be saved
2398 Write a comment which explains the meaning and usage of this setting
2399 based on :meth:`instance.type.get_description() <confattr.formatters.AbstractFormatter.get_description>` and :attr:`Config.help <confattr.config.Config.help>`.
2401 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.
2402 '''
2403 if group_dict_configs and instance.parent is not None:
2404 name = instance.parent.key_changer(instance.parent.key_prefix)
2405 else:
2406 name = instance.key
2407 if name == self.last_name:
2408 return
2410 formatter = HelpFormatterWrapper(self.config_file.formatter_class)
2411 writer.write_heading(SectionLevel.SUB_SECTION, name)
2412 writer.write_lines(formatter.format_text(instance.type.get_description(self.config_file)).rstrip())
2413 #if instance.unit:
2414 # writer.write_line('unit: %s' % instance.unit)
2415 if isinstance(instance.help, dict):
2416 for key, val in instance.help.items():
2417 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)
2418 val = inspect.cleandoc(val)
2419 writer.write_lines(formatter.format_item(bullet=key_name+': ', text=val).rstrip())
2420 elif isinstance(instance.help, str):
2421 writer.write_lines(formatter.format_text(inspect.cleandoc(instance.help)).rstrip())
2423 self.last_name = name
2426 def get_data_type_name_to_help_map(self, config_instances: 'Iterable[Config[object]]') -> 'dict[str, str]':
2427 '''
2428 :param config_instances: All config values to be saved
2429 :return: A dictionary containing the type names as keys and the help as values
2431 The returned dictionary contains the help for all data types except enumerations
2432 which occur in :paramref:`~confattr.configfile.Set.get_data_type_name_to_help_map.config_instances`.
2433 The help is gathered from the :attr:`~confattr.configfile.Set.help` attribute of the type
2434 or :meth:`Primitive.get_help() <confattr.formatters.Primitive.get_help>`.
2435 The help is cleaned up with :func:`inspect.cleandoc`.
2436 '''
2437 help_text: 'dict[str, str]' = {}
2438 for instance in config_instances:
2439 for t in instance.type.get_primitives():
2440 name = t.get_type_name()
2441 if name in help_text:
2442 continue
2444 h = t.get_help(self.config_file)
2445 if not h:
2446 continue
2447 help_text[name] = inspect.cleandoc(h)
2449 return help_text
2451 def add_help_for_data_types(self, formatter: HelpFormatterWrapper, config_instances: 'Iterable[Config[object]]') -> None:
2452 help_map = self.get_data_type_name_to_help_map(config_instances)
2453 if not help_map:
2454 return
2456 for name in sorted(help_map.keys()):
2457 formatter.add_start_section(name)
2458 formatter.add_text(help_map[name])
2459 formatter.add_end_section()
2461 def get_help_for_data_types(self, config_instances: 'Iterable[Config[object]]') -> str:
2462 formatter = self.create_formatter()
2463 self.add_help_for_data_types(formatter, config_instances)
2464 return formatter.format_help().rstrip('\n')
2466 # ------- help -------
2468 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:
2469 super().add_help_to(formatter)
2471 config_instances = list(self.iter_config_instances_to_be_saved(config_instances=self.config_file.config_instances.values()))
2472 self.last_name = None
2474 formatter.add_start_section('data types')
2475 self.add_help_for_data_types(formatter, config_instances)
2476 formatter.add_end_section()
2478 if self.config_file.enable_config_ids:
2479 normal_configs = []
2480 multi_configs = []
2481 for instance in config_instances:
2482 if isinstance(instance, MultiConfig):
2483 multi_configs.append(instance)
2484 else:
2485 normal_configs.append(instance)
2486 else:
2487 normal_configs = config_instances
2488 multi_configs = []
2490 if normal_configs:
2491 if self.config_file.enable_config_ids:
2492 formatter.add_start_section('application wide settings')
2493 else:
2494 formatter.add_start_section('settings')
2495 for instance in normal_configs:
2496 self.add_config_help(formatter, instance)
2497 formatter.add_end_section()
2499 if multi_configs:
2500 formatter.add_start_section('settings which can have different values for different objects')
2501 formatter.add_text(inspect.cleandoc(self.config_file.get_help_config_id()))
2502 for instance in multi_configs:
2503 self.add_config_help(formatter, instance)
2504 formatter.add_end_section()
2506 def add_config_help(self, formatter: HelpFormatterWrapper, instance: Config[typing.Any]) -> None:
2507 formatter.add_start_section(instance.key)
2508 formatter.add_text(instance.type.get_description(self.config_file))
2509 if isinstance(instance.help, dict):
2510 for key, val in instance.help.items():
2511 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)
2512 val = inspect.cleandoc(val)
2513 formatter.add_item(bullet=key_name+': ', text=val)
2514 elif isinstance(instance.help, str):
2515 formatter.add_text(inspect.cleandoc(instance.help))
2516 formatter.add_end_section()
2518 # ------- auto complete -------
2520 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]':
2521 if argument_pos >= len(cmd):
2522 start = ''
2523 else:
2524 start = cmd[argument_pos][:cursor_pos]
2526 if len(cmd) <= 1:
2527 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2528 elif self.is_vim_style(cmd):
2529 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2530 else:
2531 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)
2533 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]':
2534 if self.KEY_VAL_SEP in start:
2535 key, start = start.split(self.KEY_VAL_SEP, 1)
2536 start_of_line += key + self.KEY_VAL_SEP
2537 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line)
2538 else:
2539 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2541 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]':
2542 if argument_pos == 1:
2543 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)
2544 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP):
2545 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line)
2546 else:
2547 return start_of_line, [], end_of_line
2549 def get_completions_for_key(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2550 completions = [key for key in self.config_file.config_instances.keys() if key.startswith(start)]
2551 return start_of_line, completions, end_of_line
2553 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':
2554 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)
2555 if applicable:
2556 return start_of_line, completions, end_of_line
2558 instance = self.config_file.config_instances.get(key)
2559 if instance is None:
2560 return start_of_line, [], end_of_line
2562 return instance.type.get_completions(self.config_file, start_of_line, start, end_of_line)
2565class Include(ConfigFileArgparseCommand):
2567 '''
2568 Load another config file.
2570 This is useful if a config file is getting so big that you want to split it up
2571 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy
2572 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line().
2573 '''
2575 help_config_id = '''
2576 By default the loaded config file starts with which ever config id is currently active.
2577 This is useful if you want to use the same values for several config ids:
2578 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.
2580 After the include the config id is reset to the config id which was active at the beginning of the include
2581 because otherwise it might lead to confusion if the config id is changed in the included config file.
2582 '''
2584 home: 'Config[PathType]|str|None' = None
2586 def get_home(self) -> str:
2587 if not self.home:
2588 home = ""
2589 elif isinstance(self.home, str):
2590 home = self.home
2591 else:
2592 home = self.home.expand()
2593 if home:
2594 return home
2596 fn = self.config_file.context_file_name
2597 if fn is None:
2598 fn = self.config_file.get_save_path()
2599 return os.path.dirname(fn)
2602 def init_parser(self, parser: ArgumentParser) -> None:
2603 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.')
2604 if self.config_file.enable_config_ids:
2605 assert parser.description is not None
2606 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id)
2607 group = parser.add_mutually_exclusive_group()
2608 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include')
2609 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')
2611 self.nested_includes: 'list[str]' = []
2613 def run_parsed(self, args: argparse.Namespace) -> None:
2614 fn_imp = args.path
2615 fn_imp = fn_imp.replace('/', os.path.sep)
2616 fn_imp = os.path.expanduser(fn_imp)
2617 if not os.path.isabs(fn_imp):
2618 fn_imp = os.path.join(self.get_home(), fn_imp)
2620 if fn_imp in self.nested_includes:
2621 raise ParseException(f'circular include of file {fn_imp!r}')
2622 if not os.path.isfile(fn_imp):
2623 raise ParseException(f'no such file {fn_imp!r}')
2625 self.nested_includes.append(fn_imp)
2627 if self.config_file.enable_config_ids and args.no_reset_config_id_after:
2628 self.config_file.load_without_resetting_config_id(fn_imp)
2629 elif self.config_file.enable_config_ids and args.reset_config_id_before:
2630 config_id = self.config_file.config_id
2631 self.config_file.load_file(fn_imp)
2632 self.config_file.config_id = config_id
2633 else:
2634 config_id = self.config_file.config_id
2635 self.config_file.load_without_resetting_config_id(fn_imp)
2636 self.config_file.config_id = config_id
2638 assert self.nested_includes[-1] == fn_imp
2639 del self.nested_includes[-1]
2641 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]':
2642 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action
2643 if action is not None and action.dest == 'path':
2644 return self.config_file.get_completions_for_file_name(start, relative_to=self.get_home(), start_of_line=start_of_line, end_of_line=end_of_line)
2645 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)
2648class Echo(ConfigFileArgparseCommand):
2650 '''
2651 Display a message.
2653 Settings and environment variables are expanded like in the value of a set command.
2654 '''
2656 def init_parser(self, parser: ArgumentParser) -> None:
2657 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.")
2658 parser.add_argument('-r', '--raw', action='store_true', help="Do not expand settings and environment variables.")
2659 parser.add_argument('msg', nargs=argparse.ONE_OR_MORE, help="The message to display")
2661 def run_parsed(self, args: argparse.Namespace) -> None:
2662 msg = ' '.join(self.config_file.expand(m) for m in args.msg)
2663 self.ui_notifier.show(args.level, msg, ignore_filter=True)
2666 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]':
2667 if argument_pos >= len(cmd):
2668 start = ''
2669 else:
2670 start = cmd[argument_pos][:cursor_pos]
2672 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)
2673 return start_of_line, completions, end_of_line
2675class Help(ConfigFileArgparseCommand):
2677 '''
2678 Display help.
2679 '''
2681 max_width = 80
2682 max_width_name = 18
2683 min_width_sep = 2
2684 tab_size = 4
2686 def init_parser(self, parser: ArgumentParser) -> None:
2687 parser.add_argument('cmd', nargs='?', help="The command for which you want help")
2689 def run_parsed(self, args: argparse.Namespace) -> None:
2690 if args.cmd:
2691 if args.cmd not in self.config_file.command_dict:
2692 raise ParseException(f"unknown command {args.cmd!r}")
2693 cmd = self.config_file.command_dict[args.cmd]
2694 out = cmd.get_help()
2695 else:
2696 out = "The following commands are defined:\n"
2697 table = []
2698 for cmd in self.config_file.commands:
2699 name = "- %s" % "/".join(cmd.get_names())
2700 descr = cmd.get_short_description()
2701 row = (name, descr)
2702 table.append(row)
2703 out += self.format_table(table)
2705 out += "\n"
2706 out += "\nUse `help <cmd>` to get more information about a command."
2708 self.ui_notifier.show(NotificationLevel.INFO, out, ignore_filter=True, no_context=True)
2710 def format_table(self, table: 'Sequence[tuple[str, str]]') -> str:
2711 max_name_width = max(len(row[0]) for row in table)
2712 col_width_name = min(max_name_width, self.max_width_name)
2713 out: 'list[str]' = []
2714 subsequent_indent = ' ' * (col_width_name + self.min_width_sep)
2715 for name, descr in table:
2716 if not descr:
2717 out.append(name)
2718 continue
2719 if len(name) > col_width_name:
2720 out.append(name)
2721 initial_indent = subsequent_indent
2722 else:
2723 initial_indent = name.ljust(col_width_name + self.min_width_sep)
2724 out.extend(textwrap.wrap(descr, self.max_width,
2725 initial_indent = initial_indent,
2726 subsequent_indent = subsequent_indent,
2727 break_long_words = False,
2728 tabsize = self.tab_size,
2729 ))
2730 return '\n'.join(out)
2732 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]':
2733 if action and action.dest == 'cmd':
2734 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)
2735 return start_of_line, completions, end_of_line
2737 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)
2740class UnknownCommand(ConfigFileCommand, abstract=True):
2742 name = DEFAULT_COMMAND
2744 def run(self, cmd: 'Sequence[str]') -> None:
2745 raise ParseException('unknown command %r' % cmd[0])