# coding: utf-8 # # Copyright © 2010—2014 Andrey Mikhaylenko and contributors # # This file is part of Argh. # # Argh is free software under terms of the GNU Lesser # General Public License version 3 (LGPLv3) as published by the Free # Software Foundation. See the file README.rst for copying conditions. # """ Assembling ~~~~~~~~~~ Functions and classes to properly assemble your commands in a parser. """ import argparse import sys import warnings from argh.completion import COMPLETION_ENABLED from argh.compat import OrderedDict from argh.constants import ( ATTR_ALIASES, ATTR_ARGS, ATTR_NAME, ATTR_EXPECTS_NAMESPACE_OBJECT, PARSER_FORMATTER, DEFAULT_ARGUMENT_TEMPLATE, DEST_FUNCTION, ) from argh.utils import get_subparsers, get_arg_spec from argh.exceptions import AssemblingError __all__ = [ 'SUPPORTS_ALIASES', 'set_default_command', 'add_commands', 'add_subcommands', ] def _check_support_aliases(): p = argparse.ArgumentParser() s = p.add_subparsers() try: s.add_parser('x', aliases=[]) except TypeError: return False else: return True SUPPORTS_ALIASES = _check_support_aliases() """ Calculated on load. If `True`, current version of argparse supports alternative command names (can be set via :func:`~argh.decorators.aliases`). """ def _get_args_from_signature(function): if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False): return spec = get_arg_spec(function) defaults = dict(zip(*[reversed(x) for x in (spec.args, spec.defaults or [])])) defaults.update(getattr(spec, 'kwonlydefaults', None) or {}) kwonly = getattr(spec, 'kwonlyargs', []) if sys.version_info < (3,0): annotations = {} else: annotations = dict((k,v) for k,v in function.__annotations__.items() if isinstance(v, str)) # define the list of conflicting option strings # (short forms, i.e. single-character ones) chars = [a[0] for a in spec.args + kwonly] char_counts = dict((char, chars.count(char)) for char in set(chars)) conflicting_opts = tuple(char for char in char_counts if 1 < char_counts[char]) for name in spec.args + kwonly: flags = [] # name_or_flags akwargs = {} # keyword arguments for add_argument() if name in annotations: # help message: func(a : "b") -> add_argument("a", help="b") akwargs.update(help=annotations.get(name)) if name in defaults or name in kwonly: if name in defaults: akwargs.update(default=defaults.get(name)) else: akwargs.update(required=True) flags = ('-{0}'.format(name[0]), '--{0}'.format(name)) if name.startswith(conflicting_opts): # remove short name flags = flags[1:] else: # positional argument flags = (name,) # cmd(foo_bar) -> add_argument('foo-bar') flags = tuple(x.replace('_', '-') for x in flags) yield dict(option_strings=flags, **akwargs) if spec.varargs: # *args yield dict(option_strings=[spec.varargs], nargs='*') def _guess(kwargs): """ Adds types, actions, etc. to given argument specification. For example, ``default=3`` implies ``type=int``. :param arg: a :class:`argh.utils.Arg` instance """ guessed = {} # Parser actions that accept argument 'type' TYPE_AWARE_ACTIONS = 'store', 'append' # guess type/action from default value value = kwargs.get('default') if value is not None: if isinstance(value, bool): if kwargs.get('action') is None: # infer action from default value guessed['action'] = 'store_false' if value else 'store_true' elif kwargs.get('type') is None: # infer type from default value # (make sure that action handler supports this keyword) if kwargs.get('action', 'store') in TYPE_AWARE_ACTIONS: guessed['type'] = type(value) # guess type from choices (first item) if kwargs.get('choices') and 'type' not in list(guessed) + list(kwargs): guessed['type'] = type(kwargs['choices'][0]) return dict(kwargs, **guessed) def _is_positional(args, prefix_chars='-'): assert args if 1 < len(args) or args[0][0].startswith(tuple(prefix_chars)): return False else: return True def _get_parser_param_kwargs(parser, argspec): argspec = argspec.copy() # parser methods modify source data args = argspec['option_strings'] if _is_positional(args, prefix_chars=parser.prefix_chars): get_kwargs = parser._get_positional_kwargs else: get_kwargs = parser._get_optional_kwargs kwargs = get_kwargs(*args, **argspec) kwargs['dest'] = kwargs['dest'].replace('-', '_') return kwargs def _get_dest(parser, argspec): kwargs = _get_parser_param_kwargs(parser, argspec) return kwargs['dest'] def _require_support_for_default_command_with_subparsers(): if sys.version_info < (3,4): raise AssemblingError( 'Argparse library bundled with this version of Python ' 'does not support combining a default command with nested ones.') def set_default_command(parser, function): """ Sets default command (i.e. a function) for given parser. If `parser.description` is empty and the function has a docstring, it is used as the description. .. note:: An attempt to set default command to a parser which already has subparsers (e.g. added with :func:`~argh.assembling.add_commands`) results in a `AssemblingError`. .. note:: If there are both explicitly declared arguments (e.g. via :func:`~argh.decorators.arg`) and ones inferred from the function signature (e.g. via :func:`~argh.decorators.command`), declared ones will be merged into inferred ones. If an argument does not conform function signature, `AssemblingError` is raised. .. note:: If the parser was created with ``add_help=True`` (which is by default), option name ``-h`` is silently removed from any argument. """ if parser._subparsers: _require_support_for_default_command_with_subparsers() spec = get_arg_spec(function) declared_args = getattr(function, ATTR_ARGS, []) inferred_args = list(_get_args_from_signature(function)) if inferred_args and declared_args: # We've got a mixture of declared and inferred arguments # a mapping of "dest" strings to argument declarations. # # * a "dest" string is a normalized form of argument name, i.e.: # # '-f', '--foo' → 'foo' # 'foo-bar' → 'foo_bar' # # * argument declaration is a dictionary representing an argument; # it is obtained either from _get_args_from_signature() or from # an @arg decorator (as is). # dests = OrderedDict() for argspec in inferred_args: dest = _get_parser_param_kwargs(parser, argspec)['dest'] dests[dest] = argspec for declared_kw in declared_args: # an argument is declared via decorator dest = _get_dest(parser, declared_kw) if dest in dests: # the argument is already known from function signature # # now make sure that this declared arg conforms to the function # signature and therefore only refines an inferred arg: # # @arg('my-foo') maps to func(my_foo) # @arg('--my-bar') maps to func(my_bar=...) # either both arguments are positional or both are optional decl_positional = _is_positional(declared_kw['option_strings']) infr_positional = _is_positional(dests[dest]['option_strings']) if decl_positional != infr_positional: kinds = {True: 'positional', False: 'optional'} raise AssemblingError( '{func}: argument "{dest}" declared as {kind_i} ' '(in function signature) and {kind_d} (via decorator)' .format( func=function.__name__, dest=dest, kind_i=kinds[infr_positional], kind_d=kinds[decl_positional], )) # merge explicit argument declaration into the inferred one # (e.g. `help=...`) dests[dest].update(**declared_kw) else: # the argument is not in function signature varkw = getattr(spec, 'varkw', getattr(spec, 'keywords', [])) if varkw: # function accepts **kwargs; the argument goes into it dests[dest] = declared_kw else: # there's no way we can map the argument declaration # to function signature xs = (dests[x]['option_strings'] for x in dests) raise AssemblingError( '{func}: argument {flags} does not fit ' 'function signature: {sig}'.format( flags=', '.join(declared_kw['option_strings']), func=function.__name__, sig=', '.join('/'.join(x) for x in xs))) # pack the modified data back into a list inferred_args = dests.values() command_args = inferred_args or declared_args # add types, actions, etc. (e.g. default=3 implies type=int) command_args = [_guess(x) for x in command_args] for draft in command_args: draft = draft.copy() if 'help' not in draft: draft.update(help=DEFAULT_ARGUMENT_TEMPLATE) dest_or_opt_strings = draft.pop('option_strings') if parser.add_help and '-h' in dest_or_opt_strings: dest_or_opt_strings = [x for x in dest_or_opt_strings if x != '-h'] completer = draft.pop('completer', None) try: action = parser.add_argument(*dest_or_opt_strings, **draft) if COMPLETION_ENABLED and completer: action.completer = completer except Exception as e: raise type(e)('{func}: cannot add arg {args}: {msg}'.format( args='/'.join(dest_or_opt_strings), func=function.__name__, msg=e)) if function.__doc__ and not parser.description: parser.description = function.__doc__ parser.set_defaults(**{ DEST_FUNCTION: function, }) def add_commands(parser, functions, namespace=None, namespace_kwargs=None, func_kwargs=None, # deprecated args: title=None, description=None, help=None): """ Adds given functions as commands to given parser. :param parser: an :class:`argparse.ArgumentParser` instance. :param functions: a list of functions. A subparser is created for each of them. If the function is decorated with :func:`~argh.decorators.arg`, the arguments are passed to :class:`argparse.ArgumentParser.add_argument`. See also :func:`~argh.dispatching.dispatch` for requirements concerning function signatures. The command name is inferred from the function name. Note that the underscores in the name are replaced with hyphens, i.e. function name "foo_bar" becomes command name "foo-bar". :param namespace: an optional string representing the group of commands. For example, if a command named "hello" is added without the namespace, it will be available as "prog.py hello"; if the namespace if specified as "greet", then the command will be accessible as "prog.py greet hello". The namespace itself is not callable, so "prog.py greet" will fail and only display a help message. :param func_kwargs: a `dict` of keyword arguments to be passed to each nested ArgumentParser instance created per command (i.e. per function). Members of this dictionary have the highest priority, so a function's docstring is overridden by a `help` in `func_kwargs` (if present). :param namespace_kwargs: a `dict` of keyword arguments to be passed to the nested ArgumentParser instance under given `namespace`. Deprecated params that should be moved into `namespace_kwargs`: :param title: passed to :meth:`argparse.ArgumentParser.add_subparsers` as `title`. .. deprecated:: 0.26.0 Please use `namespace_kwargs` instead. :param description: passed to :meth:`argparse.ArgumentParser.add_subparsers` as `description`. .. deprecated:: 0.26.0 Please use `namespace_kwargs` instead. :param help: passed to :meth:`argparse.ArgumentParser.add_subparsers` as `help`. .. deprecated:: 0.26.0 Please use `namespace_kwargs` instead. .. note:: This function modifies the parser object. Generally side effects are bad practice but we don't seem to have any choice as ArgumentParser is pretty opaque. You may prefer :class:`~argh.helpers.ArghParser.add_commands` for a bit more predictable API. .. note:: An attempt to add commands to a parser which already has a default function (e.g. added with :func:`~argh.assembling.set_default_command`) results in `AssemblingError`. """ # FIXME "namespace" is a correct name but it clashes with the "namespace" # that represents arguments (argparse.Namespace and our ArghNamespace). # We should rename the argument here. if DEST_FUNCTION in parser._defaults: _require_support_for_default_command_with_subparsers() namespace_kwargs = namespace_kwargs or {} # FIXME remove this by 1.0 # if title: warnings.warn('argument `title` is deprecated in add_commands(),' ' use `parser_kwargs` instead', DeprecationWarning) namespace_kwargs['description'] = title if help: warnings.warn('argument `help` is deprecated in add_commands(),' ' use `parser_kwargs` instead', DeprecationWarning) namespace_kwargs['help'] = help if description: warnings.warn('argument `description` is deprecated in add_commands(),' ' use `parser_kwargs` instead', DeprecationWarning) namespace_kwargs['description'] = description # # / subparsers_action = get_subparsers(parser, create=True) if namespace: # Make a nested parser and init a deeper _SubParsersAction under it. # Create a named group of commands. It will be listed along with # root-level commands in ``app.py --help``; in that context its `title` # can be used as a short description on the right side of its name. # Normally `title` is shown above the list of commands # in ``app.py my-namespace --help``. subsubparser_kw = { 'help': namespace_kwargs.get('title'), } subsubparser = subparsers_action.add_parser(namespace, **subsubparser_kw) subparsers_action = subsubparser.add_subparsers(**namespace_kwargs) else: assert not namespace_kwargs, ('`parser_kwargs` only makes sense ' 'with `namespace`.') for func in functions: cmd_name, func_parser_kwargs = _extract_command_meta_from_func(func) # override any computed kwargs by manually supplied ones if func_kwargs: func_parser_kwargs.update(func_kwargs) # create and set up the parser for this command command_parser = subparsers_action.add_parser(cmd_name, **func_parser_kwargs) set_default_command(command_parser, func) def _extract_command_meta_from_func(func): # use explicitly defined name; if none, use function name (a_b → a-b) cmd_name = getattr(func, ATTR_NAME, func.__name__.replace('_','-')) func_parser_kwargs = { # add command help from function's docstring 'help': func.__doc__, # set default formatter 'formatter_class': PARSER_FORMATTER, } # try adding aliases for command name if SUPPORTS_ALIASES: func_parser_kwargs['aliases'] = getattr(func, ATTR_ALIASES, []) return cmd_name, func_parser_kwargs def add_subcommands(parser, namespace, functions, **namespace_kwargs): """ A wrapper for :func:`add_commands`. These examples are equivalent:: add_commands(parser, [get, put], namespace='db', namespace_kwargs={ 'title': 'database commands', 'help': 'CRUD for our silly database' }) add_subcommands(parser, 'db', [get, put], title='database commands', help='CRUD for our silly database') """ add_commands(parser, functions, namespace=namespace, namespace_kwargs=namespace_kwargs)