Coverage for pyTooling/Attributes/ArgParse/__init__.py: 86%

155 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 00:36 +0000

1# ==================================================================================================================== # 

2# _ _ _ _ _ _ _ ____ # 

3# / \ | |_| |_ _ __(_) |__ _ _| |_ ___ ___ / \ _ __ __ _| _ \ __ _ _ __ ___ ___ # 

4# / _ \| __| __| '__| | '_ \| | | | __/ _ \/ __| / _ \ | '__/ _` | |_) / _` | '__/ __|/ _ \ # 

5# _ _ _ / ___ \ |_| |_| | | | |_) | |_| | || __/\__ \_ / ___ \| | | (_| | __/ (_| | | \__ \ __/ # 

6# (_|_|_)_/ \_\__|\__|_| |_|_.__/ \__,_|\__\___||___(_)_/ \_\_| \__, |_| \__,_|_| |___/\___| # 

7# |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany # 

15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany # 

16# # 

17# Licensed under the Apache License, Version 2.0 (the "License"); # 

18# you may not use this file except in compliance with the License. # 

19# You may obtain a copy of the License at # 

20# # 

21# http://www.apache.org/licenses/LICENSE-2.0 # 

22# # 

23# Unless required by applicable law or agreed to in writing, software # 

24# distributed under the License is distributed on an "AS IS" BASIS, # 

25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

26# See the License for the specific language governing permissions and # 

27# limitations under the License. # 

28# # 

29# SPDX-License-Identifier: Apache-2.0 # 

30# ==================================================================================================================== # 

31# 

32""" 

33Attributes to describe a command line interface as decorated methods. 

34 

35An application deriving from :class:`~pyTooling.Attributes.ArgParse.ArgParseHelperMixin` declares its commands and 

36options as attributes on its handler methods. The mixin translates them into an :mod:`argparse` parser hierarchy, so 

37the command line's structure is written down once - next to the code implementing it - instead of twice. 

38 

39.. seealso:: 

40 

41 :class:`~pyTooling.Attributes.ArgParse.DefaultHandler` 

42 |rarr| Marks the method called when no sub-command was given. 

43 :class:`~pyTooling.Attributes.ArgParse.CommandHandler` 

44 |rarr| Marks the method implementing a sub-command. 

45""" 

46from argparse import ArgumentParser, Namespace 

47from pathlib import Path 

48from typing import Callable, Any, TypeVar, Optional as Nullable 

49from pyTooling.Decorators import export, readonly 

50from pyTooling.MetaClasses import ExtendedType, expects 

51from pyTooling.Exceptions import ToolingException 

52from pyTooling.Common import firstElement, firstPair, getFullyQualifiedName, StringEnum 

53from pyTooling.Attributes import Attribute 

54 

55 

56M = TypeVar("M", bound=Callable[..., Any]) 

57 

58 

59F = TypeVar("F", bound=StringEnum) 

60 

61 

62@export 

63def splitFormat(value: str, formats: type[F]) -> tuple[F, Path]: 

64 """ 

65 Split an option's value of the form ``[<format>:]<file>`` into the format and the file. 

66 

67 An option writing a file often accepts more than one format for it, and naming the format in front of the path 

68 keeps that to one option instead of two that can disagree - :pycode:`--output=json:report.json` rather than 

69 :pycode:`--output=report.json --output-format=json`. The formats are a :class:`~pyTooling.Common.StringEnum`, so a 

70 member is the string the command line spells. 

71 

72 **The format is optional.** A value naming none gets the enumeration's ``DEFAULT``; an enumeration declaring none 

73 insists on being told. **A colon alone doesn't make a format** either: a Windows drive letter 

74 (``C:\\report\\trace.json``) and a path holding a colon deeper down are paths, because a format is more than one 

75 character long and contains no path separator. 

76 

77 .. admonition:: ``Program.py`` 

78 

79 .. code-block:: python 

80 

81 class ReportFormat(StringEnum): 

82 JSON = "json" 

83 YAML = "yaml" 

84 

85 DEFAULT = JSON 

86 

87 @CommandHandler("convert", help="Convert the report.") 

88 @LongValuedFlag("--output", dest="output", metaName="[format:]file", help="Write the report.") 

89 def HandleConvert(self, args: Namespace) -> None: 

90 reportFormat, file = splitFormat(args.output, ReportFormat) 

91 

92 :param value: The option's value, as the command line spelled it. 

93 :param formats: The formats this option accepts. 

94 :returns: The format, and the file to write. 

95 :raises ValueError: If parameter 'value' is ``None``. 

96 :raises TypeError: If parameter 'value' is not of type :class:`str`. 

97 :raises ValueError: If the value names a format this option doesn't accept. |br| 

98 The note lists the formats it accepts. 

99 :raises ValueError: If the value names no format, and ``formats`` declares no ``DEFAULT``. |br| 

100 The note lists the formats it accepts. 

101 """ 

