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 if hasattr(typing, 'Protocol'):

42 class PathType(typing.Protocol):

43

44 def __init__(self, path: str) -> None:

45 ...

46

47 def expand(self) -> str:

48 ...

49

50

51# ---------- UI notifier ----------

52

53@functools.total_ordering

54 class NotificationLevel:

55

56 '''

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

58

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

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

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

62 '''

63

64 INFO: 'NotificationLevel'

65 ERROR: 'NotificationLevel'

66

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

68

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

70 '''

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

72 :param value: The name of the notification level

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

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

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

76 '''

77 if new:

78 if more_important_than and less_important_than:

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

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

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

82

83 try:

84 out = cls.get(value)

85 except ValueError:

86 pass

87 else:

88 if more_important_than and out < more_important_than:

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

90 elif less_important_than and out > less_important_than:

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

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

93 return out

94

95 return super().__new__(cls)

96

97 if more_important_than:

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

99 if less_important_than:

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

101

102 return cls.get(value)

103

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

105 if hasattr(self, '_initialized'):

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

107 return

108

109 assert new

110 self._initialized = True

111 self.value = value

112

113 if more_important_than:

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

115 elif less_important_than:

116 i = self._instances.index(less_important_than)

117 elif not self._instances:

118 i = 0

119 else:

120 assert False

121

122 self._instances.insert(i, self)

123

124 @classmethod

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

126 '''

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

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

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

130 '''

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

132

133 @classmethod

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

135 '''

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

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

138 '''

139 for lvl in cls._instances:

140 if lvl.value == value:

141 return lvl

142

143 raise ValueError('')

144

145 @classmethod

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

147 '''

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

149 '''

150 return cls._instances

151

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

153 if self.__class__ is other.__class__:

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

155 return NotImplemented

156

157 def __str__(self) -> str:

158 return self.value

159

160 def __repr__(self) -> str:

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

162

163

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

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

166

167

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

169

170 class Message:

171

172 '''

173 A message which should be displayed to the user.

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

175

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

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

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

179 '''

180

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

182 ENVIRONMENT_VARIABLES = 'environment variables'

183

184

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

186

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

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

189 notification_level: NotificationLevel

190

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

192 message: 'str|BaseException'

193

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

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

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

197 file_name: 'str|None'

198

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

200 line_number: 'int|None'

201

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

203 line: str

204

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

206 no_context: bool

207

208

209 _last_file_name: 'str|None' = None

210

211 @classmethod

212 def reset(cls) -> None:

213 '''

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

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

216 '''

217 cls._last_file_name = None

218

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

220 self.notification_level = notification_level

221 self.message = message

222 self.file_name = file_name

223 self.line_number = line_number

224 self.line = line

225 self.no_context = no_context

226

227 @property

228 def lvl(self) -> NotificationLevel:

229 '''

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

231 '''

232 return self.notification_level

233

234 def format_msg_line(self) -> str:

235 '''

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

237 '''

238 msg = str(self.message)

239 if self.line and not self.no_context:

240 if self.line_number is not None:

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

242 else:

243 lnref = 'line'

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

245

246 return msg

247

248 def format_file_name(self) -> str:

249 '''

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

251 '''

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

253 if file_name == self._last_file_name:

254 return ''

255

256 if file_name:

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

258 else:

259 out = ''

260

261 if self._last_file_name is not None:

262 out = '\n' + out

263

264 type(self)._last_file_name = file_name

265

266 return out

267

268

269 def format_file_name_msg_line(self) -> str:

270 '''

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

272 '''

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

274

275

276 def __str__(self) -> str:

277 '''

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

279 '''

280 return self.format_file_name_msg_line()

281

282 def __repr__(self) -> str:

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

284

285 @staticmethod

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

287 return repr(obj)

288

289

290 class UiNotifier:

291

292 '''

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

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

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

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

297

298 This object can also filter the messages.

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

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

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

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

303 '''

304

305 # ------- public methods -------

306

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

308 '''

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

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

311 '''

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

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

314 self._notification_level = notification_level

315 self._config_file = config_file

316

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

318 '''

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

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

321 '''

322 self._callback = callback

323

324 for msg in self._messages:

325 callback(msg)

326 self._messages.clear()

327

328

329 @property

330 def notification_level(self) -> NotificationLevel:

331 '''

332 Ignore messages that are less important than this level.

333 '''

334 if isinstance(self._notification_level, Config):

335 return self._notification_level.value

336 else:

337 return self._notification_level

338

339 @notification_level.setter

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

341 if isinstance(self._notification_level, Config):

342 self._notification_level.value = val

343 else:

344 self._notification_level = val

345

346

347 # ------- called by ConfigFile -------

348

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

350 '''

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

352 '''

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

354

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

356 '''

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

358 '''

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

360

361

362 # ------- internal methods -------

363

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

365 '''

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

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

368

369 :param notification_level: The importance of the message

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

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

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

373 '''

374 if notification_level < self.notification_level and not ignore_filter:

375 return

376

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

378 no_context = True

379

380 message = Message(

381 notification_level = notification_level,

382 message = msg,

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

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

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

386 no_context = no_context,

387 )

388

389 if self._callback:

390 self._callback(message)

391 else:

392 self._messages.append(message)

393

394

395# ---------- format help ----------

396

397 class SectionLevel(SortedEnum):

398

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

400 SECTION = 'section'

401

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

403 SUB_SECTION = 'sub-section'

404

405

406 class FormattedWriter(abc.ABC):

407

408 @abc.abstractmethod

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

410 '''

411 Write a single line of documentation.

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

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

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

415 '''

416 pass

417

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

419 '''

420 Write one or more lines of documentation.

421 '''

422 for ln in text.splitlines():

423 self.write_line(ln)

424

425 @abc.abstractmethod

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

427 '''

428 Write a heading.

429

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

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

432 in order to keep the line wrapping consistent.

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

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

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

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

437 to understand the help generated by argparse

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

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

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

441

442 :param lvl: How to format the heading

443 :param heading: The heading

444 '''

445 pass

446

447 @abc.abstractmethod

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

449 '''

450 Write a config file command.

451 '''

452 pass

453

454

455 class TextIOWriter(FormattedWriter):

456

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

458 self.f = f

459 self.ignore_empty_lines = True

460

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

462 if self.ignore_empty_lines and not line:

463 return

464

