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

1374 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2024-05-29 08:43 +0200

1#!./runmodule.sh 

2 

3''' 

4This module defines the ConfigFile class 

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

6''' 

7 

8import os 

9import shlex 

10import platform 

11import re 

12import enum 

13import argparse 

14import textwrap 

15import functools 

16import inspect 

17import io 

18import warnings 

19import abc 

20import typing 

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

22 

23import appdirs 

24 

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

26from .formatters import AbstractFormatter 

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

28from . import state 

29 

30if 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 * 

34T2 = 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. 

38DEFAULT_COMMAND = '' 

39 

40 

41if 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 

54class 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 

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

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

166 

167 

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

169 

170class 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 

290class 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 

397class 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 

406class 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 

455class 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 

469class 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 

496class 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 

516class 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 

523class 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 

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

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

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

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

544 no_multi: bool 

545 comments: bool 

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

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

548 

549 

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

551 

552class 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 

570class 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 

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

857 :return: False if an error has occurred 

858 ''' 

859 out = True 

860 for fn in self.iter_config_paths(): 

861 if os.path.isfile(fn): 

862 out &= self.load_file(fn) 

863 break 

864 

865 if env: 

866 out &= self.load_env() 

867 

868 return out 

869 

870 def load_env(self) -> bool: 

871 ''' 

872 Load settings from environment variables. 

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

874 

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

876 

877 :return: False if an error has occurred 

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

879 ''' 

880 out = True 

881 old_file_name = self.context_file_name 

882 self.context_file_name = Message.ENVIRONMENT_VARIABLES 

883 

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

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

886 name = self.get_env_name(key) 

887 if name in self.env_variables: 

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

889 elif name in config_instances: 

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

891 else: 

892 config_instances[name] = instance 

893 

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

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

896 continue 

897 if name in self.env_variables: 

898 continue 

899 

900 if name in config_instances: 

901 instance = config_instances[name] 

902 try: 

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

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

905 except ValueError as e: 

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

907 out = False 

908 else: 

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

910 out = False 

911 

912 self.context_file_name = old_file_name 

913 return out 

914 

915 

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

917 ''' 

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

919 

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

921 ''' 

922 out = key 

923 out = out.upper() 

924 for c in ' .-': 

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

926 out = self.envprefix + out 

927 return out 

928 

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

930 ''' 

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

932 

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

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

935 

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

937 :return: False if an error has occurred 

938 ''' 

939 self.config_id = None 

940 return self.load_without_resetting_config_id(fn) 

941 

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

943 out = True 

944 old_file_name = self.context_file_name 

945 self.context_file_name = fn 

946 

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

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

949 self.context_line_number = lnno 

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

951 self.context_line_number = None 

952 

953 self.context_file_name = old_file_name 

954 return out 

955 

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

957 ''' 

958 :param line: The line to be parsed 

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

960 

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

962 ''' 

963 ln = line.strip() 

964 if not ln: 

965 return True 

966 if self.is_comment(ln): 

967 return True 

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

969 return True 

970 

971 self.context_line = ln 

972 

973 try: 

974 ln_split = self.split_line(ln) 

975 except Exception as e: 

976 self.parse_error(str(e)) 

977 out = False 

978 else: 

979 out = self.parse_split_line(ln_split) 

980 

981 self.context_line = '' 

982 return out 

983 

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

985 cmd, line = self.split_one_symbol_command(line) 

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

987 if cmd: 

988 line_split.insert(0, cmd) 

989 return line_split 

990 

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

992 out = [] 

993 cmd, line = self.split_one_symbol_command(line) 

994 if cmd: 

995 out.append(cmd) 

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

997 lex.whitespace_split = True 

998 while True: 

999 try: 

1000 t = lex.get_token() 

1001 except: 

1002 out.append(lex.token) 

1003 return out 

1004 if t is None: 

1005 return out 

1006 out.append(t) 

1007 

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

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

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

1011 

1012 return None, line 

1013 

1014 

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

1016 ''' 

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

1018 

1019 :param line: The current line 

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

1021 ''' 

1022 for c in self.COMMENT_PREFIXES: 

1023 if line.startswith(c): 

1024 return True 

1025 return False 

1026 

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

1028 ''' 

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

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

1031 

1032 :param line: The current line 

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

1034 ''' 

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

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

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

1038 try: 

1039 self.check_config_id(config_id) 

1040 except ParseException as e: 

1041 self.parse_error(str(e)) 

1042 self.config_id = config_id 

1043 if self.config_id not in MultiConfig.config_ids: 

1044 MultiConfig.config_ids.append(self.config_id) 

1045 return True 

1046 return False 

1047 

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

1049 ''' 

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

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

1052 

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