102 if value is None: 

103 raise ValueError("Parameter 'value' is None.") 

104 elif not isinstance(value, str): 

105 ex = TypeError("Parameter 'value' is not of type 'str'.") 

106 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

107 raise ex 

108 

109 prefix, colon, rest = value.partition(":") 

110 if colon != "" and len(prefix) >= 2 and "/" not in prefix and "\\" not in prefix: 

111 return formats.Parse(prefix), Path(rest) 

112 elif (fileFormat := formats.Parse(None)) is None: 

113 ex = ValueError(f"'{value}' names no format, and {formats.__name__} has no default.") 

114 ex.add_note(f"Write '<format>:{value}' with one of: {', '.join(member.value for member in formats)}.") 

115 raise ex 

116 

117 return fileFormat, Path(value) 

118 

119 

120@export 

121class ArgParseError(ToolingException): 

122 """Base-exception of all exceptions raised by :mod:`pyTooling.Attributes.ArgParse`.""" 

123 

124 

125#@abstract 

126@export 

127class ArgParseAttribute(Attribute): 

128 """ 

129 Base-class for all attributes to describe a :mod:`argparse`-base command line argument parser. 

130 """ 

131 

132 

133@export 

134class _HandlerMixin(metaclass=ExtendedType, mixin=True): 

135 """ 

136 A mixin-class that offers a class field for a reference to a handler method and a matching property. 

137 """ 

138 _handler: Callable[..., Any] = None #: Reference to a method that is called to handle e.g. a sub-command. 

139 

140 @readonly 

141 def Handler(self) -> Callable[..., Any]: 

142 """ 

143 Read-only property to access the handler method (:attr:`_handler`). 

144 

145 :returns: The method called to handle the command. 

146 """ 

147 return self._handler 

148 

149 

150# FIXME: Is _HandlerMixin needed here, or for commands? 

151@export 

152class CommandLineArgument(ArgParseAttribute, _HandlerMixin): 

153 """ 

154 Base-class for all *Argument* classes. 

155 

156 An argument instance can be converted via ``AsArgument`` to a single string value or a sequence of string values 

157 (tuple) usable e.g. with :class:`subprocess.Popen`. Each argument class implements at least one ``pattern`` parameter 

158 to specify how argument are formatted. 

159 

160 There are multiple derived formats supporting: 

161 

162 * commands |br| 

163 |rarr| :class:`~pyTooling.Attributes.ArgParse.CommandHandler` 

164 * simple names (flags) |br| 

165 |rarr| :class:`~pyTooling.Attributes.ArgParse.Flag.FlagArgument`, 

166 :class:`~pyTooling.Attributes.ArgParse.BooleanFlag.BooleanFlag` 

167 * simple values (valued flags) |br| 

168 |rarr| :class:`~pyTooling.Attributes.ArgParse.Argument.StringArgument`, 

169 :class:`~pyTooling.Attributes.ArgParse.Argument.PathArgument` 

170 * names and values |br| 

171 |rarr| :class:`~pyTooling.Attributes.ArgParse.ValuedFlag.ValuedFlag`, 

172 :class:`~pyTooling.Attributes.ArgParse.OptionalValuedFlag.OptionalValuedFlag` 

173 * key-value pairs |br| 

174 |rarr| :class:`~pyTooling.Attributes.ArgParse.KeyValueFlag.NamedKeyValuePairsArgument` 

175 """ 

176 

177 # def __init__(self, args: Iterable, kwargs: Mapping) -> None: 

178 # """ 

179 # The constructor expects ``args`` for positional and/or ``kwargs`` for named parameters which are passed without 

180 # modification to :meth:`~ArgumentParser.add_argument`. 

181 # """ 

182 # 

183 # super().__init__(*args, **kwargs) 

184 

185 _args: tuple[Any, ...] #: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`. 

186 _kwargs: dict[str, Any] #: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`. 

187 

188 def __init__(self, *args: Any, **kwargs: Any) -> None: 