465 print(line, file=self.f)

466 self.ignore_empty_lines = False

467

468

469 class ConfigFileWriter(TextIOWriter):

470

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

472 super().__init__(f)

473 self.prefix = prefix

474

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

476 self.write_line_raw(cmd)

477

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

479 if line:

480 line = self.prefix + line

481

482 self.write_line_raw(line)

483

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

485 if lvl is SectionLevel.SECTION:

486 self.write_line('')

487 self.write_line('')

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

489 self.write_line(heading)

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

491 else:

492 self.write_line('')

493 self.write_line(heading)

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

495

496 class HelpWriter(TextIOWriter):

497

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

499 self.write_line_raw(line)

500

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

502 self.write_line('')

503 if lvl is SectionLevel.SECTION:

504 self.write_line(heading)

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

506 else:

507 self.write_line(heading)

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

509

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

511 pass # pragma: no cover

512

513

514# ---------- internal exceptions ----------

515

516 class ParseException(Exception):

517

518 '''

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

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

521 '''

522

523 class MultipleParseExceptions(Exception):

524

525 '''

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

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

528 '''

529

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

531 super().__init__()

532 self.exceptions = exceptions

533

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

535 return iter(self.exceptions)

536

537

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

539

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

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

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

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

544 no_multi: bool

545 comments: bool

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

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

548

549

550# ---------- ConfigFile class ----------

551

552 class ArgPos:

553 '''

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

555 '''

556

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

558 argument_pos: int

559

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

561 in_between: bool

562

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

564 i0: int

565

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

567 i1: int

568

569

570 class ConfigFile:

571

572 '''

573 Read or write a config file.

574

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

576 '''

577

578 COMMENT = '#'

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

580 ENTER_GROUP_PREFIX = '['

581 ENTER_GROUP_SUFFIX = ']'

582

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

584 ITEM_SEP = ','

585

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

587 KEY_SEP = ':'

588

589

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

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

592

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

594 config_id: 'ConfigId|None'

595

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

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

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

599 config_path: 'str|None' = None

600

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

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

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

604 config_directory: 'str|None' = None

605

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

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

608 config_name = 'config'

609

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

611 env_variables: 'list[str]'

612

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

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

615 envprefix: str

616

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

618 context_file_name: 'str|None' = None

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

620 context_line_number: 'int|None' = None

621 #: The line which is currently parsed.

622 context_line: str = ''

623

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

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

626 enable_config_ids: bool

627

628

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

630 command_dict: 'dict[str, ConfigFileCommand]'

631

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

633 commands: 'list[ConfigFileCommand]'

634

635

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

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

638

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

640 show_line_always: bool

641

642

643 def __init__(self, *,

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

645 appname: str,

646 authorname: 'str|None' = None,

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

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

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

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

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

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

653 enable_config_ids: 'bool|None' = None,

654 show_line_always: bool = True,

655 ) -> None:

656 '''

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

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

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

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

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

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

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

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

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

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

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

668 '''

669 self.appname = appname

670 self.authorname = authorname

671 self.ui_notifier = UiNotifier(self, notification_level)

672 state.has_any_config_file_been_instantiated = True

673 if config_instances is None:

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

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

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

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

678 state.has_config_file_been_instantiated = True

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

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

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

682 self.formatter_class = formatter_class

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

684 self.check_config_id = check_config_id

685 self.show_line_always = show_line_always

686

687 if enable_config_ids is None:

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

689 self.enable_config_ids = enable_config_ids

690

691 self.envprefix = ''

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

693 envname = self.envprefix + 'CONFIG_PATH'

694 self.env_variables.append(envname)

695 if envname in os.environ:

696 self.config_path = os.environ[envname]

697 envname = self.envprefix + 'CONFIG_DIRECTORY'

698 self.env_variables.append(envname)

699 if envname in os.environ:

700 self.config_directory = os.environ[envname]

701 envname = self.envprefix + 'CONFIG_NAME'

702 self.env_variables.append(envname)

703 if envname in os.environ:

704 self.config_name = os.environ[envname]

705

706 if commands is None:

707 commands = ConfigFileCommand.get_command_types()

708 else:

709 original_commands = commands

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

711 for cmd in original_commands:

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

713 if cmd._abstract:

714 for c in ConfigFileCommand.get_command_types():

715 if issubclass(c, cmd):

716 yield c

717 else:

718 yield cmd

719 commands = iter_commands()

720 self.command_dict = {}

721 self.commands = []

722 for cmd_type in commands:

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

724 continue

725 cmd = cmd_type(self)

726 self.commands.append(cmd)

727 for name in cmd.get_names():

728 self.command_dict[name] = cmd

729