1054 ''' 

1055 cmd = self.get_command(ln_split) 

1056 try: 

1057 cmd.run(ln_split) 

1058 except ParseException as e: 

1059 self.parse_error(str(e)) 

1060 return False 

1061 except MultipleParseExceptions as exceptions: 

1062 for exc in exceptions: 

1063 self.parse_error(str(exc)) 

1064 return False 

1065 

1066 return True 

1067 

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

1069 cmd_name = ln_split[0] 

1070 if cmd_name in self.command_dict: 

1071 cmd = self.command_dict[cmd_name] 

1072 elif DEFAULT_COMMAND in self.command_dict: 

1073 cmd = self.command_dict[DEFAULT_COMMAND] 

1074 else: 

1075 cmd = UnknownCommand(self) 

1076 return cmd 

1077 

1078 

1079 # ------- save ------- 

1080 

1081 def get_save_path(self) -> str: 

1082 ''' 

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

1084 ''' 

1085 paths = tuple(self.iter_config_paths()) 

1086 for fn in paths: 

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

1088 return fn 

1089 

1090 return paths[0] 

1091 

1092 def save(self, 

1093 if_not_existing: bool = False, 

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

1095 ) -> str: 

1096 ''' 

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

1098 Directories are created as necessary. 

1099 

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

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

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

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

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

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

1106 ''' 

1107 fn = self.get_save_path() 

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

1109 return fn 

1110 

1111 self.save_file(fn, **kw) 

1112 return fn 

1113 

1114 def save_file(self, 

1115 fn: str, 

1116 **kw: 'Unpack[SaveKwargs]' 

1117 ) -> None: 

1118 ''' 

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

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

1121 

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

1123 :raises FileNotFoundError: if the directory does not exist 

1124 

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

1126 ''' 

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

1128 fn = os.path.abspath(fn) 

1129 

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

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

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

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

1134 

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

1136 self.save_to_open_file(f, **kw) 

1137 

1138 

1139 def save_to_open_file(self, 

1140 f: typing.TextIO, 

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

1142 ) -> None: 

1143 ''' 

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

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

1146 

1147 :param f: The file to write to 

1148 

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

1150 ''' 

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

1152 self.save_to_writer(writer, **kw) 

1153 

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

1155 ''' 

1156 Save the current values of all settings. 

1157 

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

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

1160 

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

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

1163 ''' 

1164 self.set_save_default_arguments(kw) 

1165 commands = list(self.commands) 

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

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

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

1169 for cmd in tuple(commands): 

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

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

1172 commands.remove(cmd) 

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

1174 for cmd in commands: 

1175 cmd.should_write_heading = write_headings 

1176 cmd.save(writer, **kw) 

1177 

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

1179 ''' 

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

1181 ''' 

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

1183 kw.setdefault('ignore', None) 

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

1185 kw.setdefault('comments', True) 

1186 

1187 

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

1189 ''' 

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

1191 

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

1193 ''' 

1194 return readable_quote(val) 

1195 

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

1197 ''' 

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

1199 ''' 

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

1201 

1202 def get_help_config_id(self) -> str: 

1203 ''' 

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

1205 ''' 

1206 return f''' 

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

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

1209 ''' 

1210 

1211 

1212 # ------- formatting and parsing of values ------- 

1213 

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

1215 ''' 

1216 :param instance: The config value to be saved 

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

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

1219 

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

1221 ''' 

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

1223 

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

1225 return type.format_value(self, value) 

1226 

1227 

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

1229 ''' 

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

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

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

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

1234 ''' 

1235 if not raw: 

1236 value = self.expand(value) 

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

1238 

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

1240 ''' 

1241 Parse a value to the given data type. 

1242 

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

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

1245 :param value: The value to be parsed 

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

1247 ''' 

1248 return t.parse_value(self, value) 

1249 

1250 

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

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

1253 

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

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

1256 n = arg.count('%') 

1257 if n % 2 == 1: 

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

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

1260 

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

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

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

1264 

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

1266 ''' 

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

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

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

1270 

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

1272 

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

1274 

1275 ``!conversion`` is one of: 

1276 

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

1278 - ``!r``: :func:`repr` 

1279 - ``!s``: :class:`str` 

1280 - ``!a``: :func:`ascii` 

1281 

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

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

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

1285 ''' 

1286 key = m.group(1) 

1287 if not key: 

1288 return '%' 

1289 

1290 if ':' in key: 

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

1292 else: 

1293 fmt = None 

1294 if '!' in key: 

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

1296 else: 

1297 stringifier = None 

1298 

1299 if key not in self.config_instances: 

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

1301 instance = self.config_instances[key] 

1302 

1303 if stringifier is None and fmt is None: 

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

1305 elif stringifier is None: 

1306 assert fmt is not None 

1307 try: 

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

1309 except Exception as e: 

1310 raise ParseException(e) 

1311 

1312 val: object 

1313 if stringifier == '': 

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

1315 else: 

1316 val = instance.get_value(config_id=None) 

1317 if stringifier == 'r': 

1318 val = repr(val) 

1319 elif stringifier == 's': 

1320 val = str(val) 

1321 elif stringifier == 'a': 

1322 val = ascii(val) 

1323 else: 

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

1325 

1326 if fmt is None: 

1327 assert isinstance(val, str) 

1328 return val 

1329 

1330 try: 

1331 return format(val, fmt) 

1332 except ValueError as e: 

1333 raise ParseException(e) 

1334 

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

1336 ''' 

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

