__init__.py
Summary
- Lines:
138 of 155 covered (89.0%)
- Excluded lines:
2
- Branches:
28 of 38 taken (73.7%), 6 lines partially
- Coverage:
86.0%
- Directory:
- Missed lines:
236-237, 246, 365, 424, 435, 444, 480-488, 520, 549
Source
pyTooling/Attributes/ArgParse/__init__.pycoveredpartialuncoveredexcludednot executable
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.
34
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.
38
39.. seealso::
40
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
54
55
56M = TypeVar("M", bound=Callable[..., Any])
57
58
59F = TypeVar("F", bound=StringEnum)
60
61
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.
66
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.
71
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.
76
77 .. admonition:: ``Program.py``
78
79 .. code-block:: python
80
81 class ReportFormat(StringEnum):
82 JSON = "json"
83 YAML = "yaml"
84
85 DEFAULT = JSON
86
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)
91
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
108
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
116
117 return fileFormat, Path(value)
118
119
120@export
121class ArgParseError(ToolingException):
122 """Base-exception of all exceptions raised by :mod:`pyTooling.Attributes.ArgParse`."""
123
124
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 """
131
132
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.
139
140 @readonly
141 def Handler(self) -> Callable[..., Any]:
142 """
143 Read-only property to access the handler method (:attr:`_handler`).
144
145 :returns: The method called to handle the command.
146 """
147 return self._handler
148
149
150# FIXME: Is _HandlerMixin needed here, or for commands?
151@export
152class CommandLineArgument(ArgParseAttribute, _HandlerMixin):
153 """
154 Base-class for all *Argument* classes.
155
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.
159
160 There are multiple derived formats supporting:
161
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 """
176
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)
184
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`.
187
188 def __init__(self, *args: Any, **kwargs: Any) -> None:
189 """
190 Initializes a command line argument.
191
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.
194
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
201
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`.
207
208 :returns: Tuple of positional parameters.
209 """
210 return self._args
211
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`.
217
218 :returns: Dictionary of named parameters.
219 """
220 return self._kwargs
221
222
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.
229
230 def __init__(self, groupName: str) -> None:
231 """
232 Initializes a command group attribute.
233
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
238
239 @readonly
240 def GroupName(self) -> str:
241 """
242 Read-only property to access the name of the command group (:attr:`_groupName`).
243
244 :returns: Name of the command group.
245 """
246 return self.__groupName
247
248
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
280
281
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.
286
287 .. attention::
288
289 It's an error, if more than one method is annotated with this attribute.
290 """
291
292 def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
293 """
294 Apply this attribute to the handler method.
295
296 The handler method is stored in :attr:`_handler`.
297
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)
303
304
305@export
306class CommandHandler(ArgParseAttribute, _HandlerMixin): #, _KwArgsMixin):
307 """
308 Marks a handler method as responsible for the given command.
309
310 A sub-command parser is constructed for it with :meth:`~argparse.ArgumentParser.add_subparsers`.
311 """
312
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`.
318
319 def __init__(self, command: str, help: str = "", **kwargs: Any) -> None:
320 """
321 Initializes a command handler attribute.
322
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
332
333 self._kwargs["help"] = help
334
335 def __call__(self, func: M) -> M:
336 """
337 Apply this attribute to the handler method.
338
339 The handler method is stored in :attr:`_handler`.
340
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)
346
347 @readonly
348 def Command(self) -> str:
349 """
350 Read-only property to access the command a sub-command parser adheres to (:attr:`_command`).
351
352 :returns: Name of the command.
353 """
354 return self._command
355
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`.
362
363 :returns: Tuple of positional parameters.
364 """
365 return self._args
366
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`.
373
374 :returns: Dictionary of named parameters.
375 """
376 return self._kwargs
377
378
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.
390
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.
395
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
400
401 super().__init__()
402
403 self._subParser = None
404 self._subParsers = {}
405 self._formatter = kwargs["formatter_class"] if "formatter_class" in kwargs else None
406
407 if "formatter_class" in kwargs:
408 self._formatter = kwargs["formatter_class"]
409
410 if "allow_abbrev" not in kwargs:
411 kwargs["allow_abbrev"] = False
412
413 if "exit_on_error" not in kwargs:
414 kwargs["exit_on_error"] = False
415
416 # create a commandline argument parser
417 self._mainParser = ArgumentParser(**kwargs)
418
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:
424 raise ArgParseError("Marked default handler multiple times with 'DefaultAttribute'.")
425
426 # set default handler for the main parser
427 self._mainParser.set_defaults(func=firstElement(attributes).Handler)
428
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)
433
434 elif methodCount > 1:
435 raise ArgParseError("Marked more then one handler as default handler with 'DefaultAttribute'.")
436
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')
442
443 if len(attributes) > 1:
444 raise ArgParseError("Marked command handler multiple times with 'CommandHandler'.")
445
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
451
452 kwArgs["allow_abbrev"] = False if "allow_abbrev" not in kwargs else kwargs["allow_abbrev"]
453
454 subParser = self._subParser.add_parser(attribute.Command, **kwArgs)
455 subParser.set_defaults(func=attribute.Handler)
456
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)
461
462 self._subParsers[attribute.Command] = subParser
463
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.
468
469 .. attention::
470
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**.
474
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.")
489
490 def Run(self, enableAutoComplete: bool = True) -> None:
491 """
492 Parse the command line arguments and call the handler method the command selects.
493
494 :param enableAutoComplete: Optional, if ``True``, register the parser with ``argcomplete``, if that package is
495 installed.
496 """
497 if enableAutoComplete:
498 self._EnabledAutoComplete()
499
500 self._ParseArguments()
501
502 def _EnabledAutoComplete(self) -> None:
503 """
504 Register the main parser with ``argcomplete`` for shell completion.
505
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
513
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)
521
522 def _RouteToHandler(self, args: Namespace) -> None:
523 """
524 Call the handler method the parsed arguments select.
525
526 The handler is stored as an unbound function, so it is called with the application object as first parameter.
527
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)
532
533 @readonly
534 def MainParser(self) -> ArgumentParser:
535 """
536 Read-only property to access the main argument parser (:attr:`_mainParser`).
537
538 :returns: The main argument parser.
539 """
540 return self._mainParser
541
542 @readonly
543 def SubParsers(self) -> dict[str, ArgumentParser]:
544 """
545 Read-only property to access the sub-parsers (:attr:`_subParser`).
546
547 :returns: Dictionary of command names and their sub-parsers.
548 """
549 return self._subParsers
550
551
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