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
« 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
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.*'!")
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
53M = TypeVar("M", bound=Callable)
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 """
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.
71 @readonly
72 def Handler(self) -> Callable:
73 """Returns the handler method."""
74 return self._handler
77# FIXME: Is _HandlerMixin needed here, or for commands?
78@export
79class CommandLineArgument(ArgParseAttribute, _HandlerMixin):
80 """
81 Base-class for all *Argument* classes.
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.
87 There are multiple derived formats supporting:
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 """
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)
109 _args: Tuple
110 _kwargs: Dict
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
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
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
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
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
152 @readonly
153 def GroupName(self) -> str:
154 """Returns the name of the command group."""
155 return self.__groupName
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
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.
196 It's an error, if more than one method is annotated with this attribute.
197 """
199 def __call__(self, func: Callable) -> Callable:
200 self._handler = func
201 return super().__call__(func)
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 """
210 _command: str
211 _help: str
212 # FIXME: extract to mixin?
213 _args: Tuple
214 _kwargs: Dict
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
226 self._kwargs["help"] = help
228 def __call__(self, func: M) -> M:
229 self._handler = func
230 return super().__call__(func)
232 @readonly
233 def Command(self) -> str:
234 """Returns the 'command' a sub-command parser adheres to."""
235 return self._command
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
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
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
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
273 super().__init__()
275 self._subParser = None
276 self._subParsers = {}
277 self._formatter = kwargs["formatter_class"] if "formatter_class" in kwargs else None
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
284 # create a commandline argument parser
285 self._mainParser = ArgumentParser(**kwargs)
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'.")
294 # set default handler for the main parser
295 self._mainParser.set_defaults(func=firstElement(attributes).Handler)
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)
302 elif methodCount > 1:
303 raise Exception("Marked more then one handler as default handler with 'DefaultAttribute'.")
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')
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'.")
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
320 kwArgs["allow_abbrev"] = False if "allow_abbrev" not in kwargs else kwargs["allow_abbrev"]
322 subParser = self._subParser.add_parser(attribute.Command, **kwArgs)
323 subParser.set_defaults(func=attribute.Handler)
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)
330 self._subParsers[attribute.Command] = subParser
332 def Run(self, enableAutoComplete: bool = True) -> None:
333 if enableAutoComplete:
334 self._EnabledAutoComplete()
336 self._ParseArguments()
338 def _EnabledAutoComplete(self) -> None:
339 try:
340 from argcomplete import autocomplete
341 autocomplete(self._mainParser)
342 except ImportError: # pragma: no cover
343 pass
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)
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)
354 @readonly
355 def MainParser(self) -> ArgumentParser:
356 """Returns the main parser."""
357 return self._mainParser
359 @readonly
360 def SubParsers(self) -> Dict:
361 """Returns the sub-parsers."""
362 return self._subParsers
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