730 def iter_config_instances(self,

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

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

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

734 '''

735 :param config_instances: The settings to consider

736 :param ignore: Skip these settings

737

738 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.

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

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

741 '''

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

743 if ignore is not None:

744 tmp = set()

745 for c in ignore:

746 if isinstance(c, DictConfig):

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

748 else:

749 tmp.add(c)

750 should_be_ignored = lambda c: c in tmp

751 else:

752 should_be_ignored = lambda c: False

753

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

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

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

757 for c in config_instances:

758 if isinstance(c, DictConfig):

759 yield from c.iter_configs()

760 else:

761 yield c

762 for c in expand_configs():

763 if should_be_ignored(c):

764 continue

765

766 yield c

767

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

769 '''

770 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.

771

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

773

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

775 '''

776 self.ui_notifier.set_ui_callback(callback)

777

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

779 '''

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

781

782 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.

783 The first one installed is used.

784 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.

785 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``.

786

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

788 '''

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

790 try:

791 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

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

793 except ImportError:

794 try:

795 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

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

797 except ImportError:

798 AppDirs = appdirs.AppDirs

799

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

801

802 return self._appdirs

803

804 # ------- load -------

805

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

807 '''

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

809

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

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

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

813 it's value is yielded and nothing else.

814 '''

815 if self.config_directory:

816 yield self.config_directory

817 return

818

819 appdirs = self.get_app_dirs()

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

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

822

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

824 '''

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

826

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

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

829

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

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

832

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

834 '''

835 if self.config_path:

836 yield self.config_path

837 return

838

839 for path in self.iter_user_site_config_paths():

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

841

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

843 '''

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

845

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

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

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

849 :return: False if an error has occurred

850 '''

851 out = True

852 for fn in self.iter_config_paths():

853 if os.path.isfile(fn):

854 out &= self.load_file(fn)

855 break

856

857 if env:

858 out &= self.load_env()

859

860 return out

861

862 def load_env(self) -> bool:

863 '''

864 Load settings from environment variables.

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

866

867 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>`.

868

869 :return: False if an error has occurred

870 :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`

871 '''

872 out = True

873 old_file_name = self.context_file_name

874 self.context_file_name = Message.ENVIRONMENT_VARIABLES

875

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

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

878 name = self.get_env_name(key)

879 if name in self.env_variables:

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

881 elif name in config_instances:

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

883 else:

884 config_instances[name] = instance

885

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

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

888 continue

889 if name in self.env_variables:

890 continue

891

892 if name in config_instances:

893 instance = config_instances[name]

894 try:

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

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

897 except ValueError as e:

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

899 out = False

900 else:

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

902 out = False

903

904 self.context_file_name = old_file_name

905 return out

906

907

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

909 '''

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

911

912 :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.

913 '''

914 out = key

915 out = out.upper()

916 for c in ' .-':

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

918 out = self.envprefix + out

919 return out

920

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

922 '''

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

924

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

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

927

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

929 :return: False if an error has occurred

930 '''

931 self.config_id = None

932 return self.load_without_resetting_config_id(fn)

933

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

935 out = True

936 old_file_name = self.context_file_name

937 self.context_file_name = fn

938

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

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

941 self.context_line_number = lnno

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

943 self.context_line_number = None

944

945 self.context_file_name = old_file_name

946 return out

947

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

949 '''

950 :param line: The line to be parsed

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

952

953 :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.

954 '''

955 ln = line.strip()

956 if not ln:

957 return True

958 if self.is_comment(ln):

959 return True

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

961 return True

962

963 self.context_line = ln

964

965 try:

966 ln_split = self.split_line(ln)

967 except Exception as e:

968 self.parse_error(str(e))

969 out = False

970 else:

971 out = self.parse_split_line(ln_split)

972

973 self.context_line = ''

974 return out

975

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

977 cmd, line = self.split_one_symbol_command(line)

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

979 if cmd:

980 line_split.insert(0, cmd)

981 return line_split

982

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

984 out = []

985 cmd, line = self.split_one_symbol_command(line)

986 if cmd:

987 out.append(cmd)

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

989 lex.whitespace_split = True

990 while True:

991 try:

992 t = lex.get_token()

993 except:

994 out.append(lex.token)

995 return out

996 if t is None:

997 return out

998 out.append(t)

999

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

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

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

1003

1004 return None, line

1005

1006

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

1008 '''

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

1010

1011 :param line: The current line

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

1013 '''

1014 for c in self.COMMENT_PREFIXES:

1015 if line.startswith(c):

1016 return True

1017 return False

1018

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

1020 '''

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

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

1023

1024 :param line: The current line

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

1026 '''

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

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

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

1030 try:

1031 self.check_config_id(config_id)

1032 except ParseException as e:

1033 self.parse_error(str(e))

1034 self.config_id = config_id

1035 if self.config_id not in MultiConfig.config_ids:

1036 MultiConfig.config_ids.append(self.config_id)

1037 return True

1038 return False

1039

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

1041 '''

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

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

1044

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

1046 '''

1047 cmd = self.get_command(ln_split)

1048 try:

1049 cmd.run(ln_split)

1050 except ParseException as e:

1051 self.parse_error(str(e))

1052 return False

1053 except MultipleParseExceptions as exceptions:

1054 for exc in exceptions:

1055 self.parse_error(str(exc))

1056 return False

1057

1058 return True

1059

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

1061 cmd_name = ln_split[0]

1062 if cmd_name in self.command_dict:

1063 cmd = self.command_dict[cmd_name]

1064 elif DEFAULT_COMMAND in self.command_dict:

1065 cmd = self.command_dict[DEFAULT_COMMAND]

1066 else:

1067 cmd = UnknownCommand(self)

1068 return cmd

1069

1070

1071 # ------- save -------

1072

1073 def get_save_path(self) -> str:

1074 '''

1075 :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.

1076 '''

1077 paths = tuple(self.iter_config_paths())

1078 for fn in paths:

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

1080 return fn

1081

1082 return paths[0]

1083

1084 def save(self,

1085 if_not_existing: bool = False,

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

1087 ) -> str:

1088 '''

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

1090 Directories are created as necessary.

1091

1092 :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.

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

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

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

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

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

1098 '''

1099 fn = self.get_save_path()

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

1101 return fn

1102

1103 # because os.path.dirname is not able to handle a file name without path

1104 fn = os.path.abspath(fn)

1105

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

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

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

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

1110 self.save_file(fn, **kw)

1111 return fn

1112

1113 def save_file(self,

1114 fn: str,

1115 **kw: 'Unpack[SaveKwargs]'

1116 ) -> None:

1117 '''

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

1119

1120 :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.

1121 :raises FileNotFoundError: if the directory does not exist

1122

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

1124 '''

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

1126 self.save_to_open_file(f, **kw)

1127

1128

1129 def save_to_open_file(self,

1130 f: typing.TextIO,

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

1132 ) -> None:

1133 '''

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

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

1136

1137 :param f: The file to write to

1138

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

1140 '''

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

1142 self.save_to_writer(writer, **kw)

1143

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

1145 '''

1146 Save the current values of all settings.

1147

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

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

1150

1151 - 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

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

1153 '''

1154 self.set_save_default_arguments(kw)

1155 commands = list(self.commands)

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

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

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

1159 for cmd in tuple(commands):

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

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

1162 commands.remove(cmd)

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

1164 for cmd in commands:

1165 cmd.should_write_heading = write_headings

1166 cmd.save(writer, **kw)

1167

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

1169 '''

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

1171 '''

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

1173 kw.setdefault('ignore', None)

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

1175 kw.setdefault('comments', True)

1176

1177

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

1179 '''

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

1181

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

1183 '''

1184 return readable_quote(val)

1185

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

1187 '''

1188 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`.

1189 '''

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

1191

1192 def get_help_config_id(self) -> str:

1193 '''

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

1195 '''

1196 return f'''

1197 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.

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

1199 '''

1200

1201

1202 # ------- formatting and parsing of values -------

1203

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

1205 '''

1206 :param instance: The config value to be saved

1207 :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

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

1209

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

1211 '''

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

1213

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

1215 return type.format_value(self, value)

1216

1217

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

1219 '''

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

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

1222 :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

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

1224 '''

1225 if not raw:

1226 value = self.expand(value)

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

1228

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

1230 '''

1231 Parse a value to the given data type.

1232

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

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

1235 :param value: The value to be parsed

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

1237 '''

1238 return t.parse_value(self, value)

1239

1240

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

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

1243

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

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

1246 n = arg.count('%')

1247 if n % 2 == 1:

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

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

1250

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

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

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

1254

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

1256 '''

1257 :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``

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

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

1260

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

1262

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

1264

1265 ``!conversion`` is one of:

1266

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

1268 - ``!r``: :func:`repr`

1269 - ``!s``: :class:`str`

1270 - ``!a``: :func:`ascii`

1271

1272 ``: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>`__.

1273 :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.

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

1275 '''

1276 key = m.group(1)

1277 if not key:

1278 return '%'

1279

1280 if ':' in key:

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

1282 else:

1283 fmt = None

1284 if '!' in key:

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

1286 else:

1287 stringifier = None

1288

1289 if key not in self.config_instances:

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

1291 instance = self.config_instances[key]

1292

1293 if stringifier is None and fmt is None:

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

1295 elif stringifier is None:

1296 assert fmt is not None

1297 try:

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

1299 except Exception as e:

1300 raise ParseException(e)

1301

1302 val: object

1303 if stringifier == '':

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

1305 else:

1306 val = instance.get_value(config_id=None)

1307 if stringifier == 'r':

1308 val = repr(val)

1309 elif stringifier == 's':

1310 val = str(val)

1311 elif stringifier == 'a':

1312 val = ascii(val)

1313 else:

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

1315

1316 if fmt is None:

1317 assert isinstance(val, str)

1318 return val

1319

1320 try:

1321 return format(val, fmt)

1322 except ValueError as e:

1323 raise ParseException(e)

1324

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

1326 '''

1327 :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

1328 :return: The expanded form of the environment variable

1329

1330 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:

1331

1332 - ``${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.

1333 - ``${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.

1334 - ``${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.

1335 - ``${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.

1336

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

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

1339 '''

1340 env = m.group(1)

1341 for op in '-=?+':

1342 if ':' + op in env:

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

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

1345 elif op in env:

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

1347 isset = env in os.environ

1348 else:

1349 continue

1350

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

1352 if op == '-':

1353 if isset:

1354 return val

1355 else:

1356 return arg

1357 elif op == '=':

1358 if isset:

1359 return val

1360 else:

1361 os.environ[env] = arg

1362 return arg

1363 elif op == '?':

1364 if isset:

1365 return val

1366 else:

1367 if not arg:

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

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

1370 raise ParseException(arg)

1371 elif op == '+':

1372 if isset:

1373 return arg

1374 else:

1375 return ''

1376 else:

1377 assert False

1378

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

1380

1381

1382 # ------- help -------

1383

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

1385 import platform

1386 formatter = self.create_formatter()

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

1388 for path in self.iter_config_paths():

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

1390

1391 writer.write_line('')

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

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

1394 writer.write_line('- XDG_CONFIG_HOME')

1395 writer.write_line('- XDG_CONFIG_DIRS')

1396 for env in self.env_variables:

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

1398

1399 writer.write_line('')

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

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

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

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

1404

1405 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)))

