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

133 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-25 22:22 +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.Common import firstElement, firstPair 

39 from pyTooling.Attributes import Attribute 

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

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

42 

43 try: 

44 from Decorators import export, readonly 

45 from MetaClasses import ExtendedType 

46 from Common import firstElement, firstPair 

47 from Attributes import Attribute 

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

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

50 raise ex 

51 

52 

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

54 

55 

56#@abstract 

57@export 

58class ArgParseAttribute(Attribute): 

59 """ 

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

61 """ 

62 

63 

64@export 

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

66 """ 

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

68 """ 

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

70 

71 @readonly 

72 def Handler(self) -> Callable: 

73 """Returns the handler method.""" 

74 return self._handler 

75 

76 

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

78@export 

79class CommandLineArgument(ArgParseAttribute, _HandlerMixin): 

80 """ 

81 Base-class for all *Argument* classes. 

82 

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

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

85 to specify how argument are formatted. 

86 

87 There are multiple derived formats supporting: 

88 

89 * commands |br| 

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

91 * simple names (flags) |br| 

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

93 * simple values (vlaued flags) |br| 

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

95 * names and values |br| 

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

97 * key-value pairs |br| 

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

99 """ 

100 

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

102 # """ 

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

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

105 # """ 

106 # 

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

108 

109 _args: Tuple 

110 _kwargs: Dict 

111 

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

113 """ 

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

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

116 """ 

117 super().__init__() 

118 self._args = args 

119 self._kwargs = kwargs 

120 

121 @readonly 

122 def Args(self) -> Tuple: 

123 """ 

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

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

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 return self._kwargs 

136 

137 

138@export 

139class CommandGroupAttribute(ArgParseAttribute): 

140 """ 

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

142 """ 

143 __groupName: str = None 

144 

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

146 """ 

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

148 """ 

149 super().__init__() 

150 self.__groupName = groupName 

151 

152 @readonly 

153 def GroupName(self) -> str: 

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

155 return self.__groupName 

156 

157 

158# @export 

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

160# """ 

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

162# """ 

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

164# 

165# @readonly 

166# def KWArgs(self) -> Dict: 

167# """ 

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

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

170# """ 

171# return self._kwargs 

172# 

173# 

174# @export 

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

176# """ 

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

178# """ 

179# 

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

181# 

182# @readonly 

183# def Args(self) -> Tuple: 

184# """ 

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

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

187# """ 

188# return self._args 

189 

190 

191@export 

192class DefaultHandler(ArgParseAttribute, _HandlerMixin): 

193 """ 

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

195 

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

197 """ 

198 

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

200 self._handler = func 

201 return super().__call__(func) 

202 

203 

204@export 

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

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

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

208 """ 

209 

210 _command: str 

211 _help: str 

212 # FIXME: extract to mixin? 

213 _args: Tuple 

214 _kwargs: Dict 

215 

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

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

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

219 """ 

220 super().__init__() 

221 self._command = command 

222 self._help = help 

223 self._args = tuple() 

224 self._kwargs = kwargs 

225 

226 self._kwargs["help"] = help 

227 

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

229 self._handler = func 

230 return super().__call__(func) 

231 

232 @readonly 

233 def Command(self) -> str: 

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

235 return self._command 

236 

237# FIXME: extract to mixin? 

238 @readonly 

239 def Args(self) -> Tuple: 

240 """ 

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

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

243 """ 

244 return self._args 

245 

246 # FIXME: extract to mixin? 

247 @readonly 

248 def KWArgs(self) -> Dict: 

249 """ 

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

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

252 """ 

253 return self._kwargs 

254 

255 

256@export 

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

258 """ 

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

260 """ 

261 _mainParser: ArgumentParser 

262 _formatter: Any # TODO: Find type 

263 _subParser: Any # TODO: Find type 

264 _subParsers: Dict # TODO: Find type 

265 

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

267 """ 

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

269 :class:`ArgumentParser` constructor. 

270 """ 

271 from .Argument import CommandLineArgument 

272 

273 super().__init__() 

274 

275 self._subParser = None 

276 self._subParsers = {} 

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

278 

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

280 self._formatter = kwargs["formatter_class"] 

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

282 kwargs["allow_abbrev"] = False 

283 

284 # create a commandline argument parser 

285 self._mainParser = ArgumentParser(**kwargs) 

286 

287 # Search for 'DefaultHandler' marked method 

288 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler) 

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

290 defaultMethod, attributes = firstPair(methods) 

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

292 raise Exception("Marked default handler multiple times with 'DefaultAttribute'.") 

293 

294 # set default handler for the main parser 

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

296 

297 # Add argument descriptions for the main parser 

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

299 for methodAttribute in methodAttributes: 

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

301 

302 elif methodCount > 1: 

303 raise Exception("Marked more then one handler as default handler with 'DefaultAttribute'.") 

304 

305 # Search for 'CommandHandler' marked methods 

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

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

308 if self._subParser is None: 

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

310 

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

312 raise Exception("Marked command handler multiple times with 'CommandHandler'.") 

313 

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

315 attribute = firstElement(attributes) 

316 kwArgs = attribute.KWArgs.copy() 

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

318 kwArgs["formatter_class"] = self._formatter 

319 

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

321 

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

323 subParser.set_defaults(func=attribute.Handler) 

324 

325 # Add arguments for the sub-parsers 

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

327 for methodAttribute in methodAttributes: 

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

329 

330 self._subParsers[attribute.Command] = subParser 

331 

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

333 if enableAutoComplete: 

334 self._EnabledAutoComplete() 

335 

336 self._ParseArguments() 

337 

338 def _EnabledAutoComplete(self) -> None: 

339 try: 

340 from argcomplete import autocomplete 

341 autocomplete(self._mainParser) 

342 except ImportError: # pragma: no cover 

343 pass 

344 

345 def _ParseArguments(self) -> None: 

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

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

348 self._RouteToHandler(parsed) 

349 

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

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

352 args.func(self, args) 

353 

354 @readonly 

355 def MainParser(self) -> ArgumentParser: 

356 """Returns the main parser.""" 

357 return self._mainParser 

358 

359 @readonly 

360 def SubParsers(self) -> Dict: 

361 """Returns the sub-parsers.""" 

362 return self._subParsers 

363 

364 

365# String 

366# StringList 

367# Path 

368# PathList 

369# Delimiter 

370# ValuedFlag --option=value 

371# ValuedFlagList --option=foo --option=bar 

372# OptionalValued --option --option=foo 

373# ValuedTuple 

374