189 """ 

190 Initializes a command line argument. 

191 

192 This base-class collects the parameters :meth:`~argparse.ArgumentParser.add_argument` will be called with; the 

193 derived classes assemble them from named parameters instead. 

194 

195 :param args: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`. 

196 :param kwargs: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`. 

197 """ 

198 super().__init__() 

199 self._args = args 

200 self._kwargs = kwargs 

201 

202 @readonly 

203 def Args(self) -> tuple[Any, ...]: 

204 """ 

205 A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are 

206 passed without modification to :class:`~ArgumentParser`. 

207 

208 :returns: Tuple of positional parameters. 

209 """ 

210 return self._args 

211 

212 @readonly 

213 def KWArgs(self) -> dict[str, Any]: 

214 """ 

215 A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are 

216 passed without modification to :class:`~ArgumentParser`. 

217 

218 :returns: Dictionary of named parameters. 

219 """ 

220 return self._kwargs 

221 

222 

223@export 

224class CommandGroupAttribute(ArgParseAttribute): 

225 """ 

226 *Experimental* attribute to group sub-commands in groups for better readability in a ``prog.py --help`` call. 

227 """ 

228 __groupName: str = None #: Name of the group the sub-commands are collected in. 

229 

230 def __init__(self, groupName: str) -> None: 

231 """ 

232 Initializes a command group attribute. 

233 

234 :param groupName: Name of the group the annotated commands are listed under in the help page. 

235 """ 

236 super().__init__() 

237 self.__groupName = groupName 

238 

239 @readonly 

240 def GroupName(self) -> str: 

241 """ 

242 Read-only property to access the name of the command group (:attr:`_groupName`). 

243 

244 :returns: Name of the command group. 

245 """ 

246 return self.__groupName 

247 

248 

249# @export 

250# class _KwArgsMixin(metaclass=ExtendedType, mixin=True): 

251# """ 

252# A mixin-class that offers a class field for named parameters (```**kwargs``) and a matching property. 

253# """ 

254# _kwargs: Dict #: A dictionary of additional keyword parameters. 

255# 

256# @readonly 

257# def KWArgs(self) -> Dict: 

258# """ 

259# A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are 

260# passed without modification to :class:`~ArgumentParser`. 

261# """ 

262# return self._kwargs 

263# 

264# 

265# @export 

266# class _ArgsMixin(_KwArgsMixin, mixin=True): 

267# """ 

268# A mixin-class that offers a class field for positional parameters (```*args``) and a matching property. 

269# """ 

270# 

271# _args: Tuple #: A tuple of additional positional parameters. 

272# 

273# @readonly 

274# def Args(self) -> Tuple: 

275# """ 

276# A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are 

277# passed without modification to :class:`~ArgumentParser`. 

278# """ 

279# return self._args 

280 

281 

282@export 

283class DefaultHandler(ArgParseAttribute, _HandlerMixin): 

284 """ 

285 Marks a handler method as *default* handler. This method is called if no sub-command is given. 

286 

287 .. attention:: 

288 

289 It's an error, if more than one method is annotated with this attribute. 

290 """ 

291 

292 def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: 

293 """ 

294 Apply this attribute to the handler method. 

295 

296 The handler method is stored in :attr:`_handler`. 

297 

298 :param func: The method handling the case that no sub-command was given. 

299 :returns: The same method, now carrying this attribute. 

300 """ 

301 self._handler = func 

302 return super().__call__(func) 

303 

304 

305@export 

306class CommandHandler(ArgParseAttribute, _HandlerMixin): #, _KwArgsMixin): 

307 """ 

308 Marks a handler method as responsible for the given command. 

309 

310 A sub-command parser is constructed for it with :meth:`~argparse.ArgumentParser.add_subparsers`. 

311 """ 

312 

313 _command: str #: Name of the sub-command this handler is responsible for. 

314 _help: str #: Help text of the sub-command, displayed in the help page. 

315 # FIXME: extract to mixin? 

316 _args: tuple[Any, ...] #: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`. 

317 _kwargs: dict[str, Any] #: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`. 

318 

319 def __init__(self, command: str, help: str = "", **kwargs: Any) -> None: 

320 """ 

321 Initializes a command handler attribute. 

322 

323 :param command: Name of the sub-command on the command line. 

324 :param help: Optional, help text shown for the sub-command. Default: ``""``. 

325 :param kwargs: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`. 

326 """ 

327 super().__init__() 

328 self._command = command 

329 self._help = help 

330 self._args = tuple() 

331 self._kwargs = kwargs 

332 