1406

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

1408 for cmd in self.commands:

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

1410 writer.write_heading(SectionLevel.SECTION, names)

1411 writer.write_lines(cmd.get_help())

1412

1413 def create_formatter(self) -> HelpFormatterWrapper:

1414 return HelpFormatterWrapper(self.formatter_class)

1415

1416 def get_help(self) -> str:

1417 '''

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

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

1420

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

1422 '''

1423 doc = io.StringIO()

1424 self.write_help(HelpWriter(doc))

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

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

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

1428 # Therefore I am stripping the trailing \n.

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

1430

1431

1432 # ------- auto complete -------

1433

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

1435 '''

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

1437

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

1439 :param cursor_pos: The position of the cursor

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

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

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

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

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

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

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

1447 *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.

1448 '''

1449 original_ln = line

1450 stripped_line = line.lstrip()

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

1452 cursor_pos -= len(indentation)

1453 line = stripped_line

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

1455 out = self.get_completions_enter_group(line, cursor_pos)

1456 else:

1457 out = self.get_completions_command(line, cursor_pos)

1458

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

1460 return out

1461

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

1463 '''

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

1465

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

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

1468 '''

1469 start = line

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

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

1472 return '', groups, ''

1473

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

1475 '''

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

1477

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

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

1480 '''

1481 if not line:

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

1483

1484 ln_split = self.split_line_ignore_errors(line)

1485 assert ln_split

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

1487

1488 if a.in_between:

1489 start_of_line = line[:cursor_pos]

1490 end_of_line = line[cursor_pos:]

1491 else:

1492 start_of_line = line[:a.i0]

1493 end_of_line = line[a.i1:]

1494

1495 if a.argument_pos == 0:

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

1497 else:

1498 cmd = self.get_command(ln_split)

1499 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)

1500

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

1502 '''

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

1504 '''

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

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

1507 out = ArgPos()

1508 out.in_between = True

1509

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

1511 out.argument_pos = 0

1512 out.i0 = 0

1513 out.i1 = 0

1514

1515 n_ln = len(line)

1516 i_ln = 0

1517 n_arg = len(ln_split)

1518 out.argument_pos = 0

1519 i_in_arg = 0

1520 assert out.argument_pos < n_ln

1521 while True:

1522 if out.in_between:

1523 assert i_in_arg == 0

1524 if i_ln >= n_ln:

1525 assert out.argument_pos >= n_arg - 1

1526 out.i0 = i_ln

1527 return out

1528 elif line[i_ln].isspace():

1529 i_ln += 1

1530 else:

1531 out.i0 = i_ln

1532 if i_ln >= cursor_pos:

1533 return out

1534 if out.argument_pos >= n_arg:

1535 assert line[i_ln] == '#'

1536 out.i0 = len(line)

1537 return out

1538 out.in_between = False

1539 else:

1540 if i_ln >= n_ln:

1541 assert out.argument_pos >= n_arg - 1

