Coverage for .tox/cov/lib/python3.11/site-packages/confattr/configfile.py: 100%

1#!./runmodule.sh

2

3'''

4 This module defines the ConfigFile class

5 which can be used to load and save config files.

6'''

7

8 import os

9 import shlex

10 import platform

11 import re

12 import enum

13 import argparse

14 import textwrap

15 import functools

16 import inspect

17 import io

18 import warnings

19 import abc

20 import typing

21 from collections.abc import Iterable, Iterator, Sequence, Callable

22

23 import appdirs

24

25 from .config import Config, DictConfig, MultiConfig, ConfigId

26 from .formatters import AbstractFormatter

27 from .utils import HelpFormatter, HelpFormatterWrapper, SortedEnum, readable_quote

28 from . import state

29

30 if typing.TYPE_CHECKING:

31 from typing_extensions import Unpack

32

33# T is already used in config.py and I cannot use the same name because both are imported with *

34 T2 = typing.TypeVar('T2')

35

36

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.

38 DEFAULT_COMMAND = ''

39

40

41

42# ---------- UI notifier ----------

43

44@functools.total_ordering

45 class NotificationLevel:

46

47 '''

48 Instances of this class indicate how important a message is.

49

50 I am not using an enum anymore in order to allow users to add custom levels.

51 Like an enum, however, ``NotificationLevel('error')`` returns the existing instance instead of creating a new one.

52 In order to create a new instance use :meth:`~confattr.configfile.NotificationLevel.new`.

53 '''

54

55 INFO: 'NotificationLevel'

56 ERROR: 'NotificationLevel'

57

58 _instances: 'list[NotificationLevel]' = []

59

60 def __new__(cls, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel':

61 '''

62 :return: An existing instance (see :meth:`~confattr.configfile.NotificationLevel.get`) or a new instance if :paramref:`~confattr.configfile.NotificationLevel.new` is true (see :meth:`~confattr.configfile.NotificationLevel.new`)

63 :param value: The name of the notification level

64 :param new: If false: return an existing instance with :meth:`~confattr.configfile.NotificationLevel.get`. If true: create a new instance.

65 :param more_important_than: If :paramref:`~confattr.configfile.NotificationLevel.new` is true either this or :paramref:`~confattr.configfile.NotificationLevel.less_important_than` must be given.

66 :param less_important_than: If :paramref:`~confattr.configfile.NotificationLevel.new` is true either this or :paramref:`~confattr.configfile.NotificationLevel.more_important_than` must be given.

67 '''

68 if new:

69 if more_important_than and less_important_than:

70 raise TypeError("more_important_than and less_important_than are mutually exclusive, you can only pass one of them")

71 elif cls._instances and not (more_important_than or less_important_than):

72 raise TypeError(f"you must specify how important {value!r} is by passing either more_important_than or less_important_than")

73

74 try:

75 out = cls.get(value)

76 except ValueError:

77 pass

78 else:

79 if more_important_than and out < more_important_than:

80 raise ValueError(f"{out} is already defined and it's less important than {more_important_than}")

81 elif less_important_than and out > less_important_than:

82 raise ValueError(f"{out} is already defined and it's more important than {less_important_than}")

83 warnings.warn(f"{out!r} is already defined, ignoring", stacklevel=3)

84 return out

85

86 return super().__new__(cls)

87

88 if more_important_than:

89 raise TypeError('more_important_than must not be passed when new = False')

90 if less_important_than:

91 raise TypeError('less_important_than must not be passed when new = False')

92

93 return cls.get(value)

94

95 def __init__(self, value: str, *, new: bool = False, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> None:

96 if hasattr(self, '_initialized'):

97 # __init__ is called every time, even if __new__ has returned an old object

98 return

99

100 assert new

101 self._initialized = True

102 self.value = value

103

104 if more_important_than:

105 i = self._instances.index(more_important_than) + 1

106 elif less_important_than:

107 i = self._instances.index(less_important_than)

108 elif not self._instances:

109 i = 0

110 else:

111 assert False

112

113 self._instances.insert(i, self)

114

115 @classmethod

116 def new(cls, value: str, *, more_important_than: 'NotificationLevel|None' = None, less_important_than: 'NotificationLevel|None' = None) -> 'NotificationLevel':

117 '''

118 :param value: A name for the new notification level

119 :param more_important_than: Specify the importance of the new notification level. Either this or :paramref:`~confattr.configfile.NotificationLevel.new.less_important_than` must be given but not both.

120 :param less_important_than: Specify the importance of the new notification level. Either this or :paramref:`~confattr.configfile.NotificationLevel.new.more_important_than` must be given but not both.

121 '''

122 return cls(value, more_important_than=more_important_than, less_important_than=less_important_than, new=True)

123

124 @classmethod

125 def get(cls, value: str) -> 'NotificationLevel':

126 '''

127 :return: The instance of this class for the given value

128 :raises ValueError: If there is no instance for the given value

129 '''

130 for lvl in cls._instances:

131 if lvl.value == value:

132 return lvl

133

134 raise ValueError('')

135

136 @classmethod

137 def get_instances(cls) -> 'Sequence[NotificationLevel]':

138 '''

139 :return: A sequence of all instances of this class

140 '''

141 return cls._instances

142

143 def __lt__(self, other: typing.Any) -> bool:

144 if self.__class__ is other.__class__:

145 return self._instances.index(self) < self._instances.index(other)

146 return NotImplemented

147

148 def __str__(self) -> str:

149 return self.value

150

151 def __repr__(self) -> str:

152 return "%s(%r)" % (type(self).__name__, self.value)

153

154

155 NotificationLevel.INFO = NotificationLevel.new('info')

156 NotificationLevel.ERROR = NotificationLevel.new('error', more_important_than=NotificationLevel.INFO)

157

158

159 UiCallback: 'typing.TypeAlias' = 'Callable[[Message], None]'

160

161 class Message:

162

163 '''

164 A message which should be displayed to the user.

165 This is passed to the callback of the user interface which has been registered with :meth:`ConfigFile.set_ui_callback() <confattr.configfile.ConfigFile.set_ui_callback>`.

166

167 If you want full control how to display messages to the user you can access the attributes directly.

168 Otherwise you can simply convert this object to a str, e.g. with ``str(msg)``.

169 I recommend to use different colors for different values of :attr:`~confattr.configfile.Message.notification_level`.

170 '''

171

172 #: The value of :attr:`~confattr.configfile.Message.file_name` while loading environment variables.

173 ENVIRONMENT_VARIABLES = 'environment variables'

174

175

176 __slots__ = ('notification_level', 'message', 'file_name', 'line_number', 'line', 'no_context')

177

178 #: The importance of this message. I recommend to display messages of different importance levels in different colors.

179 #: :class:`~confattr.configfile.ConfigFile` does not output messages which are less important than the :paramref:`~confattr.configfile.ConfigFile.notification_level` setting which has been passed to it's constructor.

180 notification_level: NotificationLevel

181

182 #: The string or exception which should be displayed to the user

183 message: 'str|BaseException'

184

185 #: The name of the config file which has caused this message.

186 #: If this equals :const:`~confattr.configfile.Message.ENVIRONMENT_VARIABLES` it is not a file but the message has occurred while reading the environment variables.

187 #: This is None if :meth:`ConfigFile.parse_line() <confattr.configfile.ConfigFile.parse_line>` is called directly, e.g. when parsing the input from a command line.

188 file_name: 'str|None'

189

190 #: The number of the line in the config file. This is None if :attr:`~confattr.configfile.Message.file_name` is not a file name.

191 line_number: 'int|None'

192

193 #: The line where the message occurred. This is an empty str if there is no line, e.g. when loading environment variables.

194 line: str

195

196 #: If true: don't show line and line number.

197 no_context: bool

198

199

200 _last_file_name: 'str|None' = None

201

202 @classmethod

203 def reset(cls) -> None:

204 '''

205 If you are using :meth:`~confattr.configfile.Message.format_file_name_msg_line` or :meth:`~confattr.configfile.Message.__str__`

206 you must call this method when the widget showing the error messages is cleared.

207 '''

208 cls._last_file_name = None

209

210 def __init__(self, notification_level: NotificationLevel, message: 'str|BaseException', file_name: 'str|None' = None, line_number: 'int|None' = None, line: 'str' = '', no_context: bool = False) -> None:

211 self.notification_level = notification_level

212 self.message = message

213 self.file_name = file_name

214 self.line_number = line_number

215 self.line = line

216 self.no_context = no_context

217

218 @property

219 def lvl(self) -> NotificationLevel:

220 '''

221 An abbreviation for :attr:`~confattr.configfile.Message.notification_level`

222 '''

223 return self.notification_level

224

225 def format_msg_line(self) -> str:

226 '''

227 The return value includes the attributes :attr:`~confattr.configfile.Message.message`, :attr:`~confattr.configfile.Message.line_number` and :attr:`~confattr.configfile.Message.line` if they are set.

228 '''

229 msg = str(self.message)

230 if self.line and not self.no_context:

231 if self.line_number is not None:

232 lnref = 'line %s' % self.line_number

233 else:

234 lnref = 'line'

235 return f'{msg} in {lnref} {self.line!r}'

236

237 return msg

238

239 def format_file_name(self) -> str:

240 '''

241 :return: A header including the :attr:`~confattr.configfile.Message.file_name` if the :attr:`~confattr.configfile.Message.file_name` is different from the last time this function has been called or an empty string otherwise

242 '''

243 file_name = '' if self.file_name is None else self.file_name

244 if file_name == self._last_file_name:

245 return ''

246

247 if file_name:

248 out = f'While loading {file_name}:\n'

249 else:

250 out = ''

251

252 if self._last_file_name is not None:

253 out = '\n' + out

254

255 type(self)._last_file_name = file_name

256

257 return out

258

259

260 def format_file_name_msg_line(self) -> str:

261 '''

262 :return: The concatenation of the return values of :meth:`~confattr.configfile.Message.format_file_name` and :meth:`~confattr.configfile.Message.format_msg_line`

263 '''

264 return self.format_file_name() + self.format_msg_line()

265

266

267 def __str__(self) -> str:

268 '''

269 :return: The return value of :meth:`~confattr.configfile.Message.format_file_name_msg_line`

270 '''

271 return self.format_file_name_msg_line()

272

273 def __repr__(self) -> str:

274 return f'{type(self).__name__}(%s)' % ', '.join(f'{a}={self._format_attribute(getattr(self, a))}' for a in self.__slots__)

275

276 @staticmethod

277 def _format_attribute(obj: object) -> str:

278 return repr(obj)

279

280

281 class UiNotifier:

282

283 '''

284 Most likely you will want to load the config file before creating the UI (user interface).

285 But if there are errors in the config file the user will want to know about them.

286 This class takes the messages from :class:`~confattr.configfile.ConfigFile` and stores them until the UI is ready.

287 When you call :meth:`~confattr.configfile.UiNotifier.set_ui_callback` the stored messages will be forwarded and cleared.

288

289 This object can also filter the messages.

290 :class:`~confattr.configfile.ConfigFile` calls :meth:`~confattr.configfile.UiNotifier.show_info` every time a setting is changed.

291 If you load an entire config file this can be many messages and the user probably does not want to see them all.

292 Therefore this object drops all messages of :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>` by default.

293 Pass :paramref:`~confattr.configfile.UiNotifier.notification_level` to the constructor if you don't want that.

294 '''

295

296 # ------- public methods -------

297

298 def __init__(self, config_file: 'ConfigFile|None' = None, notification_level: 'Config[NotificationLevel]|NotificationLevel' = NotificationLevel.ERROR) -> None:

299 '''

300 :param config_file: Is used to add context information to messages, to which file and to which line a message belongs.

301 :param notification_level: Messages which are less important than this notification level will be ignored. I recommend to pass a :class:`~confattr.config.Config` instance so that users can decide themselves what they want to see.

302 '''

303 self._messages: 'list[Message]' = []

304 self._callback: 'UiCallback|None' = None

305 self._notification_level = notification_level

306 self._config_file = config_file

307

308 def set_ui_callback(self, callback: UiCallback) -> None:

309 '''

310 Call :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for all messages which have been saved by :meth:`~confattr.configfile.UiNotifier.show` and clear all saved messages afterwards.

311 Save :paramref:`~confattr.configfile.UiNotifier.set_ui_callback.callback` for :meth:`~confattr.configfile.UiNotifier.show` to call.

312 '''

313 self._callback = callback

314

315 for msg in self._messages:

316 callback(msg)

317 self._messages.clear()

318

319

320 @property

321 def notification_level(self) -> NotificationLevel:

322 '''

323 Ignore messages that are less important than this level.

324 '''

325 if isinstance(self._notification_level, Config):

326 return self._notification_level.value

327 else:

328 return self._notification_level

329

330 @notification_level.setter

331 def notification_level(self, val: NotificationLevel) -> None:

332 if isinstance(self._notification_level, Config):

333 self._notification_level.value = val

334 else:

335 self._notification_level = val

336

337

338 # ------- called by ConfigFile -------

339

340 def show_info(self, msg: str, *, ignore_filter: bool = False) -> None:

341 '''

342 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.INFO <confattr.configfile.NotificationLevel.INFO>`.

343 '''

344 self.show(NotificationLevel.INFO, msg, ignore_filter=ignore_filter)

345

346 def show_error(self, msg: 'str|BaseException', *, ignore_filter: bool = False) -> None:

347 '''

348 Call :meth:`~confattr.configfile.UiNotifier.show` with :const:`NotificationLevel.ERROR <confattr.configfile.NotificationLevel.ERROR>`.

349 '''

350 self.show(NotificationLevel.ERROR, msg, ignore_filter=ignore_filter)

351

352

353 # ------- internal methods -------

354

355 def show(self, notification_level: NotificationLevel, msg: 'str|BaseException', *, ignore_filter: bool = False, no_context: bool = False) -> None:

356 '''

357 If a callback for the user interface has been registered with :meth:`~confattr.configfile.UiNotifier.set_ui_callback` call that callback.

358 Otherwise save the message so that :meth:`~confattr.configfile.UiNotifier.set_ui_callback` can forward the message when :meth:`~confattr.configfile.UiNotifier.set_ui_callback` is called.

359

360 :param notification_level: The importance of the message

361 :param msg: The message to be displayed on the user interface

362 :param ignore_filter: If true: Show the message even if :paramref:`~confattr.configfile.UiNotifier.show.notification_level` is smaller then the :paramref:`UiNotifier.notification_level <confattr.configfile.UiNotifier.notification_level>`.

363 :param no_context: If true: don't show line and line number.

364 '''

365 if notification_level < self.notification_level and not ignore_filter:

366 return

367

368 if self._config_file and not self._config_file.context_line_number and not self._config_file.show_line_always:

369 no_context = True

370

371 message = Message(

372 notification_level = notification_level,

373 message = msg,

374 file_name = self._config_file.context_file_name if self._config_file else None,

375 line_number = self._config_file.context_line_number if self._config_file else None,

376 line = self._config_file.context_line if self._config_file else '',

377 no_context = no_context,

378 )

379

380 if self._callback:

381 self._callback(message)

382 else:

383 self._messages.append(message)

384

385

386# ---------- format help ----------

387

388 class SectionLevel(SortedEnum):

389

390 #: Is used to separate different commands in :meth:`ConfigFile.write_help() <confattr.configfile.ConfigFile.write_help>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>`

391 SECTION = 'section'

392

393 #: Is used for subsections in :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>` such as the "data types" section in the help of the set command

394 SUB_SECTION = 'sub-section'

395

396

397 class FormattedWriter(abc.ABC):

398

399 @abc.abstractmethod

400 def write_line(self, line: str) -> None:

401 '''

402 Write a single line of documentation.

403 :paramref:`~confattr.configfile.FormattedWriter.write_line.line` may *not* contain a newline.

404 If :paramref:`~confattr.configfile.FormattedWriter.write_line.line` is empty it does not need to be prefixed with a comment character.

405 Empty lines should be dropped if no other lines have been written before.

406 '''

407 pass

408

409 def write_lines(self, text: str) -> None:

410 '''

411 Write one or more lines of documentation.

412 '''

413 for ln in text.splitlines():

414 self.write_line(ln)

415

416 @abc.abstractmethod

417 def write_heading(self, lvl: SectionLevel, heading: str) -> None:

418 '''

419 Write a heading.

420

421 This object should *not* add an indentation depending on the section

422 because if the indentation is increased the line width should be decreased

423 in order to keep the line wrapping consistent.

424 Wrapping lines is handled by :class:`confattr.utils.HelpFormatter`,

425 i.e. before the text is passed to this object.

426 It would be possible to use :class:`argparse.RawTextHelpFormatter` instead

427 and handle line wrapping on a higher level but that would require

428 to understand the help generated by argparse

429 in order to know how far to indent a broken line.

430 One of the trickiest parts would probably be to get the indentation of the usage right.

431 Keep in mind that the term "usage" can differ depending on the language settings of the user.

432

433 :param lvl: How to format the heading

434 :param heading: The heading

435 '''

436 pass

437

438 @abc.abstractmethod

439 def write_command(self, cmd: str) -> None:

440 '''

441 Write a config file command.

442 '''

443 pass

444

445

446 class TextIOWriter(FormattedWriter):

447

448 def __init__(self, f: 'typing.TextIO|None') -> None:

449 self.f = f

450 self.ignore_empty_lines = True

451

452 def write_line_raw(self, line: str) -> None:

453 if self.ignore_empty_lines and not line:

454 return

455

456 print(line, file=self.f)

457 self.ignore_empty_lines = False

458

459

460 class ConfigFileWriter(TextIOWriter):

461

462 def __init__(self, f: 'typing.TextIO|None', prefix: str) -> None:

463 super().__init__(f)

464 self.prefix = prefix

465

466 def write_command(self, cmd: str) -> None:

467 self.write_line_raw(cmd)

468

469 def write_line(self, line: str) -> None:

470 if line:

471 line = self.prefix + line

472

473 self.write_line_raw(line)

474

475 def write_heading(self, lvl: SectionLevel, heading: str) -> None:

476 if lvl is SectionLevel.SECTION:

477 self.write_line('')

478 self.write_line('')

479 self.write_line('=' * len(heading))

480 self.write_line(heading)

481 self.write_line('=' * len(heading))

482 else:

483 self.write_line('')

484 self.write_line(heading)

485 self.write_line('-' * len(heading))

486

487 class HelpWriter(TextIOWriter):

488

489 def write_line(self, line: str) -> None:

490 self.write_line_raw(line)

491

492 def write_heading(self, lvl: SectionLevel, heading: str) -> None:

493 self.write_line('')

494 if lvl is SectionLevel.SECTION:

495 self.write_line(heading)

496 self.write_line('=' * len(heading))

497 else:

498 self.write_line(heading)

499 self.write_line('-' * len(heading))

500

501 def write_command(self, cmd: str) -> None:

502 pass # pragma: no cover

503

504

505# ---------- internal exceptions ----------

506

507 class ParseException(Exception):

508

509 '''

510 This is raised by :class:`~confattr.configfile.ConfigFileCommand` implementations and functions passed to :paramref:`~confattr.configfile.ConfigFile.check_config_id` in order to communicate an error in the config file like invalid syntax or an invalid value.

511 Is caught in :class:`~confattr.configfile.ConfigFile`.

512 '''

513

514 class MultipleParseExceptions(Exception):

515

516 '''

517 This is raised by :class:`~confattr.configfile.ConfigFileCommand` implementations in order to communicate that multiple errors have occured on the same line.

518 Is caught in :class:`~confattr.configfile.ConfigFile`.

519 '''

520

521 def __init__(self, exceptions: 'Sequence[ParseException]') -> None:

522 super().__init__()

523 self.exceptions = exceptions

524

525 def __iter__(self) -> 'Iterator[ParseException]':

526 return iter(self.exceptions)

527

528

529# ---------- data types for **kw args ----------

530

531 if hasattr(typing, 'TypedDict'): # python >= 3.8 # pragma: no cover. This is tested but in a different environment which is not known to coverage.

532 class SaveKwargs(typing.TypedDict, total=False):

533 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]'

534 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]] | None'