1338 :return: The expanded form of the environment variable 

1339 

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

1341 

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

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

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

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

1346 

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

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

1349 ''' 

1350 env = m.group(1) 

1351 for op in '-=?+': 

1352 if ':' + op in env: 

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

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

1355 elif op in env: 

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

1357 isset = env in os.environ 

1358 else: 

1359 continue 

1360 

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

1362 if op == '-': 

1363 if isset: 

1364 return val 

1365 else: 

1366 return arg 

1367 elif op == '=': 

1368 if isset: 

1369 return val 

1370 else: 

1371 os.environ[env] = arg 

1372 return arg 

1373 elif op == '?': 

1374 if isset: 

1375 return val 

1376 else: 

1377 if not arg: 

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

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

1380 raise ParseException(arg) 

1381 elif op == '+': 

1382 if isset: 

1383 return arg 

1384 else: 

1385 return '' 

1386 else: 

1387 assert False 

1388 

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

1390 

1391 

1392 # ------- help ------- 

1393 

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

1395 import platform 

1396 formatter = self.create_formatter() 

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

1398 for path in self.iter_config_paths(): 

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

1400 

1401 writer.write_line('') 

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

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

1404 writer.write_line('- XDG_CONFIG_HOME') 

1405 writer.write_line('- XDG_CONFIG_DIRS') 

1406 for env in self.env_variables: 

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

1408 

1409 writer.write_line('') 

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

1411 \ 

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

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

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

1415 

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

1417 

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

1419 for cmd in self.commands: 

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

1421 writer.write_heading(SectionLevel.SECTION, names) 

1422 writer.write_lines(cmd.get_help()) 

1423 

1424 def create_formatter(self) -> HelpFormatterWrapper: 

1425 return HelpFormatterWrapper(self.formatter_class) 

1426 

1427 def get_help(self) -> str: 

1428 ''' 

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

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

1431 

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

1433 ''' 

1434 doc = io.StringIO() 

1435 self.write_help(HelpWriter(doc)) 

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

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

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

1439 # Therefore I am stripping the trailing \n. 

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

1441 

1442 

1443 # ------- auto complete ------- 

1444 

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

1446 ''' 

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

1448 

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

1450 :param cursor_pos: The position of the cursor 

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

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

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

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

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

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

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

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

1459 ''' 

1460 original_ln = line 

1461 stripped_line = line.lstrip() 

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

1463 cursor_pos -= len(indentation) 

1464 line = stripped_line 

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

1466 out = self.get_completions_enter_group(line, cursor_pos) 

1467 else: 

1468 out = self.get_completions_command(line, cursor_pos) 

1469 

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

1471 return out 

1472 

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

1474 ''' 

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

1476 

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

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

1479 ''' 

1480 start = line 

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

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

1483 return '', groups, '' 

1484 

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

1486 ''' 

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

1488 

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

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

1491 ''' 

1492 if not line: 

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

1494 

1495 ln_split = self.split_line_ignore_errors(line) 

1496 assert ln_split 

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

1498 

1499 if a.in_between: 

1500 start_of_line = line[:cursor_pos] 

1501 end_of_line = line[cursor_pos:] 

1502 else: 

1503 start_of_line = line[:a.i0] 

1504 end_of_line = line[a.i1:] 

1505 

1506 if a.argument_pos == 0: 

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

1508 else: 

1509 cmd = self.get_command(ln_split) 

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

1511 

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

1513 ''' 

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

1515 ''' 

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

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

1518 out = ArgPos() 

1519 out.in_between = True 

1520 

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

1522 out.argument_pos = 0 

1523 out.i0 = 0 

1524 out.i1 = 0 

1525 

1526 n_ln = len(line) 

1527 i_ln = 0 

1528 n_arg = len(ln_split) 

1529 out.argument_pos = 0 

1530 i_in_arg = 0 

1531 assert out.argument_pos < n_ln 

1532 while True: 

1533 if out.in_between: 

1534 assert i_in_arg == 0 

1535 if i_ln >= n_ln: 

1536 assert out.argument_pos >= n_arg - 1 

1537 out.i0 = i_ln 

1538 return out 

1539 elif line[i_ln].isspace(): 

1540 i_ln += 1 

1541 else: 

1542 out.i0 = i_ln 

1543 if i_ln >= cursor_pos: 

1544 return out 

1545 if out.argument_pos >= n_arg: 

1546 assert line[i_ln] == '#' 

1547 out.i0 = len(line) 

1548 return out 

1549 out.in_between = False 

1550 else: 

1551 if i_ln >= n_ln: 

1552 assert out.argument_pos >= n_arg - 1 

1553 out.i1 = i_ln 

1554 return out 

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

1556 if line[i_ln].isspace(): 

1557 out.i1 = i_ln 

1558 if i_ln >= cursor_pos: 

1559 return out 

1560 out.in_between = True 

1561 i_ln += 1 

1562 out.argument_pos += 1 

1563 i_in_arg = 0 

1564 elif line[i_ln] in CHARS_REMOVED_BY_SHLEX: 

1565 i_ln += 1 

1566 else: 

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

1568 assert line[i_ln] == '#' 

1569 assert out.argument_pos == n_arg - 1 

1570 out.i1 = i_ln 

1571 return out 

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

1573 i_ln += 1 

1574 i_in_arg += 1 

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

1576 out.in_between = True 

1577 out.argument_pos += 1 

1578 out.i0 = i_ln 

1579 i_in_arg = 0 

1580 else: 

1581 assert line[i_ln] in CHARS_REMOVED_BY_SHLEX 

1582 i_ln += 1 

1583 

1584 

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

1586 start = line[:cursor_pos] 

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

1588 return start_of_line, completions, end_of_line 

1589 

1590 

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

1592 r''' 

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

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

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

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

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

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