333 self._kwargs["help"] = help 

334 

335 def __call__(self, func: M) -> M: 

336 """ 

337 Apply this attribute to the handler method. 

338 

339 The handler method is stored in :attr:`_handler`. 

340 

341 :param func: The method handling the sub-command. 

342 :returns: The same method, now carrying this attribute. 

343 """ 

344 self._handler = func 

345 return super().__call__(func) 

346 

347 @readonly 

348 def Command(self) -> str: 

349 """ 

350 Read-only property to access the command a sub-command parser adheres to (:attr:`_command`). 

351 

352 :returns: Name of the command. 

353 """ 

354 return self._command 

355 

356# FIXME: extract to mixin? 

357 @readonly 

358 def Args(self) -> tuple[Any, ...]: 

359 """ 

360 A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are 

361 passed without modification to :class:`~ArgumentParser`. 

362 

363 :returns: Tuple of positional parameters. 

364 """ 

365 return self._args 

366 

367 # FIXME: extract to mixin? 

368 @readonly 

369 def KWArgs(self) -> dict[str, Any]: 

370 """ 

371 A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are 

372 passed without modification to :class:`~ArgumentParser`. 

373 

374 :returns: Dictionary of named parameters. 

375 """ 

376 return self._kwargs 

377 

378 

379@export 

380class ArgParseHelperMixin(metaclass=ExtendedType, mixin=True): 

381 """ 

382 Mixin-class to implement an :mod:`argparse`-base command line argument processor. 

383 """ 

384 _mainParser: ArgumentParser #: The main argument parser of the application. 

385 # TODO: Find type 

386 _formatter: Any #: Help page formatter class used by every parser. 

387 # TODO: Find type 

388 _subParser: Any #: The sub-parser action the sub-commands are registered at. 

389 _subParsers: dict[str, ArgumentParser] #: Sub-command name to its argument parser. 

390 

391 def __init__(self, **kwargs: Any) -> None: 

392 """ 

393 The mixin-constructor expects an optional list of named parameters which are passed without modification to the 

394 :class:`ArgumentParser` constructor. 

395 

396 :param kwargs: Named parameters forwarded to the :class:`~argparse.ArgumentParser` constructor. 

397 :raises ArgParseError: If more than one method is marked as the default handler. 

398 """ 

399 from .Argument import CommandLineArgument 

400 

401 super().__init__() 

402 

403 self._subParser = None 

404 self._subParsers = {} 

405 self._formatter = kwargs["formatter_class"] if "formatter_class" in kwargs else None 

406 

407 if "formatter_class" in kwargs: 

408 self._formatter = kwargs["formatter_class"] 

409 

410 if "allow_abbrev" not in kwargs: 410 ↛ 413line 410 didn't jump to line 413 because the condition on line 410 was always true

411 kwargs["allow_abbrev"] = False 

412 

413 if "exit_on_error" not in kwargs: 413 ↛ 417line 413 didn't jump to line 417 because the condition on line 413 was always true

414 kwargs["exit_on_error"] = False 

415 

416 # create a commandline argument parser 

417 self._mainParser = ArgumentParser(**kwargs) 

418 

419 # Search for 'DefaultHandler' marked method 

420 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler) 

421 if (methodCount := len(methods)) == 1: 

422 defaultMethod, attributes = firstPair(methods) 

423 if len(attributes) > 1: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 raise ArgParseError("Marked default handler multiple times with 'DefaultAttribute'.") 

425 

426 # set default handler for the main parser 

427 self._mainParser.set_defaults(func=firstElement(attributes).Handler) 

428 

429 # Add argument descriptions for the main parser 

430 methodAttributes = defaultMethod.GetAttributes(CommandLineArgument) # ArgumentAttribute) 

431 for methodAttribute in methodAttributes: 

432 self._mainParser.add_argument(*methodAttribute.Args, **methodAttribute.KWArgs) 

433 

434 elif methodCount > 1: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true

435 raise ArgParseError("Marked more then one handler as default handler with 'DefaultAttribute'.") 

436 

437 # Search for 'CommandHandler' marked methods 

438 methods: dict[Callable[..., Any], tuple[CommandHandler]] = self.GetMethodsWithAttributes(predicate=CommandHandler) 

439 for method, attributes in methods.items(): 

440 if self._subParser is None: 

441 self._subParser = self._mainParser.add_subparsers(help='sub-command help') 

442 

443 if len(attributes) > 1: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 raise ArgParseError("Marked command handler multiple times with 'CommandHandler'.") 