535 no_multi: bool

536 comments: bool

537 commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]'

538 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]'

539

540

541# ---------- ConfigFile class ----------

542

543 class ArgPos:

544 '''

545 This is an internal class, the return type of :meth:`ConfigFile.find_arg() <confattr.configfile.ConfigFile.find_arg>`

546 '''

547

548 #: The index of the argument in :paramref:`~confattr.configfile.ConfigFile.find_arg.ln_split` where the cursor is located and which shall be completed. Please note that this can be one bigger than :paramref:`~confattr.configfile.ConfigFile.find_arg.ln_split` is long if the line ends on a space or a comment and the cursor is behind/in that space/comment. In that case :attr:`~confattr.configfile.ArgPos.in_between` is true.

549 argument_pos: int

550

551 #: If true: The cursor is between two arguments, before the first argument or after the last argument. :attr:`~confattr.configfile.ArgPos.argument_pos` refers to the next argument, :attr:`argument_pos-1 <confattr.configfile.ArgPos.argument_pos>` to the previous argument. :attr:`~confattr.configfile.ArgPos.i0` is the start of the next argument, :attr:`~confattr.configfile.ArgPos.i1` is the end of the previous argument.

552 in_between: bool

553

554 #: The index in :paramref:`~confattr.configfile.ConfigFile.find_arg.line` where the argument having the cursor starts (inclusive) or the start of the next argument if :attr:`~confattr.configfile.ArgPos.in_between` is true

555 i0: int

556

557 #: The index in :paramref:`~confattr.configfile.ConfigFile.find_arg.line` where the current word ends (exclusive) or the end of the previous argument if :attr:`~confattr.configfile.ArgPos.in_between` is true

558 i1: int

559

560

561 class ConfigFile:

562

563 '''

564 Read or write a config file.

565

566 All :class:`~confattr.config.Config` objects must be instantiated before instantiating this class.

567 '''

568

569 COMMENT = '#'

570 COMMENT_PREFIXES = ('"', '#')

571 ENTER_GROUP_PREFIX = '['

572 ENTER_GROUP_SUFFIX = ']'

573

574 #: How to separete several element in a collection (list, set, dict)

575 ITEM_SEP = ','

576

577 #: How to separate key and value in a dict

578 KEY_SEP = ':'

579

580

581 #: The :class:`~confattr.config.Config` instances to load or save

582 config_instances: 'dict[str, Config[typing.Any]]'

583

584 #: While loading a config file: The group that is currently being parsed, i.e. an identifier for which object(s) the values shall be set. This is set in :meth:`~confattr.configfile.ConfigFile.enter_group` and reset in :meth:`~confattr.configfile.ConfigFile.load_file`.

585 config_id: 'ConfigId|None'

586

587 #: Override the config file which is returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.

588 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_directory` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.

589 #: If the environment variable ``APPNAME_CONFIG_PATH`` is set this attribute is set to it's value in the constructor (where ``APPNAME`` is the value which is passed as :paramref:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.)

590 config_path: 'str|None' = None

591

592 #: Override the config directory which is returned by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths`.

593 #: You should set either this attribute or :attr:`~confattr.configfile.ConfigFile.config_path` in your tests with :meth:`monkeypatch.setattr() <pytest.MonkeyPatch.setattr>`.

594 #: If the environment variable ``APPNAME_CONFIG_DIRECTORY`` is set this attribute is set to it's value in the constructor (where ``APPNAME`` is the value which is passed as :paramref:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.)

595 config_directory: 'str|None' = None

596

597 #: The name of the config file used by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.

598 #: Can be changed with the environment variable ``APPNAME_CONFIG_NAME`` (where ``APPNAME`` is the value which is passed as :paramref:`~confattr.configfile.ConfigFile.appname` to the constructor but in all upper case letters and hyphens and spaces replaced by underscores.).

599 config_name = 'config'

600

601 #: Contains the names of the environment variables for :attr:`~confattr.configfile.ConfigFile.config_path`, :attr:`~confattr.configfile.ConfigFile.config_directory` and :attr:`~confattr.configfile.ConfigFile.config_name`—in capital letters and prefixed with :attr:`~confattr.configfile.ConfigFile.envprefix`.

602 env_variables: 'list[str]'

603

604 #: A prefix that is prepended to the name of environment variables in :meth:`~confattr.configfile.ConfigFile.get_env_name`.

605 #: It is set in the constructor by first setting it to an empty str and then passing the value of :paramref:`~confattr.configfile.ConfigFile.appname` to :meth:`~confattr.configfile.ConfigFile.get_env_name` and appending an underscore.

606 envprefix: str

607

608 #: The name of the file which is currently loaded. If this equals :attr:`Message.ENVIRONMENT_VARIABLES <confattr.configfile.Message.ENVIRONMENT_VARIABLES>` it is no file name but an indicator that environment variables are loaded. This is :obj:`None` if :meth:`~confattr.configfile.ConfigFile.parse_line` is called directly (e.g. the input from a command line is parsed).

609 context_file_name: 'str|None' = None

610 #: The number of the line which is currently parsed. This is :obj:`None` if :attr:`~confattr.configfile.ConfigFile.context_file_name` is not a file name.

611 context_line_number: 'int|None' = None

612 #: The line which is currently parsed.

613 context_line: str = ''

614

615 #: If true: ``[config-id]`` syntax is allowed in config file, config ids are included in help, config id related options are available for include.

616 #: If false: It is not possible to set different values for different objects (but default values for :class:`~confattr.config.MultiConfig` instances can be set)

617 enable_config_ids: bool

618

619

620 #: A mapping from the name to the object for all commands that are available in this config file. If a command has :attr:`~confattr.configfile.ConfigFileCommand.aliases` every alias appears in this mapping, too. Use :attr:`~confattr.configfile.ConfigFile.commands` instead if you want to iterate over all available commands. This is generated in the constructor based on :paramref:`~confattr.configfile.ConfigFile.commands` if it is given or based on the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>` otherwise. Note that you are passing a sequence of *types* as argument but this attribute contains the instantiated *objects*.

621 command_dict: 'dict[str, ConfigFileCommand]'

622

623 #: A list of all commands that are available in this config file. This is generated in the constructor based on :paramref:`~confattr.configfile.ConfigFile.commands` if it is given or based on the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>` otherwise. Note that you are passing a sequence of *types* as argument but this attribute contains the instantiated *objects*. In contrast to :attr:`~confattr.configfile.ConfigFile.command_dict` this list contains every command only once.

624 commands: 'list[ConfigFileCommand]'

625

626

627 #: See :paramref:`~confattr.configfile.ConfigFile.check_config_id`

628 check_config_id: 'Callable[[ConfigId], None]|None'

629

630 #: If this is true :meth:`ui_notifier.show() <confattr.configfile.UiNotifier.show>` concatenates :attr:`~confattr.configfile.ConfigFile.context_line` to the message even if :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set.

631 show_line_always: bool

632

633

634 def __init__(self, *,

635 notification_level: 'Config[NotificationLevel]' = NotificationLevel.ERROR, # type: ignore [assignment] # yes, passing a NotificationLevel directly is possible but I don't want users to do that in order to give the users of their applications the freedom to set this the way they need it

636 appname: str,

637 authorname: 'str|None' = None,

638 config_instances: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]|None' = None,

639 ignore: 'Iterable[Config[typing.Any] | DictConfig[typing.Any, typing.Any]]|None' = None,

640 commands: 'Iterable[type[ConfigFileCommand]|abc.ABCMeta]|None' = None,

641 ignore_commands: 'Sequence[type[ConfigFileCommand]|abc.ABCMeta]|None' = None,

642 formatter_class: 'type[argparse.HelpFormatter]' = HelpFormatter,

643 check_config_id: 'Callable[[ConfigId], None]|None' = None,

644 enable_config_ids: 'bool|None' = None,

645 show_line_always: bool = True,

646 ) -> None:

647 '''

648 :param notification_level: A :class:`~confattr.config.Config` which the users of your application can set to choose whether they want to see information which might be interesting for debugging a config file. A :class:`~confattr.configfile.Message` with a priority lower than this value is *not* passed to the callback registered with :meth:`~confattr.configfile.ConfigFile.set_ui_callback`.

649 :param appname: The name of the application, required for generating the path of the config file if you use :meth:`~confattr.configfile.ConfigFile.load` or :meth:`~confattr.configfile.ConfigFile.save` and as prefix of environment variable names

650 :param authorname: The name of the developer of the application, on MS Windows useful for generating the path of the config file if you use :meth:`~confattr.configfile.ConfigFile.load` or :meth:`~confattr.configfile.ConfigFile.save`

651 :param config_instances: The settings supported in this config file. None means all settings which have been defined when this object is created.

652 :param ignore: These settings are *not* supported by this config file even if they are contained in :paramref:`~confattr.configfile.ConfigFile.config_instances`.

653 :param commands: The commands (as subclasses of :class:`~confattr.configfile.ConfigFileCommand` or :class:`~confattr.configfile.ConfigFileArgparseCommand`) allowed in this config file, if this is :obj:`None`: use the return value of :meth:`ConfigFileCommand.get_command_types() <confattr.configfile.ConfigFileCommand.get_command_types>`. Abstract classes are expanded to all non-abstract subclasses.

654 :param ignore_commands: A sequence of commands (as subclasses of :class:`~confattr.configfile.ConfigFileCommand` or :class:`~confattr.configfile.ConfigFileArgparseCommand`) which are *not* allowed in this config file. May contain abstract classes. All commands which are contained in this sequence or which are a subclass of an item in this sequence are not allowed, regardless of whether they are passed to :paramref:`~confattr.configfile.ConfigFile.commands` or not.

655 :param formatter_class: Is used to clean up doc strings and wrap lines in the help

656 :param check_config_id: Is called every time a configuration group is opened (except for :attr:`Config.default_config_id <confattr.config.Config.default_config_id>`—that is always allowed). The callback should raise a :class:`~confattr.configfile.ParseException` if the config id is invalid.

657 :param enable_config_ids: see :attr:`~confattr.configfile.ConfigFile.enable_config_ids`. If None: Choose True or False automatically based on :paramref:`~confattr.configfile.ConfigFile.check_config_id` and the existence of :class:`~confattr.config.MultiConfig`/:class:`~confattr.config.MultiDictConfig`

658 :param show_line_always: If false: when calling :meth:`UiNotifier.show() <confattr.configfile.UiNotifier.show>` :attr:`~confattr.configfile.ConfigFile.context_line` and :attr:`~confattr.configfile.ConfigFile.context_line_number` are concatenated to the message if both are set. If :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set it is assumed that the line comes from a command line interface where the user just entered it and it is still visible so there is no need to print it again. If :paramref:`~confattr.configfile.ConfigFile.show_line_always` is true (the default) :attr:`~confattr.configfile.ConfigFile.context_line` is concatenated even if :attr:`~confattr.configfile.ConfigFile.context_line_number` is not set. That is useful when you use :meth:`~confattr.configfile.ConfigFile.parse_line` to parse a command which has been assigned to a keyboard shortcut.

659 '''

