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

128 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 07:24 +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# 

32from argparse import ArgumentParser, Namespace 

33from typing import Callable, Dict, Tuple, Any, TypeVar 

34 

35from pyTooling.Decorators import export, readonly 

36from pyTooling.MetaClasses import ExtendedType 

37from pyTooling.Exceptions import ToolingException 

38from pyTooling.Common import firstElement, firstPair 

39from pyTooling.Attributes import Attribute 

40 

41 

42M = TypeVar("M", bound=Callable) 

43 

44 

45@export 

46class ArgParseException(ToolingException): 

47 pass 

48 

49 

50#@abstract 

51@export 

52class ArgParseAttribute(Attribute): 

53 """ 

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

55 """ 

56 

57 

58@export 

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

60 """ 

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

62 """ 

63 _handler: Callable = None #: Reference to a method that is called to handle e.g. a sub-command. 

64 

65 @readonly 

66 def Handler(self) -> Callable: 

67 """ 

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

69 

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

71 """ 

72 return self._handler 

73 

74 

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

76@export 

77class CommandLineArgument(ArgParseAttribute, _HandlerMixin): 

78 """ 

79 Base-class for all *Argument* classes. 

80 

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

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

83 to specify how argument are formatted. 

84 

85 There are multiple derived formats supporting: 

86 

87 * commands |br| 

88 |rarr| :mod:`~pyTooling.Attribute.ArgParse.Command` 

89 * simple names (flags) |br| 

90 |rarr| :mod:`~pyTooling.Attribute.ArgParse.Flag`, :mod:`~pyTooling.Attribute.ArgParse.BooleanFlag` 

91 * simple values (vlaued flags) |br| 

92 |rarr| :class:`~pyTooling.Attribute.ArgParse.Argument.StringArgument`, :class:`~pyTooling.Attribute.ArgParse.Argument.PathArgument` 

93 * names and values |br| 

94 |rarr| :mod:`~pyTooling.Attribute.ArgParse.ValuedFlag`, :mod:`~pyTooling.Attribute.ArgParse.OptionalValuedFlag` 

95 * key-value pairs |br| 

96 |rarr| :mod:`~pyTooling.Attribute.ArgParse.NamedKeyValuePair` 

97 """ 

98 

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

100 # """ 

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

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

103 # """ 

104 # 

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

106 

107 _args: Tuple 

108 _kwargs: Dict 

109 

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

111 """ 

112 The constructor expects positional (``*args``) and/or named parameters (``**kwargs``) which are passed without 

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

114 """ 

115 super().__init__() 

116 self._args = args 

117 self._kwargs = kwargs 

118 

119 @readonly 

120 def Args(self) -> Tuple: 

121 """ 

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

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

124 

125 :returns: Tuple of positional parameters. 

126 """ 

127 return self._args 

128 

129 @readonly 

130 def KWArgs(self) -> Dict: 

131 """ 

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

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

134 

135 :returns: Dictionary of named parameters. 

136 """ 

137 return self._kwargs 

138 

139 

140@export 

141class CommandGroupAttribute(ArgParseAttribute): 

142 """ 

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

144 """ 

145 __groupName: str = None 

146 

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

148 """ 

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

150 """ 

151 super().__init__() 

152 self.__groupName = groupName 

153 

154 @readonly 

155 def GroupName(self) -> str: 

156 """ 

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

158 

159 :returns: Name of the command group. 

160 """ 

161 return self.__groupName 

162 

163 

164# @export 

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

166# """ 

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

168# """ 

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

170# 

171# @readonly 

172# def KWArgs(self) -> Dict: 

173# """ 

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

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

176# """ 

177# return self._kwargs 

178# 

179# 

180# @export 

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

182# """ 

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

184# """ 

185# 

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

187# 

188# @readonly 

189# def Args(self) -> Tuple: 

190# """ 

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

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

193# """ 

194# return self._args 

195 

196 

197@export 

198class DefaultHandler(ArgParseAttribute, _HandlerMixin): 

199 """ 

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

201 

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

203 """ 

204 

205 def __call__(self, func: Callable) -> Callable: 

206 self._handler = func 

207 return super().__call__(func) 

208 

209 

