Source code for clize.runner

# clize -- A command-line argument parser for Python
# Copyright (C) 2011-2022 by Yann Kaiser and contributors. See AUTHORS and
# COPYING for details.
import contextlib
import pathlib
import sys
import os
import typing
import warnings
from functools import partial, update_wrapper
import itertools
import shutil

from sigtools.modifiers import annotate, autokwoargs, kwoargs
from sigtools.specifiers import forwards_to_method, signature

from clize import util, errors, parser, parameters

class _BasicHelper(object):
    def __init__(self, description, usages):
        if description is not None:
            self.description = description
        if usages is not None:
            def _usages():
                return usages
            self.usages = _usages

class _CliWrapper(object):
    def __init__(self, obj, description, usages):
        update_wrapper(self, obj)
        if description is not None or usages is not None:
            obj.helper = _BasicHelper(description, usages)
        self.cli = obj

def cli_commands(obj, namef, clizer):
    cmds = util.OrderedDict()
        names = util.dict_from_names(obj).items()
    except AttributeError:
        raise ValueError("Cannot guess name for anonymous objects "
                         "(lists, dicts, etc)")
    func_to_names = util.OrderedDict()
    for key, val in names:
        if not key:
        names = tuple(namef(name) for name in util.maybe_iter(key))
        cli = clizer.get_cli(val)
        for name in names:
            func_to_names.setdefault(cli, []).append(name)
    cmds = util.OrderedDict((tuple(names), cli) for cli, names in func_to_names.items())
    cmd_by_name = {name: cli for names, cli in cmds.items() for name in names}
    return cmds, cmd_by_name