1599 ''' 

1600 if exclude is None: 

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

1602 exclude = '$none' 

1603 else: 

1604 exclude = r'^\.' 

1605 reo = re.compile(exclude) 

1606 

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

1608 if os.path.sep in start: 

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

1610 directory += os.path.sep 

1611 quoted_directory = self.quote_path(directory) 

1612 

1613 start_of_line += quoted_directory 

1614 directory = os.path.expanduser(directory) 

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

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

1617 directory = os.path.normpath(directory) 

1618 else: 

1619 directory = relative_to 

1620 

1621 try: 

1622 names = os.listdir(directory) 

1623 except (FileNotFoundError, NotADirectoryError): 

1624 return start_of_line, [], end_of_line 

1625 

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

1627 for name in names: 

1628 if reo.match(name): 

1629 continue 

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

1631 continue 

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

1633 continue 

1634 

1635 quoted_name = self.quote(name) 

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

1637 quoted_name += os.path.sep 

1638 

1639 out.append(quoted_name) 

1640 

1641 return start_of_line, out, end_of_line 

1642 

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

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

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

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

1647 if path_split[i]: 

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

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

1650 

1651 

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

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

1654 if applicable: 

1655 return applicable, start_of_line, completions, end_of_line 

1656 

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

1658 

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

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

1661 return False, start_of_line, [], end_of_line 

1662 

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

1664 start_of_line = start_of_line + start[:i] 

1665 start = start[i:] 

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

1667 return True, start_of_line, completions, end_of_line 

1668 

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

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

1671 if i < 0: 

1672 return False, start_of_line, [], end_of_line 

1673 i += 2 

1674 

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

1676 return False, start_of_line, [], end_of_line 

1677 

1678 start_of_line = start_of_line + start[:i] 

1679 start = start[i:] 

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

1681 return True, start_of_line, completions, end_of_line 

1682 

1683 

1684 # ------- error handling ------- 

1685 

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

1687 ''' 

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

1689 

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

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

1692 

1693 :param msg: The error message 

1694 ''' 

1695 self.ui_notifier.show_error(msg) 

1696 

1697 

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

1699 

1700class ConfigFileCommand(abc.ABC): 

1701 

1702 ''' 

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

1704 

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

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

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

1708 

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

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

1711 ''' 

1712 

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

1714 name: str 

1715 

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

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

1718 

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

1720 help: str 

1721 

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

1723 should_write_heading: bool = False 

1724 

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

1726 config_file: ConfigFile 

1727 

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

1729 ui_notifier: UiNotifier 

1730 

1731 _abstract: bool 

1732 

1733 

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

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

1736 

1737 @classmethod 

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

1739 ''' 

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

1741 ''' 

1742 return tuple(cls._subclasses) 

1743 

1744 @classmethod 

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

1746 ''' 

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

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

1749 ''' 

1750 if cmd_type in cls._subclasses: 

1751 cls._subclasses.remove(cmd_type) 

1752 for name in cmd_type.get_names(): 

1753 cls._used_names.remove(name) 

1754 

1755 @classmethod 

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

1757 ''' 

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

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

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

1761 ''' 

1762 cls._abstract = abstract 

1763 if replace: 

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

1765 

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

1767 parent = parent_commands[0] 

1768 if 'name' not in cls.__dict__: 

1769 cls.name = parent.get_name() 

1770 if 'aliases' not in cls.__dict__: 

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

1772 for parent in parent_commands[1:]: 

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

1774 

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

1776 for parent in parent_commands: 

1777 cls.delete_command_type(parent) 

1778 

1779 if not abstract: 

1780 cls._subclasses.append(cls) 

1781 for name in cls.get_names(): 

1782 if name in cls._used_names and not replace: 

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

1784 cls._used_names.add(name) 

1785 

1786 @classmethod 

1787 def get_name(cls) -> str: 

1788 ''' 

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

