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

680 sort: 'bool|None' = True

681 else:

682 sort = None

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

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

685 self.formatter_class = formatter_class

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

687 self.check_config_id = check_config_id

688 self.show_line_always = show_line_always

689

690 if enable_config_ids is None:

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

692 self.enable_config_ids = enable_config_ids

693

694 self.envprefix = ''

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

696 envname = self.envprefix + 'CONFIG_PATH'

697 self.env_variables.append(envname)

698 if envname in os.environ:

699 self.config_path = os.environ[envname]

700 envname = self.envprefix + 'CONFIG_DIRECTORY'

701 self.env_variables.append(envname)

702 if envname in os.environ:

703 self.config_directory = os.environ[envname]

704 envname = self.envprefix + 'CONFIG_NAME'

705 self.env_variables.append(envname)

706 if envname in os.environ:

707 self.config_name = os.environ[envname]

708

709 if commands is None:

710 commands = ConfigFileCommand.get_command_types()

711 else:

712 original_commands = commands

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

714 for cmd in original_commands:

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

716 if cmd._abstract:

717 for c in ConfigFileCommand.get_command_types():

718 if issubclass(c, cmd):

719 yield c

720 else:

721 yield cmd

722 commands = iter_commands()

723 self.command_dict = {}

724 self.commands = []

725 for cmd_type in commands:

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

727 continue

728 cmd = cmd_type(self)

729 self.commands.append(cmd)

730 for name in cmd.get_names():

731 self.command_dict[name] = cmd

732