1542 out.i1 = i_ln

1543 return out

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

1545 if line[i_ln].isspace():

1546 out.i1 = i_ln

1547 if i_ln >= cursor_pos:

1548 return out

1549 out.in_between = True

1550 i_ln += 1

1551 out.argument_pos += 1

1552 i_in_arg = 0

1553 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX:

1554 i_ln += 1

1555 else:

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

1557 assert line[i_ln] == '#'

1558 assert out.argument_pos == n_arg - 1

1559 out.i1 = i_ln

1560 return out

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

1562 i_ln += 1

1563 i_in_arg += 1

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

1565 out.in_between = True

1566 out.argument_pos += 1

1567 out.i0 = i_ln

1568 i_in_arg = 0

1569 else:

1570 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX

1571 i_ln += 1

1572

1573

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

1575 start = line[:cursor_pos]

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

1577 return start_of_line, completions, end_of_line

1578

1579

1580 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]':

1581 r'''

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

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

1584 :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.

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

1586 :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)``.

1587 :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).

1588 '''

1589 if exclude is None:

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

1591 exclude = '$none'

1592 else:

1593 exclude = r'^\.'

1594 reo = re.compile(exclude)

1595

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

1597 if os.path.sep in start:

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

1599 directory += os.path.sep

1600 quoted_directory = self.quote_path(directory)

1601

1602 start_of_line += quoted_directory

1603 directory = os.path.expanduser(directory)

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

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

1606 directory = os.path.normpath(directory)

1607 else:

1608 directory = relative_to

1609

1610 try:

1611 names = os.listdir(directory)

1612 except (FileNotFoundError, NotADirectoryError):

1613 return start_of_line, [], end_of_line

1614

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

1616 for name in names:

1617 if reo.match(name):

1618 continue

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

1620 continue

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

1622 continue

1623

1624 quoted_name = self.quote(name)

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

1626 quoted_name += os.path.sep

1627

1628 out.append(quoted_name)

1629

1630 return start_of_line, out, end_of_line

1631

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

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

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

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

1636 if path_split[i]:

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

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

1639

1640

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

1642 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)

1643 if applicable:

1644 return applicable, start_of_line, completions, end_of_line

1645

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

1647

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

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

1650 return False, start_of_line, [], end_of_line

1651

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

1653 start_of_line = start_of_line + start[:i]

1654 start = start[i:]

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

1656 return True, start_of_line, completions, end_of_line

1657

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

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

1660 if i < 0:

1661 return False, start_of_line, [], end_of_line

1662 i += 2

1663

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

1665 return False, start_of_line, [], end_of_line

1666

1667 start_of_line = start_of_line + start[:i]

1668 start = start[i:]

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

1670 return True, start_of_line, completions, end_of_line

1671

1672

1673 # ------- error handling -------

1674

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

1676 '''

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

1678

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

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

1681

1682 :param msg: The error message

1683 '''

1684 self.ui_notifier.show_error(msg)

1685

1686

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

1688

1689 class ConfigFileCommand(abc.ABC):

1690

1691 '''

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

1693

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

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

1696 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`.

1697

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

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

1700 '''

1701

1702 #: 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.

1703 name: str

1704

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

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

1707

1708 #: 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.

1709 help: str

1710

1711 #: 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.

1712 should_write_heading: bool = False

1713

1714 #: 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`.

1715 config_file: ConfigFile

1716

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

1718 ui_notifier: UiNotifier

1719

1720 _abstract: bool

1721

1722

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

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

1725

1726 @classmethod

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

1728 '''

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

1730 '''

1731 return tuple(cls._subclasses)

1732

1733 @classmethod

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

1735 '''

1736 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.

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

1738 '''

1739 if cmd_type in cls._subclasses:

1740 cls._subclasses.remove(cmd_type)

1741 for name in cmd_type.get_names():

1742 cls._used_names.remove(name)

1743

1744 @classmethod

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

1746 '''

1747 :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

1748 :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`

1749 :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

1750 '''

1751 cls._abstract = abstract

1752 if replace:

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

1754

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

1756 parent = parent_commands[0]

1757 if 'name' not in cls.__dict__:

1758 cls.name = parent.get_name()

1759 if 'aliases' not in cls.__dict__:

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

1761 for parent in parent_commands[1:]:

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

1763

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

1765 for parent in parent_commands:

1766 cls.delete_command_type(parent)

1767

1768 if not abstract:

1769 cls._subclasses.append(cls)

1770 for name in cls.get_names():

1771 if name in cls._used_names and not replace:

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

1773 cls._used_names.add(name)

1774

1775 @classmethod

1776 def get_name(cls) -> str:

1777 '''

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

1779

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

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

1782 '''

1783 if 'name' in cls.__dict__:

1784 return cls.name

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

1786

1787 @classmethod

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

1789 '''

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

1791

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

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

1794

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

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

1797 '''

1798 yield cls.get_name()

1799 if 'aliases' in cls.__dict__:

1800 for name in cls.aliases:

1801 yield name

1802

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

1804 self.config_file = config_file

1805 self.ui_notifier = config_file.ui_notifier

1806

1807 @abc.abstractmethod

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

1809 '''

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

1811

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

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

1814 '''

1815 raise NotImplementedError()

1816

1817

1818 def create_formatter(self) -> HelpFormatterWrapper:

1819 return self.config_file.create_formatter()

1820

1821 def get_help_attr_or_doc_str(self) -> str:

1822 '''

1823 :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`.

1824 '''

1825 if hasattr(self, 'help'):

1826 doc = self.help

1827 elif self.__doc__:

1828 doc = self.__doc__

1829 else:

1830 doc = ''

1831

1832 return inspect.cleandoc(doc)

1833

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

1835 '''

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

1837 '''

1838 formatter.add_text(self.get_help_attr_or_doc_str())

1839

1840 def get_help(self) -> str:

1841 '''

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

1843

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

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

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

1847

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

1849 '''

1850 formatter = self.create_formatter()

1851 self.add_help_to(formatter)

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

1853

1854 def get_short_description(self) -> str:

1855 '''

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

1857 '''

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

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

1860 if len(out) > 1:

1861 return out[1]

1862 return ""

1863 return out[0]

1864

1865 def save(self,

1866 writer: FormattedWriter,

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

1868 ) -> None:

1869 '''

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

1871

1872 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.

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

1874

1875 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>`.

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

1877

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

1879

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

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

1882

1883 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>`.

1884

1885 The default implementation does nothing.

1886 '''

1887 pass

1888

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

1890

1891

1892 # ------- auto complete -------

1893

1894 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]':

1895 '''

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

1897 :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.

1898 :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.

1899 :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.

1900 :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.

1901 :param end_of_line: The third return value.

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

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

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

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

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

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

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

1909 '''

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

1911 return start_of_line, completions, end_of_line

1912

1913

1914 class ArgumentParser(argparse.ArgumentParser):

1915

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

1917 '''

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

1919 '''

1920 raise ParseException(message)

1921

1922 class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True):

1923

1924 '''

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

1926

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

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

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

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

1931

1932 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`.

1933 '''

1934

1935 #: 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`

1936 parser: ArgumentParser

1937

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

1939 super().__init__(config_file)

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

1941 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)

1942 self.init_parser(self.parser)

1943

1944 @abc.abstractmethod

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

1946 '''

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

1948

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

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

1951 '''

1952 pass

1953

1954 @staticmethod

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

1956 '''

1957 This method:

1958

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

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

1961 - generates a help string containing the allowed values

1962

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

1964 '''

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

1966 for v in type:

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

1968 return v

1969 raise TypeError()

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

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

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

1973

1974 def get_help(self) -> str:

1975 '''

1976 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`.

1977 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.

1978 '''

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

1980

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

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

1983 if not cmd:

1984 return # pragma: no cover

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

1986 if cmd[0] in self._names:

1987 cmd = cmd[1:]

1988 args = self.parser.parse_args(cmd)

1989 self.run_parsed(args)

1990

1991 @abc.abstractmethod

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

1993 '''

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

1995 '''

1996 pass

1997

1998 # ------- auto complete -------

1999

2000 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]':

2001 if in_between:

2002 start = ''

2003 else:

2004 start = cmd[argument_pos][:cursor_pos]

2005

2006 if self.after_positional_argument_marker(cmd, argument_pos):

2007 pos = self.get_position(cmd, argument_pos)

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

2009

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

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

2012 if prevarg:

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

2014

2015 if self.is_option_start(start):

2016 if '=' in start:

2017 i = start.index('=')

2018 option_name = start[:i]

2019 i += 1

2020 start_of_line += start[:i]

2021 start = start[i:]

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

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

2024

2025 pos = self.get_position(cmd, argument_pos)

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

2027

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

2029 '''

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

2031 '''

2032 pos = 0

2033 n = len(cmd)

2034 options_allowed = True

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

2036 for i in range(1, argument_pos):

2037 if options_allowed and i < n:

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

2039 options_allowed = False

2040 continue

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

2042 continue

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

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

2045 continue

2046 pos += 1

2047

2048 return pos

2049

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

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

2052

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

2054 '''

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

2056 '''

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

2058

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

2060 if argument_pos >= len(cmd):

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

2062

2063 arg = cmd[argument_pos]

2064 if '=' in arg:

2065 # argument of option is already given within arg

2066 return None

2067 if not self.is_option_start(arg):

2068 return None

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

2070 action = self.get_action_for_option(arg)

2071 if action is None:

2072 return None

2073 if action.nargs != 0:

2074 return arg

2075 return None

2076

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

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

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

2080 if action is None:

2081 continue

2082 if action.nargs != 0:

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

2084 return None

2085

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

2087 action = self.get_action_for_option(out)

2088 if action is None:

2089 return None

2090 if action.nargs != 0:

2091 return out

2092 return None

2093

2094

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

2096 completions = []

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

2098 for opt in a.option_strings:

2099 if len(opt) <= 2:

2100 # this is trivial to type but not self explanatory

2101 # => not helpful for auto completion

2102 continue

2103 if opt.startswith(start):

2104 completions.append(opt)

2105 return start_of_line, completions, end_of_line

2106

2107 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]':

2108 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)

2109

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

2111 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)

2112

2113

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

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

2116 if option_name in a.option_strings:

2117 return a

2118 return None

2119

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

2121 actions = self.parser._get_positional_actions()

2122 if argument_pos < len(actions):

2123 return actions[argument_pos]

2124 return None

2125

2126 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]':

2127 if action is None:

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

2129 elif not action.choices:

2130 completions = []

2131 else:

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

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

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

2135 return start_of_line, completions, end_of_line

2136

2137

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

2139

2140 class Set(ConfigFileCommand):

2141

2142 r'''

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

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

2145

2146 Change the value of a setting.

2147

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

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

2150 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.

2151

2152 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.

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

2154

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

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

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

2158 '''

2159

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

2161 KEY_VAL_SEP = '='

2162

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

2164

2165 raw = False

2166

2167 # ------- load -------

2168

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

2170 '''

2171 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`.

2172

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

2174 '''

2175 if self.is_vim_style(cmd):

2176 self.set_multiple(cmd)

2177 else:

2178 self.set_with_spaces(cmd)

2179

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

2181 '''

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

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

2184 - 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`.

2185

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

2187 '''

2188 try:

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

2190 if cmd[1] in self.FLAGS_RAW:

2191 i = 2

2192 else:

2193 i = 1

2194 return self.KEY_VAL_SEP in cmd[i]

2195 except IndexError:

2196 raise ParseException('no settings given')

2197

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

2199 '''

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

2201

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

2203 '''

2204 if cmd[1] in self.FLAGS_RAW:

2205 cmd = cmd[2:]

2206 self.raw = True

2207 else:

2208 cmd = cmd[1:]

2209 self.raw = False

2210

2211 n = len(cmd)

2212 if n == 2:

2213 key, value = cmd

2214 self.parse_key_and_set_value(key, value)

2215 elif n == 3:

2216 key, sep, value = cmd

2217 if sep != self.KEY_VAL_SEP:

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

2219 self.parse_key_and_set_value(key, value)

2220 elif n == 1:

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

2222 else:

2223 assert n >= 4

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

2225

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

2227 '''

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

2229

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

2231 '''

2232 self.raw = False

2233 exceptions = []

2234 for arg in cmd[1:]:

2235 if arg in self.FLAGS_RAW:

2236 self.raw = True

2237 continue

2238 try:

2239 if not self.KEY_VAL_SEP in arg:

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

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

2242 self.parse_key_and_set_value(key, value)

2243 except ParseException as e:

2244 exceptions.append(e)

2245 if exceptions:

2246 raise MultipleParseExceptions(exceptions)

2247

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

2249 '''

2250 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>`.

2251

2252 :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`

2253 '''

2254 if key not in self.config_file.config_instances:

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

2256

2257 instance = self.config_file.config_instances[key]

2258 try:

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

2260 except ValueError as e:

2261 raise ParseException(str(e))

2262

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

2264 '''

2265 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`.

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

2267 '''

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

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

2270

2271

2272 # ------- save -------

2273

2274 def iter_config_instances_to_be_saved(self,

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

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

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

2278 '''

2279 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.

2280 '''

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

2282 if config.wants_to_be_exported():

2283 yield config

2284

2285 #: 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`.

2286 last_name: 'str|None'

2287

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

2289 '''

2290 :param writer: The file to write to

2291 :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>`.

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

2293

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

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

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

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

2298 '''

2299 no_multi = kw['no_multi']

2300 comments = kw['comments']

2301

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

2303 normal_configs = []

2304 multi_configs = []

2305 if no_multi:

2306 normal_configs = config_instances

2307 else:

2308 for instance in config_instances:

2309 if isinstance(instance, MultiConfig):

2310 multi_configs.append(instance)

2311 else:

2312 normal_configs.append(instance)

2313

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

2315

2316 if normal_configs:

2317 if multi_configs:

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

2319 elif self.should_write_heading:

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

2321

2322 if comments:

2323 type_help = self.get_help_for_data_types(normal_configs)

2324 if type_help:

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

2326 writer.write_lines(type_help)

2327

2328 for instance in normal_configs:

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

2330

2331 if multi_configs:

2332 if normal_configs:

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

2334 elif self.should_write_heading:

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

2336

2337 if comments:

2338 type_help = self.get_help_for_data_types(multi_configs)

2339 if type_help:

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

2341 writer.write_lines(type_help)

2342

2343 for instance in multi_configs:

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

2345

2346 for config_id in MultiConfig.config_ids:

2347 writer.write_line('')

2348 self.config_file.write_config_id(writer, config_id)

2349 for instance in multi_configs:

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

2351

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

2353 '''

2354 :param writer: The file to write to

2355 :param instance: The config value to be saved

2356 :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

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

2358

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

2360 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`.

2361 '''

2362 if kw['comments']:

2363 self.write_config_help(writer, instance)

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

2365 value = self.config_file.quote(value)

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

2367 raw = ' --raw'

2368 else:

2369 raw = ''

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

2371 writer.write_command(ln)

2372

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

2374 '''

2375 :param writer: The output to write to

2376 :param instance: The config value to be saved

2377

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

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

2380

2381 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.

2382 '''

2383 if group_dict_configs and instance.parent is not None:

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

2385 else:

2386 name = instance.key

2387 if name == self.last_name:

2388 return

2389

2390 formatter = HelpFormatterWrapper(self.config_file.formatter_class)

2391 writer.write_heading(SectionLevel.SUB_SECTION, name)

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

2393 #if instance.unit:

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

2395 if isinstance(instance.help, dict):

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

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

2398 val = inspect.cleandoc(val)

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

2400 elif isinstance(instance.help, str):

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

2402

2403 self.last_name = name

2404

2405

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

2407 '''

2408 :param config_instances: All config values to be saved

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

2410

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

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

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

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

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

2416 '''

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

2418 for instance in config_instances:

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

2420 name = t.get_type_name()

2421 if name in help_text:

2422 continue

2423

2424 h = t.get_help(self.config_file)

2425 if not h:

2426 continue

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

2428

2429 return help_text

2430

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

2432 help_map = self.get_data_type_name_to_help_map(config_instances)

2433 if not help_map:

2434 return

2435

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

2437 formatter.add_start_section(name)

2438 formatter.add_text(help_map[name])

2439 formatter.add_end_section()

2440

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

2442 formatter = self.create_formatter()

2443 self.add_help_for_data_types(formatter, config_instances)

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

2445

2446 # ------- help -------

2447

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

2449 super().add_help_to(formatter)

2450

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

2452 self.last_name = None

2453

2454 formatter.add_start_section('data types')

2455 self.add_help_for_data_types(formatter, config_instances)

2456 formatter.add_end_section()

2457

2458 if self.config_file.enable_config_ids:

2459 normal_configs = []

2460 multi_configs = []

2461 for instance in config_instances:

2462 if isinstance(instance, MultiConfig):

2463 multi_configs.append(instance)

2464 else:

2465 normal_configs.append(instance)

2466 else:

2467 normal_configs = config_instances

2468 multi_configs = []

2469

2470 if normal_configs:

2471 if self.config_file.enable_config_ids:

2472 formatter.add_start_section('application wide settings')

2473 else:

2474 formatter.add_start_section('settings')

2475 for instance in normal_configs:

2476 self.add_config_help(formatter, instance)

2477 formatter.add_end_section()

2478

2479 if multi_configs:

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

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

2482 for instance in multi_configs:

2483 self.add_config_help(formatter, instance)

2484 formatter.add_end_section()

2485

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

2487 formatter.add_start_section(instance.key)

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

2489 if isinstance(instance.help, dict):

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

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

2492 val = inspect.cleandoc(val)

2493 formatter.add_item(bullet=key_name+': ', text=val)

2494 elif isinstance(instance.help, str):

2495 formatter.add_text(inspect.cleandoc(instance.help))

2496 formatter.add_end_section()

2497

2498 # ------- auto complete -------

2499

2500 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]':

2501 if argument_pos >= len(cmd):

2502 start = ''

2503 else:

2504 start = cmd[argument_pos][:cursor_pos]

2505

2506 if len(cmd) <= 1:

2507 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2508 elif self.is_vim_style(cmd):

2509 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

2510 else:

2511 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

2512

2513 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]':

2514 if self.KEY_VAL_SEP in start:

2515 key, start = start.split(self.KEY_VAL_SEP, 1)

2516 start_of_line += key + self.KEY_VAL_SEP

2517 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line)

2518 else:

2519 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2520

2521 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]':

2522 if argument_pos == 1:

2523 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2524 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP):

2525 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line)

2526 else:

2527 return start_of_line, [], end_of_line

2528

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

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

2531 return start_of_line, completions, end_of_line

2532

2533 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

2534 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)