1790  

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

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

1793 ''' 

1794 if 'name' in cls.__dict__: 

1795 return cls.name 

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

1797 

1798 @classmethod 

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

1800 ''' 

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

1802  

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

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

1805 

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

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

1808 ''' 

1809 yield cls.get_name() 

1810 if 'aliases' in cls.__dict__: 

1811 for name in cls.aliases: 

1812 yield name 

1813 

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

1815 self.config_file = config_file 

1816 self.ui_notifier = config_file.ui_notifier 

1817 

1818 @abc.abstractmethod 

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

1820 ''' 

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

1822 

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

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

1825 ''' 

1826 raise NotImplementedError() 

1827 

1828 

1829 def create_formatter(self) -> HelpFormatterWrapper: 

1830 return self.config_file.create_formatter() 

1831 

1832 def get_help_attr_or_doc_str(self) -> str: 

1833 ''' 

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

1835 ''' 

1836 if hasattr(self, 'help'): 

1837 doc = self.help 

1838 elif self.__doc__: 

1839 doc = self.__doc__ 

1840 else: 

1841 doc = '' 

1842 

1843 return inspect.cleandoc(doc) 

1844 

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

1846 ''' 

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

1848 ''' 

1849 formatter.add_text(self.get_help_attr_or_doc_str()) 

1850 

1851 def get_help(self) -> str: 

1852 ''' 

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

1854 

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

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

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

1858 

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

1860 ''' 

1861 formatter = self.create_formatter() 

1862 self.add_help_to(formatter) 

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

1864 

1865 def get_short_description(self) -> str: 

1866 ''' 

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

1868 ''' 

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

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

1871 if len(out) > 1: 

1872 return out[1] 

1873 return "" 

1874 return out[0] 

1875 

1876 def save(self, 

1877 writer: FormattedWriter, 

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

1879 ) -> None: 

1880 ''' 

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

1882 

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

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

1885 

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

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

1888 

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

1890 

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

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

1893 

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

1895 

1896 The default implementation does nothing. 

1897 ''' 

1898 pass 

1899 

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

1901 

1902 

1903 # ------- auto complete ------- 

1904 

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

1906 ''' 

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

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

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

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

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

1912 :param end_of_line: The third return value. 

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

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

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

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

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

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

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

1920 ''' 

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

1922 return start_of_line, completions, end_of_line 

1923 

1924 

1925class ArgumentParser(argparse.ArgumentParser): 

1926 

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

1928 ''' 

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

1930 ''' 

1931 raise ParseException(message) 

1932 

1933class ConfigFileArgparseCommand(ConfigFileCommand, abstract=True): 

1934 

1935 ''' 

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

1937 

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

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

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

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

1942 

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

1944 ''' 

1945 

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

1947 parser: ArgumentParser 

1948 

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

1950 super().__init__(config_file) 

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

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

1953 self.init_parser(self.parser) 

1954 

1955 @abc.abstractmethod 

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

1957 ''' 

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

1959 

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

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

1962 ''' 

1963 pass 

1964 

1965 @staticmethod 

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

1967 ''' 

1968 This method: 

1969 

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

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

1972 - generates a help string containing the allowed values 

1973 

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

1975 ''' 

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

1977 for v in type: 

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

1979 return v 

1980 raise TypeError() 

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

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

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

1984 

1985 def get_help(self) -> str: 

1986 ''' 

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

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

1989 ''' 

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

1991 

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

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

1994 if not cmd: 

1995 return # pragma: no cover 

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

1997 if cmd[0] in self._names: 

1998 cmd = cmd[1:] 

1999 args = self.parser.parse_args(cmd) 

2000 self.run_parsed(args) 

2001 

2002 @abc.abstractmethod 

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

2004 ''' 

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

2006 ''' 

2007 pass 

2008 

2009 # ------- auto complete ------- 

2010 

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

2012 if in_between: 

2013 start = '' 

2014 else: 

2015 start = cmd[argument_pos][:cursor_pos] 

2016 

2017 if self.after_positional_argument_marker(cmd, argument_pos): 

2018 pos = self.get_position(cmd, argument_pos) 

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

2020 

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

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

2023 if prevarg: 

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

2025 

2026 if self.is_option_start(start): 

2027 if '=' in start: 

2028 i = start.index('=') 

2029 option_name = start[:i] 

2030 i += 1 

2031 start_of_line += start[:i] 

2032 start = start[i:] 

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

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

2035 

2036 pos = self.get_position(cmd, argument_pos) 

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

2038 

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

2040 ''' 

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

2042 ''' 

2043 pos = 0 

2044 n = len(cmd) 

2045 options_allowed = True 

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

2047 for i in range(1, argument_pos): 

2048 if options_allowed and i < n: 

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

2050 options_allowed = False 

2051 continue 

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

2053 continue 

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

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

2056 continue 

2057 pos += 1 

2058 

2059 return pos 

2060 

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

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

2063 

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

2065 ''' 

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

