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
« 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.
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.
39.. seealso::
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
55M = TypeVar("M", bound=Callable[..., Any])
58@export
59class ArgParseException(ToolingException):
60 """Base-exception of all exceptions raised by :mod:`pyTooling.Attributes.ArgParse`."""
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 """
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.
78 @readonly
79 def Handler(self) -> Callable[..., Any]:
80 """
81 Read-only property to access the handler method (:attr:`_handler`).
83 :returns: The method called to handle the command.
84 """
85 return self._handler
88# FIXME: Is _HandlerMixin needed here, or for commands?
89@export
90class CommandLineArgument(ArgParseAttribute, _HandlerMixin):
91 """
92 Base-class for all *Argument* classes.
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.
98 There are multiple derived formats supporting:
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 """
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)
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`.
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
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`.
141 :returns: Tuple of positional parameters.
142 """
143 return self._args
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`.
151 :returns: Dictionary of named parameters.
152 """
153 return self._kwargs
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.
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
170 @readonly
171 def GroupName(self) -> str:
172 """
173 Read-only property to access the name of the command group (:attr:`_groupName`).
175 :returns: Name of the command group.
176 """
177 return self.__groupName
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
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.
218 .. attention::
220 It's an error, if more than one method is annotated with this attribute.
221 """
223 def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
224 """
225 Apply this attribute to the handler method.
227 The handler method is stored in :attr:`_handler`.
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)
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 """
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`.
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
258 self._kwargs["help"] = help
260 def __call__(self, func: M) -> M:
261 """
262 Apply this attribute to the handler method.
264 The handler method is stored in :attr:`_handler`.
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)
272 @readonly
273 def Command(self) -> str:
274 """
275 Read-only property to access the command a sub-command parser adheres to (:attr:`_command`).
277 :returns: Name of the command.
278 """
279 return self._command
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`.
288 :returns: Tuple of positional parameters.
289 """
290 return self._args
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`.
299 :returns: Dictionary of named parameters.
300 """
301 return self._kwargs
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.
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.
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
326 super().__init__()
328 self._subParser = None
329 self._subParsers = {}
330 self._formatter = kwargs["formatter_class"] if "formatter_class" in kwargs else None
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
339 # create a commandline argument parser
340 self._mainParser = ArgumentParser(**kwargs)
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'.")
349 # set default handler for the main parser
350 self._mainParser.set_defaults(func=firstElement(attributes).Handler)
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)
357 elif methodCount > 1:
358 raise ArgParseException("Marked more then one handler as default handler with 'DefaultAttribute'.")
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')
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'.")
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
375 kwArgs["allow_abbrev"] = False if "allow_abbrev" not in kwargs else kwargs["allow_abbrev"]
377 subParser = self._subParser.add_parser(attribute.Command, **kwArgs)
378 subParser.set_defaults(func=attribute.Handler)
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)
385 self._subParsers[attribute.Command] = subParser
387 def Run(self, enableAutoComplete: bool = True) -> None:
388 """
389 Parse the command line arguments and call the handler method the command selects.
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()
397 self._ParseArguments()
399 def _EnabledAutoComplete(self) -> None:
400 """
401 Register the main parser with ``argcomplete`` for shell completion.
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
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)
419 def _RouteToHandler(self, args: Namespace) -> None:
420 """
421 Call the handler method the parsed arguments select.
423 The handler is stored as an unbound function, so it is called with the application object as first parameter.
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)
430 @readonly
431 def MainParser(self) -> ArgumentParser:
432 """
433 Read-only property to access the main argument parser (:attr:`_mainParser`).
435 :returns: The main argument parser.
436 """
437 return self._mainParser
439 @readonly
440 def SubParsers(self) -> dict[str, ArgumentParser]:
441 """
442 Read-only property to access the sub-parsers (:attr:`_subParser`).
444 :returns: Dictionary of command names and their sub-parsers.
445 """
446 return self._subParsers
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