2535 if applicable:

2536 return start_of_line, completions, end_of_line

2537

2538 instance = self.config_file.config_instances.get(key)

2539 if instance is None:

2540 return start_of_line, [], end_of_line

2541

2542 return instance.type.get_completions(self.config_file, start_of_line, start, end_of_line)

2543

2544

2545 class Include(ConfigFileArgparseCommand):

2546

2547 '''

2548 Load another config file.

2549

2550 This is useful if a config file is getting so big that you want to split it up

2551 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy

2552 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line().

2553 '''

2554

2555 help_config_id = '''

2556 By default the loaded config file starts with which ever config id is currently active.

2557 This is useful if you want to use the same values for several config ids:

2558 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.

2559

2560 After the include the config id is reset to the config id which was active at the beginning of the include

2561 because otherwise it might lead to confusion if the config id is changed in the included config file.

2562 '''

2563

2564 home: 'Config[PathType]|str|None' = None

2565

2566 def get_home(self) -> str:

2567 if not self.home:

2568 home = ""

2569 elif isinstance(self.home, str):

2570 home = self.home

2571 else:

2572 home = self.home.expand()

2573 if home:

2574 return home

2575

2576 fn = self.config_file.context_file_name

2577 if fn is None:

2578 fn = self.config_file.get_save_path()

2579 return os.path.dirname(fn)

2580

2581

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

2583 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.')

2584 if self.config_file.enable_config_ids:

2585 assert parser.description is not None

2586 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id)

2587 group = parser.add_mutually_exclusive_group()

2588 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include')

2589 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')

2590

2591 self.nested_includes: 'list[str]' = []

2592

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

2594 fn_imp = args.path

2595 fn_imp = fn_imp.replace('/', os.path.sep)

2596 fn_imp = os.path.expanduser(fn_imp)

2597 if not os.path.isabs(fn_imp):

2598 fn_imp = os.path.join(self.get_home(), fn_imp)

2599

2600 if fn_imp in self.nested_includes:

2601 raise ParseException(f'circular include of file {fn_imp!r}')

2602 if not os.path.isfile(fn_imp):

2603 raise ParseException(f'no such file {fn_imp!r}')

2604

2605 self.nested_includes.append(fn_imp)

2606

2607 if self.config_file.enable_config_ids and args.no_reset_config_id_after:

2608 self.config_file.load_without_resetting_config_id(fn_imp)

2609 elif self.config_file.enable_config_ids and args.reset_config_id_before:

2610 config_id = self.config_file.config_id

2611 self.config_file.load_file(fn_imp)

2612 self.config_file.config_id = config_id

2613 else:

2614 config_id = self.config_file.config_id

2615 self.config_file.load_without_resetting_config_id(fn_imp)

2616 self.config_file.config_id = config_id

2617

2618 assert self.nested_includes[-1] == fn_imp

2619 del self.nested_includes[-1]

2620

2621 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]':

2622 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action

2623 if action is not None and action.dest == 'path':

2624 return self.config_file.get_completions_for_file_name(start, relative_to=self.get_home(), start_of_line=start_of_line, end_of_line=end_of_line)

2625 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)

2626

2627

2628 class Echo(ConfigFileArgparseCommand):

2629

2630 '''

2631 Display a message.

2632

2633 Settings and environment variables are expanded like in the value of a set command.

2634 '''

2635

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

2637 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.")

2638 parser.add_argument('-r', '--raw', action='store_true', help="Do not expand settings and environment variables.")

2639 parser.add_argument('msg', nargs=argparse.ONE_OR_MORE, help="The message to display")

2640

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

2642 msg = ' '.join(self.config_file.expand(m) for m in args.msg)

2643 self.ui_notifier.show(args.level, msg, ignore_filter=True)

2644

2645

2646 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]':

2647 if argument_pos >= len(cmd):

2648 start = ''

2649 else:

2650 start = cmd[argument_pos][:cursor_pos]

2651

2652 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)

2653 return start_of_line, completions, end_of_line

2654

2655 class Help(ConfigFileArgparseCommand):

2656

2657 '''

2658 Display help.

2659 '''

2660

2661 max_width = 80

2662 max_width_name = 18

2663 min_width_sep = 2

2664 tab_size = 4

2665

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

2667 parser.add_argument('cmd', nargs='?', help="The command for which you want help")

2668

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

2670 if args.cmd:

2671 if args.cmd not in self.config_file.command_dict:

2672 raise ParseException(f"unknown command {args.cmd!r}")

2673 cmd = self.config_file.command_dict[args.cmd]

2674 out = cmd.get_help()

2675 else:

2676 out = "The following commands are defined:\n"

2677 table = []

2678 for cmd in self.config_file.commands:

2679 name = "- %s" % "/".join(cmd.get_names())

2680 descr = cmd.get_short_description()

2681 row = (name, descr)

2682 table.append(row)

2683 out += self.format_table(table)

2684

2685 out += "\n"

2686 out += "\nUse `help <cmd>` to get more information about a command."

2687

2688 self.ui_notifier.show(NotificationLevel.INFO, out, ignore_filter=True, no_context=True)

2689

2690 def format_table(self, table: 'Sequence[tuple[str, str]]') -> str:

2691 max_name_width = max(len(row[0]) for row in table)

2692 col_width_name = min(max_name_width, self.max_width_name)

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

2694 subsequent_indent = ' ' * (col_width_name + self.min_width_sep)

2695 for name, descr in table:

2696 if not descr:

2697 out.append(name)

2698 continue

2699 if len(name) > col_width_name:

2700 out.append(name)

2701 initial_indent = subsequent_indent

2702 else:

2703 initial_indent = name.ljust(col_width_name + self.min_width_sep)

2704 out.extend(textwrap.wrap(descr, self.max_width,

2705 initial_indent = initial_indent,

2706 subsequent_indent = subsequent_indent,

2707 break_long_words = False,

2708 tabsize = self.tab_size,

2709 ))

2710 return '\n'.join(out)

2711

2712 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]':

2713 if action and action.dest == 'cmd':

2714 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)

2715 return start_of_line, completions, end_of_line

2716

2717 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)

2718

2719

2720 class UnknownCommand(ConfigFileCommand, abstract=True):

2721

2722 name = DEFAULT_COMMAND

2723

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

2725 raise ParseException('unknown command %r' % cmd[0])