733 def iter_config_instances(self,

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

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

736 *,

737 sort: 'bool|None',

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

739 '''

740 :param config_instances: The settings to consider

741 :param ignore: Skip these settings

742 :param sort: If :obj:`None`: sort :paramref:`~confattr.configfile.ConfigFile.iter_config_instances.config_instances` if it is a :class:`set`

743

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

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

746 '''

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

748 if ignore is not None:

749 tmp = set()

750 for c in ignore:

751 if isinstance(c, DictConfig):

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

753 else:

754 tmp.add(c)

755 should_be_ignored = lambda c: c in tmp

756 else:

757 should_be_ignored = lambda c: False

758

759 if sort is None:

760 sort = isinstance(config_instances, set)

761 if sort:

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

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

764 for c in config_instances:

765 if isinstance(c, DictConfig):

766 yield from c.iter_configs()

767 else:

768 yield c

769 for c in expand_configs():

770 if should_be_ignored(c):

771 continue

772

773 yield c

774

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

776 '''

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

778

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

780

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

782 '''

783 self.ui_notifier.set_ui_callback(callback)

784

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

786 '''

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

788

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

790 The first one installed is used.

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

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

793

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

795 '''

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

797 try:

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

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

800 except ImportError:

801 try:

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

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

804 except ImportError:

805 AppDirs = appdirs.AppDirs

806

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

808

809 return self._appdirs

810

811 # ------- load -------

812

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

814 '''

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

816

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

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

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

820 it's value is yielded and nothing else.

821 '''

822 if self.config_directory:

823 yield self.config_directory

824 return

825

826 appdirs = self.get_app_dirs()

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

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

829

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

831 '''

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

833

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

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

836

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

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

839

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

841 '''

842 if self.config_path:

843 yield self.config_path

844 return

845

846 for path in self.iter_user_site_config_paths():

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

848

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

850 '''

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

852

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

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

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

856 :return: False if an error has occurred

857 '''

858 out = True

859 for fn in self.iter_config_paths():

860 if os.path.isfile(fn):

861 out &= self.load_file(fn)

862 break

863

864 if env:

865 out &= self.load_env()

866

867 return out

868

869 def load_env(self) -> bool:

870 '''

871 Load settings from environment variables.

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

873

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

875

876 :return: False if an error has occurred

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

878 '''

879 out = True

880 old_file_name = self.context_file_name

881 self.context_file_name = Message.ENVIRONMENT_VARIABLES

882

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

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

885 name = self.get_env_name(key)

886 if name in self.env_variables:

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

888 elif name in config_instances:

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

890 else:

891 config_instances[name] = instance

892

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

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

895 continue

896 if name in self.env_variables:

897 continue

898

899 if name in config_instances:

900 instance = config_instances[name]

901 try:

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

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

904 except ValueError as e:

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

906 out = False

907 else:

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

909 out = False

910

911 self.context_file_name = old_file_name

912 return out

913

914

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

916 '''

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

918

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

920 '''

921 out = key

922 out = out.upper()

923 for c in ' .-':

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

925 out = self.envprefix + out

926 return out

927

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

929 '''

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

931

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

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

934

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

936 :return: False if an error has occurred

937 '''

938 self.config_id = None

939 return self.load_without_resetting_config_id(fn)

940

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

942 out = True

943 old_file_name = self.context_file_name

944 self.context_file_name = fn

945

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

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

948 self.context_line_number = lnno

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

950 self.context_line_number = None

951

952 self.context_file_name = old_file_name

953 return out

954

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

956 '''

957 :param line: The line to be parsed

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

959

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

961 '''

962 ln = line.strip()

963 if not ln:

964 return True

965 if self.is_comment(ln):

966 return True

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

968 return True

969

970 self.context_line = ln

971

972 try:

973 ln_split = self.split_line(ln)

974 except Exception as e:

975 self.parse_error(str(e))

976 out = False

977 else:

978 out = self.parse_split_line(ln_split)

979

980 self.context_line = ''

981 return out

982

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

984 cmd, line = self.split_one_symbol_command(line)

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

986 if cmd:

987 line_split.insert(0, cmd)

988 return line_split

989

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

991 out = []

992 cmd, line = self.split_one_symbol_command(line)

993 if cmd:

994 out.append(cmd)

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

996 lex.whitespace_split = True

997 while True:

998 try:

999 t = lex.get_token()

1000 except:

1001 out.append(lex.token)

1002 return out

1003 if t is None:

1004 return out

1005 out.append(t)

1006

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

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

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

1010

1011 return None, line

1012

1013

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

1015 '''

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

1017

1018 :param line: The current line

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

1020 '''

1021 for c in self.COMMENT_PREFIXES:

1022 if line.startswith(c):

1023 return True

1024 return False

1025

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

1027 '''

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

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

1030

1031 :param line: The current line

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

1033 '''

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

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

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

1037 try:

1038 self.check_config_id(config_id)

1039 except ParseException as e:

1040 self.parse_error(str(e))

1041 self.config_id = config_id

1042 if self.config_id not in MultiConfig.config_ids:

1043 MultiConfig.config_ids.append(self.config_id)

1044 return True

1045 return False

1046

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

1048 '''

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

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

1051

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

1053 '''

1054 cmd = self.get_command(ln_split)

1055 try:

1056 cmd.run(ln_split)

1057 except ParseException as e:

1058 self.parse_error(str(e))

1059 return False

1060 except MultipleParseExceptions as exceptions:

1061 for exc in exceptions:

1062 self.parse_error(str(exc))

1063 return False

1064

1065 return True

1066

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

1068 cmd_name = ln_split[0]

1069 if cmd_name in self.command_dict:

1070 cmd = self.command_dict[cmd_name]

1071 elif DEFAULT_COMMAND in self.command_dict:

1072 cmd = self.command_dict[DEFAULT_COMMAND]

1073 else:

1074 cmd = UnknownCommand(self)

1075 return cmd

1076

1077

1078 # ------- save -------

1079

1080 def get_save_path(self) -> str:

1081 '''

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

1083 '''

1084 paths = tuple(self.iter_config_paths())

1085 for fn in paths:

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

1087 return fn

1088

1089 return paths[0]

1090

1091 def save(self,

1092 if_not_existing: bool = False,

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

1094 ) -> str:

1095 '''

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

1097 Directories are created as necessary.

1098

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

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

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

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

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

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

1105 '''

1106 fn = self.get_save_path()

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

1108 return fn

1109

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 Directories are created as necessary, with `mode 0700 <https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation>`__ as specified by the `XDG Base Directory Specification standard <https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html>`__.

1120

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

1122 :raises FileNotFoundError: if the directory does not exist

1123

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

1125 '''

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

1127 fn = os.path.abspath(fn)

1128

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

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

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

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

1133

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

1135 self.save_to_open_file(f, **kw)

1136

1137

1138 def save_to_open_file(self,

1139 f: typing.TextIO,

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

1141 ) -> None:

1142 '''

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

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

1145

1146 :param f: The file to write to

1147

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

1149 '''

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

1151 self.save_to_writer(writer, **kw)

1152

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

1154 '''

1155 Save the current values of all settings.

1156

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

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

1159

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

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

1162 '''

1163 self.set_save_default_arguments(kw)

1164 commands = list(self.commands)

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

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

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

1168 for cmd in tuple(commands):

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

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

1171 commands.remove(cmd)

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

1173 for cmd in commands:

1174 cmd.should_write_heading = write_headings

1175 cmd.save(writer, **kw)

1176

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

1178 '''

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

1180 '''

1181 kw.setdefault('config_instances', self.config_instances.values())

1182 kw.setdefault('ignore', None)

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

1184 kw.setdefault('comments', True)

1185

1186

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

1188 '''

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

1190

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

1192 '''

1193 return readable_quote(val)

1194

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

1196 '''

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

1198 '''

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

1200

1201 def get_help_config_id(self) -> str:

1202 '''

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

1204 '''

1205 return f'''

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

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

1208 '''

1209

1210

1211 # ------- formatting and parsing of values -------

1212

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

1214 '''

1215 :param instance: The config value to be saved

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

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

1218

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

1220 '''

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

1222

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

1224 return type.format_value(self, value)

1225

1226

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

1228 '''

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

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

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

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

1233 '''

1234 if not raw:

1235 value = self.expand(value)

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

1237

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

1239 '''

1240 Parse a value to the given data type.

1241

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

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

1244 :param value: The value to be parsed

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

1246 '''

1247 return t.parse_value(self, value)

1248

1249

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

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

1252

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

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

1255 n = arg.count('%')

1256 if n % 2 == 1:

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

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

1259

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

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

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

1263

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

1265 '''

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

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

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

1269

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

1271

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

1273

1274 ``!conversion`` is one of:

1275

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

1277 - ``!r``: :func:`repr`

1278 - ``!s``: :class:`str`

1279 - ``!a``: :func:`ascii`

1280

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

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

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

1284 '''

1285 key = m.group(1)

1286 if not key:

1287 return '%'

1288

1289 if ':' in key:

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

1291 else:

1292 fmt = None

1293 if '!' in key:

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

1295 else:

1296 stringifier = None

1297

1298 if key not in self.config_instances:

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

1300 instance = self.config_instances[key]

1301

1302 if stringifier is None and fmt is None:

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

1304 elif stringifier is None:

1305 assert fmt is not None

1306 try:

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

1308 except Exception as e:

1309 raise ParseException(e)

1310

1311 val: object

1312 if stringifier == '':

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

1314 else:

1315 val = instance.get_value(config_id=None)

1316 if stringifier == 'r':

1317 val = repr(val)

1318 elif stringifier == 's':

1319 val = str(val)

1320 elif stringifier == 'a':

1321 val = ascii(val)

1322 else:

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

1324

1325 if fmt is None:

1326 assert isinstance(val, str)

1327 return val

1328

1329 try:

1330 return format(val, fmt)

1331 except ValueError as e:

1332 raise ParseException(e)

1333

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

1335 '''

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

1337 :return: The expanded form of the environment variable

1338

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

1340

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

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

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

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

1345

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

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

1348 '''

1349 env = m.group(1)

1350 for op in '-=?+':

1351 if ':' + op in env:

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

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

1354 elif op in env:

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

1356 isset = env in os.environ

1357 else:

1358 continue

1359

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

1361 if op == '-':

1362 if isset:

1363 return val

1364 else:

1365 return arg

1366 elif op == '=':

1367 if isset:

1368 return val

1369 else:

1370 os.environ[env] = arg

1371 return arg

1372 elif op == '?':

1373 if isset:

1374 return val

1375 else:

1376 if not arg:

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

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

1379 raise ParseException(arg)

1380 elif op == '+':

1381 if isset:

1382 return arg

1383 else:

1384 return ''

1385 else:

1386 assert False

1387

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

1389

1390

1391 # ------- help -------

1392

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

1394 import platform

1395 formatter = self.create_formatter()

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

1397 for path in self.iter_config_paths():

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

1399

1400 writer.write_line('')

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

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

1403 writer.write_line('- XDG_CONFIG_HOME')

1404 writer.write_line('- XDG_CONFIG_DIRS')

1405 for env in self.env_variables:

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

1407

1408 writer.write_line('')

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

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

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

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

1413

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

1415

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

1417 for cmd in self.commands:

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

1419 writer.write_heading(SectionLevel.SECTION, names)

1420 writer.write_lines(cmd.get_help())

1421

1422 def create_formatter(self) -> HelpFormatterWrapper:

1423 return HelpFormatterWrapper(self.formatter_class)

1424

1425 def get_help(self) -> str:

1426 '''

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

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

1429

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

1431 '''

1432 doc = io.StringIO()

1433 self.write_help(HelpWriter(doc))

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

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

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

1437 # Therefore I am stripping the trailing \n.

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

1439

1440

1441 # ------- auto complete -------

1442

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

1444 '''

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

1446

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

1448 :param cursor_pos: The position of the cursor

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

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

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

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

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

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

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

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

1457 '''

1458 original_ln = line

1459 stripped_line = line.lstrip()

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

1461 cursor_pos -= len(indentation)

1462 line = stripped_line

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

1464 out = self.get_completions_enter_group(line, cursor_pos)

1465 else:

1466 out = self.get_completions_command(line, cursor_pos)

1467

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

1469 return out

1470

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

1472 '''

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

1474

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

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

1477 '''

1478 start = line

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

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

1481 return '', groups, ''

1482

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

1484 '''

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

1486

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

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

1489 '''

1490 if not line:

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

1492

1493 ln_split = self.split_line_ignore_errors(line)

1494 assert ln_split

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

1496

1497 if a.in_between:

1498 start_of_line = line[:cursor_pos]

1499 end_of_line = line[cursor_pos:]

1500 else:

1501 start_of_line = line[:a.i0]

1502 end_of_line = line[a.i1:]

1503

1504 if a.argument_pos == 0:

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

1506 else:

1507 cmd = self.get_command(ln_split)

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

1509

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

1511 '''

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

1513 '''

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

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

1516 out = ArgPos()

1517 out.in_between = True

1518

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

1520 out.argument_pos = 0

1521 out.i0 = 0

1522 out.i1 = 0

1523

1524 n_ln = len(line)

1525 i_ln = 0

1526 n_arg = len(ln_split)

1527 out.argument_pos = 0

1528 i_in_arg = 0

1529 assert out.argument_pos < n_ln

1530 while True:

1531 if out.in_between:

1532 assert i_in_arg == 0

1533 if i_ln >= n_ln:

1534 assert out.argument_pos >= n_arg - 1

1535 out.i0 = i_ln

1536 return out

1537 elif line[i_ln].isspace():

1538 i_ln += 1

1539 else:

1540 out.i0 = i_ln

1541 if i_ln >= cursor_pos:

1542 return out

1543 if out.argument_pos >= n_arg:

1544 assert line[i_ln] == '#'

1545 out.i0 = len(line)

1546 return out

1547 out.in_between = False

1548 else:

1549 if i_ln >= n_ln:

1550 assert out.argument_pos >= n_arg - 1

1551 out.i1 = i_ln

1552 return out

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

1554 if line[i_ln].isspace():

1555 out.i1 = i_ln

1556 if i_ln >= cursor_pos:

1557 return out

1558 out.in_between = True

1559 i_ln += 1

1560 out.argument_pos += 1

1561 i_in_arg = 0

1562 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX:

1563 i_ln += 1

1564 else:

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

1566 assert line[i_ln] == '#'

1567 assert out.argument_pos == n_arg - 1

1568 out.i1 = i_ln

1569 return out

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

1571 i_ln += 1

1572 i_in_arg += 1

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

1574 out.in_between = True

1575 out.argument_pos += 1

1576 out.i0 = i_ln

1577 i_in_arg = 0

1578 else:

1579 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX

1580 i_ln += 1

1581

1582

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

1584 start = line[:cursor_pos]

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

1586 return start_of_line, completions, end_of_line

1587

1588

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

1590 r'''

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

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

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

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

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

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

1597 '''

1598 if exclude is None:

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

1600 exclude = '$none'

1601 else:

1602 exclude = r'^\.'

1603 reo = re.compile(exclude)

1604

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

1606 if os.path.sep in start:

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

1608 directory += os.path.sep

1609 quoted_directory = self.quote_path(directory)

1610

1611 start_of_line += quoted_directory

1612 directory = os.path.expanduser(directory)

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

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

1615 directory = os.path.normpath(directory)

1616 else:

1617 directory = relative_to

1618

1619 try:

1620 names = os.listdir(directory)

1621 except (FileNotFoundError, NotADirectoryError):

1622 return start_of_line, [], end_of_line

1623

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

1625 for name in names:

1626 if reo.match(name):

1627 continue

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

1629 continue

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

1631 continue

1632

1633 quoted_name = self.quote(name)

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

1635 quoted_name += os.path.sep

1636

1637 out.append(quoted_name)

1638

1639 return start_of_line, out, end_of_line

1640

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

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

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

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

1645 if path_split[i]:

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

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

1648

1649

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

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

1652 if applicable:

1653 return applicable, start_of_line, completions, end_of_line

1654

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

1656

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

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

1659 return False, start_of_line, [], end_of_line

1660

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

1662 start_of_line = start_of_line + start[:i]

1663 start = start[i:]

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

1665 return True, start_of_line, completions, end_of_line

1666

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

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

1669 if i < 0:

1670 return False, start_of_line, [], end_of_line

1671 i += 2

1672

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

1674 return False, start_of_line, [], end_of_line

1675

1676 start_of_line = start_of_line + start[:i]

1677 start = start[i:]

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

1679 return True, start_of_line, completions, end_of_line

1680

1681

1682 # ------- error handling -------

1683

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

1685 '''

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

1687

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

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

1690

1691 :param msg: The error message

1692 '''

1693 self.ui_notifier.show_error(msg)

1694

1695

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

1697

1698 class ConfigFileCommand(abc.ABC):

1699

1700 '''

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

1702

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

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

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

1706

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

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

1709 '''

1710

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

1712 name: str

1713

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

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

1716

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

1718 help: str

1719

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

1721 should_write_heading: bool = False

1722

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

1724 config_file: ConfigFile

1725

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

1727 ui_notifier: UiNotifier

1728

1729 _abstract: bool

1730

1731

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

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

1734

1735 @classmethod

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

1737 '''

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

1739 '''

1740 return tuple(cls._subclasses)

1741

1742 @classmethod

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

1744 '''

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

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

1747 '''

1748 if cmd_type in cls._subclasses:

1749 cls._subclasses.remove(cmd_type)

1750 for name in cmd_type.get_names():

1751 cls._used_names.remove(name)

1752

1753 @classmethod

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

1755 '''

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

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

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

1759 '''

1760 cls._abstract = abstract

1761 if replace:

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

1763

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

1765 parent = parent_commands[0]

1766 if 'name' not in cls.__dict__:

1767 cls.name = parent.get_name()

1768 if 'aliases' not in cls.__dict__:

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

1770 for parent in parent_commands[1:]:

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

1772

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

1774 for parent in parent_commands:

1775 cls.delete_command_type(parent)

1776

1777 if not abstract:

1778 cls._subclasses.append(cls)

1779 for name in cls.get_names():

1780 if name in cls._used_names and not replace:

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

1782 cls._used_names.add(name)

1783

1784 @classmethod

1785 def get_name(cls) -> str:

1786 '''

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

1788

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

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

1791 '''

1792 if 'name' in cls.__dict__:

1793 return cls.name

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

1795

1796 @classmethod

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

1798 '''

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

1800

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

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

1803

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

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

1806 '''

1807 yield cls.get_name()

1808 if 'aliases' in cls.__dict__:

1809 for name in cls.aliases:

1810 yield name

1811

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

1813 self.config_file = config_file

1814 self.ui_notifier = config_file.ui_notifier

1815

1816 @abc.abstractmethod

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

1818 '''

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

1820

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

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

1823 '''

1824 raise NotImplementedError()

1825

1826

1827 def create_formatter(self) -> HelpFormatterWrapper:

1828 return self.config_file.create_formatter()

1829

1830 def get_help_attr_or_doc_str(self) -> str:

1831 '''

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

1833 '''

1834 if hasattr(self, 'help'):

1835 doc = self.help

1836 elif self.__doc__:

1837 doc = self.__doc__

1838 else:

1839 doc = ''

1840

1841 return inspect.cleandoc(doc)

1842

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

1844 '''

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

1846 '''

1847 formatter.add_text(self.get_help_attr_or_doc_str())

1848

1849 def get_help(self) -> str:

1850 '''

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

1852

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

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

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

1856

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

1858 '''

1859 formatter = self.create_formatter()

1860 self.add_help_to(formatter)

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

1862

1863 def get_short_description(self) -> str:

1864 '''

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

1866 '''

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

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

1869 if len(out) > 1:

1870 return out[1]

1871 return ""

1872 return out[0]

1873

1874 def save(self,

1875 writer: FormattedWriter,

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

1877 ) -> None:

1878 '''

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

1880

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

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

1883

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

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

1886

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

1888

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

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

1891

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

1893

1894 The default implementation does nothing.

1895 '''

1896 pass

1897

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

1899

1900

1901 # ------- auto complete -------

1902

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

1904 '''

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

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

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

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

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

1910 :param end_of_line: The third return value.

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

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

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

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

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

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

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

1918 '''

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

1920 return start_of_line, completions, end_of_line

1921

1922

1923 class ArgumentParser(argparse.ArgumentParser):

1924

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

1926 '''

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

1928 '''

1929 raise ParseException(message)

1930

1931 class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True):

1932

1933 '''

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

1935

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

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

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

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

1940

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

1942 '''

1943

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

1945 parser: ArgumentParser

1946

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

1948 super().__init__(config_file)

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

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

1951 self.init_parser(self.parser)

1952

1953 @abc.abstractmethod

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

1955 '''

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

1957

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

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

1960 '''

1961 pass

1962

1963 @staticmethod

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

1965 '''

1966 This method:

1967

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

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

1970 - generates a help string containing the allowed values

1971

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

1973 '''

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

1975 for v in type:

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

1977 return v

1978 raise TypeError()

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

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

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

1982

1983 def get_help(self) -> str:

1984 '''

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

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

1987 '''

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

1989

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

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

1992 if not cmd:

1993 return # pragma: no cover

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

1995 if cmd[0] in self._names:

1996 cmd = cmd[1:]

1997 args = self.parser.parse_args(cmd)

1998 self.run_parsed(args)

1999

2000 @abc.abstractmethod

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

2002 '''

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

2004 '''

2005 pass

2006

2007 # ------- auto complete -------

2008

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

2010 if in_between:

2011 start = ''

2012 else:

2013 start = cmd[argument_pos][:cursor_pos]

2014

2015 if self.after_positional_argument_marker(cmd, argument_pos):

2016 pos = self.get_position(cmd, argument_pos)

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

2018

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

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

2021 if prevarg:

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

2023

2024 if self.is_option_start(start):

2025 if '=' in start:

2026 i = start.index('=')

2027 option_name = start[:i]

2028 i += 1

2029 start_of_line += start[:i]

2030 start = start[i:]

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

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

2033

2034 pos = self.get_position(cmd, argument_pos)

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

2036

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

2038 '''

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

2040 '''

2041 pos = 0

2042 n = len(cmd)

2043 options_allowed = True

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

2045 for i in range(1, argument_pos):

2046 if options_allowed and i < n:

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

2048 options_allowed = False

2049 continue

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

2051 continue

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

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

2054 continue

2055 pos += 1

2056

2057 return pos

2058

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

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

2061

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

2063 '''

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

2065 '''

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

2067

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

2069 if argument_pos >= len(cmd):

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

2071

2072 arg = cmd[argument_pos]

2073 if '=' in arg:

2074 # argument of option is already given within arg

2075 return None

2076 if not self.is_option_start(arg):

2077 return None

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

2079 action = self.get_action_for_option(arg)

2080 if action is None:

2081 return None

2082 if action.nargs != 0:

2083 return arg

2084 return None

2085

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

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

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

2089 if action is None:

2090 continue

2091 if action.nargs != 0:

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

2093 return None

2094

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

2096 action = self.get_action_for_option(out)

2097 if action is None:

2098 return None

2099 if action.nargs != 0:

2100 return out

2101 return None

2102

2103

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

2105 completions = []

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

2107 for opt in a.option_strings:

2108 if len(opt) <= 2:

2109 # this is trivial to type but not self explanatory

2110 # => not helpful for auto completion

2111 continue

2112 if opt.startswith(start):

2113 completions.append(opt)

2114 return start_of_line, completions, end_of_line

2115

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

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

2118

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

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

2121

2122

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

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

2125 if option_name in a.option_strings:

2126 return a

2127 return None

2128

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

2130 actions = self.parser._get_positional_actions()

2131 if argument_pos < len(actions):

2132 return actions[argument_pos]

2133 return None

2134

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

2136 if action is None:

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

2138 elif not action.choices:

2139 completions = []

2140 else:

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

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

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

2144 return start_of_line, completions, end_of_line

2145

2146

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

2148

2149 class Set(ConfigFileCommand):

2150

2151 r'''

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

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

2154

2155 Change the value of a setting.

2156

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

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

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

2160

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

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

2163

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

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

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

2167 '''

2168

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

2170 KEY_VAL_SEP = '='

2171

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

2173

2174 raw = False

2175

2176 # ------- load -------

2177

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

2179 '''

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

2181

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

2183 '''

2184 if self.is_vim_style(cmd):

2185 self.set_multiple(cmd)

2186 else:

2187 self.set_with_spaces(cmd)

2188

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

2190 '''

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

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

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

2194

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

2196 '''

2197 try:

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

2199 if cmd[1] in self.FLAGS_RAW:

2200 i = 2

2201 else:

2202 i = 1

2203 return self.KEY_VAL_SEP in cmd[i]

2204 except IndexError:

2205 raise ParseException('no settings given')

2206

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

2208 '''

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

2210

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

2212 '''

2213 if cmd[1] in self.FLAGS_RAW:

2214 cmd = cmd[2:]

2215 self.raw = True

2216 else:

2217 cmd = cmd[1:]

2218 self.raw = False

2219

2220 n = len(cmd)

2221 if n == 2:

2222 key, value = cmd

2223 self.parse_key_and_set_value(key, value)

2224 elif n == 3:

2225 key, sep, value = cmd

2226 if sep != self.KEY_VAL_SEP:

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

2228 self.parse_key_and_set_value(key, value)

2229 elif n == 1:

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

2231 else:

2232 assert n >= 4

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

2234

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

2236 '''

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

2238

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

2240 '''

2241 self.raw = False

2242 exceptions = []

2243 for arg in cmd[1:]:

2244 if arg in self.FLAGS_RAW:

2245 self.raw = True

2246 continue

2247 try:

2248 if not self.KEY_VAL_SEP in arg:

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

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

2251 self.parse_key_and_set_value(key, value)

2252 except ParseException as e:

2253 exceptions.append(e)

2254 if exceptions:

2255 raise MultipleParseExceptions(exceptions)

2256

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

2258 '''

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

2260

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

2262 '''

2263 if key not in self.config_file.config_instances:

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

2265

2266 instance = self.config_file.config_instances[key]

2267 try:

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

2269 except ValueError as e:

2270 raise ParseException(str(e))

2271

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

2273 '''

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

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

2276 '''

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

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

2279

2280

2281 # ------- save -------

2282

2283 def iter_config_instances_to_be_saved(self,

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

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

2286 *,

2287 sort: 'bool|None' = None,

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

2289 '''

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

2291 '''

2292 for config in self.config_file.iter_config_instances(config_instances, ignore, sort=sort):

2293 if config.wants_to_be_exported():

2294 yield config

2295

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

2297 last_name: 'str|None'

2298

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

2300 '''

2301 :param writer: The file to write to

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

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

2304

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

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

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

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

2309 '''

2310 no_multi = kw['no_multi']

2311 comments = kw['comments']

2312

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

2314 normal_configs = []

2315 multi_configs = []

2316 if no_multi:

2317 normal_configs = config_instances

2318 else:

2319 for instance in config_instances:

2320 if isinstance(instance, MultiConfig):

2321 multi_configs.append(instance)

2322 else:

2323 normal_configs.append(instance)

2324

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

2326

2327 if normal_configs:

2328 if multi_configs:

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

2330 elif self.should_write_heading:

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

2332

2333 if comments:

2334 type_help = self.get_help_for_data_types(normal_configs)

2335 if type_help:

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

2337 writer.write_lines(type_help)

2338

2339 for instance in normal_configs:

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

2341

2342 if multi_configs:

2343 if normal_configs:

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

2345 elif self.should_write_heading:

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

2347

2348 if comments:

2349 type_help = self.get_help_for_data_types(multi_configs)

2350 if type_help:

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

2352 writer.write_lines(type_help)

2353

2354 for instance in multi_configs:

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

2356

2357 for config_id in MultiConfig.config_ids:

2358 writer.write_line('')

2359 self.config_file.write_config_id(writer, config_id)

2360 for instance in multi_configs:

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

2362

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

2364 '''

2365 :param writer: The file to write to

2366 :param instance: The config value to be saved

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

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

2369

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

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

2372 '''

2373 if kw['comments']:

2374 self.write_config_help(writer, instance)

2375 if instance.is_value_valid():

2376 is_valid = True

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

2378 value = self.config_file.quote(value)

2379 else:

2380 is_valid = False

2381 value = ""

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

2383 raw = ' --raw'

2384 else:

2385 raw = ''

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

2387 if is_valid:

2388 writer.write_command(ln)

2389 else:

2390 writer.write_line(ln)

2391

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

2393 '''

2394 :param writer: The output to write to

2395 :param instance: The config value to be saved

2396

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

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

2399

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

2401 '''

2402 if group_dict_configs and instance.parent is not None:

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

2404 else:

2405 name = instance.key

2406 if name == self.last_name:

2407 return

2408

2409 formatter = HelpFormatterWrapper(self.config_file.formatter_class)

2410 writer.write_heading(SectionLevel.SUB_SECTION, name)

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

2412 #if instance.unit:

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

2414 if isinstance(instance.help, dict):

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

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

2417 val = inspect.cleandoc(val)

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

2419 elif isinstance(instance.help, str):

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

2421

2422 self.last_name = name

2423

2424

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

2426 '''

2427 :param config_instances: All config values to be saved

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

2429

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

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

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

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

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

2435 '''

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

2437 for instance in config_instances:

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

2439 name = t.get_type_name()

2440 if name in help_text:

2441 continue

2442

2443 h = t.get_help(self.config_file)

2444 if not h:

2445 continue

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

2447

2448 return help_text

2449

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

2451 help_map = self.get_data_type_name_to_help_map(config_instances)

2452 if not help_map:

2453 return

2454

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

2456 formatter.add_start_section(name)

2457 formatter.add_text(help_map[name])

2458 formatter.add_end_section()

2459

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

2461 formatter = self.create_formatter()

2462 self.add_help_for_data_types(formatter, config_instances)

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

2464

2465 # ------- help -------

2466

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

2468 super().add_help_to(formatter)

2469

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

2471 self.last_name = None

2472

2473 formatter.add_start_section('data types')

2474 self.add_help_for_data_types(formatter, config_instances)

2475 formatter.add_end_section()

2476

2477 if self.config_file.enable_config_ids:

2478 normal_configs = []

2479 multi_configs = []

2480 for instance in config_instances:

2481 if isinstance(instance, MultiConfig):

2482 multi_configs.append(instance)

2483 else:

2484 normal_configs.append(instance)

2485 else:

2486 normal_configs = config_instances

2487 multi_configs = []

2488

2489 if normal_configs:

2490 if self.config_file.enable_config_ids:

2491 formatter.add_start_section('application wide settings')

2492 else:

2493 formatter.add_start_section('settings')

2494 for instance in normal_configs:

2495 self.add_config_help(formatter, instance)

2496 formatter.add_end_section()

2497

2498 if multi_configs:

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

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

2501 for instance in multi_configs:

2502 self.add_config_help(formatter, instance)

2503 formatter.add_end_section()

2504

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

2506 formatter.add_start_section(instance.key)

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

2508 if isinstance(instance.help, dict):

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

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

2511 val = inspect.cleandoc(val)

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

2513 elif isinstance(instance.help, str):

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

2515 formatter.add_end_section()

2516

2517 # ------- auto complete -------

2518

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

2520 if argument_pos >= len(cmd):

2521 start = ''

2522 else:

2523 start = cmd[argument_pos][:cursor_pos]

2524

2525 if len(cmd) <= 1:

2526 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2527 elif self.is_vim_style(cmd):

2528 return self.get_completions_for_vim_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

2529 else:

2530 return self.get_completions_for_ranger_style_arg(cmd, argument_pos, start, start_of_line=start_of_line, end_of_line=end_of_line)

2531

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

2533 if self.KEY_VAL_SEP in start:

2534 key, start = start.split(self.KEY_VAL_SEP, 1)

2535 start_of_line += key + self.KEY_VAL_SEP

2536 return self.get_completions_for_value(key, start, start_of_line=start_of_line, end_of_line=end_of_line)

2537 else:

2538 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2539

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

2541 if argument_pos == 1:

2542 return self.get_completions_for_key(start, start_of_line=start_of_line, end_of_line=end_of_line)

2543 elif argument_pos == 2 or (argument_pos == 3 and cmd[2] == self.KEY_VAL_SEP):

2544 return self.get_completions_for_value(cmd[1], start, start_of_line=start_of_line, end_of_line=end_of_line)

2545 else:

2546 return start_of_line, [], end_of_line

2547

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

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

2550 return start_of_line, completions, end_of_line

2551

2552 def get_completions_for_value(self, key: str, start: str, *, start_of_line: str, end_of_line: str) -> 'tuple[str, list[str], str]':

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

2554 if applicable:

2555 return start_of_line, completions, end_of_line

2556

2557 instance = self.config_file.config_instances.get(key)

2558 if instance is None:

2559 return start_of_line, [], end_of_line

2560

2561 return instance.type.get_completions(self.config_file, start_of_line, start, end_of_line)

2562

2563

2564 class Include(ConfigFileArgparseCommand):

2565

2566 '''

2567 Load another config file.

2568

2569 This is useful if a config file is getting so big that you want to split it up

2570 or if you want to have different config files for different use cases which all include the same standard config file to avoid redundancy

2571 or if you want to bind several commands to one key which executes one command with ConfigFile.parse_line().

2572 '''

2573

2574 help_config_id = '''

2575 By default the loaded config file starts with which ever config id is currently active.

2576 This is useful if you want to use the same values for several config ids:

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

2578

2579 After the include the config id is reset to the config id which was active at the beginning of the include

2580 because otherwise it might lead to confusion if the config id is changed in the included config file.

2581 '''

2582

2583 home: 'Config[PathType]|str|None' = None

2584

2585 def get_home(self) -> str:

2586 if not self.home:

2587 home = ""

2588 elif isinstance(self.home, str):

2589 home = self.home

2590 else:

2591 home = self.home.expand()

2592 if home:

2593 return home

2594

2595 fn = self.config_file.context_file_name

2596 if fn is None:

2597 fn = self.config_file.get_save_path()

2598 return os.path.dirname(fn)

2599

2600

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

2602 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.')

2603 if self.config_file.enable_config_ids:

2604 assert parser.description is not None

2605 parser.description += '\n\n' + inspect.cleandoc(self.help_config_id)

2606 group = parser.add_mutually_exclusive_group()

2607 group.add_argument('--reset-config-id-before', action='store_true', help='Ignore any config id which might be active when starting the include')

2608 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')

2609

2610 self.nested_includes: 'list[str]' = []

2611

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

2613 fn_imp = args.path

2614 fn_imp = fn_imp.replace('/', os.path.sep)

2615 fn_imp = os.path.expanduser(fn_imp)

2616 if not os.path.isabs(fn_imp):

2617 fn_imp = os.path.join(self.get_home(), fn_imp)

2618

2619 if fn_imp in self.nested_includes:

2620 raise ParseException(f'circular include of file {fn_imp!r}')

2621 if not os.path.isfile(fn_imp):

2622 raise ParseException(f'no such file {fn_imp!r}')

2623

2624 self.nested_includes.append(fn_imp)

2625

2626 if self.config_file.enable_config_ids and args.no_reset_config_id_after:

2627 self.config_file.load_without_resetting_config_id(fn_imp)

2628 elif self.config_file.enable_config_ids and args.reset_config_id_before:

2629 config_id = self.config_file.config_id

2630 self.config_file.load_file(fn_imp)

2631 self.config_file.config_id = config_id

2632 else:

2633 config_id = self.config_file.config_id

2634 self.config_file.load_without_resetting_config_id(fn_imp)

2635 self.config_file.config_id = config_id

2636

2637 assert self.nested_includes[-1] == fn_imp

2638 del self.nested_includes[-1]

2639

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

2641 # action does not have a name and metavar is None if not explicitly set, dest is the only way to identify the action

2642 if action is not None and action.dest == 'path':

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

2644 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)

2645

2646

2647 class Echo(ConfigFileArgparseCommand):

2648

2649 '''

2650 Display a message.

2651

2652 Settings and environment variables are expanded like in the value of a set command.

2653 '''

2654

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

2656 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.")

2657 parser.add_argument('-r', '--raw', action='store_true', help="Do not expand settings and environment variables.")

2658 parser.add_argument('msg', nargs=argparse.ONE_OR_MORE, help="The message to display")

2659

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

2661 msg = ' '.join(self.config_file.expand(m) for m in args.msg)

2662 self.ui_notifier.show(args.level, msg, ignore_filter=True)

2663

2664

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

2666 if argument_pos >= len(cmd):

2667 start = ''

2668 else:

2669 start = cmd[argument_pos][:cursor_pos]

2670

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

2672 return start_of_line, completions, end_of_line

2673

2674 class Help(ConfigFileArgparseCommand):

2675

2676 '''

2677 Display help.

2678 '''

2679

2680 max_width = 80

2681 max_width_name = 18

2682 min_width_sep = 2

2683 tab_size = 4

2684

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

2686 parser.add_argument('cmd', nargs='?', help="The command for which you want help")

2687

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

2689 if args.cmd:

2690 if args.cmd not in self.config_file.command_dict:

2691 raise ParseException(f"unknown command {args.cmd!r}")

2692 cmd = self.config_file.command_dict[args.cmd]

2693 out = cmd.get_help()

2694 else:

2695 out = "The following commands are defined:\n"

2696 table = []

2697 for cmd in self.config_file.commands:

2698 name = "- %s" % "/".join(cmd.get_names())

2699 descr = cmd.get_short_description()

2700 row = (name, descr)

2701 table.append(row)

2702 out += self.format_table(table)

2703

2704 out += "\n"

2705 out += "\nUse `help <cmd>` to get more information about a command."

2706

2707 self.ui_notifier.show(NotificationLevel.INFO, out, ignore_filter=True, no_context=True)

2708

2709 def format_table(self, table: 'Sequence[tuple[str, str]]') -> str:

2710 max_name_width = max(len(row[0]) for row in table)

2711 col_width_name = min(max_name_width, self.max_width_name)

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

2713 subsequent_indent = ' ' * (col_width_name + self.min_width_sep)

2714 for name, descr in table:

2715 if not descr:

2716 out.append(name)

2717 continue

2718 if len(name) > col_width_name:

2719 out.append(name)

2720 initial_indent = subsequent_indent

2721 else:

2722 initial_indent = name.ljust(col_width_name + self.min_width_sep)

2723 out.extend(textwrap.wrap(descr, self.max_width,

2724 initial_indent = initial_indent,

2725 subsequent_indent = subsequent_indent,

2726 break_long_words = False,

2727 tabsize = self.tab_size,

2728 ))

2729 return '\n'.join(out)

2730

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

2732 if action and action.dest == 'cmd':

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

2734 return start_of_line, completions, end_of_line

2735

2736 return super().get_completions_for_action(action, start, start_of_line=start_of_line, end_of_line=end_of_line)

2737

2738

2739 class UnknownCommand(ConfigFileCommand, abstract=True):

2740

2741 name = DEFAULT_COMMAND

2742

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

2744 raise ParseException('unknown command %r' % cmd[0])