"""
This module contains helpers related to writing scripts and creating command
line utilities.
"""
import functools
import signal
import sys
from typing import Callable, Optional
from pytool.lang import UNSET
# Handle the optional configargparse lib
try:
import configargparse as argparse # type: ignore[import]
DefaultFormatter = argparse.ArgumentDefaultsRawHelpFormatter
HAS_CAP = True
except ImportError:
import argparse
DefaultFormatter = argparse.RawDescriptionHelpFormatter
HAS_CAP = False
import pytool.text
try:
import pyconfig # type: ignore[import]
except ImportError:
pyconfig = None
# Implicitly handle gevent
try:
import gevent # type: ignore[import]
except ImportError:
signal_handler = signal.signal
else:
try:
signal_handler = gevent.signal_handler
except AttributeError:
# handle gevent<=1.4.0
signal_handler = gevent.signal
__all__ = [
"RELOAD_SIGNAL",
"STOP_SIGNAL",
"Command",
]
try:
RELOAD_SIGNAL = signal.SIGUSR1
STOP_SIGNAL = signal.SIGTERM
except AttributeError:
# These signal symbols don't exist on Windows
RELOAD_SIGNAL = 10
STOP_SIGNAL = 15
[docs]
class Command(object):
"""
Base class for creating commands that can be run easily as scripts. This
class is designed to be used with the ``console_scripts`` entry point to
create Python-based commands for your packages.
.. versionadded:: 3.11.0
If the `configargparse <https://github.com/bw2/ConfigArgParse>`_
library is installed, Pytool will automatically use that as a drop-in
replacement for the stdlib `argparse
<https://docs.python.org/3/howto/argparse.html>`_ module that is used
by default.
You should use :meth:`parser_opts` to give additional configuration
arguments if you want to enable `configargparse` features like
automatically using environment variables.
**Hello world example**::
# hello.py
from pytool.cmd import Command
class HelloWorld(Command):
def run(self):
print "Hello World."
The only thing that *must* be defined in the subclass is the :meth:`run`
method, which should contain the code to launch your application, all other
methods are optional.
**Example setup.py**::
# setup.py
from setuptools import setup
setup(
# ...
entry_points={
'console_scripts':[
'helloworld = hello:HelloWorld.console_script',
],
},
)
When using an entry point script, the :class:`Command` has a special
:meth:`console_script` method for launching the application.
**Starting without an entry point script**::
# hello.py [cont'd]
if __name__ == '__main__':
import sys
HelloWorld().start(sys.argv[1:])
The :meth:`start` method always requires an argument - even if it's just
an empty list.
**More complex example**::
from pytool.cmd import Command
class HelloAll(Command):
def set_opts(self):
self.opt('--world', default='World', help="use a different "
"world")
self.opt('--verbose', '-v', action='store_true', help="use "
"more verbose output")
def run(self):
print "Hello", self.args.world
if self.args.verbose:
print "Hola", self.args.world
Whenever there are arguments for a command, they're made available for your
use as :attr:`self.args`. This object is created by :mod:`argparse` so
refer to that documentation for more information.
"""
def __init__(self):
self.parser = argparse.ArgumentParser(add_help=False, **self.parser_opts())
self.subparsers = None
self.set_opts()
self.opt("--help", action="help", help="display this help and exit")
[docs]
def parser_opts(self) -> dict:
"""Subclasses should override this method to return a dictionary of
additional arguments to the parser instance.
**Example**::
class MyCommand(Command):
def parser_opts(self):
return dict(
description="Manual description for cmd.",
add_env_var_help=True,
auto_env_var_prefix='mycmd_',
)
"""
return dict()
[docs]
def set_opts(self) -> None:
"""Subclasses should override this method to configure the command
line arguments and options.
**Example**::
class MyCommand(Command):
def set_opts(self):
self.opt('--verbose', '-v', action='store_true',
help="be more verbose")
def run(self):
if self.args.verbose:
print "I'm verbose."
"""
pass
[docs]
def opt(self, *args, **kwargs) -> None:
"""Add an option to this command. This takes the same arguments as
:meth:`ArgumentParser.add_argument`.
"""
self.parser.add_argument(*args, **kwargs)
[docs]
def run(self) -> None:
"""Subclasses should override this method to start the command
process. In other words, this is where the magic happens.
.. versionchanged:: 3.15.0
By default, this will just print help and exit.
"""
self.parser.print_help()
sys.exit(1)
[docs]
def describe(self, description: str) -> None:
"""
Describe the command in more detail. This will be displayed in addition
to the argument help.
This automatically strips leading indentation but does not strip all
formatting like the ``ArgumentParser(description='')`` keyword.
**Example**::
class MyCommand(Command):
def set_opts(self):
self.describe(\"\"\"
This is an example command. To use the example command,
run it.\"\"\")
def run(self):
pass
"""
# This has to be called from within set_opts(), when the parser exists
if not self.parser:
return
description = pytool.text.wrap(description)
# Update the parser object with the new description
self.parser.description = description
# And use the raw class so it doesn't strip our formatting
self.parser.formatter_class = CommandFormatter
[docs]
def subcommand(
self,
name: str,
opt_func: Optional[Callable] = None,
run_func=None,
*args,
**kwargs,
) -> None:
"""
Add a subcommand `name` with setup `opt_func` and main `run_func` to
the argument parser.
Any additional positional or keyword arguments will be passed to the
``ArgumentParser`` instance created.
.. versionchanged:: 3.15.0
Either `opt_func` or `run_func` may be omitted, in which case a
method with a name matching the subcommand name plus ``_opts`` will
be bound to `opt_func` and a method matching the subcommand name
will be bound to `run_func`.
For example, a subcommand ``'write'`` will bind the methods
:meth:`write_opts` and :meth:`write`.
.. versionadded:: 3.12.0
**Example**::
class MyCommand(Command):
def set_opts(self):
self.subcommand('thing', self.thing, self.run_thing)
def thing(self):
# Set thing specific options
self.opt('--thing', 't', help="Thing to do")
def run_thing(self):
# This runs if the thing subcommand is invoked
pass
def run(self):
# This runs if there is no subcommand
:param str name: Subcommand name
:param function opt_func: Options function to add
:param function run_func: Run function to add
:param args: Arguments to pass to the subparser constructor
:param kwargs: Keyword arguments to pass to the subparser constructor
"""
if not self.subparsers:
self.subparsers = self.parser.add_subparsers(
title="subcommands", dest="command"
)
if opt_func is None:
opt_func = getattr(self, name.replace("-", "_") + "_opts", None)
if run_func is None:
run_func = getattr(self, name.replace("-", "_"), self.parser.print_help)
# Map help text to command descriptions for extra convenience
if "help" in kwargs and "description" not in kwargs:
kwargs["description"] = kwargs["help"]
# Provide good wrapping
if "description" in kwargs:
kwargs["description"] = pytool.text.wrap(kwargs["description"])
kwargs.setdefault("formatter_class", CommandFormatter)
# Propagate environment variable prefix settings
prefix = getattr(self.parser, "_auto_env_var_prefix", UNSET)
if prefix is not UNSET:
kwargs.setdefault("auto_env_var_prefix", prefix)
# Propagate environment variable help settings
add_help = getattr(self.parser, "_add_env_var_help", UNSET)
if add_help is not UNSET:
kwargs.setdefault("add_env_var_help", add_help)
parser = self.subparsers.add_parser(name, *args, **kwargs)
parser.set_defaults(func=run_func)
# Shenanigans so we can reuse self.opt()
if opt_func:
_opt = self.opt
self.opt = parser.add_argument
opt_func()
self.opt = _opt
# Give it back for any further fiddling
return parser
[docs]
@classmethod
def console_script(cls) -> None:
"""Method used to start the command when launched from a distutils
console script.
"""
cls().start(sys.argv[1:])
[docs]
def start(self, args: list[str]) -> None:
"""Starts a command and registers single handlers."""
# Unfortunately this doesn't work and I don't know why... will fix
# it later.
# self.args = self.parser.parse_intermixed_args(args)
self.args = self.parser.parse_args(args)
signal_handler(RELOAD_SIGNAL, self.reload)
signal_handler(STOP_SIGNAL, self.stop)
if self.subparsers and self.args.command:
return self.args.func()
self.run()
[docs]
def stop(self, *args, **kwargs):
"""
Exits the currently running process with status `0`.
Override this in your subclass if you wish to implement different
SIGINT or SIGTERM handling for your process.
"""
sys.exit(0)
[docs]
def reload(self):
"""
Reloads `pyconfig <https://pypi.org/project/pyconfig/>`_ if it is
available.
Override this in your subclass if you wish to implement different
reloading behavior.
"""
if pyconfig:
pyconfig.reload()
class CommandFormatter(DefaultFormatter):
"""
Helper class that allows for wide formatting of the options blocks in the
command, which makes it much more readable.
"""
def __init__(self, *args, **kwargs):
width = pytool.text.columns()
max_help_position = max(40, int(width / 2))
kwargs["max_help_position"] = max_help_position
super(CommandFormatter, self).__init__(*args, **kwargs)
def opt(*args, **kwargs) -> Callable:
"""
Factory function for creating :class:`Command.opt` bindings at the class
level for reuse in subcommands.
**Example**::
class MyCommand(Command):
opt_url = opt('--url', help="Target URL")
def set_opts(self):
self.subcommand('send', self.send_opts, self.send)
self.subcommand('listen', self.listen_opts, self.listen)
def send_opts(self):
# This automatically calls self.opt with the correct arguments
self.opt_url()
def listen_opts(self):
# It can be reused easily
self.opt_url()
# ...
"""
if args:
name = "opt_" + args[0].lstrip("-")
def opt(self):
self.opt(*args, **kwargs)
opt.__name__ = name
return opt
def run(func):
"""
Decorates a function to handle thrown errors by writing the exception
string to stderr and then exiting with status 1.
**Example**::
class MyCommand(Command):
@run
def run(self):
...
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as err:
sys.stderr.write(str(err))
sys.stderr.write("\n")
sys.stderr.flush()
sys.exit(1)
return wrapper