# Copyright Louis Paternault 2016-2023
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""A replacement for `argparse` dispatching subcommand calls to functions, modules or executables.
Parsing arguments
-----------------
.. autoclass:: ArgumentParser()
:no-members:
Adding subcommands
------------------
Adding subcommands to your program starts the same way as with `argparse
<https://docs.python.org/3/library/argparse.html#sub-commands>`_: one has to
call :meth:`ArgumentParser.add_subparsers`, and then call one of the methods of
the returned object. With :mod:`argparse`, this object only have one method
:meth:`~_SubCommandsDispatch.add_parser`. This module adds several new methods.
.. _ondouble:
Subcommands defined twice
^^^^^^^^^^^^^^^^^^^^^^^^^
Most of the methods creating subcommands accept an `ondouble` arguments, which
tells what to do when adding a subcommand that already exists:
- .. data:: ERROR
Raise an :exc:`AttributeError` exception;
- .. data:: IGNORE
:noindex:
The new subcommand is silently ignored;
- .. data:: DOUBLE
The new subcommand is added to the parser, and :mod:`argparse` deals with
it. This does not seem to be documented, but it seems that the parser then
contains two subcommands with the same name.
.. deprecated:: 1.3.0
Python3.11 raises an error if two subparsers have the same name.
.. _importerror:
Import errors
^^^^^^^^^^^^^
When using methods :meth:`~_SubCommandsDispatch.add_module` and
:meth:`~_SubCommandsDispatch.add_submodules`, modules are imported. But some
modules can be impossible to import because of errors. Both these methods have
the argument ``onerror`` to define what to do with such modules:
- .. data:: RAISE
Raise an exception (propagate the exception raised by the module).
- .. data:: IGNORE
Silently ignore this module.
.. _return:
Return value
^^^^^^^^^^^^
Unfortunately, different methods make :meth:`ArgumentParser.parse_args` return
different types of values. The two possible behaviours are illustrated below::
>>> from argdispatch import ArgumentParser
>>> def add(args):
... print(int(args[0]) + int(args[1]))
...
>>> parser = ArgumentParser()
>>> subparsers = parser.add_subparsers()
>>> parser1 = subparsers.add_parser("foo")
>>> parser1.add_argument("--arg")
_StoreAction(
option_strings=['--arg'], dest='arg', nargs=None, const=None, default=None,
type=None, choices=None, help=None, metavar=None,
)
>>> subparsers.add_function(add)
>>> parser.parse_args("foo --arg 3".split())
Namespace(arg='3')
>>> parser.parse_args("add 3 4".split())
7
The ``NameSpace(...)`` is the object *returned* by
:meth:`~ArgumentParser.parse_args`, while the ``7`` is *printed* by function,
and the interpreter then exits (by calling :func:`sys.exit`).
Call to :meth:`~ArgumentParser.parse_args`, when parsing a subcommand defined by:
- legacy method :meth:`~_SubCommandsDispatch.add_parser`, returns a
:class:`~ArgumentParser.Namespace` (this method is (almost) unchanged
compared to :mod:`argparse`);
- new methods do not return anything, but exit the program with :meth:`sys.exit`.
Thus, we do recommand not to mix them, to make source code easier to read, but
technically, it is possible.
Subcommand definition
^^^^^^^^^^^^^^^^^^^^^
Here are all the :class:`_SubCommandsDispatch` commands to define subcommands.
- Legacy subcommand
.. automethod:: _SubCommandsDispatch.add_parser
- Function subcommand
.. automethod:: _SubCommandsDispatch.add_function
- Module subcommands
Those methods are compatible with `PEP 420 <https://www.python.org/dev/peps/pep-0420/>`__
`namespace packages <https://packaging.python.org/guides/packaging-namespace-packages/>`__.
.. automethod:: _SubCommandsDispatch.add_module
.. automethod:: _SubCommandsDispatch.add_submodules
- Entry points subcommands
Those methods deal with `setuptools entry points
<https://setuptools.readthedocs.io/en/latest/pkg_resources.html#entry-points>`__.
.. automethod:: _SubCommandsDispatch.add_entrypoints_modules
.. automethod:: _SubCommandsDispatch.add_entrypoints_functions
- Executable subcommands
.. automethod:: _SubCommandsDispatch.add_executable
.. automethod:: _SubCommandsDispatch.add_pattern_executables
.. automethod:: _SubCommandsDispatch.add_prefix_executables
"""
import argparse
import enum
import importlib
import os
import pkgutil
import re
import runpy
import subprocess
import sys
import types
from argparse import * # pylint: disable=wildcard-import
import pkg_resources
VERSION = "1.3.1"
__AUTHOR__ = "Louis Paternault (spalax@gresille.org)"
__COPYRIGHT__ = "(C) 2017-2023 Louis Paternault. GNU GPL 3 or later."
if sys.version_info < (3, 11):
__all__ = argparse.__all__ + ["ERROR", "IGNORE", "DOUBLE", "RAISE"]
else:
__all__ = argparse.__all__ + ["ERROR", "IGNORE", "RAISE"]
################################################################################
# Constants
class _Constants(enum.IntEnum):
"""Subclass of :class:`enum.IntEnum` to display only the constant name in the documentation."""
ERROR = enum.auto()
IGNORE = enum.auto()
DOUBLE = enum.auto()
RAISE = enum.auto()
ERROR = _Constants.ERROR
IGNORE = _Constants.IGNORE
RAISE = _Constants.RAISE
if sys.version_info < (3, 11):
DOUBLE = _Constants.DOUBLE
################################################################################
# Misc utilities
def _first_non_empty_line(text):
if text is None:
return ""
try:
return [line.strip() for line in text.split("\n") if line.strip()][0]
except IndexError:
return ""
def _make_bin_executable(executable):
"""Return a function that, when called, execute the given executable"""
def run(args):
"""Call executable with given arguments"""
return subprocess.call([executable] + args)
return run
def _make_module_executable(module):
"""Return a function that, when called, execute the given module"""
def run(args):
"""Call the module with given arguments"""
sys.argv = [module] + args
runpy.run_module(module, run_name="__main__")
return 0
return run
def _is_package(module): # pylint: disable=inconsistent-return-statements
"""Return ``True`` iff module is a package.
Precondition: Argument is either a module or a string.
"""
if isinstance(module, types.ModuleType):
return module.__name__ == module.__package__
if isinstance(module, str):
spec = importlib.util.find_spec(module)
if spec is None:
raise ImportError(f"No module named '{module}'.")
return spec.submodule_search_locations is not None
################################################################################
# Redefinition of some argparse classes
[docs]
class ArgumentParser(ArgumentParser): # pylint: disable=function-redefined
"""Create a new :class:`ArgumentParser` object.
There is no visible changes compared to :class:`argparse.ArgumentParser`.
For internal changes, see :ref:`advanced`.
"""
def add_subparsers(self, *args, **kwargs):
# pylint: disable=arguments-differ
if "action" not in kwargs:
kwargs["action"] = _SubCommandsDispatch
return super().add_subparsers(*args, **kwargs)
[docs]
class _SubCommandsDispatch(
argparse._SubParsersAction
): # pylint: disable=protected-access
"""Object returned by the :meth:`argparse.ArgumentParser.add_subparsers` method.
Its methods :meth:`add_*` are used to add subcommands to the parser.
"""
def __call__(self, *args, **kwargs):
# pylint: disable=signature-differs
if self._name_dispatcher_map.get(args[2][0], None) is not None:
sys.exit(self._name_dispatcher_map[args[2][0]](args[2][1:]))
return super().__call__(*args, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._name_dispatcher_map = {}
[docs]
def add_parser( # pylint: disable=inconsistent-return-statements
self, *args, **kwargs
):
"""Add a subparser, and return an :class:`ArgumentParser` object.
This is the same method as the original :mod:`argparse`, excepted that
an ``ondouble`` argument has been added.
.. warning::
Depending of value of the `ondouble` argument, this method may
return a :class:`ArgumentParser` object, or `None`.
If argument `ondouble` is :data:`IGNORE`, and the command name is
already defined, this function returns nothing (`None`). Otherwise,
it returns an :class:`ArgumentParser` object.
:param ondouble: See :ref:`ondouble`. Default is :data:`ERROR`
(default was :data:`DOUBLE` before version 1.3.0).
:return: A :class:`ArgumentParser` object, or `None`.
:raises: A :class:`ValueError` exception, if argument `ondouble` is
:data:`ERROR`, and command name already exists.
.. versionchanged:: 1.3.0
Before version 1.3.0, default value for ``ondouble`` was :data:`DOUBLE`.
It is now :data:`ERROR`.
"""
# pylint: disable=arguments-differ
if sys.version_info < (3, 11):
ondouble = kwargs.pop("ondouble", DOUBLE)
else:
ondouble = kwargs.pop("ondouble", ERROR)
if args[0] in self._name_dispatcher_map:
if ondouble == IGNORE:
return
if ondouble == ERROR:
raise ValueError(f"Subcommand '{args[0]}' is already defined.")
if ondouble == DOUBLE:
pass
else:
self._name_dispatcher_map[args[0]] = None
return super().add_parser(*args, **kwargs)
[docs]
def add_executable(self, executable, command=None, *, help=None, ondouble=ERROR):
"""Add a subcommand matching a system executable.
:param str executable: Name of the executable to use.
:param str command: Name of the subcommand. If ``None``, the executable is used.
:param str help: A brief description of what the subcommand does. If
`None`, use an empty help.
:param ondouble: See :ref:`ondouble`. Default is :data:`ERROR`.
"""
# pylint: disable=redefined-builtin
if command is None:
command = executable
if command in self._name_dispatcher_map:
if ondouble == IGNORE:
return
if ondouble == ERROR:
raise ValueError(f"Subcommand '{command}' is already defined.")
if ondouble == DOUBLE:
self.add_parser(command, help=help, ondouble=DOUBLE)
else:
self._name_dispatcher_map[command] = _make_bin_executable(executable)
super().add_parser(command, help=help)
[docs]
def add_pattern_executables(self, pattern, *, path=None, ondouble=IGNORE):
"""Add all the executables in path matching the regular expression.
If `pattern` contains a group named `command`, this is used as the
subcommand name. Otherwise, the executable name is used.
:param str pattern: Regular expression defining the executables to add as subcommand.
:param iterable path: Iterator on paths in which executable has to been
searched for. If `None`, use the ``PATH`` environment variable.
This arguments *replaces* the ``PATH`` environment variable: if you
want to extend it, use ``":".join(["my/custom", "path",
os.environ.get("PATH", "")])``.
:param ondouble: See :ref:`ondouble`. Default is :data:`IGNORE`.
"""
executables = set()
if path is None:
path = os.environ["PATH"].split(":")
compiled = re.compile(pattern)
for pathitem in path:
if not os.path.isdir(pathitem):
continue
for filename in os.listdir(pathitem):
fullpath = os.path.join(pathitem, filename)
if fullpath in executables:
continue
if os.path.isfile(fullpath) and os.access(fullpath, os.X_OK):
match = compiled.match(filename)
if match:
if "command" in match.groupdict():
command = match.groupdict()["command"]
else:
command = filename
executables.add(fullpath)
self.add_executable(fullpath, command, ondouble=ondouble)
[docs]
def add_prefix_executables(self, prefix, *, path=None, ondouble=IGNORE):
"""Add all the executables starting with ``prefix``
The subcommand name used is the executable name, without the prefix.
:param prefix: Common prefix of all the executables to use as subcommands.
:param iterable path: Iterator on paths in which executable has to been
searched for. See
:meth:`~_SubCommandsDispatch.add_pattern_executables` for more
information.
:param ondouble: See :ref:`ondouble`. Default is :data:`IGNORE`.
"""
return self.add_pattern_executables(
rf"^{prefix}(?P<command>.*)$", path=path, ondouble=ondouble
)
[docs]
def add_function(self, function, command=None, *, help=None, ondouble=ERROR):
"""Add a subcommand matching a python function.
:param function: Function to use.
:param str command: Name of the subcommand. If ``None``, the function name is used.
:param str help: A brief description of what the subcommand does. If
`None`, use the first non-empty line of the function docstring.
:param ondouble: See :ref:`ondouble`. Default is :data:`ERROR`.
This function is approximatively called using::
sys.exit(function(args))
It must either return something which will be transimtted to
:func:`sys.exit`, or directly exit using :meth:`sys.exit`. If it raises
an exception, this exception is not catched by :mod:`argdispatch`.
"""
# pylint: disable=redefined-builtin
if command is None:
command = function.__name__
if help is None:
help = _first_non_empty_line(function.__doc__)
if command in self._name_dispatcher_map:
if ondouble == IGNORE:
return
if ondouble == ERROR:
raise ValueError(f"Subcommand '{command}' is already defined.")
if ondouble == DOUBLE:
self.add_parser(command, help=help, ondouble=DOUBLE)
else:
self._name_dispatcher_map[command] = function
super().add_parser(command, help=help)
[docs]
def add_module(
self,
module,
command=None,
*,
help=None,
ondouble=ERROR,
onerror=RAISE,
forcemain=False,
): # pylint: disable=line-too-long
"""Add a subcommand matching a python module.
When such a subcommand is parsed, ``python -m module`` is called with
the remaining arguments.
:param module: Module or package to use. If a package, the ``__main__`` submodule is used.
This argument can either be a string or an already imported module.
Both cases are shown in the following examble::
import foo
parser = ArgumentParser()
subparser = parser.add_subparsers()
# Argument `foo` is a module.
subparser.add_module(foo)
# Argument `bar` is a string.
subparser.add_module("bar")
Note that the only way to import a *relative* module is by importing it yourself,
then passing the module as argument to this method.
:param str command: Name of the subcommand. If ``None``, the module name is used.
:param str help: A brief description of what the subcommand does. If
`None`, use the first non-empty line of the module docstring, only
if the module is not a package. Otherwise, an empty message is
used.
:param ondouble: See :ref:`ondouble`. Default is :data:`ERROR`.
:param onerror: See :ref:`importerror`. Default is :data:`RAISE`.
:param forcemain: Raise error if parameter `module` is not a package
containing a `__main__` module
(this error may be ignored if parameter `onerror` is :data:`IGNORE`).
Default is `False`.
"""
# pylint: disable=redefined-builtin, too-many-branches, too-many-arguments
if not isinstance(module, (types.ModuleType, str)):
raise TypeError("Argument `module` must be a string or a module.")
if isinstance(module, types.ModuleType):
modulename = module.__name__
elif isinstance(module, str):
modulename = module
# Test forcemain option
if forcemain:
try:
if not _is_package(module):
raise ImportError(f"Module '{modulename}' is not a package.")
if importlib.util.find_spec(f"{modulename}.__main__") is None:
raise ImportError(
f"Package '{modulename}' is missing a '__main__' module."
)
except ImportError:
if onerror == RAISE:
raise
if onerror == IGNORE:
return
imported = None
if isinstance(module, types.ModuleType):
imported = module
elif isinstance(module, str):
try:
if _is_package(module):
imported = importlib.import_module(module)
except: # pylint: disable=bare-except
if onerror == RAISE:
raise
if onerror == IGNORE:
return
if command is None:
command = modulename
if help is None and imported is not None:
help = _first_non_empty_line(imported.__doc__)
if command in self._name_dispatcher_map:
if ondouble == IGNORE:
return
if ondouble == ERROR:
raise ValueError(f"Subcommand '{command}' is already defined.")
if ondouble == DOUBLE:
self.add_parser(command, help=help, ondouble=DOUBLE)
else:
self._name_dispatcher_map[command] = _make_module_executable(modulename)
super().add_parser(command, help=help)
[docs]
def add_submodules(self, module, *, ondouble=IGNORE, onerror=IGNORE):
"""Add subcommands matching `module`'s submodules.
The modules that are used as subcommands are submodules of `module`
(without recursion), that themselves contain a ``__main__`` submodule.
:param module: Module to use.
It can either a string or a module (see :meth:`~_SubCommandsDispatch.add_module`).
:param ondouble: See :ref:`ondouble`. Default is :data:`IGNORE`.
:param onerror: See :ref:`importerror`. Default is :data:`IGNORE`.
"""
if isinstance(module, str) and module.startswith("."):
raise NotImplementedError(
"Method does not support (yet?) relative modules "
"with a string argument. "
"Import this module yourself, "
"and pass the *module* as an argument to this method."
)
if isinstance(module, types.ModuleType):
path = module.__path__
prefix = module.__name__ + "."
elif isinstance(module, str):
path = (
os.path.join(pythonpath, module.replace(".", "/"))
for pythonpath in sys.path
)
prefix = module + "."
else:
raise TypeError("Argument `module` must be a string or a module.")
for __finder, name, ispkg in pkgutil.iter_modules(path, prefix):
if not ispkg:
continue
try:
if importlib.util.find_spec(f"{name}.__main__") is None:
continue
except Exception: # pylint: disable=broad-except
if onerror == RAISE:
raise
if onerror == IGNORE:
continue
self.add_module(
name,
command=name.split(".")[-1],
ondouble=ondouble,
onerror=onerror,
forcemain=True,
)
[docs]
def add_entrypoints_modules(
self, group, *, ondouble=IGNORE, onerror=IGNORE, forcemain=False
):
"""Add modules listed in entry points `group` as subcommands.
:param str group: The entry point group listing the functions to be used as subcommands.
:param ondouble: See :ref:`ondouble`. Default is :data:`IGNORE`.
:param onerror: See :ref:`importerror`. Default is :data:`IGNORE`.
:param forcemain: Raise error if parameter `module` is not a package
containing a `__main__` module
(this error may be ignored, and the faulty module ignored as well,
if parameter `onerror` is :data:`IGNORE`).
Default is `False`.
"""
for entrypoint in pkg_resources.iter_entry_points(group):
try:
module = entrypoint.load()
modulename = module.__name__
if forcemain:
if not _is_package(module):
raise ImportError(f"Module '{modulename}' is not a package.")
if importlib.util.find_spec(f"{modulename}.__main__") is None:
raise ImportError(
f"Package '{modulename}' is missing a '__main__' module."
)
self.add_module(module, command=entrypoint.name, ondouble=ondouble)
except: # pylint: disable=bare-except
if onerror == IGNORE:
continue
if onerror == RAISE:
raise
[docs]
def add_entrypoints_functions(self, group, *, ondouble=IGNORE, onerror=IGNORE):
"""Add functions listed in entry points `group` as subcommands.
:param str group: The entry point group listing the functions to be used as subcommands.
:param ondouble: See :ref:`ondouble`. Default is :data:`IGNORE`.
:param onerror: See :ref:`importerror`. Default is :data:`IGNORE`.
"""
for entrypoint in pkg_resources.iter_entry_points(group):
try:
self.add_function(
entrypoint.load(), command=entrypoint.name, ondouble=ondouble
)
except: # pylint: disable=bare-except
if onerror == IGNORE:
continue
if onerror == RAISE:
raise