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

139 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-18 22:20 +0000

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

2# _ _ _ _ _ _ _ ____ # 

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

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

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

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

7# |___/ # 

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

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

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

14# Copyright 2017-2025 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 

35try: 

36 from pyTooling.Decorators import export, readonly 

37 from pyTooling.MetaClasses import ExtendedType 

38 from pyTooling.Exceptions import ToolingException 

39 from pyTooling.Common import firstElement, firstPair 

40 from pyTooling.Attributes import Attribute 

41except (ImportError, ModuleNotFoundError): # pragma: no cover 

42 print("[pyTooling.Attributes.ArgParse] Could not import from 'pyTooling.*'!") 

43 

44 try: 

45 from Decorators import export, readonly 

46 from MetaClasses import ExtendedType 

47 from Exceptions import ToolingException 

48 from Common import firstElement, firstPair 

49 from Attributes import Attribute 

50 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

51 print("[pyTooling.Attributes.ArgParse] Could not import directly!") 

52 raise ex 

53 

54 

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

56 

57 

58@export 

59class ArgParseException(ToolingException): 

60 pass 

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 = None #: Reference to a method that is called to handle e.g. a sub-command. 

77 

78 @readonly 

79 def Handler(self) -> Callable: 

80 """Returns the handler method.""" 

81 return self._handler 

82 

83 

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

85@export 

86class CommandLineArgument(ArgParseAttribute, _HandlerMixin): 

87 """ 

88 Base-class for all *Argument* classes. 

89 

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

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

92 to specify how argument are formatted. 

93 

94 There are multiple derived formats supporting: 

95 

96 * commands |br| 

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

98 * simple names (flags) |br| 

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

100 * simple values (vlaued flags) |br| 

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

102 * names and values |br| 

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

104 * key-value pairs |br| 

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

106 """ 

107 

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

109 # """ 

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

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

112 # """ 

113 # 

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

115 

116 _args: Tuple 

117 _kwargs: Dict 

118 

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

120 """ 

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

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

123 """ 

124 super().__init__() 

125 self._args = args 

126 self._kwargs = kwargs 

127 

128 @readonly 

129 def Args(self) -> Tuple: 

130 """ 

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

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

133 """ 

134 return self._args 

135 

136 @readonly 

137 def KWArgs(self) -> Dict: 

138 """ 

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

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

141 """ 

142 return self._kwargs 

143 

144 

145@export 

146class CommandGroupAttribute(ArgParseAttribute): 

147 """ 

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

149 """ 

150 __groupName: str = None 

151 

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

153 """ 

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

155 """ 

156 super().__init__() 

157 self.__groupName = groupName 

158 

159 @readonly 

160 def GroupName(self) -> str: 

161 """Returns the name of the command group.""" 

162 return self.__groupName 

163 

164 

165# @export 

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

167# """ 

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

169# """ 

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

171# 

172# @readonly 

173# def KWArgs(self) -> Dict: 

174# """ 

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

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

177# """ 

178# return self._kwargs 

179# 

180# 

181# @export 

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

183# """ 

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

185# """ 

186# 

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

188# 

189# @readonly 

190# def Args(self) -> Tuple: 

191# """ 

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

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

194# """ 

195# return self._args 

196 

197 

198@export 

199class DefaultHandler(ArgParseAttribute, _HandlerMixin): 

200 """ 

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

202 

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

204 """ 

205 

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

207 self._handler = func 

208 return super().__call__(func) 

209 

210 

211@export 

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

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

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

215 """ 

216 

217 _command: str 

218 _help: str 

219 # FIXME: extract to mixin? 

220 _args: Tuple 

221 _kwargs: Dict 

222 

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

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

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

226 """ 

227 super().__init__() 

228 self._command = command 

229 self._help = help 

230 self._args = tuple() 

231 self._kwargs = kwargs 

232 

233 self._kwargs["help"] = help 

234 

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

236 self._handler = func 

237 return super().__call__(func) 

238 

239 @readonly 

240 def Command(self) -> str: 

241 """Returns the 'command' a sub-command parser adheres to.""" 

242 return self._command 

243 

244# FIXME: extract to mixin? 

245 @readonly 

246 def Args(self) -> Tuple: 

247 """ 

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

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

250 """ 

251 return self._args 

252 

253 # FIXME: extract to mixin? 

254 @readonly 

255 def KWArgs(self) -> Dict: 

256 """ 

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

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

259 """ 

260 return self._kwargs 

261 

262 

263@export 

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

265 """ 

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

267 """ 

268 _mainParser: ArgumentParser 

269 _formatter: Any # TODO: Find type 

270 _subParser: Any # TODO: Find type 

271 _subParsers: Dict # TODO: Find type 

272 

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

274 """ 

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

276 :class:`ArgumentParser` constructor. 

277 """ 

278 from .Argument import CommandLineArgument 

279 

280 super().__init__() 

281 

282 self._subParser = None 

283 self._subParsers = {} 

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

285 

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

287 self._formatter = kwargs["formatter_class"] 

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

289 kwargs["allow_abbrev"] = False 

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

291 kwargs["exit_on_error"] = False 

292 

293 # create a commandline argument parser 

294 self._mainParser = ArgumentParser(**kwargs) 

295 

296 # Search for 'DefaultHandler' marked method 

297 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler) 

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

299 defaultMethod, attributes = firstPair(methods) 

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

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

302 

303 # set default handler for the main parser 

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

305 

306 # Add argument descriptions for the main parser 

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

308 for methodAttribute in methodAttributes: 

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

310 

311 elif methodCount > 1: 

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

313 

314 # Search for 'CommandHandler' marked methods 

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

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

317 if self._subParser is None: 

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

319 

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

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

322 

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

324 attribute = firstElement(attributes) 

325 kwArgs = attribute.KWArgs.copy() 

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

327 kwArgs["formatter_class"] = self._formatter 

328 

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

330 

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

332 subParser.set_defaults(func=attribute.Handler) 

333 

334 # Add arguments for the sub-parsers 

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

336 for methodAttribute in methodAttributes: 

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

338 

339 self._subParsers[attribute.Command] = subParser 

340 

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

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

343 self._EnabledAutoComplete() 

344 

345 self._ParseArguments() 

346 

347 def _EnabledAutoComplete(self) -> None: 

348 try: 

349 from argcomplete import autocomplete 

350 autocomplete(self._mainParser) 

351 except ImportError: # pragma: no cover 

352 pass 

353 

354 def _ParseArguments(self) -> None: 

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

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

357 self._RouteToHandler(parsed) 

358 

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

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

361 args.func(self, args) 

362 

363 @readonly 

364 def MainParser(self) -> ArgumentParser: 

365 """Returns the main parser.""" 

366 return self._mainParser 

367 

368 @readonly 

369 def SubParsers(self) -> Dict: 

370 """Returns the sub-parsers.""" 

371 return self._subParsers 

372 

373 

374# String 

375# StringList 

376# Path 

377# PathList 

378# Delimiter 

379# ValuedFlag --option=value 

380# ValuedFlagList --option=foo --option=bar 

381# OptionalValued --option --option=foo 

382# ValuedTuple 

383