210@export 

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

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

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

214 """ 

215 

216 _command: str 

217 _help: str 

218 # FIXME: extract to mixin? 

219 _args: Tuple 

220 _kwargs: Dict 

221 

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

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

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

225 """ 

226 super().__init__() 

227 self._command = command 

228 self._help = help 

229 self._args = tuple() 

230 self._kwargs = kwargs 

231 

232 self._kwargs["help"] = help 

233 

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

235 self._handler = func 

236 return super().__call__(func) 

237 

238 @readonly 

239 def Command(self) -> str: 

240 """ 

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

242 

243 :returns: Name of the command. 

244 """ 

245 return self._command 

246 

247# FIXME: extract to mixin? 

248 @readonly 

249 def Args(self) -> Tuple: 

250 """ 

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

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

253 

254 :returns: Tuple of positional parameters. 

255 """ 

256 return self._args 

257 

258 # FIXME: extract to mixin? 

259 @readonly 

260 def KWArgs(self) -> Dict: 

261 """ 

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

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

264 

265 :returns: Dictionary of named parameters. 

266 """ 

267 return self._kwargs 

268 

269 

270@export 

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

272 """ 

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

274 """ 

275 _mainParser: ArgumentParser 

276 _formatter: Any # TODO: Find type 

277 _subParser: Any # TODO: Find type 

278 _subParsers: Dict[str, ArgumentParser] 

279 

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

281 """ 

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

283 :class:`ArgumentParser` constructor. 

284 """ 

285 from .Argument import CommandLineArgument 

286 

287 super().__init__() 

288 

289 self._subParser = None 

290 self._subParsers = {} 

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

292 

293 if "formatter_class" in kwargs: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 self._formatter = kwargs["formatter_class"] 

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

296 kwargs["allow_abbrev"] = False 

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

298 kwargs["exit_on_error"] = False 

299 

300 # create a commandline argument parser 

301 self._mainParser = ArgumentParser(**kwargs) 

302 

303 # Search for 'DefaultHandler' marked method 

304 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler) 

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

306 defaultMethod, attributes = firstPair(methods) 

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

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

309 

310 # set default handler for the main parser 

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

312 

313 # Add argument descriptions for the main parser 

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

315 for methodAttribute in methodAttributes: 

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

317 

318 elif methodCount > 1: 

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

320 

321 # Search for 'CommandHandler' marked methods 

322 methods: Dict[Callable, Tuple[CommandHandler]] = self.GetMethodsWithAttributes(predicate=CommandHandler) 

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

324 if self._subParser is None: 

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

326 

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

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

329 

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

331 attribute = firstElement(attributes) 

332 kwArgs = attribute.KWArgs.copy() 

333 if "formatter_class" not in kwArgs and self._formatter is not None: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 kwArgs["formatter_class"] = self._formatter 

335 

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

337 

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

339 subParser.set_defaults(func=attribute.Handler) 

340 

341 # Add arguments for the sub-parsers 

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

343 for methodAttribute in methodAttributes: 

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

345 

346 self._subParsers[attribute.Command] = subParser 

347 

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

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

350 self._EnabledAutoComplete() 

351 

352 self._ParseArguments() 

353 

354 def _EnabledAutoComplete(self) -> None: 

355 try: 

356 from argcomplete import autocomplete 

357 autocomplete(self._mainParser) 

358 except ImportError: # pragma: no cover 

359 pass 

360 

361 def _ParseArguments(self) -> None: 

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

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

364 self._RouteToHandler(parsed) 

365 

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

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

368 args.func(self, args) 

369 

370 @readonly 

371 def MainParser(self) -> ArgumentParser: 

372 """ 

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

374 

375 :returns: The main argument parser. 

376 """ 

377 return self._mainParser 

378 

379 @readonly 

380 def SubParsers(self) -> Dict[str, ArgumentParser]: 

381 """ 

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

383 

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

385 """ 

386 return self._subParsers 

387 

388 

389# String 

390# StringList 

391# Path 

392# PathList 

393# Delimiter 

394# ValuedFlag --option=value 

395# ValuedFlagList --option=foo --option=bar 

396# OptionalValued --option --option=foo 

397# ValuedTuple 

398