Coverage for pyTooling/Attributes/ArgParse/__init__.py: 86%
155 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 00:36 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 00:36 +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 pathlib import Path
48from typing import Callable, Any, TypeVar, Optional as Nullable
49from pyTooling.Decorators import export, readonly
50from pyTooling.MetaClasses import ExtendedType, expects
51from pyTooling.Exceptions import ToolingException
52from pyTooling.Common import firstElement, firstPair, getFullyQualifiedName, StringEnum
53from pyTooling.Attributes import Attribute
56M = TypeVar("M", bound=Callable[..., Any])
59F = TypeVar("F", bound=StringEnum)
62@export
63def splitFormat(value: str, formats: type[F]) -> tuple[F, Path]:
64 """
65 Split an option's value of the form ``[<format>:]<file>`` into the format and the file.
67 An option writing a file often accepts more than one format for it, and naming the format in front of the path
68 keeps that to one option instead of two that can disagree - :pycode:`--output=json:report.json` rather than
69 :pycode:`--output=report.json --output-format=json`. The formats are a :class:`~pyTooling.Common.StringEnum`, so a
70 member is the string the command line spells.
72 **The format is optional.** A value naming none gets the enumeration's ``DEFAULT``; an enumeration declaring none
73 insists on being told. **A colon alone doesn't make a format** either: a Windows drive letter
74 (``C:\\report\\trace.json``) and a path holding a colon deeper down are paths, because a format is more than one
75 character long and contains no path separator.
77 .. admonition:: ``Program.py``
79 .. code-block:: python
81 class ReportFormat(StringEnum):
82 JSON = "json"
83 YAML = "yaml"
85 DEFAULT = JSON
87 @CommandHandler("convert", help="Convert the report.")
88 @LongValuedFlag("--output", dest="output", metaName="[format:]file", help="Write the report.")
89 def HandleConvert(self, args: Namespace) -> None:
90 reportFormat, file = splitFormat(args.output, ReportFormat)
92 :param value: The option's value, as the command line spelled it.
93 :param formats: The formats this option accepts.
94 :returns: The format, and the file to write.
95 :raises ValueError: If parameter 'value' is ``None``.
96 :raises TypeError: If parameter 'value' is not of type :class:`str`.
97 :raises ValueError: If the value names a format this option doesn't accept. |br|
98 The note lists the formats it accepts.
99 :raises ValueError: If the value names no format, and ``formats`` declares no ``DEFAULT``. |br|
100 The note lists the formats it accepts.
101 """
102 if value is None:
103 raise ValueError("Parameter 'value' is None.")
104 elif not isinstance(value, str):
105 ex = TypeError("Parameter 'value' is not of type 'str'.")
106 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
107 raise ex
109 prefix, colon, rest = value.partition(":")
110 if colon != "" and len(prefix) >= 2 and "/" not in prefix and "\\" not in prefix:
111 return formats.Parse(prefix), Path(rest)
112 elif (fileFormat := formats.Parse(None)) is None:
113 ex = ValueError(f"'{value}' names no format, and {formats.__name__} has no default.")
114 ex.add_note(f"Write '<format>:{value}' with one of: {', '.join(member.value for member in formats)}.")
115 raise ex
117 return fileFormat, Path(value)
120@export
121class ArgParseError(ToolingException):
122 """Base-exception of all exceptions raised by :mod:`pyTooling.Attributes.ArgParse`."""
125#@abstract
126@export
127class ArgParseAttribute(Attribute):
128 """
129 Base-class for all attributes to describe a :mod:`argparse`-base command line argument parser.
130 """
133@export
134class _HandlerMixin(metaclass=ExtendedType, mixin=True):
135 """
136 A mixin-class that offers a class field for a reference to a handler method and a matching property.
137 """
138 _handler: Callable[..., Any] = None #: Reference to a method that is called to handle e.g. a sub-command.
140 @readonly
141 def Handler(self) -> Callable[..., Any]:
142 """
143 Read-only property to access the handler method (:attr:`_handler`).
145 :returns: The method called to handle the command.
146 """
147 return self._handler
150# FIXME: Is _HandlerMixin needed here, or for commands?
151@export
152class CommandLineArgument(ArgParseAttribute, _HandlerMixin):
153 """
154 Base-class for all *Argument* classes.
156 An argument instance can be converted via ``AsArgument`` to a single string value or a sequence of string values
157 (tuple) usable e.g. with :class:`subprocess.Popen`. Each argument class implements at least one ``pattern`` parameter
158 to specify how argument are formatted.
160 There are multiple derived formats supporting:
162 * commands |br|
163 |rarr| :class:`~pyTooling.Attributes.ArgParse.CommandHandler`
164 * simple names (flags) |br|
165 |rarr| :class:`~pyTooling.Attributes.ArgParse.Flag.FlagArgument`,
166 :class:`~pyTooling.Attributes.ArgParse.BooleanFlag.BooleanFlag`
167 * simple values (valued flags) |br|
168 |rarr| :class:`~pyTooling.Attributes.ArgParse.Argument.StringArgument`,
169 :class:`~pyTooling.Attributes.ArgParse.Argument.PathArgument`
170 * names and values |br|
171 |rarr| :class:`~pyTooling.Attributes.ArgParse.ValuedFlag.ValuedFlag`,
172 :class:`~pyTooling.Attributes.ArgParse.OptionalValuedFlag.OptionalValuedFlag`
173 * key-value pairs |br|
174 |rarr| :class:`~pyTooling.Attributes.ArgParse.KeyValueFlag.NamedKeyValuePairsArgument`
175 """
177 # def __init__(self, args: Iterable, kwargs: Mapping) -> None:
178 # """
179 # The constructor expects ``args`` for positional and/or ``kwargs`` for named parameters which are passed without
180 # modification to :meth:`~ArgumentParser.add_argument`.
181 # """
182 #
183 # super().__init__(*args, **kwargs)
185 _args: tuple[Any, ...] #: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`.
186 _kwargs: dict[str, Any] #: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`.
188 def __init__(self, *args: Any, **kwargs: Any) -> None:
189 """
190 Initializes a command line argument.
192 This base-class collects the parameters :meth:`~argparse.ArgumentParser.add_argument` will be called with; the
193 derived classes assemble them from named parameters instead.
195 :param args: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`.
196 :param kwargs: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_argument`.
197 """
198 super().__init__()
199 self._args = args
200 self._kwargs = kwargs
202 @readonly
203 def Args(self) -> tuple[Any, ...]:
204 """
205 A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are
206 passed without modification to :class:`~ArgumentParser`.
208 :returns: Tuple of positional parameters.
209 """
210 return self._args
212 @readonly
213 def KWArgs(self) -> dict[str, Any]:
214 """
215 A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are
216 passed without modification to :class:`~ArgumentParser`.
218 :returns: Dictionary of named parameters.
219 """
220 return self._kwargs
223@export
224class CommandGroupAttribute(ArgParseAttribute):
225 """
226 *Experimental* attribute to group sub-commands in groups for better readability in a ``prog.py --help`` call.
227 """
228 __groupName: str = None #: Name of the group the sub-commands are collected in.
230 def __init__(self, groupName: str) -> None:
231 """
232 Initializes a command group attribute.
234 :param groupName: Name of the group the annotated commands are listed under in the help page.
235 """
236 super().__init__()
237 self.__groupName = groupName
239 @readonly
240 def GroupName(self) -> str:
241 """
242 Read-only property to access the name of the command group (:attr:`_groupName`).
244 :returns: Name of the command group.
245 """
246 return self.__groupName
249# @export
250# class _KwArgsMixin(metaclass=ExtendedType, mixin=True):
251# """
252# A mixin-class that offers a class field for named parameters (```**kwargs``) and a matching property.
253# """
254# _kwargs: Dict #: A dictionary of additional keyword parameters.
255#
256# @readonly
257# def KWArgs(self) -> Dict:
258# """
259# A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are
260# passed without modification to :class:`~ArgumentParser`.
261# """
262# return self._kwargs
263#
264#
265# @export
266# class _ArgsMixin(_KwArgsMixin, mixin=True):
267# """
268# A mixin-class that offers a class field for positional parameters (```*args``) and a matching property.
269# """
270#
271# _args: Tuple #: A tuple of additional positional parameters.
272#
273# @readonly
274# def Args(self) -> Tuple:
275# """
276# A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are
277# passed without modification to :class:`~ArgumentParser`.
278# """
279# return self._args
282@export
283class DefaultHandler(ArgParseAttribute, _HandlerMixin):
284 """
285 Marks a handler method as *default* handler. This method is called if no sub-command is given.
287 .. attention::
289 It's an error, if more than one method is annotated with this attribute.
290 """
292 def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
293 """
294 Apply this attribute to the handler method.
296 The handler method is stored in :attr:`_handler`.
298 :param func: The method handling the case that no sub-command was given.
299 :returns: The same method, now carrying this attribute.
300 """
301 self._handler = func
302 return super().__call__(func)
305@export
306class CommandHandler(ArgParseAttribute, _HandlerMixin): #, _KwArgsMixin):
307 """
308 Marks a handler method as responsible for the given command.
310 A sub-command parser is constructed for it with :meth:`~argparse.ArgumentParser.add_subparsers`.
311 """
313 _command: str #: Name of the sub-command this handler is responsible for.
314 _help: str #: Help text of the sub-command, displayed in the help page.
315 # FIXME: extract to mixin?
316 _args: tuple[Any, ...] #: Positional parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`.
317 _kwargs: dict[str, Any] #: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`.
319 def __init__(self, command: str, help: str = "", **kwargs: Any) -> None:
320 """
321 Initializes a command handler attribute.
323 :param command: Name of the sub-command on the command line.
324 :param help: Optional, help text shown for the sub-command. Default: ``""``.
325 :param kwargs: Named parameters forwarded to :meth:`~argparse.ArgumentParser.add_subparsers`.
326 """
327 super().__init__()
328 self._command = command
329 self._help = help
330 self._args = tuple()
331 self._kwargs = kwargs
333 self._kwargs["help"] = help
335 def __call__(self, func: M) -> M:
336 """
337 Apply this attribute to the handler method.
339 The handler method is stored in :attr:`_handler`.
341 :param func: The method handling the sub-command.
342 :returns: The same method, now carrying this attribute.
343 """
344 self._handler = func
345 return super().__call__(func)
347 @readonly
348 def Command(self) -> str:
349 """
350 Read-only property to access the command a sub-command parser adheres to (:attr:`_command`).
352 :returns: Name of the command.
353 """
354 return self._command
356# FIXME: extract to mixin?
357 @readonly
358 def Args(self) -> tuple[Any, ...]:
359 """
360 A tuple of additional positional parameters (``*args``) passed to the attribute. These additional parameters are
361 passed without modification to :class:`~ArgumentParser`.
363 :returns: Tuple of positional parameters.
364 """
365 return self._args
367 # FIXME: extract to mixin?
368 @readonly
369 def KWArgs(self) -> dict[str, Any]:
370 """
371 A dictionary of additional named parameters (``**kwargs``) passed to the attribute. These additional parameters are
372 passed without modification to :class:`~ArgumentParser`.
374 :returns: Dictionary of named parameters.
375 """
376 return self._kwargs
379@export
380class ArgParseHelperMixin(metaclass=ExtendedType, mixin=True):
381 """
382 Mixin-class to implement an :mod:`argparse`-base command line argument processor.
383 """
384 _mainParser: ArgumentParser #: The main argument parser of the application.
385 # TODO: Find type
386 _formatter: Any #: Help page formatter class used by every parser.
387 # TODO: Find type
388 _subParser: Any #: The sub-parser action the sub-commands are registered at.
389 _subParsers: dict[str, ArgumentParser] #: Sub-command name to its argument parser.
391 def __init__(self, **kwargs: Any) -> None:
392 """
393 The mixin-constructor expects an optional list of named parameters which are passed without modification to the
394 :class:`ArgumentParser` constructor.
396 :param kwargs: Named parameters forwarded to the :class:`~argparse.ArgumentParser` constructor.
397 :raises ArgParseError: If more than one method is marked as the default handler.
398 """
399 from .Argument import CommandLineArgument
401 super().__init__()
403 self._subParser = None
404 self._subParsers = {}
405 self._formatter = kwargs["formatter_class"] if "formatter_class" in kwargs else None
407 if "formatter_class" in kwargs:
408 self._formatter = kwargs["formatter_class"]
410 if "allow_abbrev" not in kwargs: 410 ↛ 413line 410 didn't jump to line 413 because the condition on line 410 was always true
411 kwargs["allow_abbrev"] = False
413 if "exit_on_error" not in kwargs: 413 ↛ 417line 413 didn't jump to line 417 because the condition on line 413 was always true
414 kwargs["exit_on_error"] = False
416 # create a commandline argument parser
417 self._mainParser = ArgumentParser(**kwargs)
419 # Search for 'DefaultHandler' marked method
420 methods = self.GetMethodsWithAttributes(predicate=DefaultHandler)
421 if (methodCount := len(methods)) == 1:
422 defaultMethod, attributes = firstPair(methods)
423 if len(attributes) > 1: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 raise ArgParseError("Marked default handler multiple times with 'DefaultAttribute'.")
426 # set default handler for the main parser
427 self._mainParser.set_defaults(func=firstElement(attributes).Handler)
429 # Add argument descriptions for the main parser
430 methodAttributes = defaultMethod.GetAttributes(CommandLineArgument) # ArgumentAttribute)
431 for methodAttribute in methodAttributes:
432 self._mainParser.add_argument(*methodAttribute.Args, **methodAttribute.KWArgs)
434 elif methodCount > 1: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true
435 raise ArgParseError("Marked more then one handler as default handler with 'DefaultAttribute'.")
437 # Search for 'CommandHandler' marked methods
438 methods: dict[Callable[..., Any], tuple[CommandHandler]] = self.GetMethodsWithAttributes(predicate=CommandHandler)
439 for method, attributes in methods.items():
440 if self._subParser is None:
441 self._subParser = self._mainParser.add_subparsers(help='sub-command help')
443 if len(attributes) > 1: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true
444 raise ArgParseError("Marked command handler multiple times with 'CommandHandler'.")
446 # Add a sub parser for each command / handler pair
447 attribute = firstElement(attributes)
448 kwArgs = attribute.KWArgs.copy()
449 if "formatter_class" not in kwArgs and self._formatter is not None:
450 kwArgs["formatter_class"] = self._formatter
452 kwArgs["allow_abbrev"] = False if "allow_abbrev" not in kwargs else kwargs["allow_abbrev"]
454 subParser = self._subParser.add_parser(attribute.Command, **kwArgs)
455 subParser.set_defaults(func=attribute.Handler)
457 # Add arguments for the sub-parsers
458 methodAttributes = method.GetAttributes(CommandLineArgument) # ArgumentAttribute)
459 for methodAttribute in methodAttributes:
460 subParser.add_argument(*methodAttribute.Args, **methodAttribute.KWArgs)
462 self._subParsers[attribute.Command] = subParser
464 @expects("WriteWarning", "WriteError")
465 def _PrintHelp(self, command: Nullable[str] = None) -> None:
466 """
467 Helper method to print the command line parser's help page, or the help page of one sub-command.
469 .. attention::
471 This method writes through the ``Write***`` methods of
472 :class:`~pyTooling.TerminalUI.TerminalApplication`, which this mixin-class does not provide, so the
473 application class has to derive from **both**.
475 :param command: Optional, the sub-command to print the help page for. If ``None``, the main
476 parser's help page is printed. Default: ``None``.
477 :raises UnfulfilledExpectationError: If the application class doesn't also derive from
478 :class:`~pyTooling.TerminalUI.TerminalApplication`.
479 """
480 if command is None:
481 self._mainParser.print_help()
482 elif command == "help":
483 self.WriteWarning("This is a recursion ...")
484 else:
485 try:
486 self._subParsers[command].print_help()
487 except KeyError:
488 self.WriteError(f"Command {command} is unknown.")
490 def Run(self, enableAutoComplete: bool = True) -> None:
491 """
492 Parse the command line arguments and call the handler method the command selects.
494 :param enableAutoComplete: Optional, if ``True``, register the parser with ``argcomplete``, if that package is
495 installed.
496 """
497 if enableAutoComplete: 497 ↛ 500line 497 didn't jump to line 500 because the condition on line 497 was always true
498 self._EnabledAutoComplete()
500 self._ParseArguments()
502 def _EnabledAutoComplete(self) -> None:
503 """
504 Register the main parser with ``argcomplete`` for shell completion.
506 The package is optional: when it isn't installed, completion is silently unavailable.
507 """
508 try:
509 from argcomplete import autocomplete
510 autocomplete(self._mainParser)
511 except ImportError: # pragma: no cover
512 pass
514 def _ParseArguments(self) -> None:
515 """
516 Parse the command line arguments and route them to the selected handler method.
517 """
518 # parse command line options and process split arguments in callback functions
519 parsed, args = self._mainParser.parse_known_args()
520 self._RouteToHandler(parsed)
522 def _RouteToHandler(self, args: Namespace) -> None:
523 """
524 Call the handler method the parsed arguments select.
526 The handler is stored as an unbound function, so it is called with the application object as first parameter.
528 :param args: The parsed command line arguments.
529 """
530 # because func is a function (unbound to an object), it MUST be called with self as a first parameter
531 args.func(self, args)
533 @readonly
534 def MainParser(self) -> ArgumentParser:
535 """
536 Read-only property to access the main argument parser (:attr:`_mainParser`).
538 :returns: The main argument parser.
539 """
540 return self._mainParser
542 @readonly
543 def SubParsers(self) -> dict[str, ArgumentParser]:
544 """
545 Read-only property to access the sub-parsers (:attr:`_subParser`).
547 :returns: Dictionary of command names and their sub-parsers.
548 """
549 return self._subParsers
552# String
553# StringList
554# Path
555# PathList
556# Delimiter
557# ValuedFlag --option=value
558# ValuedFlagList --option=foo --option=bar
559# OptionalValued --option --option=foo
560# ValuedTuple