2067 ''' 

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

2069 

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

2071 if argument_pos >= len(cmd): 

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

2073 

2074 arg = cmd[argument_pos] 

2075 if '=' in arg: 

2076 # argument of option is already given within arg 

2077 return None 

2078 if not self.is_option_start(arg): 

2079 return None 

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

2081 action = self.get_action_for_option(arg) 

2082 if action is None: 

2083 return None 

2084 if action.nargs != 0: 

2085 return arg 

2086 return None 

2087 

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

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

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

2091 if action is None: 

2092 continue 

2093 if action.nargs != 0: 

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

2095 return None 

2096 

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

2098 action = self.get_action_for_option(out) 

2099 if action is None: 

2100 return None 

2101 if action.nargs != 0: 

2102 return out 

2103 return None 

2104 

2105 

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

2107 completions = [] 

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

2109 for opt in a.option_strings: 

2110 if len(opt) <= 2: 

2111 # this is trivial to type but not self explanatory 

2112 # => not helpful for auto completion 

2113 continue 

2114 if opt.startswith(start): 

2115 completions.append(opt) 

2116 return start_of_line, completions, end_of_line 

2117 

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

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

2120 

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

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

2123 

2124 

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

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

2127 if option_name in a.option_strings: 

2128 return a 

2129 return None 

2130 

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

2132 actions = self.parser._get_positional_actions() 

2133 if argument_pos < len(actions): 

2134 return actions[argument_pos] 

2135 return None 

2136 

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

2138 if action is None: 

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

2140 elif not action.choices: 

2141 completions = [] 

2142 else: 

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

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

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

2146 return start_of_line, completions, end_of_line 

2147 

2148 

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

2150 

2151class Set(ConfigFileCommand): 

2152 

2153 r''' 

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

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

2156 

2157 Change the value of a setting. 

2158 

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

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

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

2162 

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

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

2165 

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

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

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

2169 ''' 

2170 

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

2172 KEY_VAL_SEP = '=' 

2173 

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

2175 

2176 raw = False 

2177 

2178 # ------- load ------- 

2179 

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

2181 ''' 

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

2183 

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

2185 ''' 

2186 if self.is_vim_style(cmd): 

2187 self.set_multiple(cmd) 

2188 else: 

2189 self.set_with_spaces(cmd) 

2190 

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

2192 ''' 

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

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

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

2196 

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

2198 ''' 

2199 try: 

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

2201 if cmd[1] in self.FLAGS_RAW: 

2202 i = 2 

2203 else: 

2204 i = 1 

2205 return self.KEY_VAL_SEP in cmd[i] 

2206 except IndexError: 

2207 raise ParseException('no settings given') 

2208 

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

2210 ''' 

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

2212 

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

2214 ''' 

2215 if cmd[1] in self.FLAGS_RAW: 

2216 cmd = cmd[2:] 

2217 self.raw = True 

2218 else: 

2219 cmd = cmd[1:] 

2220 self.raw = False 

2221 

2222 n = len(cmd) 

2223 if n == 2: 

2224 key, value = cmd 

2225 self.parse_key_and_set_value(key, value) 

2226 elif n == 3: 

2227 key, sep, value = cmd 

2228 if sep != self.KEY_VAL_SEP: 

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

2230 self.parse_key_and_set_value(key, value) 

2231 elif n == 1: 

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

2233 else: 

2234 assert n >= 4 

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

2236 

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

2238 ''' 

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

2240 

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

2242 ''' 

2243 self.raw = False 

2244 exceptions = [] 

2245 for arg in cmd[1:]: 

2246 if arg in self.FLAGS_RAW: 

2247 self.raw = True 

2248 continue 

2249 try: 

2250 if not self.KEY_VAL_SEP in arg: 

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

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

2253 self.parse_key_and_set_value(key, value) 

2254 except ParseException as e: 

2255 exceptions.append(e) 

2256 if exceptions: 

2257 raise MultipleParseExceptions(exceptions) 

2258 

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

2260 ''' 

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

2262 

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

2264 ''' 

2265 if key not in self.config_file.config_instances: 

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

2267 

2268 instance = self.config_file.config_instances[key] 

2269 try: 

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

2271 except ValueError as e: 

2272 raise ParseException(str(e)) 

2273 

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

2275 ''' 

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

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

2278 ''' 

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

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

2281 

2282 

2283 # ------- save ------- 

2284 

2285 def iter_config_instances_to_be_saved(self, 

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

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

2288 *, 

2289 sort: 'bool|None' = None, 

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

2291 ''' 

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

2293 ''' 

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

2295 if config.wants_to_be_exported(): 

2296 yield config 

2297 

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

2299 last_name: 'str|None' 

2300 

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

2302 ''' 

2303 :param writer: The file to write to 

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

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

2306 

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

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

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

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

