Coverage for pyTooling/Attributes/ArgParse/__init__.py: 84%
137 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-13 00:18 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-13 00:18 +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, Optional as Nullable
48from pyTooling.Decorators import export, readonly
49from pyTooling.MetaClasses import ExtendedType, expects
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 ArgParseError(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 Initializes a command line argument.
130 This base-class collects the parameters :meth:`~argparse.ArgumentParser.add_argument` will be called with; the
131 derived classes assemble them from named parameters instead.
133 :param args: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`.
134 :param kwargs: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`.
135 """
136 super().__init__()
137 self._args = args
138 self._kwargs = kwargs
140 @readonly
141 def Args(self) -> tuple[Any, ...]:
142 """
143 A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are
144 passed without modification to :class:`~ArgumentParser`.
146 :returns: Tuple of positional parameters.
147 """
148 return self._args
150 @readonly
151 def KWArgs(self) -> dict[str, Any]:
152 """
153 A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are
154 passed without modification to :class:`~ArgumentParser`.
156 :returns: Dictionary of named parameters.
157 """
158 return self._kwargs
161@export
162class CommandGroupAttribute(ArgParseAttribute):
163 """
164 *Experimental* attribute to group sub-commands in groups for better readability in a ``prog.py --help`` call.
165 """
166 __groupName: str = None #: Name of the group the sub-commands are collected in.
168 def __init__(self, groupName: str) -> None:
169 """
170 Initializes a command group attribute.
172 :param groupName: Name of the group the annotated commands are listed under in the help page.
173 """
174 super().__init__()
175 self.__groupName = groupName
177 @readonly
178 def GroupName(self) -> str:
179 """
180 Read-only property to access the name of the command group (:attr:`_groupName`).
182 :returns: Name of the command group.
183 """
184 return self.__groupName
187# @export
188# class _KwArgsMixin(metaclass=ExtendedType, mixin=True):
189# """
190# A mixin-class that offers a class field for named parameters (```**kwargs``) and a matching property.
191# """
192# _kwargs: Dict #: A dictionary of additional keyword parameters.
193#
194# @readonly
195# def KWArgs(self) -> Dict:
196# """
197# A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are
198# passed without modification to :class:`~ArgumentParser`.
199# """
200# return self._kwargs
201#
202#
203# @export
204# class _ArgsMixin(_KwArgsMixin, mixin=True):
205# """
206# A mixin-class that offers a class field for positional parameters (```*args``) and a matching property.
207# """
208#
209# _args: Tuple #: A tuple of additional positional parameters.
210#
211# @readonly
212# def Args(self) -> Tuple:
213# """
214# A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are
215# passed without modification to :class:`~ArgumentParser`.
216# """
217# return self._args
220@export
221class DefaultHandler(ArgParseAttribute, _HandlerMixin):
222 """
223 Marks a handler method as *default* handler. This method is called if no sub-command is given.
225 .. attention::
227 It's an error, if more than one method is annotated with this attribute.
228 """
230 def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
231 """
232 Apply this attribute to the handler method.
234 The handler method is stored in :attr:`_handler`.
236 :param func: The method handling the case that no sub-command was given.
237 :returns: The same method, now carrying this attribute.
238 """
239 self._handler = func
240 return super().__call__(func)
243@export
244class CommandHandler(ArgParseAttribute, _HandlerMixin): #, _KwArgsMixin):
245 """
246 Marks a handler method as responsible for the given command.
248 A sub-command parser is constructed for it with :meth:`~argparse.ArgumentParser.add_subparsers`.
249 """
251 _command: str #: Name of the sub-command this handler is responsible for.
252 _help: str #: Help text of the sub-command, displayed in the help page.
253 # FIXME: extract to mixin?
254 _args: tuple[Any, ...] #: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`.
255 _kwargs: dict[str, Any] #: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`.
257 def __init__(self, command: str, help: str = "", **kwargs: Any) -> None:
258 """
259 Initializes a command handler attribute.
261 :param command: Name of the sub-command on the command line.
262 :param help: Optional, help text shown for the sub-command. Default: ``""``.
263 :param kwargs: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`.
264 """
265 super().__init__()
266 self._command = command
267 self._help = help
268 self._args = tuple()
269 self._kwargs = kwargs
271 self._kwargs["help"] = help
273 def __call__(self, func: M) -> M:
274 """
275 Apply this attribute to the handler method.
277 The handler method is stored in :attr:`_handler`.
279 :param func: The method handling the sub-command.
280 :returns: The same method, now carrying this attribute.
281 """
282 self._handler = func
283 return super().__call__(func)
285 @readonly
286 def Command(self) -> str:
287 """
288 Read-only property to access the command a sub-command parser adheres to (:attr:`_command`).
290 :returns: Name of the command.
291 """
292 return self._command
294# FIXME: extract to mixin?
295 @readonly
296 def Args(self) -> tuple[Any, ...]:
297 """
298 A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are
299 passed without modification to :class:`~ArgumentParser`.
301 :returns: Tuple of positional parameters.
302 """
303 return self._args
305 # FIXME: extract to mixin?
306 @readonly
307 def KWArgs(self) -> dict[str, Any]:
308 """
309 A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are
310 passed without modification to :class:`~ArgumentParser`.
312 :returns: Dictionary of named parameters.
313 """
314 return self._kwargs
317@export
318class ArgParseHelperMixin(metaclass=ExtendedType, mixin=True):
319 """
320 Mixin-class to implement an :mod:`argparse`-base command line argument processor.
321 """
322 _mainParser: ArgumentParser #: The main argument parser of the application.
323 # TODO: Find type
324 _formatter: Any #: Help page formatter class used by every parser.
325 # TODO: Find type
326 _subParser: Any #: The sub-parser action the sub-commands are registered at.
327 _subParsers: dict[str, ArgumentParser] #: Sub-command name to its argument parser.
329 def __init__(self, **kwargs: Any) -> None:
330 """
331 The mixin-constructor expects an optional list of named parameters which are passed without modification to the
332 :class:`ArgumentParser` constructor.
334 :param kwargs: Named parameters forwarded to the :class:`~argparse.ArgumentParser` constructor.
335 :raises ArgParseError: If more than one method is marked as the default handler.
336 """
337 from .Argument import CommandLineArgument
339 super().__init__()
341 self._subParser = None
342 self._subParsers = {}
343 self._formatter = kwargs["formatter_class"] if "formatter_class" in kwargs else None
345 if "formatter_class" in kwargs:
346 self._formatter = kwargs["formatter_class"]
347 if "allow_abbrev" not in kwargs: 347 ↛ 349line 347 didn't jump to line 349 because the condition on line 347 was always true
348 kwargs["allow_abbrev"] = False
349 if "exit_on_error" not in kwargs: 349 ↛ 353line 349 didn't jump to line 353 because the condition on line 349 was always true
350 kwargs["exit_on_error"] = False
352 # create a commandline argument parser
353 self._mainParser = ArgumentParser(**kwargs)
355 # Search for 'DefaultHandler' marked method
356 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler)
357 if (methodCount := len(methods)) == 1:
358 defaultMethod, attributes = firstPair(methods)
359 if len(attributes) > 1: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 raise ArgParseError("Marked default handler multiple times with 'DefaultAttribute'.")
362 # set default handler for the main parser
363 self._mainParser.set_defaults(func=firstElement(attributes).Handler)
365 # Add argument descriptions for the main parser
366 methodAttributes = defaultMethod.GetAttributes(CommandLineArgument) # ArgumentAttribute)
367 for methodAttribute in methodAttributes:
368 self._mainParser.add_argument(*methodAttribute.Args, **methodAttribute.KWArgs)
370 elif methodCount > 1: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true
371 raise ArgParseError("Marked more then one handler as default handler with 'DefaultAttribute'.")
373 # Search for 'CommandHandler' marked methods
374 methods: dict[Callable[..., Any], tuple[CommandHandler]] = self.GetMethodsWithAttributes(predicate=CommandHandler)
375 for method, attributes in methods.items():
376 if self._subParser is None:
377 self._subParser = self._mainParser.add_subparsers(help='sub-command help')
379 if len(attributes) > 1: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 raise ArgParseError("Marked command handler multiple times with 'CommandHandler'.")
382 # Add a sub parser for each command / handler pair
383 attribute = firstElement(attributes)
384 kwArgs = attribute.KWArgs.copy()
385 if "formatter_class" not in kwArgs and self._formatter is not None:
386 kwArgs["formatter_class"] = self._formatter
388 kwArgs["allow_abbrev"] = False if "allow_abbrev" not in kwargs else kwargs["allow_abbrev"]
390 subParser = self._subParser.add_parser(attribute.Command, **kwArgs)
391 subParser.set_defaults(func=attribute.Handler)
393 # Add arguments for the sub-parsers
394 methodAttributes = method.GetAttributes(CommandLineArgument) # ArgumentAttribute)
395 for methodAttribute in methodAttributes:
396 subParser.add_argument(*methodAttribute.Args, **methodAttribute.KWArgs)
398 self._subParsers[attribute.Command] = subParser
400 @expects("WriteWarning", "WriteError")
401 def _PrintHelp(self, command: Nullable[str] = None) -> None:
402 """
403 Helper method to print the command line parser's help page, or the help page of one sub-command.
405 .. attention::
407 This method writes through the ``Write***`` methods of
408 :class:`~pyTooling.TerminalUI.TerminalApplication`, which this mixin-class does not provide, so the
409 application class has to derive from **both**.
411 :param command: Optional, the sub-command to print the help page for. If ``None``, the main
412 parser's help page is printed. Default: ``None``.
413 :raises UnfulfilledExpectationError: If the application class doesn't also derive from
414 :class:`~pyTooling.TerminalUI.TerminalApplication`.
415 """
416 if command is None:
417 self._mainParser.print_help()
418 elif command == "help":
419 self.WriteWarning("This is a recursion ...")
420 else:
421 try:
422 self._subParsers[command].print_help()
423 except KeyError:
424 self.WriteError(f"Command {command} is unknown.")
426 def Run(self, enableAutoComplete: bool = True) -> None:
427 """
428 Parse the command line arguments and call the handler method the command selects.
430 :param enableAutoComplete: Optional, if ``True``, register the parser with ``argcomplete``, if that package is
431 installed.
432 """
433 if enableAutoComplete: 433 ↛ 436line 433 didn't jump to line 436 because the condition on line 433 was always true
434 self._EnabledAutoComplete()
436 self._ParseArguments()
438 def _EnabledAutoComplete(self) -> None:
439 """
440 Register the main parser with ``argcomplete`` for shell completion.
442 The package is optional: when it isn't installed, completion is silently unavailable.
443 """
444 try:
445 from argcomplete import autocomplete
446 autocomplete(self._mainParser)
447 except ImportError: # pragma: no cover
448 pass
450 def _ParseArguments(self) -> None:
451 """
452 Parse the command line arguments and route them to the selected handler method.
453 """
454 # parse command line options and process split arguments in callback functions
455 parsed, args = self._mainParser.parse_known_args()
456 self._RouteToHandler(parsed)
458 def _RouteToHandler(self, args: Namespace) -> None:
459 """
460 Call the handler method the parsed arguments select.
462 The handler is stored as an unbound function, so it is called with the application object as first parameter.
464 :param args: The parsed command line arguments.
465 """
466 # because func is a function (unbound to an object), it MUST be called with self as a first parameter
467 args.func(self, args)
469 @readonly
470 def MainParser(self) -> ArgumentParser:
471 """
472 Read-only property to access the main argument parser (:attr:`_mainParser`).
474 :returns: The main argument parser.
475 """
476 return self._mainParser
478 @readonly
479 def SubParsers(self) -> dict[str, ArgumentParser]:
480 """
481 Read-only property to access the sub-parsers (:attr:`_subParser`).
483 :returns: Dictionary of command names and their sub-parsers.
484 """
485 return self._subParsers
488# String
489# StringList
490# Path
491# PathList
492# Delimiter
493# ValuedFlag --option=value
494# ValuedFlagList --option=foo --option=bar
495# OptionalValued --option --option=foo
496# ValuedTuple