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

137 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-13 00:18 +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 typing import Callable, Any, TypeVar, Optional as Nullable 

48from pyTooling.Decorators import export, readonly 

49from pyTooling.MetaClasses import ExtendedType, expects 

50from pyTooling.Exceptions import ToolingException 

51from pyTooling.Common import firstElement, firstPair 

52from pyTooling.Attributes import Attribute 

53 

54 

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

56 

57 

58@export 

59class ArgParseError(ToolingException): 

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

61 

62 

63#@abstract 

64@export 

65class ArgParseAttribute(Attribute): 

66 """ 

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

68 """ 

69 

70 

71@export 

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

73 """ 

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

75 """ 

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

77 

78 @readonly 

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

80 """ 

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

82 

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

84 """ 

85 return self._handler 

86 

87 

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

89@export 

90class CommandLineArgument(ArgParseAttribute, _HandlerMixin): 

91 """ 

92 Base-class for all *Argument* classes. 

93 

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

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

96 to specify how argument are formatted. 

97 

98 There are multiple derived formats supporting: 

99 

100 * commands |br| 

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

102 * simple names (flags) |br| 

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

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

105 * simple values (valued flags) |br| 

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

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

108 * names and values |br| 

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

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

111 * key-value pairs |br| 

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

113 """ 

114 

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

116 # """ 

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

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

119 # """ 

120 # 

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

122 

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

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

125 

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

127 """ 

128 Initializes a command line argument. 

129 

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

131 derived classes assemble them from named parameters instead. 

132 

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

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

135 """ 

136 super().__init__() 

137 self._args = args 

138 self._kwargs = kwargs 

139 

140 @readonly 

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

142 """ 

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

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

145 

146 :returns: Tuple of positional parameters. 

147 """ 

148 return self._args 

149 

150 @readonly 

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

152 """ 

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

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

155 

156 :returns: Dictionary of named parameters. 

157 """ 

158 return self._kwargs 

159 

160 

161@export 

162class CommandGroupAttribute(ArgParseAttribute): 

163 """ 

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

165 """ 

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

167 

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

169 """ 

170 Initializes a command group attribute. 

171 

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

173 """ 

174 super().__init__() 

175 self.__groupName = groupName 

176 

177 @readonly 

178 def GroupName(self) -> str: 

179 """ 

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

181 

182 :returns: Name of the command group. 

183 """ 

184 return self.__groupName 

185 

186 

187# @export 

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

189# """ 

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

191# """ 

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

193# 

194# @readonly 

195# def KWArgs(self) -> Dict: 

196# """ 

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

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

199# """ 

200# return self._kwargs 

201# 

202# 

203# @export 

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

205# """ 

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

207# """ 

208# 

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

210# 

211# @readonly 

212# def Args(self) -> Tuple: 

213# """ 

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

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

216# """ 

217# return self._args 

218 

219 

220@export 

221class DefaultHandler(ArgParseAttribute, _HandlerMixin): 

222 """ 

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

224 

225 .. attention:: 

226 

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

228 """ 

229 

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

231 """ 

232 Apply this attribute to the handler method. 

233 

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

235 

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

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

238 """ 

239 self._handler = func 

240 return super().__call__(func) 

241 

242 

243@export 

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

245 """ 

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

247 

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

249 """ 

250 

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

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

253 # FIXME: extract to mixin? 

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

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

256 

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

258 """ 

259 Initializes a command handler attribute. 

260 

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

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

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

264 """ 

265 super().__init__() 

266 self._command = command 

267 self._help = help 

268 self._args = tuple() 

269 self._kwargs = kwargs 

270 

271 self._kwargs["help"] = help 

272 

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

274 """ 

275 Apply this attribute to the handler method. 

276 

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

278 

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

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

281 """ 

282 self._handler = func 

283 return super().__call__(func) 

284 

285 @readonly 

286 def Command(self) -> str: 

287 """ 

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

289 

290 :returns: Name of the command. 

291 """ 

292 return self._command 

293 

294# FIXME: extract to mixin? 

295 @readonly 

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

297 """ 

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

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

300 

301 :returns: Tuple of positional parameters. 

302 """ 

303 return self._args 

304 

305 # FIXME: extract to mixin? 

306 @readonly 

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

308 """ 

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

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

311 

312 :returns: Dictionary of named parameters. 

313 """ 

314 return self._kwargs 

315 

316 

317@export 

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

319 """ 

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

321 """ 

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

323 # TODO: Find type 

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

325 # TODO: Find type 

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

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

328 

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

330 """ 

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

332 :class:`ArgumentParser` constructor. 

333 

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

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

336 """ 

337 from .Argument import CommandLineArgument 

338 

339 super().__init__() 

340 

341 self._subParser = None 

342 self._subParsers = {} 

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

344 

345 if "formatter_class" in kwargs: 

346 self._formatter = kwargs["formatter_class"] 

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

348 kwargs["allow_abbrev"] = False 

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

350 kwargs["exit_on_error"] = False 

351 

352 # create a commandline argument parser 

353 self._mainParser = ArgumentParser(**kwargs) 

354 

355 # Search for 'DefaultHandler' marked method 

356 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler) 

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

358 defaultMethod, attributes = firstPair(methods) 

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

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

361 

362 # set default handler for the main parser 

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

364 

365 # Add argument descriptions for the main parser 

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

367 for methodAttribute in methodAttributes: 

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

369 

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

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

372 

373 # Search for 'CommandHandler' marked methods 

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

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

376 if self._subParser is None: 

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

378 

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

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

381 

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

383 attribute = firstElement(attributes) 

384 kwArgs = attribute.KWArgs.copy() 

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

386 kwArgs["formatter_class"] = self._formatter 

387 

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

389 

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

391 subParser.set_defaults(func=attribute.Handler) 

392 

393 # Add arguments for the sub-parsers 

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

395 for methodAttribute in methodAttributes: 

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

397 

398 self._subParsers[attribute.Command] = subParser 

399 

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

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

402 """ 

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

404 

405 .. attention:: 

406 

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

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

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

410 

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

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

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

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

415 """ 

416 if command is None: 

417 self._mainParser.print_help() 

418 elif command == "help": 

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

420 else: 

421 try: 

422 self._subParsers[command].print_help() 

423 except KeyError: 

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

425 

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

427 """ 

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

429 

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

431 installed. 

432 """ 

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

434 self._EnabledAutoComplete() 

435 

436 self._ParseArguments() 

437 

438 def _EnabledAutoComplete(self) -> None: 

439 """ 

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

441 

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

443 """ 

444 try: 

445 from argcomplete import autocomplete 

446 autocomplete(self._mainParser) 

447 except ImportError: # pragma: no cover 

448 pass 

449 

450 def _ParseArguments(self) -> None: 

451 """ 

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

453 """ 

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

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

456 self._RouteToHandler(parsed) 

457 

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

459 """ 

460 Call the handler method the parsed arguments select. 

461 

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

463 

464 :param args: The parsed command line arguments. 

465 """ 

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

467 args.func(self, args) 

468 

469 @readonly 

470 def MainParser(self) -> ArgumentParser: 

471 """ 

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

473 

474 :returns: The main argument parser. 

475 """ 

476 return self._mainParser 

477 

478 @readonly 

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

480 """ 

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

482 

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

484 """ 

485 return self._subParsers 

486 

487 

488# String 

489# StringList 

490# Path 

491# PathList 

492# Delimiter 

493# ValuedFlag --option=value 

494# ValuedFlagList --option=foo --option=bar 

495# OptionalValued --option --option=foo 

496# ValuedTuple