660 self.appname = appname

661 self.authorname = authorname

662 self.ui_notifier = UiNotifier(self, notification_level)

663 if config_instances is None:

664 # I am setting has_config_file_been_instantiated only if no config_instances have been passed

665 # because if the user passes an explicit list of config_instances

666 # then it's clear that Config instances created later on are ignored by this ConfigFile

667 # so no TimingException should be raised if instantiating another Config.

668 state.has_config_file_been_instantiated = True

669 config_instances = Config.iter_instances() # this does not return a list or tuple and that is important for sorting

670 self.config_instances = {i.key: i for i in self.iter_config_instances(config_instances, ignore)}

671 self.config_id: 'ConfigId|None' = None

672 self.formatter_class = formatter_class

673 self.env_variables: 'list[str]' = []

674 self.check_config_id = check_config_id

675 self.show_line_always = show_line_always

676

677 if enable_config_ids is None:

678 enable_config_ids = self.check_config_id is not None or any(isinstance(cfg, MultiConfig) for cfg in self.config_instances.values())

679 self.enable_config_ids = enable_config_ids

680

681 self.envprefix = ''

682 self.envprefix = self.get_env_name(appname + '_')

683 envname = self.envprefix + 'CONFIG_PATH'

684 self.env_variables.append(envname)

685 if envname in os.environ:

686 self.config_path = os.environ[envname]

687 envname = self.envprefix + 'CONFIG_DIRECTORY'

688 self.env_variables.append(envname)

689 if envname in os.environ:

690 self.config_directory = os.environ[envname]

691 envname = self.envprefix + 'CONFIG_NAME'

692 self.env_variables.append(envname)

693 if envname in os.environ:

694 self.config_name = os.environ[envname]

695

696 if commands is None:

697 commands = ConfigFileCommand.get_command_types()

698 else:

699 original_commands = commands

700 def iter_commands() -> 'Iterator[type[ConfigFileCommand]]':

701 for cmd in original_commands:

702 cmd = typing.cast('type[ConfigFileCommand]', cmd)

703 if cmd._abstract:

704 for c in ConfigFileCommand.get_command_types():

705 if issubclass(c, cmd):

706 yield c

707 else:

708 yield cmd

709 commands = iter_commands()

710 self.command_dict = {}

711 self.commands = []

712 for cmd_type in commands:

713 if ignore_commands and any(issubclass(cmd_type, i_c) for i_c in ignore_commands):

714 continue

715 cmd = cmd_type(self)

716 self.commands.append(cmd)

717 for name in cmd.get_names():

718 self.command_dict[name] = cmd

719

720 def iter_config_instances(self,

721 config_instances: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]',

722 ignore: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]|None',

723 ) -> 'Iterator[Config[object]]':

724 '''

725 :param config_instances: The settings to consider

726 :param ignore: Skip these settings

727

728 Iterate over all given :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.config_instances` and expand all :class:`~confattr.config.DictConfig` instances into the :class:`~confattr.config.Config` instances they consist of.

729 Sort the resulting list if :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.config_instances` is not a :class:`list` or a :class:`tuple`.

730 Yield all :class:`~confattr.config.Config` instances which are not (directly or indirectly) contained in :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.ignore`.

731 '''

732 should_be_ignored: 'Callable[[Config[typing.Any]], bool]'

733 if ignore is not None:

734 tmp = set()

735 for c in ignore:

736 if isinstance(c, DictConfig):

737 tmp |= set(c._values.values())

738 else:

739 tmp.add(c)

740 should_be_ignored = lambda c: c in tmp

741 else:

742 should_be_ignored = lambda c: False

743

744 if not isinstance(config_instances, (list, tuple)):

745 config_instances = sorted(config_instances, key=lambda c: c.key_prefix if isinstance(c, DictConfig) else c.key)

746 def expand_configs() -> 'Iterator[Config[typing.Any]]':

747 for c in config_instances:

748 if isinstance(c, DictConfig):

749 yield from c.iter_configs()

750 else:

751 yield c

752 for c in expand_configs():

753 if should_be_ignored(c):

754 continue

755

756 yield c

757

758 def set_ui_callback(self, callback: UiCallback) -> None:

759 '''

760 Register a callback to a user interface in order to show messages to the user like syntax errors or invalid values in the config file.

761

762 Messages which occur before this method is called are stored and forwarded as soon as the callback is registered.

763

764 :param ui_callback: A function to display messages to the user

765 '''

766 self.ui_notifier.set_ui_callback(callback)

767

768 def get_app_dirs(self) -> 'appdirs.AppDirs':

769 '''

770 Create or get a cached `AppDirs <https://github.com/ActiveState/appdirs/blob/master/README.rst#appdirs-for-convenience>`__ instance with multipath support enabled.

771

772 When creating a new instance, `platformdirs <https://pypi.org/project/platformdirs/>`__, `xdgappdirs <https://pypi.org/project/xdgappdirs/>`__ and `appdirs <https://pypi.org/project/appdirs/>`__ are tried, in that order.

773 The first one installed is used.

774 appdirs, the original of the two forks and the only one of the three with type stubs, is specified in pyproject.toml as a hard dependency so that at least one of the three should always be available.

775 I am not very familiar with the differences but if a user finds that appdirs does not work for them they can choose to use an alternative with ``pipx inject appname xdgappdirs|platformdirs``.

776

777 These libraries should respect the environment variables ``XDG_CONFIG_HOME`` and ``XDG_CONFIG_DIRS``.

778 '''

779 if not hasattr(self, '_appdirs'):

780 try:

781 import platformdirs # type: ignore [import-not-found] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs

782 AppDirs = typing.cast('type[appdirs.AppDirs]', platformdirs.PlatformDirs) # pragma: no cover # This is tested but in a different tox environment

783 except ImportError:

784 try:

785 import xdgappdirs # type: ignore [import-not-found] # this library is not typed and not necessarily installed, I am relying on it's compatibility with appdirs

786 AppDirs = typing.cast('type[appdirs.AppDirs]', xdgappdirs.AppDirs) # pragma: no cover # This is tested but in a different tox environment

787 except ImportError:

788 AppDirs = appdirs.AppDirs

789

790 self._appdirs = AppDirs(self.appname, self.authorname, multipath=True)

791

792 return self._appdirs

793

794 # ------- load -------

795

796 def iter_user_site_config_paths(self) -> 'Iterator[str]':

797 '''

798 Iterate over all directories which are searched for config files, user specific first.

799

800 The directories are based on :meth:`~confattr.configfile.ConfigFile.get_app_dirs`

801 unless :attr:`~confattr.configfile.ConfigFile.config_directory` has been set.

802 If :attr:`~confattr.configfile.ConfigFile.config_directory` has been set

803 it's value is yielded and nothing else.

804 '''

805 if self.config_directory:

806 yield self.config_directory

807 return

808

809 appdirs = self.get_app_dirs()

810 yield from appdirs.user_config_dir.split(os.path.pathsep)

811 yield from appdirs.site_config_dir.split(os.path.pathsep)

812

813 def iter_config_paths(self) -> 'Iterator[str]':

814 '''

815 Iterate over all paths which are checked for config files, user specific first.

816

817 Use this method if you want to tell the user where the application is looking for it's config file.

818 The first existing file yielded by this method is used by :meth:`~confattr.configfile.ConfigFile.load`.

819

820 The paths are generated by joining the directories yielded by :meth:`~confattr.configfile.ConfigFile.iter_user_site_config_paths` with

821 :attr:`ConfigFile.config_name <confattr.configfile.ConfigFile.config_name>`.

822

823 If :attr:`~confattr.configfile.ConfigFile.config_path` has been set this method yields that path instead and no other paths.

824 '''

825 if self.config_path:

826 yield self.config_path

827 return

828

829 for path in self.iter_user_site_config_paths():

830 yield os.path.join(path, self.config_name)

831

832 def load(self, *, env: bool = True) -> bool:

833 '''

834 Load the first existing config file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths`.

835

836 If there are several config files a user specific config file is preferred.

837 If a user wants a system wide config file to be loaded, too, they can explicitly include it in their config file.

838 :param env: If true: call :meth:`~confattr.configfile.ConfigFile.load_env` after loading the config file.

839 :return: False if an error has occurred

840 '''

841 out = True

842 for fn in self.iter_config_paths():

843 if os.path.isfile(fn):

844 out &= self.load_file(fn)

845 break

846

847 if env:

848 out &= self.load_env()

849

850 return out

851

852 def load_env(self) -> bool:

853 '''

854 Load settings from environment variables.

855 The name of the environment variable belonging to a setting is generated with :meth:`~confattr.configfile.ConfigFile.get_env_name`.

856

857 Environment variables not matching a setting or having an invalid value are reported with :meth:`self.ui_notifier.show_error() <confattr.configfile.UiNotifier.show_error>`.

858

859 :return: False if an error has occurred

860 :raises ValueError: if two settings have the same environment variable name (see :meth:`~confattr.configfile.ConfigFile.get_env_name`) or the environment variable name for a setting collides with one of the standard environment variables listed in :attr:`~confattr.configfile.ConfigFile.env_variables`

861 '''

862 out = True

863 old_file_name = self.context_file_name

864 self.context_file_name = Message.ENVIRONMENT_VARIABLES

865

866 config_instances: 'dict[str, Config[object]]' = {}

867 for key, instance in self.config_instances.items():

868 name = self.get_env_name(key)

869 if name in self.env_variables:

870 raise ValueError(f'setting {instance.key!r} conflicts with environment variable {name!r}')

871 elif name in config_instances:

872 raise ValueError(f'settings {instance.key!r} and {config_instances[name].key!r} result in the same environment variable {name!r}')

873 else:

874 config_instances[name] = instance

875

876 for name, value in os.environ.items():

877 if not name.startswith(self.envprefix):

878 continue

879 if name in self.env_variables:

880 continue

881

882 if name in config_instances:

883 instance = config_instances[name]

884 try:

885 instance.set_value(config_id=None, value=self.parse_value(instance, value, raw=True))

886 self.ui_notifier.show_info(f'set {instance.key} to {self.format_value(instance, config_id=None)}')

887 except ValueError as e:

888 self.ui_notifier.show_error(f"{e} while trying to parse environment variable {name}='{value}'")

889 out = False

890 else:

891 self.ui_notifier.show_error(f"unknown environment variable {name}='{value}'")

892 out = False

893

894 self.context_file_name = old_file_name

895 return out

896

897

898 def get_env_name(self, key: str) -> str:

899 '''

900 Convert the key of a setting to the name of the corresponding environment variable.

901

902 :return: An all upper case version of :paramref:`~confattr.configfile.ConfigFile.get_env_name.key` with all hyphens, dots and spaces replaced by underscores and :attr:`~confattr.configfile.ConfigFile.envprefix` prepended to the result.

903 '''

904 out = key

905 out = out.upper()

906 for c in ' .-':

907 out = out.replace(c, '_')

908 out = self.envprefix + out

909 return out

910

911 def load_file(self, fn: str) -> bool:

912 '''

913 Load a config file and change the :class:`~confattr.config.Config` objects accordingly.

914

915 Use :meth:`~confattr.configfile.ConfigFile.set_ui_callback` to get error messages which appeared while loading the config file.

916 You can call :meth:`~confattr.configfile.ConfigFile.set_ui_callback` after this method without loosing any messages.

917

918 :param fn: The file name of the config file (absolute or relative path)

919 :return: False if an error has occurred

920 '''

921 self.config_id = None

922 return self.load_without_resetting_config_id(fn)

923

924 def load_without_resetting_config_id(self, fn: str) -> bool:

925 out = True

926 old_file_name = self.context_file_name

927 self.context_file_name = fn

928

929 with open(fn, 'rt') as f:

930 for lnno, ln in enumerate(f, 1):

931 self.context_line_number = lnno

932 out &= self.parse_line(line=ln)

933 self.context_line_number = None

934

935 self.context_file_name = old_file_name

936 return out

937

938 def parse_line(self, line: str) -> bool:

939 '''

940 :param line: The line to be parsed

941 :return: True if line is valid, False if an error has occurred

942

943 :meth:`~confattr.configfile.ConfigFile.parse_error` is called if something goes wrong (i.e. if the return value is False), e.g. invalid key or invalid value.

944 '''

945 ln = line.strip()

946 if not ln:

947 return True

948 if self.is_comment(ln):

949 return True

950 if self.enable_config_ids and self.enter_group(ln):

951 return True

952

953 self.context_line = ln

954

955 try:

956 ln_split = self.split_line(ln)

957 except Exception as e:

958 self.parse_error(str(e))

959 out = False

960 else:

961 out = self.parse_split_line(ln_split)

962

963 self.context_line = ''

964 return out

965

966 def split_line(self, line: str) -> 'list[str]':

967 cmd, line = self.split_one_symbol_command(line)

968 line_split = shlex.split(line, comments=True)

969 if cmd:

970 line_split.insert(0, cmd)

971 return line_split

972

973 def split_line_ignore_errors(self, line: str) -> 'list[str]':

974 out = []

975 cmd, line = self.split_one_symbol_command(line)

976 if cmd:

977 out.append(cmd)

978 lex = shlex.shlex(line, posix=True)

979 lex.whitespace_split = True

980 while True:

981 try:

982 t = lex.get_token()

983 except:

984 out.append(lex.token)

985 return out

986 if t is None:

987 return out

988 out.append(t)

989

990 def split_one_symbol_command(self, line: str) -> 'tuple[str|None, str]':

991 if line and not line[0].isalnum() and line[0] in self.command_dict:

992 return line[0], line[1:]

993

994 return None, line

995

996

997 def is_comment(self, line: str) -> bool:

998 '''

999 Check if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment.

1000

1001 :param line: The current line

1002 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.is_comment.line` is a comment

1003 '''

1004 for c in self.COMMENT_PREFIXES:

1005 if line.startswith(c):

1006 return True

1007 return False

1008

1009 def enter_group(self, line: str) -> bool:

1010 '''

1011 Check if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group and set :attr:`~confattr.configfile.ConfigFile.config_id` if it does.

1012 Call :meth:`~confattr.configfile.ConfigFile.parse_error` if :meth:`~confattr.configfile.ConfigFile.check_config_id` raises a :class:`~confattr.configfile.ParseException`.

1013

1014 :param line: The current line

1015 :return: :obj:`True` if :paramref:`~confattr.configfile.ConfigFile.enter_group.line` starts a new group

1016 '''

1017 if line.startswith(self.ENTER_GROUP_PREFIX) and line.endswith(self.ENTER_GROUP_SUFFIX):

1018 config_id = typing.cast(ConfigId, line[len(self.ENTER_GROUP_PREFIX):-len(self.ENTER_GROUP_SUFFIX)])

1019 if self.check_config_id and config_id != Config.default_config_id:

1020 try:

1021 self.check_config_id(config_id)

1022 except ParseException as e:

1023 self.parse_error(str(e))

1024 self.config_id = config_id

1025 if self.config_id not in MultiConfig.config_ids:

1026 MultiConfig.config_ids.append(self.config_id)

1027 return True

1028 return False

1029

1030 def parse_split_line(self, ln_split: 'Sequence[str]') -> bool:

1031 '''

1032 Call the corresponding command in :attr:`~confattr.configfile.ConfigFile.command_dict`.

1033 If any :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is raised catch it and call :meth:`~confattr.configfile.ConfigFile.parse_error`.

1034

1035 :return: False if a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` has been caught, True if no exception has been caught

1036 '''