2311 ''' 

2312 no_multi = kw['no_multi'] 

2313 comments = kw['comments'] 

2314 

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

2316 normal_configs = [] 

2317 multi_configs = [] 

2318 if no_multi: 

2319 normal_configs = config_instances 

2320 else: 

2321 for instance in config_instances: 

2322 if isinstance(instance, MultiConfig): 

2323 multi_configs.append(instance) 

2324 else: 

2325 normal_configs.append(instance) 

2326 

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

2328 

2329 if normal_configs: 

2330 if multi_configs: 

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

2332 elif self.should_write_heading: 

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

2334 

2335 if comments: 

2336 type_help = self.get_help_for_data_types(normal_configs) 

2337 if type_help: 

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

2339 writer.write_lines(type_help) 

2340 

2341 for instance in normal_configs: 

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

2343 

2344 if multi_configs: 

2345 if normal_configs: 

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

2347 elif self.should_write_heading: 

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

2349 

2350 if comments: 

2351 type_help = self.get_help_for_data_types(multi_configs) 

2352 if type_help: 

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

2354 writer.write_lines(type_help) 

2355 

2356 for instance in multi_configs: 

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

2358 

2359 for config_id in MultiConfig.config_ids: 

2360 writer.write_line('') 

2361 self.config_file.write_config_id(writer, config_id) 

2362 for instance in multi_configs: 

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

2364 

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

2366 ''' 

2367 :param writer: The file to write to 

2368 :param instance: The config value to be saved 

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

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

2371 

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

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

2374 ''' 

2375 if kw['comments']: 

2376 self.write_config_help(writer, instance) 

2377 if instance.is_value_valid(): 

2378 is_valid = True 

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

2380 value = self.config_file.quote(value) 

2381 else: 

2382 is_valid = False 

2383 value = "" 

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

2385 raw = ' --raw' 

2386 else: 

2387 raw = '' 

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

2389 if is_valid: 

2390 writer.write_command(ln) 

2391 else: 

2392 writer.write_line(ln) 

2393 

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

2395 ''' 

2396 :param writer: The output to write to 

2397 :param instance: The config value to be saved 

2398 

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

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

2401 

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

2403 ''' 

2404 if group_dict_configs and instance.parent is not None: 

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

2406 else: 

2407 name = instance.key 

2408 if name == self.last_name: 

2409 return 

2410 

2411 formatter = HelpFormatterWrapper(self.config_file.formatter_class) 

2412 writer.write_heading(SectionLevel.SUB_SECTION, name) 

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

2414 #if instance.unit: 

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

2416 if isinstance(instance.help, dict): 

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

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

2419 val = inspect.cleandoc(val) 

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

2421 elif isinstance(instance.help, str): 

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

2423 

2424 self.last_name = name 

2425 

2426 

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

2428 ''' 

2429 :param config_instances: All config values to be saved 

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

2431 

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

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

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

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

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

2437 ''' 

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

2439 for instance in config_instances: 

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

2441 name = t.get_type_name() 

2442 if name in help_text: 

2443 continue 

2444 

2445 h = t.get_help(self.config_file) 

2446 if not h: 

2447 continue 

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

2449 

2450 return help_text 

2451 

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

2453 help_map = self.get_data_type_name_to_help_map(config_instances) 

2454 if not help_map: 

2455 return 

2456 

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

2458 formatter.add_start_section(name) 

2459 formatter.add_text(help_map[name]) 

2460 formatter.add_end_section() 

2461 

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

2463 formatter = self.create_formatter() 

2464 self.add_help_for_data_types(formatter, config_instances) 

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

2466 

2467 # ------- help ------- 

2468 

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

2470 super().add_help_to(formatter) 

2471 

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

2473 self.last_name = None 

2474 

2475 formatter.add_start_section('data types') 

2476 self.add_help_for_data_types(formatter, config_instances) 

2477 formatter.add_end_section() 

2478 

2479 if self.config_file.enable_config_ids: 

2480 normal_configs = [] 

2481 multi_configs = [] 

2482 for instance in config_instances: 

2483 if isinstance(instance, MultiConfig): 

2484 multi_configs.append(instance) 

2485 else: 

2486 normal_configs.append(instance) 

2487 else: 

2488 normal_configs = config_instances 

2489 multi_configs = [] 

2490 

2491 if normal_configs: 

2492 if self.config_file.enable_config_ids: 

2493 formatter.add_start_section('application wide settings') 

2494 else: 

2495 formatter.add_start_section('settings') 

2496 for instance in normal_configs: 

2497 self.add_config_help(formatter, instance) 

2498 formatter.add_end_section() 

2499 

2500 if multi_configs: 

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

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

2503 for instance in multi_configs: 

2504 self.add_config_help(formatter, instance) 

2505 formatter.add_end_section() 

2506 

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

2508 formatter.add_start_section(instance.key) 

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

2510 if isinstance(instance.help, dict): 

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

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

2513 val = inspect.cleandoc(val) 

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

2515 elif isinstance(instance.help, str): 

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

2517 formatter.add_end_section() 

2518 

2519 # ------- auto complete ------- 

2520 

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

2522 if argument_pos >= len(cmd): 

2523 start = '' 

2524 else: 

2525 start = cmd[argument_pos][:cursor_pos] 

2526 

2527 if len(cmd) <= 1: 

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

2529 elif self.is_vim_style(cmd): 

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

2531 else: 

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

2533 

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

2535 if self.KEY_VAL_SEP in start: 

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

2537 start_of_line += key + self.KEY_VAL_SEP 

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

2539 else: 

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

2541 

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

2543 if argument_pos == 1: 

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

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

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

2547 else: 

2548 return start_of_line, [], end_of_line 

2549 

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

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

2552 return start_of_line, completions, end_of_line 

2553 

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

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

2556 if applicable: 

2557 return start_of_line, completions, end_of_line 

2558 

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

2560 if instance is None: 

2561 return start_of_line, [], end_of_line 

2562 

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

2564 

2565 

2566class Include(ConfigFileArgparseCommand): 

2567 

2568 ''' 

