__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:

ArgParse

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