from __future__ import print_function
import sys
from collections import OrderedDict
from face.utils import unwrap_text, get_rdep_map, echo
from face.errors import ArgumentParseError, CommandLineError, UsageError
from face.parser import Parser, Flag
from face.helpers import HelpHandler
from face.middleware import (inject,
get_arg_names,
is_middleware,
face_middleware,
check_middleware,
get_middleware_chain,
_BUILTIN_PROVIDES)
from boltons.strutils import camel2under
from boltons.iterutils import unique
def _get_default_name(func):
from functools import partial
if isinstance(func, partial):
func = func.func # just one level of partial for now
# func_name on py2, __name__ on py3
ret = getattr(func, 'func_name', getattr(func, '__name__', None)) # most functions hit this
if ret is None:
ret = camel2under(func.__class__.__name__).lower() # callable instances, etc.
return ret
def _docstring_to_doc(func):
doc = func.__doc__
if not doc:
return ''
unwrapped = unwrap_text(doc)
try:
ret = [g for g in unwrapped.splitlines() if g][0]
except IndexError:
ret = ''
return ret
def default_print_error(msg):
return echo.err(msg)
DEFAULT_HELP_HANDLER = HelpHandler()
# TODO: should name really go here?
[docs]class Command(Parser):
"""The central type in the face framework. Instantiate a Command,
populate it with flags and subcommands, and then call
command.run() to execute your CLI.
Note that only the first three constructor arguments are
positional, the rest are keyword-only.
Args:
func (callable): The function called when this command is
run with an argv that contains no subcommands.
name (str): The name of this command, used when this
command is included as a subcommand. (Defaults to name
of function)
doc (str): A description or message that appears in various
help outputs.
flags (list): A list of Flag instances to initialize the
Command with. Flags can always be added later with the
.add() method.
posargs (bool): Pass True if the command takes positional
arguments. Defaults to False. Can also pass a PosArgSpec
instance.
post_posargs (bool): Pass True if the command takes
additional positional arguments after a conventional '--'
specifier.
help (bool): Pass False to disable the automatically added
--help flag. Defaults to True. Also accepts a HelpHandler
instance, see those docs for more details.
middlewares (list): A list of @face_middleware decorated
callables which participate in dispatch. Also addable
via the .add() method. See Middleware docs for more
details.
"""
def __init__(self, func, name=None, doc=None, **kwargs):
name = name if name is not None else _get_default_name(func)
if doc is None:
doc = _docstring_to_doc(func)
# TODO: default posargs if none by inspecting func
super(Command, self).__init__(name, doc,
flags=kwargs.pop('flags', None),
posargs=kwargs.pop('posargs', None),
post_posargs=kwargs.pop('post_posargs', None),
flagfile=kwargs.pop('flagfile', True))
_help = kwargs.pop('help', DEFAULT_HELP_HANDLER)
self.help_handler = _help
# TODO: if func is callable, check that "next_" isn't taken
self._path_func_map = OrderedDict()
self._path_func_map[()] = func
middlewares = list(kwargs.pop('middlewares', None) or [])
self._path_mw_map = OrderedDict()
self._path_mw_map[()] = []
self._path_wrapped_map = OrderedDict()
self._path_wrapped_map[()] = func
for mw in middlewares:
self.add_middleware(mw)
if kwargs:
raise TypeError('unexpected keyword arguments: %r' % sorted(kwargs.keys()))
if _help:
if _help.flag:
self.add(_help.flag)
if _help.subcmd:
self.add(_help.func, _help.subcmd) # for 'help' as a subcmd
if not func and not _help:
raise ValueError('Command requires a handler function or help handler'
' to be set, not: %r' % func)
return
@property
def func(self):
return self._path_func_map[()]
[docs] def add(self, *a, **kw):
"""Add a flag, subcommand, or middleware to this Command.
If the first argument is a callable, this method contructs a
Command from it and the remaining arguments, all of which are
optional. See the Command docs for for full details on names
and defaults.
If the first argument is a string, this method constructs a
Flag from that flag string and the rest of the method
arguments, all of which are optional. See the Flag docs for
more options.
If the argument is already an instance of Flag or Command, an
exception is only raised on conflicting subcommands and
flags. See add_command for details.
Middleware is only added if it is already decorated with
@face_middleware. Use .add_middleware() for automatic wrapping
of callables.
"""
# TODO: need to check for middleware provides names + flag names
# conflict
target = a[0]
if is_middleware(target):
return self.add_middleware(target)
subcmd = a[0]
if not isinstance(subcmd, Command) and callable(subcmd) or subcmd is None:
subcmd = Command(*a, **kw) # attempt to construct a new subcmd
if isinstance(subcmd, Command):
self.add_command(subcmd)
return subcmd
flag = a[0]
if not isinstance(flag, Flag):
flag = Flag(*a, **kw) # attempt to construct a Flag from arguments
super(Command, self).add(flag)
return flag
[docs] def add_command(self, subcmd):
"""Add a Command, and all of its subcommands, as a subcommand of this
Command.
Middleware from the current command is layered on top of the
subcommand's. An exception may be raised if there are
conflicting middlewares or subcommand names.
"""
if not isinstance(subcmd, Command):
raise TypeError('expected Command instance, not: %r' % subcmd)
self_mw = self._path_mw_map[()]
super(Command, self).add(subcmd)
# map in new functions
for path in self.subprs_map:
if path not in self._path_func_map:
self._path_func_map[path] = subcmd._path_func_map[path[1:]]
sub_mw = subcmd._path_mw_map[path[1:]]
self._path_mw_map[path] = self_mw + sub_mw # TODO: check for conflicts
return
[docs] def add_middleware(self, mw):
"""Add a single middleware to this command. Outermost middleware
should be added first. Remember: first added, first called.
"""
if not is_middleware(mw):
mw = face_middleware(mw)
check_middleware(mw)
for flag in mw._face_flags:
self.add(flag)
for path, mws in self._path_mw_map.items():
self._path_mw_map[path] = [mw] + mws # TODO: check for conflicts
return
# TODO: add_flag()
[docs] def get_flag_map(self, path=(), with_hidden=True):
"""Command's get_flag_map differs from Parser's in that it filters
the flag map to just the flags used by the endpoint at the
associated subcommand *path*.
"""
flag_map = super(Command, self).get_flag_map(path=path, with_hidden=with_hidden)
dep_names = self.get_dep_names(path)
if 'args_' in dep_names or 'flags_' in dep_names:
# the argument parse result and flag dict both capture
# _all_ the flags, so for functions accepting these
# arguments we bypass filtering.
# Also note that by setting an argument default in the
# function definition, the dependency becomes "weak", and
# this bypassing of filtering will not trigger, unless
# another function in the chain has a non-default,
# "strong" dependency. This behavior is especially useful
# for middleware.
# TODO: add decorator for the corner case where a function
# accepts these arguments and doesn't use them all.
return OrderedDict(flag_map)
return OrderedDict([(k, f) for k, f in flag_map.items() if f.name in dep_names
or f is self.flagfile_flag or f is self.help_handler.flag])
[docs] def get_dep_names(self, path=()):
"""Get a list of the names of all required arguments of a command (and
any associated middleware).
By specifying *path*, the same can be done for any subcommand.
"""
func = self._path_func_map[path]
if not func:
return [] # for when no handler is specified
mws = self._path_mw_map[path]
# start out with all args of handler function, which gets stronger dependencies
required_args = set(get_arg_names(func, only_required=False))
dep_map = {func: set(required_args)}
for mw in mws:
arg_names = set(get_arg_names(mw, only_required=True))
for provide in mw._face_provides:
dep_map[provide] = arg_names
if not mw._face_optional:
# all non-optional middlewares get their args required, too.
required_args.update(arg_names)
rdep_map = get_rdep_map(dep_map)
recursive_required_args = rdep_map[func].union(required_args)
return sorted(recursive_required_args)
[docs] def prepare(self, paths=None):
"""Compile and validate one or more subcommands to ensure all
dependencies are met. Call this once all flags, subcommands,
and middlewares have been added (using .add()).
This method is automatically called by .run() method, but it
only does so for the specific subcommand being invoked. More
conscientious users may want to call this method with no
arguments to validate that all subcommands are ready for
execution.
"""
# TODO: also pre-execute help formatting to make sure all
# values are sane there, too
if paths is None:
paths = self._path_func_map.keys()
for path in paths:
func = self._path_func_map[path]
if func is None:
continue # handled by run()
prs = self.subprs_map[path] if path else self
provides = []
if prs.posargs.provides:
provides += [prs.posargs.provides]
if prs.post_posargs.provides:
provides += [prs.post_posargs.provides]
deps = self.get_dep_names(path)
flag_names = [f.name for f in self.get_flags(path=path)]
all_mws = self._path_mw_map[path]
# filter out unused middlewares
mws = [mw for mw in all_mws if not mw._face_optional
or [p for p in mw._face_provides if p in deps]]
provides += _BUILTIN_PROVIDES + flag_names
try:
wrapped = get_middleware_chain(mws, func, provides)
except NameError as ne:
ne.args = (ne.args[0] + ' (in path: %r)' % (path,),)
raise
self._path_wrapped_map[path] = wrapped
return
[docs] def run(self, argv=None, extras=None, print_error=None):
"""Parses arguments and dispatches to the appropriate subcommand
handler. If there is a parse error due to invalid user input,
an error is printed and a CommandLineError is raised. If not
caught, a CommandLineError will exit the process, typically
with status code 1. Also handles dispatching to the
appropriate HelpHandler, if configured.
Defaults to handling the arguments on the command line
(``sys.argv``), but can also be explicitly passed arguments
via the *argv* parameter.
Args:
argv (list): A sequence of strings representing the
command-line arguments. Defaults to ``sys.argv``.
extras (dict): A map of additional arguments to be made
available to the subcommand's handler function.
print_error (callable): The function that formats/prints
error messages before program exit on CLI errors.
.. note::
For efficiency, :meth:`run()` only checks the subcommand
invoked by *argv*. To ensure that all subcommands are
configured properly, call :meth:`prepare()`.
"""
if print_error is None or print_error is True:
print_error = default_print_error
elif print_error and not callable(print_error):
raise TypeError('expected callable for print_error, not %r'
% print_error)
kwargs = dict(extras) if extras else {}
kwargs['print_error_'] = print_error # TODO: print_error_ in builtin provides?
try:
prs_res = self.parse(argv=argv)
except ArgumentParseError as ape:
prs_res = ape.prs_res
# even if parsing failed, check if the caller was trying to access the help flag
cmd = prs_res.to_cmd_scope()['subcommand_']
if cmd.help_handler and prs_res.flags and prs_res.flags.get(cmd.help_handler.flag.name):
kwargs.update(prs_res.to_cmd_scope())
return inject(cmd.help_handler.func, kwargs)
msg = 'error: ' + (prs_res.name or self.name)
if prs_res.subcmds:
msg += ' ' + ' '.join(prs_res.subcmds or ())
# args attribute, nothing to do with cmdline args this is
# the standard-issue Exception
e_msg = ape.args[0]
if e_msg:
msg += ': ' + e_msg
cle = CommandLineError(msg)
if print_error:
print_error(msg)
raise cle
kwargs.update(prs_res.to_cmd_scope())
# default in case no middlewares have been installed
func = self._path_func_map[prs_res.subcmds]
cmd = kwargs['subcommand_']
if cmd.help_handler and (not func or (prs_res.flags and prs_res.flags.get(cmd.help_handler.flag.name))):
return inject(cmd.help_handler.func, kwargs)
elif not func: # pragma: no cover
raise RuntimeError('expected command handler or help handler to be set')
self.prepare(paths=[prs_res.subcmds])
wrapped = self._path_wrapped_map.get(prs_res.subcmds, func)
try:
ret = inject(wrapped, kwargs)
except UsageError as ue:
if print_error:
print_error(ue.format_message())
raise
return ret