2569 Load another config file. 

2570 

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

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

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

2574 ''' 

2575 

2576 help_config_id = ''' 

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

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

2579 Write the set commands without a config id to a separate config file and include this file for every config id where these settings shall apply. 

2580 

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

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

2583 ''' 

2584 

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

2586 

2587 def get_home(self) -> str: 

2588 if not self.home: 

2589 home = "" 

2590 elif isinstance(self.home, str): 

2591 home = self.home 

2592 else: 

2593 home = self.home.expand() 

2594 if home: 

2595 return home 

2596 

2597 fn = self.config_file.context_file_name 

2598 if fn is None: 

2599 fn = self.config_file.get_save_path() 

2600 return os.path.dirname(fn) 

2601 

2602 

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

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

2605 if self.config_file.enable_config_ids: 

2606 assert parser.description is not None 

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

2608 group = parser.add_mutually_exclusive_group() 

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

2610 group.add_argument('--no-reset-config-id-after', action='store_true', help='Treat the included lines as if they were written in the same config file instead of the include command') 

2611 

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

2613 

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

2615 fn_imp = args.path 

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

2617 fn_imp = os.path.expanduser(fn_imp) 

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

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

2620 

2621 if fn_imp in self.nested_includes: 

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

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

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

2625 

2626 self.nested_includes.append(fn_imp) 

2627 

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

2629 self.config_file.load_without_resetting_config_id(fn_imp) 

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

2631 config_id = self.config_file.config_id 

2632 self.config_file.load_file(fn_imp) 

2633 self.config_file.config_id = config_id 

2634 else: 

2635 config_id = self.config_file.config_id 

2636 self.config_file.load_without_resetting_config_id(fn_imp) 

2637 self.config_file.config_id = config_id 

2638 

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

2640 del self.nested_includes[-1] 

2641 

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

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

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

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

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

2647 

2648 

2649class Echo(ConfigFileArgparseCommand): 

2650 

2651 ''' 

2652 Display a message. 

2653 

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

2655 ''' 

2656 

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

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

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

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

2661 

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

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

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

2665 

2666 

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

2668 if argument_pos >= len(cmd): 

2669 start = '' 

2670 else: 

2671 start = cmd[argument_pos][:cursor_pos] 

2672 

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

2674 return start_of_line, completions, end_of_line 

2675 

2676class Help(ConfigFileArgparseCommand): 

2677 

2678 ''' 

2679 Display help. 

2680 ''' 

2681 

2682 max_width = 80 

2683 max_width_name = 18 

2684 min_width_sep = 2 

2685 tab_size = 4 

2686 

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

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

2689 

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

2691 if args.cmd: 

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

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

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

2695 out = cmd.get_help() 

2696 else: 

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

2698 table = [] 

2699 for cmd in self.config_file.commands: 

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

2701 descr = cmd.get_short_description() 

2702 row = (name, descr) 

2703 table.append(row) 

2704 out += self.format_table(table) 

2705 

2706 out += "\n" 

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

2708 

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

2710 

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

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

2713 col_width_name = min(max_name_width, self.max_width_name) 

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

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

2716 for name, descr in table: 

2717 if not descr: 

2718 out.append(name) 

2719 continue 

2720 if len(name) > col_width_name: 

2721 out.append(name) 

2722 initial_indent = subsequent_indent 

2723 else: 

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

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

2726 initial_indent = initial_indent, 

2727 subsequent_indent = subsequent_indent, 

2728 break_long_words = False, 

2729 tabsize = self.tab_size, 

2730 )) 

2731 return '\n'.join(out) 

2732 

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

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

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

2736 return start_of_line, completions, end_of_line 

2737 

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

2739 

2740 

2741class UnknownCommand(ConfigFileCommand, abstract=True): 

2742 

2743 name = DEFAULT_COMMAND 

2744 

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

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