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

127 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +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 

48from pyTooling.Decorators import export, readonly 

49from pyTooling.MetaClasses import ExtendedType 

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 ArgParseException(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 The constructor expects positional (``*args``) and/or named parameters (``**kwargs``) which are passed without 

129 modification to :meth:`~ArgumentParser.add_argument`. 

130 """ 

131 super().__init__() 

132 self._args = args 

133 self._kwargs = kwargs 

134 

135 @readonly 

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

137 """ 

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

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

140 

141 :returns: Tuple of positional parameters. 

142 """ 

143 return self._args 

144 

145 @readonly 

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

147 """ 

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

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

150 

151 :returns: Dictionary of named parameters. 

152 """ 

153 return self._kwargs 

154 

155 

156@export 

157class CommandGroupAttribute(ArgParseAttribute): 

158 """ 

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

160 """ 

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

162 

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

164 """ 

165 The constructor expects a 'groupName' which can be used to group sub-commands for better readability. 

166 """ 

167 super().__init__() 

168 self.__groupName = groupName 

169 

170 @readonly 

171 def GroupName(self) -> str: 

172 """ 

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

174 

175 :returns: Name of the command group. 

176 """ 

177 return self.__groupName 

178 

179 

180# @export 

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

182# """ 

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

184# """ 

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

186# 

187# @readonly 

188# def KWArgs(self) -> Dict: 

189# """ 

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

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

192# """ 

193# return self._kwargs 

194# 

195# 

196# @export 

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

198# """ 

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

200# """ 

201# 

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

203# 

204# @readonly 

205# def Args(self) -> Tuple: 

206# """ 

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

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

209# """ 

210# return self._args 

211 

212 

213@export 

214class DefaultHandler(ArgParseAttribute, _HandlerMixin): 

215 """ 

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

217 

218 .. attention:: 

219 

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

221 """ 

222 

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

224 """ 

225 Apply this attribute to the handler method. 

226 

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

228 

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

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

231 """ 

232 self._handler = func 

233 return super().__call__(func) 

234 

235 

236@export 

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

238 """Marks a handler method as responsible for the given 'command'. This constructs 

239 a sub-command parser using :meth:`~ArgumentParser.add_subparsers`. 

240 """ 

241 

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

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

244 # FIXME: extract to mixin? 

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

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

247 

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

249 """The constructor expects a 'command' and an optional list of named parameters 

250 (keyword arguments) which are passed without modification to :meth:`~ArgumentParser.add_subparsers`. 

251 """ 

252 super().__init__() 

253 self._command = command 

254 self._help = help 

255 self._args = tuple() 

256 self._kwargs = kwargs 

257 

258 self._kwargs["help"] = help 

259 

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

261 """ 

262 Apply this attribute to the handler method. 

263 

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

265 

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

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

268 """ 

269 self._handler = func 

270 return super().__call__(func) 

271 

272 @readonly 

273 def Command(self) -> str: 

274 """ 

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

276 

277 :returns: Name of the command. 

278 """ 

279 return self._command 

280 

281# FIXME: extract to mixin? 

282 @readonly 

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

284 """ 

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

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

287 

288 :returns: Tuple of positional parameters. 

289 """ 

290 return self._args 

291 

292 # FIXME: extract to mixin? 

293 @readonly 

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

295 """ 

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

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

298 

299 :returns: Dictionary of named parameters. 

300 """ 

301 return self._kwargs 

302 

303 

304@export 

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

306 """ 

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

308 """ 

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

310 # TODO: Find type 

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

312 # TODO: Find type 

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

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

315 

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

317 """ 

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

319 :class:`ArgumentParser` constructor. 

320 

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

322 :raises ArgParseException: If more than one method is marked as the default handler. 

323 """ 

324 from .Argument import CommandLineArgument 

325 

326 super().__init__() 

327 

328 self._subParser = None 

329 self._subParsers = {} 

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

331 

332 if "formatter_class" in kwargs: 

333 self._formatter = kwargs["formatter_class"] 

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

335 kwargs["allow_abbrev"] = False 

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

337 kwargs["exit_on_error"] = False 

338 

339 # create a commandline argument parser 

340 self._mainParser = ArgumentParser(**kwargs) 

341 

342 # Search for 'DefaultHandler' marked method 

343 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler) 

344 if (methodCount := len(methods)) == 1: 344 ↛ 357line 344 didn't jump to line 357 because the condition on line 344 was always true

345 defaultMethod, attributes = firstPair(methods) 

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

347 raise ArgParseException("Marked default handler multiple times with 'DefaultAttribute'.") 

348 

349 # set default handler for the main parser 

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

351 

352 # Add argument descriptions for the main parser 

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

354 for methodAttribute in methodAttributes: 

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

356 

357 elif methodCount > 1: 

358 raise ArgParseException("Marked more then one handler as default handler with 'DefaultAttribute'.") 

359 

360 # Search for 'CommandHandler' marked methods 

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

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

363 if self._subParser is None: 

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

365 

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

367 raise ArgParseException("Marked command handler multiple times with 'CommandHandler'.") 

368 

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

370 attribute = firstElement(attributes) 

371 kwArgs = attribute.KWArgs.copy() 

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

373 kwArgs["formatter_class"] = self._formatter 

374 

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

376 

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

378 subParser.set_defaults(func=attribute.Handler) 

379 

380 # Add arguments for the sub-parsers 

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

382 for methodAttribute in methodAttributes: 

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

384 

385 self._subParsers[attribute.Command] = subParser 

386 

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

388 """ 

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

390 

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

392 installed. 

393 """ 

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

395 self._EnabledAutoComplete() 

396 

397 self._ParseArguments() 

398 

399 def _EnabledAutoComplete(self) -> None: 

400 """ 

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

402 

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

404 """ 

405 try: 

406 from argcomplete import autocomplete 

407 autocomplete(self._mainParser) 

408 except ImportError: # pragma: no cover 

409 pass 

410 

411 def _ParseArguments(self) -> None: 

412 """ 

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

414 """ 

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

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

417 self._RouteToHandler(parsed) 

418 

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

420 """ 

421 Call the handler method the parsed arguments select. 

422 

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

424 

425 :param args: The parsed command line arguments. 

426 """ 

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

428 args.func(self, args) 

429 

430 @readonly 

431 def MainParser(self) -> ArgumentParser: 

432 """ 

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

434 

435 :returns: The main argument parser. 

436 """ 

437 return self._mainParser 

438 

439 @readonly 

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

441 """ 

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

443 

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

445 """ 

446 return self._subParsers 

447 

448 

449# String 

450# StringList 

451# Path 

452# PathList 

453# Delimiter 

454# ValuedFlag --option=value 

455# ValuedFlagList --option=foo --option=bar 

456# OptionalValued --option --option=foo 

457# ValuedTuple 

458