[docs]class Clize(object): """Wraps a function into a CLI object that accepts command-line arguments and translates them to match the wrapped function's parameters.""" @forwards_to_method('__init__', 1) def __new__(cls, fn=None, **kwargs): if fn is None: return partial(cls, **kwargs) else: return super(Clize, cls).__new__(cls) def __init__(self, fn, owner=None, alt=(), extra=(), help_names=('help', 'h'), helper_class=None, hide_help=False, description=None, ): """ :param sequence alt: Alternate actions the CLI will handle. :param help_names: Names to use to trigger the help. :type help_names: sequence of strings :param helper_class: A callable to produce a helper object to be used when the help is triggered. If unset, uses `.ClizeHelp`. :type helper_class: a type like `.ClizeHelp` :param bool hide_help: Mark the parameters used to trigger the help as undocumented. """ if description: raise TypeError( "description= is only accepted when using multiple same-level commands. " "When only one main command is given, " "you can set the description by updating the main command's docstring. See " " " "for more information." ) update_wrapper(self, fn) self.func = fn self.owner = owner self.alt = util.dict_from_names(alt) self.extra = extra self.help_names = help_names self.help_aliases = [util.name_py2cli(s, kw=True) for s in help_names] self.helper_class = helper_class self.hide_help = hide_help def __class_getitem__(cls, item): return parser.ClizeAnnotations(item) def parameters(self): """Returns the parameters used to instantiate this class, minus the wrapped callable.""" return { 'owner': self.owner, 'alt': self.alt, 'help_names': self.help_names, 'helper_class': self.helper_class, 'hide_help': self.hide_help, } def _key(self): return ( self.func, self.owner, tuple(self.alt.items()), tuple(self.extra), tuple(self.help_names), tuple(self.help_aliases), self.helper_class, self.hide_help, ) def __eq__(self, other): return type(self) is type(other) and self._key() == other._key() def __hash__(self): return hash(self._key()) @classmethod def keep(cls, fn=None, **kwargs): """Instead of wrapping the decorated callable, sets its ``cli`` attribute to a `.Clize` instance. Useful if you need to use the decorator but must still be able to call the function regularly. """ if fn is None: return partial(cls.keep, **kwargs) else: fn.cli = cls(fn, **kwargs) return fn @classmethod @kwoargs(start='description') def as_is(cls, obj=None, description=None, usages=None): """Returns a CLI object which uses the given callable with no translation. The following parameters improve the decorated object's compatibility with Clize's help output: :param description: A description for the command. :param usages: A list of usages for the command. .. seealso:: :ref:`interop` """ if obj is None: return partial(cls.as_is, description=description, usages=usages) return _CliWrapper(obj, description, usages) @classmethod def get_cli(cls, obj, **kwargs): """Makes an attempt to discover a command-line interface for the given object. .. _cli-object: The process used is as follows: 1. If the object has a ``cli`` attribute, it is used with no further transformation. 2. If the object is callable, `.Clize` or whichever object this class method is used from is used to build a CLI. ``**kwargs`` are forwarded to its initializer. 3. If the object is iterable, `.SubcommandDispatcher` is used on the object, and its `cli <.SubcommandDispatcher.cli>` method is used. Most notably, `` uses this class method in order to interpret the given object(s). """ try: return obj.cli except AttributeError: pass if callable(obj): return cls(obj, **kwargs) else: try: iter(obj) except TypeError: raise TypeError(f"Don't know how to build a cli for {obj!r}") from None return SubcommandDispatcher(obj, **kwargs).cli @property def cli(self): """Returns the object itself, in order to be selected by `.get_cli`""" return self def __repr__(self): return '<Clize for {0!r}>'.format(self.func) def __get__(self, obj, owner=None): try: func = self.func.__get__(obj, owner) except AttributeError: func = self.func if func is self.func: return self params = self.parameters() params['owner'] = obj return type(self)(func, **params) @util.property_once def helper(self): """A cli object(usually inherited from `.help.Help`) when the user requests a help message. See the constructor for ways to affect this attribute.""" if self.helper_class is None: from import ClizeHelp as class_ else: class_ = self.helper_class return class_(self, self.owner) @util.property_once def signature(self): """The `.parser.CliSignature` object used to parse arguments.""" extra = itertools.chain(self._process_alt(), self.extra) with self._move_warnings_to_func(): return parser.CliSignature.from_signature( self.func_signature, extra=extra) @util.property_once def func_signature(self): return signature(self.func) @contextlib.contextmanager def _move_warnings_to_func(self): try: code = self.func.__code__ filename = code.co_filename lineno = code.co_firstlineno module = self.func.__module__ module_globals = self.func.__globals__ except AttributeError: yield else: with warnings.catch_warnings(record=True) as caught_warnings: yield for warning in caught_warnings: registry = module_globals.setdefault("__warningregistry__", {}) warnings.warn_explicit(warning.message, warning.category, filename, lineno, module, registry, module_globals) def _process_alt(self): if self.help_names: p = parser.FallbackCommandParameter( func=self.helper.cli, undocumented=self.hide_help, aliases=self.help_aliases) yield p for name, func in self.alt.items(): func = self.get_cli(func) param = parser.AlternateCommandParameter( undocumented=False, func=func, aliases=[util.name_py2cli(name, kw=True)]) yield param def __call__(self, *args): with errors.SetUserErrorContext(cli=self, pname=args[0]): func, name, posargs, kwargs = self.read_commandline(args) return func(*posargs, **kwargs) def read_commandline(self, args): """Reads the command-line arguments from args and returns a tuple with the callable to run, the name of the program, the positional and named arguments to pass to the callable. :raises: `.ArgumentError` """ ba = self.signature.read_arguments(args[1:], args[0]) func, post, posargs, kwargs = ba name = ' '.join([args[0]] + post) return func or self.func, name, posargs, kwargs
def _dispatcher_helper(*args, **kwargs): """alias for, avoiding circular import""" from import ClizeHelp, HelpForSubcommands return ClizeHelp(*args, builder=HelpForSubcommands.from_subject, **kwargs)
[docs]class SubcommandDispatcher(object): clizer = Clize def __init__(self, commands=(), description=None, footnotes=None, **kwargs): self.cmds, self.cmds_by_name = cli_commands( commands, namef=util.name_py2cli, clizer=self.clizer) self.description = description self.footnotes = footnotes self.clize_kwargs = kwargs @annotate(name=parameters.pass_name, command=parser.Parameter.LAST_OPTION) def _cli(self, name, command, *args): try: func = self.cmds_by_name[command.lower()] except KeyError: guess = util.closest_option(command, list(self.cmds_by_name)) if guess: raise errors.ArgumentError( 'Unknown command "{0}". Did you mean "{1}"?' .format(command, guess)) raise errors.ArgumentError('Unknown command "{0}"'.format(command)) return func('{0} {1}'.format(name, command), *args) @property def cli(self): c = Clize(self._cli, helper_class=_dispatcher_helper, **self.clize_kwargs) c.owner = self return c
def _get_executable(path, *, to_path=pathlib.PurePath, which=shutil.which) -> typing.Union[None, str]: """Get the shortest invocation for a given command""" if not path: return None path = to_path(path) which_result = which( if which_result and to_path(which_result) == path: return try: rel = path.relative_to(to_path()) except ValueError: return str(path) return str(rel) def main_module_name(module): try: modname = os.path.splitext(os.path.basename(module.__file__))[0] if modname == '__main__': return module.__package__ elif not module.__package__: return modname else: return module.__package__ + '.' + modname except AttributeError: return None def _fix_argv(argv, sys_path, main_module, *, platform=sys.platform, executable=sys.executable, get_executable=_get_executable, get_main_module_name=main_module_name): """Tries to restore the given sys.argv to something closer to what the user would've typed""" if not sys_path[0]: name = get_main_module_name(main_module) if name is not None: argv = argv[:] argv[0] = f'{get_executable(executable) or "python"} -m {name}' elif platform.startswith("win"): argv = argv[:] argv[0] = f'{get_executable(executable) or "python"} {argv[0]}' else: name = get_executable(argv[0]) argv = argv[:] argv[0] = name return argv
[docs]@autokwoargs def run(args=None, catch=(), exit=True, out=None, err=None, *fn, **kwargs): """Runs a function or :ref:`CLI object<cli-object>` with ``args``, prints the return value if not None, or catches the given exception types as well as `clize.UserError` and prints their string representation, then exit with the appropriate status code. :param sequence args: The arguments to pass the CLI, for instance ``('./', 'spam', 'ham')``. If unspecified, uses `sys.argv`. :param catch: Catch these exceptions and print their string representation rather than letting Python print an uncaught exception traceback. :type catch: sequence of exception classes :param bool exit: If true, exit with the appropriate status code once the function is done. :param file out: The file in which to print the return value of the command. If unspecified, uses `sys.stdout` :param file err: The file in which to print any exception text. If unspecified, uses `sys.stderr`. """ if len(fn) == 1: fn = fn[0] cli = Clize.get_cli(fn, **kwargs) if args is None: # import __main__ causes double imports when # python2.7 -m apackage # is used module = sys.modules['__main__'] args = _fix_argv(sys.argv, sys.path, module) if out is None: out = sys.stdout if err is None: err = sys.stderr try: ret = cli(*args) except tuple(catch) + (errors.UserError,) as exc: print(str(exc), file=err) if exit: sys.exit(2 if isinstance(exc, errors.ArgumentError) else 1) else: if ret is not None: print(ret, file=out) if exit: sys.exit()