445 

446 # Add a sub parser for each command / handler pair 

447 attribute = firstElement(attributes) 

448 kwArgs = attribute.KWArgs.copy() 

449 if "formatter_class" not in kwArgs and self._formatter is not None: 

450 kwArgs["formatter_class"] = self._formatter 

451 

452 kwArgs["allow_abbrev"] = False if "allow_abbrev" not in kwargs else kwargs["allow_abbrev"] 

453 

454 subParser = self._subParser.add_parser(attribute.Command, **kwArgs) 

455 subParser.set_defaults(func=attribute.Handler) 

456 

457 # Add arguments for the sub-parsers 

458 methodAttributes = method.GetAttributes(CommandLineArgument) # ArgumentAttribute) 

459 for methodAttribute in methodAttributes: 

460 subParser.add_argument(*methodAttribute.Args, **methodAttribute.KWArgs) 

461 

462 self._subParsers[attribute.Command] = subParser 

463 

464 @expects("WriteWarning", "WriteError") 

465 def _PrintHelp(self, command: Nullable[str] = None) -> None: 

466 """ 

467 Helper method to print the command line parser's help page, or the help page of one sub-command. 

468 

469 .. attention:: 

470 

471 This method writes through the ``Write***`` methods of 

472 :class:`~pyTooling.TerminalUI.TerminalApplication`, which this mixin-class does not provide, so the 

473 application class has to derive from **both**. 

474 

475 :param command: Optional, the sub-command to print the help page for. If ``None``, the main 

476 parser's help page is printed. Default: ``None``. 

477 :raises UnfulfilledExpectationError: If the application class doesn't also derive from 

478 :class:`~pyTooling.TerminalUI.TerminalApplication`. 

479 """ 

480 if command is None: 

481 self._mainParser.print_help() 

482 elif command == "help": 

483 self.WriteWarning("This is a recursion ...") 

484 else: 

485 try: 

486 self._subParsers[command].print_help() 

487 except KeyError: 

488 self.WriteError(f"Command {command} is unknown.") 

489 

490 def Run(self, enableAutoComplete: bool = True) -> None: 

491 """ 

492 Parse the command line arguments and call the handler method the command selects. 

493 

494 :param enableAutoComplete: Optional, if ``True``, register the parser with ``argcomplete``, if that package is 

495 installed. 

496 """ 

497 if enableAutoComplete: 497 ↛ 500line 497 didn't jump to line 500 because the condition on line 497 was always true

498 self._EnabledAutoComplete() 

499 

500 self._ParseArguments() 

501 

502 def _EnabledAutoComplete(self) -> None: 

503 """ 

504 Register the main parser with ``argcomplete`` for shell completion. 

505 

506 The package is optional: when it isn't installed, completion is silently unavailable. 

507 """ 

508 try: 

509 from argcomplete import autocomplete 

510 autocomplete(self._mainParser) 

511 except ImportError: # pragma: no cover 

512 pass 

513 

514 def _ParseArguments(self) -> None: 

515 """ 

516 Parse the command line arguments and route them to the selected handler method. 

517 """ 

518 # parse command line options and process split arguments in callback functions 

519 parsed, args = self._mainParser.parse_known_args() 

520 self._RouteToHandler(parsed) 

521 

522 def _RouteToHandler(self, args: Namespace) -> None: 

523 """ 

524 Call the handler method the parsed arguments select. 

525 

526 The handler is stored as an unbound function, so it is called with the application object as first parameter. 

527 

528 :param args: The parsed command line arguments. 

529 """ 

530 # because func is a function (unbound to an object), it MUST be called with self as a first parameter 

531 args.func(self, args) 

532 

533 @readonly 

534 def MainParser(self) -> ArgumentParser: 

535 """ 

536 Read-only property to access the main argument parser (:attr:`_mainParser`). 

537 

538 :returns: The main argument parser. 

539 """ 

540 return self._mainParser 

541 

542 @readonly 

543 def SubParsers(self) -> dict[str, ArgumentParser]: 

544 """ 

545 Read-only property to access the sub-parsers (:attr:`_subParser`). 

546 

547 :returns: Dictionary of command names and their sub-parsers. 

548 """ 

549 return self._subParsers 

550 

551 

552# String 

553# StringList 

554# Path 

555# PathList 

556# Delimiter 

557# ValuedFlag --option=value 

558# ValuedFlagList --option=foo --option=bar 

559# OptionalValued --option --option=foo 

560# ValuedTuple