1037 cmd = self.get_command(ln_split)

1038 try:

1039 cmd.run(ln_split)

1040 except ParseException as e:

1041 self.parse_error(str(e))

1042 return False

1043 except MultipleParseExceptions as exceptions:

1044 for exc in exceptions:

1045 self.parse_error(str(exc))

1046 return False

1047

1048 return True

1049

1050 def get_command(self, ln_split: 'Sequence[str]') -> 'ConfigFileCommand':

1051 cmd_name = ln_split[0]

1052 if cmd_name in self.command_dict:

1053 cmd = self.command_dict[cmd_name]

1054 elif DEFAULT_COMMAND in self.command_dict:

1055 cmd = self.command_dict[DEFAULT_COMMAND]

1056 else:

1057 cmd = UnknownCommand(self)

1058 return cmd

1059

1060

1061 # ------- save -------

1062

1063 def get_save_path(self) -> str:

1064 '''

1065 :return: The first existing and writable file returned by :meth:`~confattr.configfile.ConfigFile.iter_config_paths` or the first path if none of the files are existing and writable.

1066 '''

1067 paths = tuple(self.iter_config_paths())

1068 for fn in paths:

1069 if os.path.isfile(fn) and os.access(fn, os.W_OK):

1070 return fn

1071

1072 return paths[0]

1073

1074 def save(self,

1075 if_not_existing: bool = False,

1076 **kw: 'Unpack[SaveKwargs]',

1077 ) -> str:

1078 '''

1079 Save the current values of all settings to the file returned by :meth:`~confattr.configfile.ConfigFile.get_save_path`.

1080 Directories are created as necessary.

1081

1082 :param config_instances: Do not save all settings but only those given. If this is a :class:`list` they are written in the given order. If this is a :class:`set` they are sorted by their keys.

1083 :param ignore: Do not write these settings to the file.

1084 :param no_multi: Do not write several sections. For :class:`~confattr.config.MultiConfig` instances write the default values only.

1085 :param comments: Write comments with allowed values and help.

1086 :param if_not_existing: Do not overwrite the file if it is already existing.

1087 :return: The path to the file which has been written

1088 '''

1089 fn = self.get_save_path()

1090 if if_not_existing and os.path.isfile(fn):

1091 return fn

1092

1093 # "If, when attempting to write a file, the destination directory is non-existent an attempt should be made to create it with permission 0700.

1094 # If the destination directory exists already the permissions should not be changed."

1095 # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html

1096 os.makedirs(os.path.dirname(fn), exist_ok=True, mode=0o0700)

1097 self.save_file(fn, **kw)

1098 return fn

1099

1100 def save_file(self,

1101 fn: str,

1102 **kw: 'Unpack[SaveKwargs]'

1103 ) -> None:

1104 '''

1105 Save the current values of all settings to a specific file.

1106

1107 :param fn: The name of the file to write to. If this is not an absolute path it is relative to the current working directory.

1108 :raises FileNotFoundError: if the directory does not exist

1109

1110 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.

1111 '''

1112 with open(fn, 'wt') as f:

1113 self.save_to_open_file(f, **kw)

1114

1115

1116 def save_to_open_file(self,

1117 f: typing.TextIO,

1118 **kw: 'Unpack[SaveKwargs]',

1119 ) -> None:

1120 '''

1121 Save the current values of all settings to a file-like object

1122 by creating a :class:`~confattr.configfile.ConfigFileWriter` object and calling :meth:`~confattr.configfile.ConfigFile.save_to_writer`.

1123

1124 :param f: The file to write to

1125

1126 For an explanation of the other parameters see :meth:`~confattr.configfile.ConfigFile.save`.

1127 '''

1128 writer = ConfigFileWriter(f, prefix=self.COMMENT + ' ')

1129 self.save_to_writer(writer, **kw)

1130

1131 def save_to_writer(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:

1132 '''

1133 Save the current values of all settings.

1134

1135 Ensure that all keyword arguments are passed with :meth:`~confattr.configfile.ConfigFile.set_save_default_arguments`.

1136 Iterate over all :class:`~confattr.configfile.ConfigFileCommand` objects in :attr:`~confattr.configfile.ConfigFile.commands` and do for each of them:

1137

1138 - set :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading` to :obj:`True` if :python:`getattr(cmd.save, 'implemented', True)` is true for two or more of those commands or to :obj:`False` otherwise

1139 - call :meth:`~confattr.configfile.ConfigFileCommand.save`

1140 '''

1141 self.set_save_default_arguments(kw)

1142 commands = list(self.commands)

1143 if 'commands' in kw or 'ignore_commands' in kw:

1144 command_types = tuple(kw['commands']) if 'commands' in kw else None

1145 ignore_command_types = tuple(kw['ignore_commands']) if 'ignore_commands' in kw else None

1146 for cmd in tuple(commands):

1147 if (ignore_command_types and isinstance(cmd, ignore_command_types)) \

1148 or (command_types and not isinstance(cmd, command_types)):

1149 commands.remove(cmd)

1150 write_headings = len(tuple(cmd for cmd in commands if getattr(cmd.save, 'implemented', True))) >= 2

1151 for cmd in commands:

1152 cmd.should_write_heading = write_headings

1153 cmd.save(writer, **kw)

1154

1155 def set_save_default_arguments(self, kw: 'SaveKwargs') -> None:

1156 '''

1157 Ensure that all arguments are given in :paramref:`~confattr.configfile.ConfigFile.set_save_default_arguments.kw`.

1158 '''

1159 kw.setdefault('config_instances', list(self.config_instances.values()))

1160 kw.setdefault('ignore', None)

1161 kw.setdefault('no_multi', not self.enable_config_ids)

1162 kw.setdefault('comments', True)

1163

1164

1165 def quote(self, val: str) -> str:

1166 '''

1167 Quote a value if necessary so that it will be interpreted as one argument.

1168

1169 The default implementation calls :func:`~confattr.utils.readable_quote`.

1170 '''

1171 return readable_quote(val)

1172

1173 def write_config_id(self, writer: FormattedWriter, config_id: ConfigId) -> None:

1174 '''

1175 Start a new group in the config file so that all following commands refer to the given :paramref:`~confattr.configfile.ConfigFile.write_config_id.config_id`.

1176 '''

1177 writer.write_command(self.ENTER_GROUP_PREFIX + config_id + self.ENTER_GROUP_SUFFIX)

1178

1179 def get_help_config_id(self) -> str:

1180 '''

1181 :return: A help how to use :class:`~confattr.config.MultiConfig`. The return value still needs to be cleaned with :func:`inspect.cleandoc`.

1182 '''

1183 return f'''

1184 You can specify the object that a value shall refer to by inserting the line `{self.ENTER_GROUP_PREFIX}config-id{self.ENTER_GROUP_SUFFIX}` above.

1185 `config-id` must be replaced by the corresponding identifier for the object.

1186 '''

1187

1188

1189 # ------- formatting and parsing of values -------

1190

1191 def format_value(self, instance: Config[typing.Any], config_id: 'ConfigId|None') -> str:

1192 '''

1193 :param instance: The config value to be saved

1194 :param config_id: Which value to be written in case of a :class:`~confattr.config.MultiConfig`, should be :obj:`None` for a normal :class:`~confattr.config.Config` instance

1195 :return: A str representation to be written to the config file

1196

1197 Convert the value of the :class:`~confattr.config.Config` instance into a str with :meth:`~confattr.configfile.ConfigFile.format_any_value`.

1198 '''

1199 return self.format_any_value(instance.type, instance.get_value(config_id))

1200

1201 def format_any_value(self, type: 'AbstractFormatter[T2]', value: 'T2') -> str:

1202 return type.format_value(self, value)

1203

1204

1205 def parse_value(self, instance: 'Config[T2]', value: str, *, raw: bool) -> 'T2':

1206 '''

1207 :param instance: The config instance for which the value should be parsed, this is important for the data type

1208 :param value: The string representation of the value to be parsed

1209 :param raw: if false: expand :paramref:`~confattr.configfile.ConfigFile.parse_value.value` with :meth:`~confattr.configfile.ConfigFile.expand` first, if true: parse :paramref:`~confattr.configfile.ConfigFile.parse_value.value` as it is

1210 Parse a value to the data type of a given setting by calling :meth:`~confattr.configfile.ConfigFile.parse_value_part`

1211 '''

1212 if not raw:

1213 value = self.expand(value)

1214 return self.parse_value_part(instance, instance.type, value)

1215

1216 def parse_value_part(self, config: 'Config[typing.Any]', t: 'AbstractFormatter[T2]', value: str) -> 'T2':

1217 '''

1218 Parse a value to the given data type.

1219

1220 :param config: Needed for the allowed values and the key for error messages

1221 :param t: The data type to which :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` shall be parsed

1222 :param value: The value to be parsed

1223 :raises ValueError: if :paramref:`~confattr.configfile.ConfigFile.parse_value_part.value` is invalid

1224 '''

1225 return t.parse_value(self, value)

1226

1227

1228 def expand(self, arg: str) -> str:

1229 return self.expand_config(self.expand_env(arg))

1230

1231 reo_config = re.compile(r'%([^%]*)%')

1232 def expand_config(self, arg: str) -> str:

1233 n = arg.count('%')

1234 if n % 2 == 1:

1235 raise ParseException("uneven number of percent characters, use %% for a literal percent sign or --raw if you don't want expansion")

1236 return self.reo_config.sub(self.expand_config_match, arg)

1237

1238 reo_env = re.compile(r'\$\{([^{}]*)\}')

1239 def expand_env(self, arg: str) -> str:

1240 return self.reo_env.sub(self.expand_env_match, arg)

1241

1242 def expand_config_match(self, m: 're.Match[str]') -> str:

1243 '''

1244 :param m: A match of :attr:`~confattr.configfile.ConfigFile.reo_config`, group 1 is the :attr:`Config.key <confattr.config.Config.key>` possibly including a ``!conversion`` or a ``:format_spec``

1245 :return: The expanded form of the setting or ``'%'`` if group 1 is empty

1246 :raises ParseException: If ``key``, ``!conversion`` or ``:format_spec`` is invalid

1247

1248 This is based on the `Python Format String Syntax <https://docs.python.org/3/library/string.html#format-string-syntax>`__.

1249

1250 ``field_name`` is the :attr:`~confattr.config.Config.key`.

1251

1252 ``!conversion`` is one of:

1253

1254 - ``!``: :meth:`ConfigFile.format_value() <confattr.configfile.ConfigFile.format_value>`

1255 - ``!r``: :func:`repr`

1256 - ``!s``: :class:`str`

1257 - ``!a``: :func:`ascii`

1258

1259 ``:format_spec`` depends on the :attr:`Config.type <confattr.config.Config.type>`, see the `Python Format Specification Mini-Language <https://docs.python.org/3/library/string.html#formatspec>`__.

1260 :meth:`List() <confattr.formatters.List.expand_value>`, :meth:`Set() <confattr.formatters.Set.expand_value>` and :meth:`Dict() <confattr.formatters.Dict.expand_value>` implement :meth:`~confattr.formatters.AbstractFormatter.expand_value` so that you can access specific items.

1261 If :meth:`~confattr.formatters.AbstractFormatter.expand_value` raises an :class:`Exception` it is caught and reraised as a :class:`~confattr.configfile.ParseException`.

1262 '''

1263 key = m.group(1)

1264 if not key:

1265 return '%'

1266

1267 if ':' in key:

1268 key, fmt = key.split(':', 1)

1269 else:

1270 fmt = None

1271 if '!' in key:

1272 key, stringifier = key.split('!', 1)

1273 else:

1274 stringifier = None

1275

1276 if key not in self.config_instances:

1277 raise ParseException(f'invalid key {key!r}')

1278 instance = self.config_instances[key]

1279

1280 if stringifier is None and fmt is None:

1281 return self.format_value(instance, config_id=None)

1282 elif stringifier is None:

1283 assert fmt is not None

1284 try:

1285 return instance.type.expand_value(self, instance.get_value(config_id=None), format_spec=fmt)

1286 except Exception as e:

1287 raise ParseException(e)

1288

1289 val: object

1290 if stringifier == '':

1291 val = self.format_value(instance, config_id=None)

1292 else:

1293 val = instance.get_value(config_id=None)

1294 if stringifier == 'r':

1295 val = repr(val)

1296 elif stringifier == 's':

1297 val = str(val)

1298 elif stringifier == 'a':

1299 val = ascii(val)

1300 else:

1301 raise ParseException('invalid conversion %r' % stringifier)

1302

1303 if fmt is None:

1304 assert isinstance(val, str)

1305 return val

1306

1307 try:

1308 return format(val, fmt)

1309 except ValueError as e:

1310 raise ParseException(e)

1311

1312 def expand_env_match(self, m: 're.Match[str]') -> str:

1313 '''

1314 :param m: A match of :attr:`~confattr.configfile.ConfigFile.reo_env`, group 1 is the name of the environment variable possibly including one of the following expansion features

1315 :return: The expanded form of the environment variable

1316

1317 Supported are the following `parameter expansion features as defined by POSIX <https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_02>`__, except that word is not expanded:

1318

1319 - ``${parameter:-word}``/``${parameter-word}``: Use Default Values. If parameter is unset (or empty), word shall be substituted; otherwise, the value of parameter shall be substituted.

1320 - ``${parameter:=word}``/``${parameter=word}``: Assign Default Values. If parameter is unset (or empty), word shall be assigned to parameter. In all cases, the final value of parameter shall be substituted.

1321 - ``${parameter:?[word]}``/``${parameter?[word]}``: Indicate Error If Unset (or Empty). If parameter is unset (or empty), a :class:`~confattr.configfile.ParseException` shall be raised with word as message or a default error message if word is omitted. Otherwise, the value of parameter shall be substituted.

1322 - ``${parameter:+word}``/``${parameter+word}``: Use Alternative Value. If parameter is unset (or empty), empty shall be substituted; otherwise, the expansion of word shall be substituted.

1323

1324 In the patterns above, if you use a ``:`` it is checked whether parameter is unset or empty.

1325 If ``:`` is not used the check is only true if parameter is unset, empty is treated as a valid value.

1326 '''

1327 env = m.group(1)

1328 for op in '-=?+':

1329 if ':' + op in env:

1330 env, arg = env.split(':' + op, 1)

1331 isset = bool(os.environ.get(env))

1332 elif op in env:

1333 env, arg = env.split(op, 1)

1334 isset = env in os.environ

1335 else:

1336 continue

1337

1338 val = os.environ.get(env, '')

1339 if op == '-':

1340 if isset:

1341 return val

1342 else:

1343 return arg

1344 elif op == '=':

1345 if isset:

1346 return val

1347 else:

1348 os.environ[env] = arg

1349 return arg

1350 elif op == '?':

1351 if isset:

1352 return val

1353 else:

1354 if not arg:

1355 state = 'empty' if env in os.environ else 'unset'

1356 arg = f'environment variable {env} is {state}'

1357 raise ParseException(arg)

1358 elif op == '+':

1359 if isset:

1360 return arg

1361 else:

1362 return ''

1363 else:

1364 assert False

1365

1366 return os.environ.get(env, '')

1367

1368

1369 # ------- help -------

1370

1371 def write_help(self, writer: FormattedWriter) -> None:

1372 import platform

1373 formatter = self.create_formatter()

1374 writer.write_lines('The first existing file of the following paths is loaded:')

1375 for path in self.iter_config_paths():

1376 writer.write_line('- %s' % path)

1377

1378 writer.write_line('')

1379 writer.write_line('This can be influenced with the following environment variables:')

1380 if platform.system() == 'Linux': # pragma: no branch

1381 writer.write_line('- XDG_CONFIG_HOME')

1382 writer.write_line('- XDG_CONFIG_DIRS')

1383 for env in self.env_variables:

1384 writer.write_line(f'- {env}')

1385

1386 writer.write_line('')

1387 writer.write_lines(formatter.format_text(f'''\

1388 You can also use environment variables to change the values of the settings listed under `set` command.

1389 The corresponding environment variable name is the name of the setting in all upper case letters

1390 with dots, hypens and spaces replaced by underscores and prefixed with "{self.envprefix}".'''))

1391

1392 writer.write_lines(formatter.format_text('Lines in the config file which start with a %s are ignored.' % ' or '.join('`%s`' % c for c in self.COMMENT_PREFIXES)))

1393

1394 writer.write_lines('The config file may contain the following commands:')

1395 for cmd in self.commands:

1396 names = '|'.join(cmd.get_names())

1397 writer.write_heading(SectionLevel.SECTION, names)

1398 writer.write_lines(cmd.get_help())

1399

1400 def create_formatter(self) -> HelpFormatterWrapper:

1401 return HelpFormatterWrapper(self.formatter_class)

1402

1403 def get_help(self) -> str:

1404 '''

1405 A convenience wrapper around :meth:`~confattr.configfile.ConfigFile.write_help`

1406 to return the help as a str instead of writing it to a file.

1407

1408 This uses :class:`~confattr.configfile.HelpWriter`.

1409 '''

1410 doc = io.StringIO()

1411 self.write_help(HelpWriter(doc))

1412 # The generated help ends with a \n which is implicitly added by print.

1413 # If I was writing to stdout or a file that would be desired.

1414 # But if I return it as a string and then print it, the print adds another \n which would be too much.

1415 # Therefore I am stripping the trailing \n.

1416 return doc.getvalue().rstrip('\n')

1417

1418

1419 # ------- auto complete -------

1420

1421 def get_completions(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':

1422 '''

1423 Provide an auto completion for commands that can be executed with :meth:`~confattr.configfile.ConfigFile.parse_line`.

1424

1425 :param line: The entire line that is currently in the text input field

1426 :param cursor_pos: The position of the cursor

1427 :return: start of line, completions, end of line.

1428 *completions* is a list of possible completions for the word where the cursor is located.

1429 If *completions* is an empty list there are no completions available and the user input should not be changed.

1430 If *completions* is not empty it should be displayed by a user interface in a drop down menu.

1431 The *start of line* is everything on the line before the completions.

1432 The *end of line* is everything on the line after the completions.

1433 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.

1434 *start of line* and *end of line* should be the beginning and end of :paramref:`~confattr.configfile.ConfigFile.get_completions.line` but they may contain minor changes in order to keep quoting feasible.

1435 '''

1436 original_ln = line

1437 stripped_line = line.lstrip()

1438 indentation = line[:len(line) - len(stripped_line)]

1439 cursor_pos -= len(indentation)

1440 line = stripped_line

1441 if self.enable_config_ids and line.startswith(self.ENTER_GROUP_PREFIX):

1442 out = self.get_completions_enter_group(line, cursor_pos)

1443 else:

1444 out = self.get_completions_command(line, cursor_pos)

1445

1446 out = (indentation + out[0], out[1], out[2])

1447 return out

1448

1449 def get_completions_enter_group(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':

1450 '''

1451 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.

1452

1453 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_enter_group.line`

1454 and will prepend it to the first item of the return value.

1455 '''

1456 start = line

1457 groups = [self.ENTER_GROUP_PREFIX + str(cid) + self.ENTER_GROUP_SUFFIX for cid in MultiConfig.config_ids]

1458 groups = [cid for cid in groups if cid.startswith(start)]

1459 return '', groups, ''

1460

1461 def get_completions_command(self, line: str, cursor_pos: int) -> 'tuple[str, list[str], str]':

1462 '''

1463 For a description of parameters and return type see :meth:`~confattr.configfile.ConfigFile.get_completions`.

1464

1465 :meth:`~confattr.configfile.ConfigFile.get_completions` has stripped any indentation from :paramref:`~confattr.configfile.ConfigFile.get_completions_command.line`

1466 and will prepend it to the first item of the return value.

1467 '''

1468 if not line:

1469 return self.get_completions_command_name(line, cursor_pos, start_of_line='', end_of_line='')

1470

1471 ln_split = self.split_line_ignore_errors(line)

1472 assert ln_split

1473 a = self.find_arg(line, ln_split, cursor_pos)

1474

1475 if a.in_between:

1476 start_of_line = line[:cursor_pos]

1477 end_of_line = line[cursor_pos:]

1478 else:

1479 start_of_line = line[:a.i0]

1480 end_of_line = line[a.i1:]

1481

1482 if a.argument_pos == 0:

1483 return self.get_completions_command_name(line, cursor_pos, start_of_line=start_of_line, end_of_line=end_of_line)

1484 else:

1485 cmd = self.get_command(ln_split)

1486 return cmd.get_completions(ln_split, a.argument_pos, cursor_pos-a.i0, in_between=a.in_between, start_of_line=start_of_line, end_of_line=end_of_line)

1487

1488 def find_arg(self, line: str, ln_split: 'list[str]', cursor_pos: int) -> ArgPos:

1489 '''

1490 This is an internal method used by :meth:`~confattr.configfile.ConfigFile.get_completions_command`

1491 '''

1492 CHARS_REMOVED_BY_SHLEX = ('"', "'", '\\')

1493 assert cursor_pos <= len(line) # yes, cursor_pos can be == len(str)

1494 out = ArgPos()

1495 out.in_between = True

1496

1497 # init all out attributes just to be save, these should not never be used because line is not empty and not white space only

1498 out.argument_pos = 0

1499 out.i0 = 0

1500 out.i1 = 0

1501

1502 n_ln = len(line)

1503 i_ln = 0

1504 n_arg = len(ln_split)

1505 out.argument_pos = 0

1506 i_in_arg = 0

1507 assert out.argument_pos < n_ln

1508 while True:

1509 if out.in_between:

1510 assert i_in_arg == 0

1511 if i_ln >= n_ln:

1512 assert out.argument_pos >= n_arg - 1

1513 out.i0 = i_ln

1514 return out

1515 elif line[i_ln].isspace():

1516 i_ln += 1

1517 else:

1518 out.i0 = i_ln

1519 if i_ln >= cursor_pos:

1520 return out

1521 if out.argument_pos >= n_arg:

1522 assert line[i_ln] == '#'

1523 out.i0 = len(line)

1524 return out

1525 out.in_between = False

1526 else:

1527 if i_ln >= n_ln:

1528 assert out.argument_pos >= n_arg - 1

1529 out.i1 = i_ln

1530 return out

1531 elif i_in_arg >= len(ln_split[out.argument_pos]):

1532 if line[i_ln].isspace():

1533 out.i1 = i_ln

1534 if i_ln >= cursor_pos:

1535 return out

1536 out.in_between = True

1537 i_ln += 1

1538 out.argument_pos += 1

1539 i_in_arg = 0

1540 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX:

1541 i_ln += 1

1542 else:

1543 # unlike bash shlex treats a comment character inside of an argument as a comment character

1544 assert line[i_ln] == '#'

1545 assert out.argument_pos == n_arg - 1

1546 out.i1 = i_ln

1547 return out

1548 elif line[i_ln] == ln_split[out.argument_pos][i_in_arg]:

1549 i_ln += 1

1550 i_in_arg += 1

1551 if out.argument_pos == 0 and i_ln == 1 and self.split_one_symbol_command(line)[0]:

1552 out.in_between = True

1553 out.argument_pos += 1

1554 out.i0 = i_ln

1555 i_in_arg = 0

1556 else:

1557 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX

1558 i_ln += 1

1559

1560

1561 def get_completions_command_name(self, line: str, cursor_pos: int, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

1562 start = line[:cursor_pos]

1563 completions = [cmd for cmd in self.command_dict.keys() if cmd.startswith(start) and len(cmd) > 1]

1564 return start_of_line, completions, end_of_line

1565

1566

1567 def get_completions_for_file_name(self, start: str, *, relative_to: str, include: 'Callable[[str, str], bool]|None' = None, exclude: 'str|None' = None, match: 'Callable[[str, str, str], bool]' = lambda path, name, start: name.startswith(start), start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

1568 r'''

1569 :param start: The start of the path to be completed

1570 :param relative_to: If :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` is a relative path it's relative to this directory

1571 :param exclude: A regular expression. The default value :obj:`None` is interpreted differently depending on the :func:`platform.platform`. For ``Windows`` it's ``$none`` so that nothing is excluded. For others it's ``^\.`` so that hidden files and directories are excluded.

1572 :param include: A function which takes the path and file name as arguments and returns whether this file/directory is a valid completion.

1573 :param match: A callable to decide if a completion fits for the given start. It takes three arguments: the parent directory, the file/directory name and the start. If it returns true the file/direcotry is added to the list of possible completions. The default is ``lambda path, name, start: name.startswith(start)``.

1574 :return: All files and directories that start with :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.start` and do not match :paramref:`~confattr.configfile.ConfigFile.get_completions_for_file_name.exclude`. Directories are appended with :const:`os.path.sep`. :const:`os.path.sep` is appended after quoting so that it can be easily stripped if undesired (e.g. if the user interface cycles through all possible completions instead of completing the longest common prefix).

1575 '''

1576 if exclude is None:

1577 if platform.platform() == 'Windows' or os.path.split(start)[1].startswith('.'):

1578 exclude = '$none'

1579 else:

1580 exclude = r'^\.'

1581 reo = re.compile(exclude)

1582

1583 # I cannot use os.path.split because that would ignore the important difference between having a trailing separator or not

1584 if os.path.sep in start:

1585 directory, start = start.rsplit(os.path.sep, 1)

1586 directory += os.path.sep

1587 quoted_directory = self.quote_path(directory)

1588

1589 start_of_line += quoted_directory

1590 directory = os.path.expanduser(directory)

1591 if not os.path.isabs(directory):

1592 directory = os.path.join(relative_to, directory)

1593 directory = os.path.normpath(directory)

1594 else:

1595 directory = relative_to

1596

1597 try:

1598 names = os.listdir(directory)

1599 except (FileNotFoundError, NotADirectoryError):

1600 return start_of_line, [], end_of_line

1601

1602 out: 'list[str]' = []

1603 for name in names:

1604 if reo.match(name):

1605 continue

1606 if include and not include(directory, name):

1607 continue

1608 if not match(directory, name, start):

1609 continue

1610

1611 quoted_name = self.quote(name)

1612 if os.path.isdir(os.path.join(directory, name)):

1613 quoted_name += os.path.sep

1614

1615 out.append(quoted_name)

1616

1617 return start_of_line, out, end_of_line

1618

1619 def quote_path(self, path: str) -> str:

1620 path_split = path.split(os.path.sep)

1621 i0 = 1 if path_split[0] == '~' else 0

1622 for i in range(i0, len(path_split)):

1623 if path_split[i]:

1624 path_split[i] = self.quote(path_split[i])

1625 return os.path.sep.join(path_split)

1626

1627

1628 def get_completions_for_expand(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':

1629 applicable, start_of_line, completions, end_of_line = self.get_completions_for_expand_env(start, start_of_line=start_of_line, end_of_line=end_of_line)

1630 if applicable:

1631 return applicable, start_of_line, completions, end_of_line

1632

1633 return self.get_completions_for_expand_config(start, start_of_line=start_of_line, end_of_line=end_of_line)

1634

1635 def get_completions_for_expand_config(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':

1636 if start.count('%') % 2 == 0:

1637 return False, start_of_line, [], end_of_line

1638

1639 i = start.rindex('%') + 1

1640 start_of_line = start_of_line + start[:i]

1641 start = start[i:]

1642 completions = [key for key in sorted(self.config_instances.keys()) if key.startswith(start)]

1643 return True, start_of_line, completions, end_of_line

1644

1645 def get_completions_for_expand_env(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[bool, str, list[str], str]':

1646 i = start.rfind('${')

1647 if i < 0:

1648 return False, start_of_line, [], end_of_line

1649 i += 2

1650

1651 if '}' in start[i:]:

1652 return False, start_of_line, [], end_of_line

1653

1654 start_of_line = start_of_line + start[:i]

1655 start = start[i:]

1656 completions = [key for key in sorted(os.environ.keys()) if key.startswith(start)]

1657 return True, start_of_line, completions, end_of_line

1658

1659

1660 # ------- error handling -------

1661

1662 def parse_error(self, msg: str) -> None:

1663 '''

1664 Is called if something went wrong while trying to load a config file.

1665

1666 This method is called when a :class:`~confattr.configfile.ParseException` or :class:`~confattr.configfile.MultipleParseExceptions` is caught.

1667 This method compiles the given information into an error message and calls :meth:`self.ui_notifier.show_error() <confattr.configfile.UiNotifier.show_error>`.

1668

1669 :param msg: The error message

1670 '''

1671 self.ui_notifier.show_error(msg)

1672

1673

1674# ---------- base classes for commands which can be used in config files ----------

1675

1676 class ConfigFileCommand(abc.ABC):

1677

1678 '''

1679 An abstract base class for commands which can be used in a config file.

1680

1681 Subclasses must implement the :meth:`~confattr.configfile.ConfigFileCommand.run` method which is called when :class:`~confattr.configfile.ConfigFile` is loading a file.

1682 Subclasses should contain a doc string so that :meth:`~confattr.configfile.ConfigFileCommand.get_help` can provide a description to the user.

1683 Subclasses may set the :attr:`~confattr.configfile.ConfigFileCommand.name` and :attr:`~confattr.configfile.ConfigFileCommand.aliases` attributes to change the output of :meth:`~confattr.configfile.ConfigFileCommand.get_name` and :meth:`~confattr.configfile.ConfigFileCommand.get_names`.

1684

1685 All subclasses are remembered and can be retrieved with :meth:`~confattr.configfile.ConfigFileCommand.get_command_types`.

1686 They are instantiated in the constructor of :class:`~confattr.configfile.ConfigFile`.

1687 '''

1688

1689 #: The name which is used in the config file to call this command. Use an empty string to define a default command which is used if an undefined command is encountered. If this is not set :meth:`~confattr.configfile.ConfigFileCommand.get_name` returns the name of this class in lower case letters and underscores replaced by hyphens.

1690 name: str

1691

1692 #: Alternative names which can be used in the config file.

1693 aliases: 'tuple[str, ...]|list[str]'

1694

1695 #: A description which may be used by an in-app help. If this is not set :meth:`~confattr.configfile.ConfigFileCommand.get_help` uses the doc string instead.

1696 help: str

1697

1698 #: If a config file contains only a single section it makes no sense to write a heading for it. This attribute is set by :meth:`ConfigFile.save_to_writer() <confattr.configfile.ConfigFile.save_to_writer>` if there are several commands which implement the :meth:`~confattr.configfile.ConfigFileCommand.save` method. If you implement :meth:`~confattr.configfile.ConfigFileCommand.save` and this attribute is set then :meth:`~confattr.configfile.ConfigFileCommand.save` should write a section header. If :meth:`~confattr.configfile.ConfigFileCommand.save` writes several sections it should always write the headings regardless of this attribute.

1699 should_write_heading: bool = False

1700

1701 #: The :class:`~confattr.configfile.ConfigFile` that has been passed to the constructor. It determines for example the :paramref:`~confattr.configfile.ConfigFile.notification_level` and the available :paramref:`~confattr.configfile.ConfigFile.commands`.

1702 config_file: ConfigFile

1703

1704 #: The :class:`~confattr.configfile.UiNotifier` of :attr:`~confattr.configfile.ConfigFileCommand.config_file`

1705 ui_notifier: UiNotifier

1706

1707 _abstract: bool

1708

1709

1710 _subclasses: 'list[type[ConfigFileCommand]]' = []

1711 _used_names: 'set[str]' = set()

1712

1713 @classmethod

1714 def get_command_types(cls) -> 'tuple[type[ConfigFileCommand], ...]':

1715 '''

1716 :return: All subclasses of :class:`~confattr.configfile.ConfigFileCommand` which have not been deleted with :meth:`~confattr.configfile.ConfigFileCommand.delete_command_type`

1717 '''

1718 return tuple(cls._subclasses)

1719

1720 @classmethod

1721 def delete_command_type(cls, cmd_type: 'type[ConfigFileCommand]') -> None:

1722 '''

1723 Delete :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` so that it is not returned anymore by :meth:`~confattr.configfile.ConfigFileCommand.get_command_types` and that it's name can be used by another command.

1724 Do nothing if :paramref:`~confattr.configfile.ConfigFileCommand.delete_command_type.cmd_type` has already been deleted.

1725 '''

1726 if cmd_type in cls._subclasses:

1727 cls._subclasses.remove(cmd_type)

1728 for name in cmd_type.get_names():

1729 cls._used_names.remove(name)

1730

1731 @classmethod

1732 def __init_subclass__(cls, replace: bool = False, abstract: bool = False) -> None:

1733 '''

1734 :param replace: Set :attr:`~confattr.configfile.ConfigFileCommand.name` and :attr:`~confattr.configfile.ConfigFileCommand.aliases` to the values of the parent class if they are not set explicitly, delete the parent class with :meth:`~confattr.configfile.ConfigFileCommand.delete_command_type` and replace any commands with the same name

1735 :param abstract: This class is a base class for the implementation of other commands and shall *not* be returned by :meth:`~confattr.configfile.ConfigFileCommand.get_command_types`

1736 :raises ValueError: if the name or one of it's aliases is already in use and :paramref:`~confattr.configfile.ConfigFileCommand.__init_subclass__.replace` is not true

1737 '''

1738 cls._abstract = abstract

1739 if replace:

1740 parent_commands = [parent for parent in cls.__bases__ if issubclass(parent, ConfigFileCommand)]

1741

1742 # set names of this class to that of the parent class(es)

1743 parent = parent_commands[0]

1744 if 'name' not in cls.__dict__:

1745 cls.name = parent.get_name()

1746 if 'aliases' not in cls.__dict__:

1747 cls.aliases = list(parent.get_names())[1:]

1748 for parent in parent_commands[1:]:

1749 cls.aliases.extend(parent.get_names())

1750

1751 # remove parent class from the list of commands to be loaded or saved

1752 for parent in parent_commands:

1753 cls.delete_command_type(parent)

1754

1755 if not abstract:

1756 cls._subclasses.append(cls)

1757 for name in cls.get_names():

1758 if name in cls._used_names and not replace:

1759 raise ValueError('duplicate command name %r' % name)

1760 cls._used_names.add(name)

1761

1762 @classmethod

1763 def get_name(cls) -> str:

1764 '''

1765 :return: The name which is used in config file to call this command.

1766

1767 If :attr:`~confattr.configfile.ConfigFileCommand.name` is set it is returned as it is.

1768 Otherwise a name is generated based on the class name.

1769 '''

1770 if 'name' in cls.__dict__:

1771 return cls.name

1772 return cls.__name__.lower().replace("_", "-")

1773

1774 @classmethod

1775 def get_names(cls) -> 'Iterator[str]':

1776 '''

1777 :return: Several alternative names which can be used in a config file to call this command.

1778

1779 The first one is always the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_name`.

1780 If :attr:`~confattr.configfile.ConfigFileCommand.aliases` is set it's items are yielded afterwards.

1781

1782 If one of the returned items is the empty string this class is the default command

1783 and :meth:`~confattr.configfile.ConfigFileCommand.run` will be called if an undefined command is encountered.

1784 '''

1785 yield cls.get_name()

1786 if 'aliases' in cls.__dict__:

1787 for name in cls.aliases:

1788 yield name

1789

1790 def __init__(self, config_file: ConfigFile) -> None:

1791 self.config_file = config_file

1792 self.ui_notifier = config_file.ui_notifier

1793

1794 @abc.abstractmethod

1795 def run(self, cmd: 'Sequence[str]') -> None:

1796 '''

1797 Process one line which has been read from a config file

1798

1799 :raises ParseException: if there is an error in the line (e.g. invalid syntax)

1800 :raises MultipleParseExceptions: if there are several errors in the same line

1801 '''

1802 raise NotImplementedError()

1803

1804

1805 def create_formatter(self) -> HelpFormatterWrapper:

1806 return self.config_file.create_formatter()

1807

1808 def get_help_attr_or_doc_str(self) -> str:

1809 '''

1810 :return: The :attr:`~confattr.configfile.ConfigFileCommand.help` attribute or the doc string if :attr:`~confattr.configfile.ConfigFileCommand.help` has not been set, cleaned up with :func:`inspect.cleandoc`.

1811 '''

1812 if hasattr(self, 'help'):

1813 doc = self.help

1814 elif self.__doc__:

1815 doc = self.__doc__

1816 else:

1817 doc = ''

1818

1819 return inspect.cleandoc(doc)

1820

1821 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:

1822 '''

1823 Add the return value of :meth:`~confattr.configfile.ConfigFileCommand.get_help_attr_or_doc_str` to :paramref:`~confattr.configfile.ConfigFileCommand.add_help_to.formatter`.

1824 '''

1825 formatter.add_text(self.get_help_attr_or_doc_str())

1826

1827 def get_help(self) -> str:

1828 '''

1829 :return: A help text which can be presented to the user.

1830

1831 This is generated by creating a formatter with :meth:`~confattr.configfile.ConfigFileCommand.create_formatter`,

1832 adding the help to it with :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` and

1833 stripping trailing new line characters from the result of :meth:`HelpFormatterWrapper.format_help() <confattr.utils.HelpFormatterWrapper.format_help>`.

1834

1835 Most likely you don't want to override this method but :meth:`~confattr.configfile.ConfigFileCommand.add_help_to` instead.

1836 '''

1837 formatter = self.create_formatter()

1838 self.add_help_to(formatter)

1839 return formatter.format_help().rstrip('\n')

1840

1841 def get_short_description(self) -> str:

1842 '''

1843 :return: The first paragraph of the doc string/help attribute

1844 '''

1845 out = self.get_help_attr_or_doc_str().split('\n\n')

1846 if out[0].startswith('usage: '):

1847 if len(out) > 1:

1848 return out[1]

1849 return ""

1850 return out[0]

1851

1852 def save(self,

1853 writer: FormattedWriter,

1854 **kw: 'Unpack[SaveKwargs]',

1855 ) -> None:

1856 '''

1857 Implement this method if you want calls to this command to be written by :meth:`ConfigFile.save() <confattr.configfile.ConfigFile.save>`.

1858

1859 If you implement this method write a section heading with :meth:`writer.write_heading('Heading') <confattr.configfile.FormattedWriter.write_heading>` if :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading` is true.

1860 If this command writes several sections then write a heading for every section regardless of :attr:`~confattr.configfile.ConfigFileCommand.should_write_heading`.

1861

1862 Write as many calls to this command as necessary to the config file in order to create the current state with :meth:`writer.write_command('...') <confattr.configfile.FormattedWriter.write_command>`.

1863 Write comments or help with :meth:`writer.write_lines('...') <confattr.configfile.FormattedWriter.write_lines>`.

1864

1865 There is the :attr:`~confattr.configfile.ConfigFileCommand.config_file` attribute (which was passed to the constructor) which you can use to:

1866

1867 - quote arguments with :meth:`ConfigFile.quote() <confattr.configfile.ConfigFile.quote>`

1868 - call :meth:`ConfigFile.write_config_id() <confattr.configfile.ConfigFile.write_config_id>`

1869

1870 You probably don't need the comment character :attr:`ConfigFile.COMMENT <confattr.configfile.ConfigFile.COMMENT>` because :paramref:`~confattr.configfile.ConfigFileCommand.save.writer` automatically comments out everything except for :meth:`FormattedWriter.write_command() <confattr.configfile.FormattedWriter.write_command>`.

1871

1872 The default implementation does nothing.

1873 '''

1874 pass

1875

1876 save.implemented = False # type: ignore [attr-defined]

1877

1878

1879 # ------- auto complete -------

1880

1881 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

1882 '''

1883 :param cmd: The line split into arguments (including the name of this command as cmd[0])

1884 :param argument_pos: The index of the argument which shall be completed. Please note that this can be one bigger than :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.cmd` is long if the line ends on a space and the cursor is behind that space. In that case :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.in_between` is true.

1885 :param cursor_pos: The index inside of the argument where the cursor is located. This is undefined and should be ignored if :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.in_between` is true. The input from the start of the argument to the cursor should be used to filter the completions. The input after the cursor can be ignored.

1886 :param in_between: If true: The cursor is between two arguments, before the first argument or after the last argument. :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.argument_pos` refers to the next argument, :paramref:`argument_pos-1 <confattr.configfile.ConfigFileCommand.get_completions.argument_pos>` to the previous argument. :paramref:`~confattr.configfile.ConfigFileCommand.get_completions.cursor_pos` is undefined.

1887 :param start_of_line: The first return value. If ``cmd[argument_pos]`` has a pattern like ``key=value`` you can append ``key=`` to this value and return only completions of ``value`` as second return value.

1888 :param end_of_line: The third return value.

1889 :return: start of line, completions, end of line.

1890 *completions* is a list of possible completions for the word where the cursor is located.

1891 If *completions* is an empty list there are no completions available and the user input should not be changed.

1892 This should be displayed by a user interface in a drop down menu.

1893 The *start of line* is everything on the line before the completions.

1894 The *end of line* is everything on the line after the completions.

1895 In the likely case that the cursor is at the end of the line the *end of line* is an empty str.

1896 '''

1897 completions: 'list[str]' = []

1898 return start_of_line, completions, end_of_line

1899

1900

1901 class ArgumentParser(argparse.ArgumentParser):

1902

1903 def error(self, message: str) -> 'typing.NoReturn':

1904 '''

1905 Raise a :class:`~confattr.configfile.ParseException`.

1906 '''

1907 raise ParseException(message)

1908

1909 class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True):

1910

1911 '''

1912 An abstract subclass of :class:`~confattr.configfile.ConfigFileCommand` which uses :mod:`argparse` to make parsing and providing help easier.

1913

1914 You must implement the class method :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` to add the arguments to :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.

1915 Instead of :meth:`~confattr.configfile.ConfigFileArgparseCommand.run` you must implement :meth:`~confattr.configfile.ConfigFileArgparseCommand.run_parsed`.

1916 You don't need to add a usage or the possible arguments to the doc string as :mod:`argparse` will do that for you.

1917 You should, however, still give a description what this command does in the doc string.

1918

1919 You may specify :attr:`ConfigFileCommand.name <confattr.configfile.ConfigFileCommand.name>`, :attr:`ConfigFileCommand.aliases <confattr.configfile.ConfigFileCommand.aliases>` and :meth:`ConfigFileCommand.save() <confattr.configfile.ConfigFileCommand.save>` like for :class:`~confattr.configfile.ConfigFileCommand`.

1920 '''

1921

1922 #: The argument parser which is passed to :meth:`~confattr.configfile.ConfigFileArgparseCommand.init_parser` for adding arguments and which is used in :meth:`~confattr.configfile.ConfigFileArgparseCommand.run`

1923 parser: ArgumentParser

1924

1925 def __init__(self, config_file: ConfigFile) -> None:

1926 super().__init__(config_file)

1927 self._names = set(self.get_names())

1928 self.parser = ArgumentParser(prog=self.get_name(), description=self.get_help_attr_or_doc_str(), add_help=False, formatter_class=self.config_file.formatter_class)

1929 self.init_parser(self.parser)

1930

1931 @abc.abstractmethod

1932 def init_parser(self, parser: ArgumentParser) -> None:

1933 '''

1934 :param parser: The parser to add arguments to. This is the same object like :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.

1935

1936 This is an abstract method which must be implemented by subclasses.

1937 Use :meth:`ArgumentParser.add_argument() <confattr.configfile.ArgumentParser.add_argument>` to add arguments to :paramref:`~confattr.configfile.ConfigFileArgparseCommand.init_parser.parser`.

1938 '''

1939 pass

1940

1941 @staticmethod

1942 def add_enum_argument(parser: 'argparse.ArgumentParser|argparse._MutuallyExclusiveGroup', *name_or_flags: str, type: 'type[enum.Enum]') -> 'argparse.Action':

1943 '''

1944 This method:

1945

1946 - generates a function to convert the user input to an element of the enum

1947 - gives the function the name of the enum in lower case (argparse uses this in error messages)

1948 - generates a help string containing the allowed values

1949

1950 and adds an argument to the given argparse parser with that.

1951 '''

1952 def parse(name: str) -> enum.Enum:

1953 for v in type:

1954 if v.name.lower() == name:

1955 return v

1956 raise TypeError()

1957 parse.__name__ = type.__name__.lower()

1958 choices = ', '.join(v.name.lower() for v in type)

1959 return parser.add_argument(*name_or_flags, type=parse, help="one of " + choices)

1960

1961 def get_help(self) -> str:

1962 '''

1963 Creates a help text which can be presented to the user by calling :meth:`~confattr.configfile.ArgumentParser.format_help` on :attr:`~confattr.configfile.ConfigFileArgparseCommand.parser`.

1964 The return value of :meth:`~confattr.configfile.ConfigFileArgparseCommand.get_help_attr_or_doc_str` has been passed as :paramref:`~confattr.configfile.ArgumentParser.description` to the constructor of :class:`~confattr.configfile.ArgumentParser`, therefore :attr:`~confattr.configfile.ConfigFileArgparseCommand.help`/the doc string are included as well.

1965 '''

1966 return self.parser.format_help().rstrip('\n')

1967

1968 def run(self, cmd: 'Sequence[str]') -> None:

1969 # if the line was empty this method should not be called but an empty line should be ignored either way

1970 if not cmd:

1971 return # pragma: no cover

1972 # cmd[0] does not need to be in self._names if this is the default command, i.e. if '' in self._names

1973 if cmd[0] in self._names:

1974 cmd = cmd[1:]

1975 args = self.parser.parse_args(cmd)

1976 self.run_parsed(args)

1977

1978 @abc.abstractmethod

1979 def run_parsed(self, args: argparse.Namespace) -> None:

1980 '''

1981 This is an abstract method which must be implemented by subclasses.

1982 '''

1983 pass

1984

1985 # ------- auto complete -------

1986

1987 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

1988 if in_between:

1989 start = ''

1990 else:

1991 start = cmd[argument_pos][:cursor_pos]

1992

1993 if self.after_positional_argument_marker(cmd, argument_pos):

1994 pos = self.get_position(cmd, argument_pos)

1995 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

1996

1997 if argument_pos > 0: # pragma: no branch # if argument_pos was 0 this method would not be called, command names would be completed instead

1998 prevarg = self.get_option_name_if_it_takes_an_argument(cmd, argument_pos-1)

1999 if prevarg:

2000 return self.get_completions_for_option_argument(prevarg, start, start_of_line=start_of_line, end_of_line=end_of_line)

2001

2002 if self.is_option_start(start):

2003 if '=' in start:

2004 i = start.index('=')

2005 option_name = start[:i]

2006 i += 1

2007 start_of_line += start[:i]

2008 start = start[i:]

2009 return self.get_completions_for_option_argument(option_name, start, start_of_line=start_of_line, end_of_line=end_of_line)

2010 return self.get_completions_for_option_name(start, start_of_line=start_of_line, end_of_line=end_of_line)

2011

2012 pos = self.get_position(cmd, argument_pos)

2013 return self.get_completions_for_positional_argument(pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

2014

2015 def get_position(self, cmd: 'Sequence[str]', argument_pos: int) -> int:

2016 '''

2017 :return: the position of a positional argument, not counting options and their arguments

2018 '''

2019 pos = 0

2020 n = len(cmd)

2021 options_allowed = True

2022 # I am starting at 1 because cmd[0] is the name of the command, not an argument

2023 for i in range(1, argument_pos):

2024 if options_allowed and i < n:

2025 if cmd[i] == '--':

2026 options_allowed = False

2027 continue

2028 elif self.is_option_start(cmd[i]):

2029 continue

2030 # > 1 because cmd[0] is the name of the command

2031 elif i > 1 and self.get_option_name_if_it_takes_an_argument(cmd, i-1):

2032 continue

2033 pos += 1

2034

2035 return pos

2036

2037 def is_option_start(self, start: str) -> bool:

2038 return start.startswith('-') or start.startswith('+')

2039

2040 def after_positional_argument_marker(self, cmd: 'Sequence[str]', argument_pos: int) -> bool:

2041 '''

2042 :return: true if this can only be a positional argument. False means it can be both, option or positional argument.

2043 '''

2044 return '--' in cmd and cmd.index('--') < argument_pos

2045

2046 def get_option_name_if_it_takes_an_argument(self, cmd: 'Sequence[str]', argument_pos: int) -> 'str|None':

2047 if argument_pos >= len(cmd):

2048 return None # pragma: no cover # this does not happen because this method is always called for the previous argument

2049

2050 arg = cmd[argument_pos]

2051 if '=' in arg:

2052 # argument of option is already given within arg

2053 return None

2054 if not self.is_option_start(arg):

2055 return None

2056 if arg.startswith('--'):

2057 action = self.get_action_for_option(arg)

2058 if action is None:

2059 return None

2060 if action.nargs != 0:

2061 return arg

2062 return None

2063

2064 # arg is a combination of single character flags like in `tar -xzf file`

2065 for c in arg[1:-1]:

2066 action = self.get_action_for_option('-' + c)

2067 if action is None:

2068 continue

2069 if action.nargs != 0:

2070 # c takes an argument but that is already given within arg

2071 return None

2072

2073 out = '-' + arg[-1]

2074 action = self.get_action_for_option(out)

2075 if action is None:

2076 return None

2077 if action.nargs != 0:

2078 return out

2079 return None

2080

2081

2082 def get_completions_for_option_name(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2083 completions = []

2084 for a in self.parser._get_optional_actions():

2085 for opt in a.option_strings:

2086 if len(opt) <= 2:

2087 # this is trivial to type but not self explanatory

2088 # => not helpful for auto completion

2089 continue

2090 if opt.startswith(start):

2091 completions.append(opt)

2092 return start_of_line, completions, end_of_line

2093

2094 def get_completions_for_option_argument(self, option_name: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2095 return self.get_completions_for_action(self.get_action_for_option(option_name), start, start_of_line=start_of_line, end_of_line=end_of_line)

2096

2097 def get_completions_for_positional_argument(self, position: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2098 return self.get_completions_for_action(self.get_action_for_positional_argument(position), start, start_of_line=start_of_line, end_of_line=end_of_line)

2099

2100

2101 def get_action_for_option(self, option_name: str) -> 'argparse.Action|None':

2102 for a in self.parser._get_optional_actions():

2103 if option_name in a.option_strings:

2104 return a

2105 return None

2106

2107 def get_action_for_positional_argument(self, argument_pos: int) -> 'argparse.Action|None':

2108 actions = self.parser._get_positional_actions()

2109 if argument_pos < len(actions):

2110 return actions[argument_pos]

2111 return None

2112

2113 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2114 if action is None:

2115 completions: 'list[str]' = []

2116 elif not action.choices:

2117 completions = []

2118 else:

2119 completions = [str(val) for val in action.choices]

2120 completions = [val for val in completions if val.startswith(start)]

2121 completions = [self.config_file.quote(val) for val in completions]

2122 return start_of_line, completions, end_of_line

2123

2124

2125# ---------- implementations of commands which can be used in config files ----------

2126

2127 class Set(ConfigFileCommand):

2128

2129 r'''

2130 usage: set [--raw] key1=val1 [key2=val2 ...] \\

2131 set [--raw] key [=] val

2132

2133 Change the value of a setting.

2134

2135 In the first form set takes an arbitrary number of arguments, each argument sets one setting.

2136 This has the advantage that several settings can be changed at once.

2137 That is useful if you want to bind a set command to a key and process that command with ConfigFile.parse_line() if the key is pressed.

2138

2139 In the second form set takes two arguments, the key and the value. Optionally a single equals character may be added in between as third argument.

2140 This has the advantage that key and value are separated by one or more spaces which can improve the readability of a config file.

2141

2142 You can use the value of another setting with %other.key% or an environment variable with ${ENV_VAR}.

2143 If you want to insert a literal percent character use two of them: %%.

2144 You can disable expansion of settings and environment variables with the --raw flag.

2145 '''

2146

2147 #: The separator which is used between a key and it's value

2148 KEY_VAL_SEP = '='

2149

2150 FLAGS_RAW = ('-r', '--raw')

2151

2152 raw = False

2153

2154 # ------- load -------

2155

2156 def run(self, cmd: 'Sequence[str]') -> None:

2157 '''

2158 Call :meth:`~confattr.configfile.Set.set_multiple` if the first argument contains :attr:`~confattr.configfile.Set.KEY_VAL_SEP` otherwise :meth:`~confattr.configfile.Set.set_with_spaces`.

2159

2160 :raises ParseException: if something is wrong (no arguments given, invalid syntax, invalid key, invalid value)

2161 '''

2162 if self.is_vim_style(cmd):

2163 self.set_multiple(cmd)

2164 else:

2165 self.set_with_spaces(cmd)

2166

2167 def is_vim_style(self, cmd: 'Sequence[str]') -> bool:

2168 '''

2169 :paramref:`~confattr.configfile.Set.is_vim_style.cmd` has one of two possible styles:

2170 - vim inspired: set takes an arbitrary number of arguments, each argument sets one setting. Is handled by :meth:`~confattr.configfile.Set.set_multiple`.

2171 - ranger inspired: set takes two arguments, the key and the value. Optionally a single equals character may be added in between as third argument. Is handled by :meth:`~confattr.configfile.Set.set_with_spaces`.

2172

2173 :return: true if cmd has a vim inspired style, false if cmd has a ranger inspired style

2174 '''

2175 try:

2176 # cmd[0] is the name of the command, cmd[1] is the first argument

2177 if cmd[1] in self.FLAGS_RAW:

2178 i = 2

2179 else:

2180 i = 1

2181 return self.KEY_VAL_SEP in cmd[i]

2182 except IndexError:

2183 raise ParseException('no settings given')

2184

2185 def set_with_spaces(self, cmd: 'Sequence[str]') -> None:

2186 '''

2187 Process one line of the format ``set key [=] value``

2188

2189 :raises ParseException: if something is wrong (invalid syntax, invalid key, invalid value)

2190 '''

2191 if cmd[1] in self.FLAGS_RAW:

2192 cmd = cmd[2:]

2193 self.raw = True

2194 else:

2195 cmd = cmd[1:]

2196 self.raw = False

2197

2198 n = len(cmd)

2199 if n == 2:

2200 key, value = cmd

2201 self.parse_key_and_set_value(key, value)

2202 elif n == 3:

2203 key, sep, value = cmd

2204 if sep != self.KEY_VAL_SEP:

2205 raise ParseException(f'separator between key and value should be {self.KEY_VAL_SEP}, not {sep!r}')

2206 self.parse_key_and_set_value(key, value)

2207 elif n == 1:

2208 raise ParseException(f'missing value or missing {self.KEY_VAL_SEP}')

2209 else:

2210 assert n >= 4

2211 raise ParseException(f'too many arguments given or missing {self.KEY_VAL_SEP} in first argument')

2212

2213 def set_multiple(self, cmd: 'Sequence[str]') -> None:

2214 '''

2215 Process one line of the format ``set key=value [key2=value2 ...]``

2216

2217 :raises MultipleParseExceptions: if something is wrong (invalid syntax, invalid key, invalid value)

2218 '''

2219 self.raw = False

2220 exceptions = []

2221 for arg in cmd[1:]:

2222 if arg in self.FLAGS_RAW:

2223 self.raw = True

2224 continue

2225 try:

2226 if not self.KEY_VAL_SEP in arg:

2227 raise ParseException(f'missing {self.KEY_VAL_SEP} in {arg!r}')

2228 key, value = arg.split(self.KEY_VAL_SEP, 1)

2229 self.parse_key_and_set_value(key, value)

2230 except ParseException as e:

2231 exceptions.append(e)

2232 if exceptions:

2233 raise MultipleParseExceptions(exceptions)

2234

2235 def parse_key_and_set_value(self, key: str, value: str) -> None:

2236 '''

2237 Find the corresponding :class:`~confattr.config.Config` instance for :paramref:`~confattr.configfile.Set.parse_key_and_set_value.key` and call :meth:`~confattr.configfile.Set.set_value` with the return value of :meth:`config_file.parse_value() <confattr.configfile.ConfigFile.parse_value>`.

2238

2239 :raises ParseException: if key is invalid or if :meth:`config_file.parse_value() <confattr.configfile.ConfigFile.parse_value>` or :meth:`~confattr.configfile.Set.set_value` raises a :class:`ValueError`

2240 '''

2241 if key not in self.config_file.config_instances:

2242 raise ParseException(f'invalid key {key!r}')

2243

2244 instance = self.config_file.config_instances[key]

2245 try:

2246 self.set_value(instance, self.config_file.parse_value(instance, value, raw=self.raw))

2247 except ValueError as e:

2248 raise ParseException(str(e))

2249

2250 def set_value(self, instance: 'Config[T2]', value: 'T2') -> None:

2251 '''

2252 Assign :paramref:`~confattr.configfile.Set.set_value.value` to :paramref`instance` by calling :meth:`Config.set_value() <confattr.config.Config.set_value>` with :attr:`ConfigFile.config_id <confattr.configfile.ConfigFile.config_id>` of :attr:`~confattr.configfile.Set.config_file`.

2253 Afterwards call :meth:`UiNotifier.show_info() <confattr.configfile.UiNotifier.show_info>`.

2254 '''

2255 instance.set_value(self.config_file.config_id, value)

2256 self.ui_notifier.show_info(f'set {instance.key} to {self.config_file.format_value(instance, self.config_file.config_id)}')

2257

2258

2259 # ------- save -------

2260

2261 def iter_config_instances_to_be_saved(self,

2262 config_instances: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]',

2263 ignore: 'Iterable[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]|None' = None,

2264 ) -> 'Iterator[Config[object]]':

2265 '''

2266 Iterate over all :class:`~confattr.config.Config` instances yielded from :meth:`ConfigFile.iter_config_instances() <confattr.configfile.ConfigFile.iter_config_instances>` and yield all instances where :meth:`Config.wants_to_be_exported() <confattr.config.Config.wants_to_be_exported>` returns true.

2267 '''

2268 for config in self.config_file.iter_config_instances(config_instances, ignore):

2269 if config.wants_to_be_exported():

2270 yield config

2271

2272 #: A temporary variable used in :meth:`~confattr.configfile.Set.write_config_help` to prevent repeating the help of several :class:`~confattr.config.Config` instances belonging to the same :class:`~confattr.config.DictConfig`. It is reset in :meth:`~confattr.configfile.Set.save`.

2273 last_name: 'str|None'

2274

2275 def save(self, writer: FormattedWriter, **kw: 'Unpack[SaveKwargs]') -> None:

2276 '''

2277 :param writer: The file to write to

2278 :param bool no_multi: If true: treat :class:`~confattr.config.MultiConfig` instances like normal :class:`~confattr.config.Config` instances and only write their default value. If false: Separate :class:`~confattr.config.MultiConfig` instances and print them once for every :attr:`MultiConfig.config_ids <confattr.config.MultiConfig.config_ids>`.

2279 :param bool comments: If false: don't write help for data types

2280

2281 Iterate over all :class:`~confattr.config.Config` instances with :meth:`~confattr.configfile.Set.iter_config_instances_to_be_saved`,

2282 split them into normal :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig` and write them with :meth:`~confattr.configfile.Set.save_config_instance`.

2283 But before that set :attr:`~confattr.configfile.Set.last_name` to None (which is used by :meth:`~confattr.configfile.Set.write_config_help`)

2284 and write help for data types based on :meth:`~confattr.configfile.Set.get_help_for_data_types`.

2285 '''

2286 no_multi = kw['no_multi']

2287 comments = kw['comments']

2288

2289 config_instances = list(self.iter_config_instances_to_be_saved(config_instances=kw['config_instances'], ignore=kw['ignore']))

2290 normal_configs = []

2291 multi_configs = []

2292 if no_multi:

2293 normal_configs = config_instances

2294 else:

2295 for instance in config_instances:

2296 if isinstance(instance, MultiConfig):

2297 multi_configs.append(instance)

2298 else:

2299 normal_configs.append(instance)

2300

2301 self.last_name: 'str|None' = None

2302

2303 if normal_configs:

2304 if multi_configs:

2305 writer.write_heading(SectionLevel.SECTION, 'Application wide settings')

2306 elif self.should_write_heading:

2307 writer.write_heading(SectionLevel.SECTION, 'Settings')

2308

2309 if comments:

2310 type_help = self.get_help_for_data_types(normal_configs)

2311 if type_help:

2312 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')

2313 writer.write_lines(type_help)

2314

2315 for instance in normal_configs:

2316 self.save_config_instance(writer, instance, config_id=None, **kw)

2317

2318 if multi_configs:

2319 if normal_configs:

2320 writer.write_heading(SectionLevel.SECTION, 'Settings which can have different values for different objects')

2321 elif self.should_write_heading:

2322 writer.write_heading(SectionLevel.SECTION, 'Settings')

2323

2324 if comments:

2325 type_help = self.get_help_for_data_types(multi_configs)

2326 if type_help:

2327 writer.write_heading(SectionLevel.SUB_SECTION, 'Data types')

2328 writer.write_lines(type_help)

2329

2330 for instance in multi_configs:

2331 self.save_config_instance(writer, instance, config_id=instance.default_config_id, **kw)

2332

2333 for config_id in MultiConfig.config_ids:

2334 writer.write_line('')

2335 self.config_file.write_config_id(writer, config_id)

2336 for instance in multi_configs:

2337 self.save_config_instance(writer, instance, config_id, **kw)

2338

2339 def save_config_instance(self, writer: FormattedWriter, instance: 'Config[object]', config_id: 'ConfigId|None', **kw: 'Unpack[SaveKwargs]') -> None:

2340 '''

2341 :param writer: The file to write to

2342 :param instance: The config value to be saved

2343 :param config_id: Which value to be written in case of a :class:`~confattr.config.MultiConfig`, should be :obj:`None` for a normal :class:`~confattr.config.Config` instance

2344 :param bool comments: If true: call :meth:`~confattr.configfile.Set.write_config_help`

2345

2346 Convert the :class:`~confattr.config.Config` instance into a value str with :meth:`config_file.format_value() <confattr.configfile.ConfigFile.format_value>`,

2347 wrap it in quotes if necessary with :meth:`config_file.quote() <confattr.configfile.ConfigFile.quote>` and write it to :paramref:`~confattr.configfile.Set.save_config_instance.writer`.

2348 '''

2349 if kw['comments']:

2350 self.write_config_help(writer, instance)

2351 value = self.config_file.format_value(instance, config_id)

2352 value = self.config_file.quote(value)

2353 if '%' in value or '${' in value:

2354 raw = ' --raw'

2355 else:

2356 raw = ''

2357 ln = f'{self.get_name()}{raw} {instance.key} = {value}'

2358 writer.write_command(ln)

2359

2360 def write_config_help(self, writer: FormattedWriter, instance: Config[typing.Any], *, group_dict_configs: bool = True) -> None:

2361 '''

2362 :param writer: The output to write to

2363 :param instance: The config value to be saved

2364

2365 Write a comment which explains the meaning and usage of this setting

2366 based on :meth:`instance.type.get_description() <confattr.formatters.AbstractFormatter.get_description>` and :attr:`Config.help <confattr.config.Config.help>`.

2367

2368 Use :attr:`~confattr.configfile.Set.last_name` to write the help only once for all :class:`~confattr.config.Config` instances belonging to the same :class:`~confattr.config.DictConfig` instance.

2369 '''

2370 if group_dict_configs and instance.parent is not None:

2371 name = instance.parent.key_changer(instance.parent.key_prefix)

2372 else:

2373 name = instance.key

2374 if name == self.last_name:

2375 return

2376

2377 formatter = HelpFormatterWrapper(self.config_file.formatter_class)

2378 writer.write_heading(SectionLevel.SUB_SECTION, name)

2379 writer.write_lines(formatter.format_text(instance.type.get_description(self.config_file)).rstrip())

2380 #if instance.unit:

2381 # writer.write_line('unit: %s' % instance.unit)

2382 if isinstance(instance.help, dict):

2383 for key, val in instance.help.items():

2384 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)

2385 val = inspect.cleandoc(val)

2386 writer.write_lines(formatter.format_item(bullet=key_name+': ', text=val).rstrip())

2387 elif isinstance(instance.help, str):

2388 writer.write_lines(formatter.format_text(inspect.cleandoc(instance.help)).rstrip())

2389

2390 self.last_name = name

2391

2392

2393 def get_data_type_name_to_help_map(self, config_instances: 'Iterable[Config[object]]') -> 'dict[str, str]':

2394 '''

2395 :param config_instances: All config values to be saved

2396 :return: A dictionary containing the type names as keys and the help as values

2397

2398 The returned dictionary contains the help for all data types except enumerations

2399 which occur in :paramref:`~confattr.configfile.Set.get_data_type_name_to_help_map.config_instances`.

2400 The help is gathered from the :attr:`~confattr.configfile.Set.help` attribute of the type

2401 or :meth:`Primitive.get_help() <confattr.formatters.Primitive.get_help>`.

2402 The help is cleaned up with :func:`inspect.cleandoc`.

2403 '''

2404 help_text: 'dict[str, str]' = {}

2405 for instance in config_instances:

2406 for t in instance.type.get_primitives():

2407 name = t.get_type_name()

2408 if name in help_text:

2409 continue

2410

2411 h = t.get_help(self.config_file)

2412 if not h:

2413 continue

2414 help_text[name] = inspect.cleandoc(h)

2415

2416 return help_text

2417

2418 def add_help_for_data_types(self, formatter: HelpFormatterWrapper, config_instances: 'Iterable[Config[object]]') -> None:

2419 help_map = self.get_data_type_name_to_help_map(config_instances)

2420 if not help_map:

2421 return

2422

2423 for name in sorted(help_map.keys()):

2424 formatter.add_start_section(name)

2425 formatter.add_text(help_map[name])

2426 formatter.add_end_section()

2427

2428 def get_help_for_data_types(self, config_instances: 'Iterable[Config[object]]') -> str:

2429 formatter = self.create_formatter()

2430 self.add_help_for_data_types(formatter, config_instances)

2431 return formatter.format_help().rstrip('\n')

2432

2433 # ------- help -------

2434

2435 def add_help_to(self, formatter: HelpFormatterWrapper) -> None:

2436 super().add_help_to(formatter)

2437

2438 config_instances = list(self.iter_config_instances_to_be_saved(config_instances=self.config_file.config_instances.values()))

2439 self.last_name = None

2440

2441 formatter.add_start_section('data types')

2442 self.add_help_for_data_types(formatter, config_instances)

2443 formatter.add_end_section()

2444

2445 if self.config_file.enable_config_ids:

2446 normal_configs = []

2447 multi_configs = []

2448 for instance in config_instances:

2449 if isinstance(instance, MultiConfig):

2450 multi_configs.append(instance)

2451 else:

2452 normal_configs.append(instance)

2453 else:

2454 normal_configs = config_instances

2455 multi_configs = []

2456

2457 if normal_configs:

2458 if self.config_file.enable_config_ids:

2459 formatter.add_start_section('application wide settings')

2460 else:

2461 formatter.add_start_section('settings')

2462 for instance in normal_configs:

2463 self.add_config_help(formatter, instance)

2464 formatter.add_end_section()

2465

2466 if multi_configs:

2467 formatter.add_start_section('settings which can have different values for different objects')

2468 formatter.add_text(inspect.cleandoc(self.config_file.get_help_config_id()))

2469 for instance in multi_configs:

2470 self.add_config_help(formatter, instance)

2471 formatter.add_end_section()

2472

2473 def add_config_help(self, formatter: HelpFormatterWrapper, instance: Config[typing.Any]) -> None:

2474 formatter.add_start_section(instance.key)

2475 formatter.add_text(instance.type.get_description(self.config_file))

2476 if isinstance(instance.help, dict):

2477 for key, val in instance.help.items():

2478 key_name = self.config_file.format_any_value(instance.type.get_primitives()[-1], key)

2479 val = inspect.cleandoc(val)

2480 formatter.add_item(bullet=key_name+': ', text=val)

2481 elif isinstance(instance.help, str):

2482 formatter.add_text(inspect.cleandoc(instance.help))

2483 formatter.add_end_section()

2484

2485 # ------- auto complete -------

2486

2487 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2488 if argument_pos >= len(cmd):

2489 start = ''

2490 else:

2491 start = cmd[argument_pos][:cursor_pos]

2492

2493 if len(cmd) <= 1:

2494 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2495 elif self.is_vim_style(cmd):

2496 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

2497 else:

2498 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

2499

2500 def get_completions_for_vim_style_arg(self, cmd: 'Sequence[str]', argument_pos: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2501 if self.KEY_VAL_SEP in start:

2502 key, start = start.split(self.KEY_VAL_SEP, 1)

2503 start_of_line += key + self.KEY_VAL_SEP

2504 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line)

2505 else:

2506 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2507

2508 def get_completions_for_ranger_style_arg(self, cmd: 'Sequence[str]', argument_pos: int, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2509 if argument_pos == 1:

2510 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2511 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP):

2512 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line)

2513 else:

2514 return start_of_line, [], end_of_line

2515

2516 def get_completions_for_key(self, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2517 completions = [key for key in self.config_file.config_instances.keys() if key.startswith(start)]

2518 return start_of_line, completions, end_of_line

2519

2520 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2521 applicable, start_of_line, completions, end_of_line = self.config_file.get_completions_for_expand(start, start_of_line=start_of_line, end_of_line=end_of_line)

2522 if applicable:

2523 return start_of_line, completions, end_of_line

2524

2525 instance = self.config_file.config_instances.get(key)

2526 if instance is None:

2527 return start_of_line, [], end_of_line

2528

2529 return instance.type.get_completions(self.config_file, start_of_line, start, end_of_line)

2530

2531

2532 class Include(ConfigFileArgparseCommand):

2533

2534 '''

2535 Load another config file.

2536

2537 This is useful if a config file is getting so big that you want to split it up

2538 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy

2539 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line().

2540 '''

2541

2542 help_config_id = '''

2543 By default the loaded config file starts with which ever config id is currently active.

2544 This is useful if you want to use the same values for several config ids:

2545 Write the set commands without a config id to a separate config file and include this file for every config id where these settings shall apply.

2546

2547 After the include the config id is reset to the config id which was active at the beginning of the include

2548 because otherwise it might lead to confusion if the config id is changed in the included config file.

2549 '''

2550

2551 def init_parser(self, parser: ArgumentParser) -> None:

2552 parser.add_argument('path', help='The config file to load. Slashes are replaced with the directory separator appropriate for the current operating system. If the path contains a space it must be wrapped in single or double quotes.')

2553 if self.config_file.enable_config_ids:

2554 assert parser.description is not None

2555 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id)

2556 group = parser.add_mutually_exclusive_group()

2557 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include')

2558 group.add_argument('--no-reset-config-id-after', action='store_true', help='Treat the included lines as if they were written in the same config file instead of the include command')

2559

2560 self.nested_includes: 'list[str]' = []

2561

2562 def run_parsed(self, args: argparse.Namespace) -> None:

2563 fn_imp = args.path

2564 fn_imp = fn_imp.replace('/', os.path.sep)

2565 fn_imp = os.path.expanduser(fn_imp)

2566 if not os.path.isabs(fn_imp):

2567 fn = self.config_file.context_file_name

2568 if fn is None:

2569 fn = self.config_file.get_save_path()

2570 fn_imp = os.path.join(os.path.dirname(os.path.abspath(fn)), fn_imp)

2571

2572 if fn_imp in self.nested_includes:

2573 raise ParseException(f'circular include of file {fn_imp!r}')

2574 if not os.path.isfile(fn_imp):

2575 raise ParseException(f'no such file {fn_imp!r}')

2576

2577 self.nested_includes.append(fn_imp)

2578

2579 if self.config_file.enable_config_ids and args.no_reset_config_id_after:

2580 self.config_file.load_without_resetting_config_id(fn_imp)

2581 elif self.config_file.enable_config_ids and args.reset_config_id_before:

2582 config_id = self.config_file.config_id

2583 self.config_file.load_file(fn_imp)

2584 self.config_file.config_id = config_id

2585 else:

2586 config_id = self.config_file.config_id

2587 self.config_file.load_without_resetting_config_id(fn_imp)

2588 self.config_file.config_id = config_id

2589

2590 assert self.nested_includes[-1] == fn_imp

2591 del self.nested_includes[-1]

2592

2593 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2594 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action

2595 if action is not None and action.dest == 'path':

2596 return self.config_file.get_completions_for_file_name(start, relative_to=os.path.dirname(self.config_file.get_save_path()), start_of_line=start_of_line, end_of_line=end_of_line)

2597 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)

2598

2599

2600 class Echo(ConfigFileArgparseCommand):

2601

2602 '''

2603 Display a message.

2604

2605 Settings and environment variables are expanded like in the value of a set command.

2606 '''

2607

2608 def init_parser(self, parser: ArgumentParser) -> None:

2609 parser.add_argument('-l', '--level', default=NotificationLevel.INFO, type=NotificationLevel, metavar='{%s}' % ','.join(l.value for l in NotificationLevel.get_instances()), help="The notification level may influence the formatting but messages printed with echo are always displayed regardless of the notification level.")

2610 parser.add_argument('-r', '--raw', action='store_true', help="Do not expand settings and environment variables.")

2611 parser.add_argument('msg', nargs=argparse.ONE_OR_MORE, help="The message to display")

2612

2613 def run_parsed(self, args: argparse.Namespace) -> None:

2614 msg = ' '.join(self.config_file.expand(m) for m in args.msg)

2615 self.ui_notifier.show(args.level, msg, ignore_filter=True)

2616

2617

2618 def get_completions(self, cmd: 'Sequence[str]', argument_pos: int, cursor_pos: int, *, in_between: bool, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2619 if argument_pos >= len(cmd):

2620 start = ''

2621 else:

2622 start = cmd[argument_pos][:cursor_pos]

2623

2624 applicable, start_of_line, completions, end_of_line = self.config_file.get_completions_for_expand(start, start_of_line=start_of_line, end_of_line=end_of_line)

2625 return start_of_line, completions, end_of_line

2626

2627 class Help(ConfigFileArgparseCommand):

2628

2629 '''

2630 Display help.

2631 '''

2632

2633 max_width = 80

2634 max_width_name = 18

2635 min_width_sep = 2

2636 tab_size = 4

2637

2638 def init_parser(self, parser: ArgumentParser) -> None:

2639 parser.add_argument('cmd', nargs='?', help="The command for which you want help")

2640

2641 def run_parsed(self, args: argparse.Namespace) -> None:

2642 if args.cmd:

2643 if args.cmd not in self.config_file.command_dict:

2644 raise ParseException(f"unknown command {args.cmd!r}")

2645 cmd = self.config_file.command_dict[args.cmd]

2646 out = cmd.get_help()

2647 else:

2648 out = "The following commands are defined:\n"

2649 table = []

2650 for cmd in self.config_file.commands:

2651 name = "- %s" % "/".join(cmd.get_names())

2652 descr = cmd.get_short_description()

2653 row = (name, descr)

2654 table.append(row)

2655 out += self.format_table(table)

2656

2657 out += "\n"

2658 out += "\nUse `help <cmd>` to get more information about a command."

2659

2660 self.ui_notifier.show(NotificationLevel.INFO, out, ignore_filter=True, no_context=True)

2661

2662 def format_table(self, table: 'Sequence[tuple[str, str]]') -> str:

2663 max_name_width = max(len(row[0]) for row in table)

2664 col_width_name = min(max_name_width, self.max_width_name)

2665 out: 'list[str]' = []

2666 subsequent_indent = ' ' * (col_width_name + self.min_width_sep)

2667 for name, descr in table:

2668 if not descr:

2669 out.append(name)

2670 continue

2671 if len(name) > col_width_name:

2672 out.append(name)

2673 initial_indent = subsequent_indent

2674 else:

2675 initial_indent = name.ljust(col_width_name + self.min_width_sep)

2676 out.extend(textwrap.wrap(descr, self.max_width,

2677 initial_indent = initial_indent,

2678 subsequent_indent = subsequent_indent,

2679 break_long_words = False,

2680 tabsize = self.tab_size,

2681 ))

2682 return '\n'.join(out)

2683

2684 def get_completions_for_action(self, action: 'argparse.Action|None', start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2685 if action and action.dest == 'cmd':

2686 start_of_line, completions, end_of_line = self.config_file.get_completions_command_name(start, cursor_pos=len(start), start_of_line=start_of_line, end_of_line=end_of_line)

2687 return start_of_line, completions, end_of_line

2688

2689 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)

2690

2691

2692 class UnknownCommand(ConfigFileCommand, abstract=True):

2693

2694 name = DEFAULT_COMMAND

2695

2696 def run(self, cmd: 'Sequence[str]') -> None:

2697 raise ParseException('unknown command %r' % cmd[0])