# clize -- A command-line argument parser for Python
# Copyright (C) 2011-2015 by Yann Kaiser <kaiser.yann@gmail.com>
# See COPYING for details.
"""
interpret function signatures and read commandline arguments
"""
import itertools
from functools import partial
import six
from sigtools import modifiers
from clize import errors, util
class ParameterFlag(object):
def __init__(self, name, prefix='clize.Parameter'):
self.name = name
self.prefix = prefix
def __repr__(self):
return '{0.prefix}.{0.name}'.format(self)
[docs]class Parameter(object):
"""Represents a CLI parameter.
:param str display_name: The 'default' representation of the parameter.
:param bool undocumented:
If true, hides the parameter from the command help.
:param last_option:
"""
required = False
"""Is this parameter required?"""
extras = ()
"""Iterable of extra parameters this parameter incurs"""
def __init__(self, display_name, undocumented=False, last_option=None):
self.display_name = display_name
self.undocumented = undocumented
self.last_option = last_option
R = REQUIRED = ParameterFlag('REQUIRED')
"""Annotate a parameter with this to force it to be required.
Mostly only useful for ``*args`` parameters. In other cases simply don't
provide a default value."""
L = LAST_OPTION = ParameterFlag('LAST_OPTION')
"""Annotate a parameter with this and all following arguments will be
processed as positional."""
I = IGNORE = ParameterFlag('IGNORE')
U = UNDOCUMENTED = ParameterFlag('UNDOCUMENTED')
"""Parameters annotated with this will be omitted from the
documentation."""
# M = MULTIPLE = ParameterFlag('MULTIPLE')
[docs] def read_argument(self, ba, i):
"""Reads one or more arguments from ``ba.in_args`` from position ``i``.
:param clize.parser.CliBoundArguments ba:
The bound arguments object this call is expected to mutate.
:param int i:
The current position in ``ba.args``.
"""
raise NotImplementedError
[docs] def apply_generic_flags(self, ba):
"""Called after `read_argument` in order to set attributes on ``ba``
independently of the arguments.
:param clize.parser.CliBoundArguments ba:
The bound arguments object this call is expected to mutate.
"""
if self.last_option:
ba.posarg_only = True
ba.unsatisfied.discard(self)
def unsatisfied(self, ba):
return True
@util.property_once
[docs] def full_name(self):
return self.display_name + self.format_type()
def __str__(self):
return self.display_name
def show_help(self, desc, after, f, cols):
return self.full_name, getattr(self, 'description', None) or desc
[docs]class ParameterWithSourceEquivalent(Parameter):
"""Parameter that relates to a function parameter in the source.
:param str argument_name: The name of the parameter.
"""
def __init__(self, argument_name, **kwargs):
super(ParameterWithSourceEquivalent, self).__init__(**kwargs)
self.argument_name = argument_name
[docs]class ParameterWithValue(Parameter):
"""A parameter that stores a value, with possible default and/or
conversion.
:param callable typ: A callable to convert the value or raise `ValueError`.
Defaults to `.util.identity`.
:param default: A default value for the parameter or `.util.UNSET`.
"""
def __init__(self, typ=util.identity, default=util.UNSET, **kwargs):
super(ParameterWithValue, self).__init__(**kwargs)
self.typ = typ
self.default = default
@property
def required(self):
"""Tells if the parameter has no default value."""
return self.default is util.UNSET
[docs] def coerce_value(self, arg):
"""Coerces ``arg`` using the ``typ`` function. Raises
`.errors.BadArgumentFormat` if the coercion function raises
`ValueError`.
"""
try:
return self.typ(arg)
except ValueError as e:
exc = errors.BadArgumentFormat(self.typ, arg)
exc.__cause__ = e
raise exc
@util.property_once
[docs] def full_name(self):
return self.display_name + self.format_type()
def __str__(self):
if self.required:
return self.full_name
else:
return '[{0}]'.format(self.full_name)
[docs]class PositionalParameter(ParameterWithValue, ParameterWithSourceEquivalent):
"""Equivalent of a positional-only parameter in python."""
[docs] def read_argument(self, ba, i):
val = self.coerce_value(ba.in_args[i])
ba.args.append(val)
[docs]class NamedParameter(Parameter):
"""Equivalent of a keyword-only parameter in python.
:param aliases: The arguments that trigger this parameter. The first alias
is used to refer to the parameter.
:type aliases: sequence of strings
"""
def __init__(self, aliases, **kwargs):
kwargs.setdefault('display_name', aliases[0])
super(NamedParameter, self).__init__(**kwargs)
self.aliases = aliases
__key_count = itertools.count()
@classmethod
[docs] def alias_key(cls, name):
"""Key function to sort aliases in source order, but with short
forms(one dash) first."""
return len(name) - len(name.lstrip('-')), next(cls.__key_count)
@util.property_once
[docs] def full_name(self):
return ', '.join(sorted(self.aliases, key=self.alias_key)
) + self.format_type()
def __str__(self):
return '[{0}]'.format(self.display_name)
[docs] def redispatch_short_arg(self, rest, ba, i):
"""Processes the rest of an argument as if it was a new one prefixed
with one dash."""
if not rest:
return
try:
nparam = ba.sig.aliases['-' + rest[0]]
except KeyError as e:
raise errors.UnknownOption(e.args[0])
orig_args = ba.in_args
ba.in_args = ba.in_args[:i] + ('-' + rest,) + ba.in_args[i + 1:]
try:
nparam.read_argument(ba, i)
finally:
ba.in_args = orig_args
ba.unsatisfied.discard(nparam)
[docs] def get_value(self, ba, i):
arg = ba.in_args[i]
if arg.startswith('--'):
name, glued, val = arg.partition('=')
else:
arg = arg.lstrip('-')
if len(arg) > 1:
glued = True
val = arg[1:]
else:
glued = False
if not glued:
try:
val = ba.in_args[i+1]
except IndexError:
raise errors.MissingValue
ba.skip = not glued
return val
[docs]class FlagParameter(NamedParameter, ParameterWithSourceEquivalent):
"""A named parameter that takes no argument.
:param value: The value when the argument is present.
:param false_value: The value when the argument is given one of the
false value triggers using ``--param=xyz``.
"""
false_triggers = '0', 'n', 'no', 'f', 'false'
def __init__(self, value, false_value, **kwargs):
super(FlagParameter, self).__init__(**kwargs)
self.value = value
self.false_value = false_value
[docs] def read_argument(self, ba, i):
arg = ba.in_args[i]
if arg[1] == '-':
ba.kwargs[self.argument_name] = (
self.value if self.is_flag_activation(arg)
else self.false_value
)
else:
ba.kwargs[self.argument_name] = self.value
self.redispatch_short_arg(arg[2:], ba, i)
[docs] def is_flag_activation(self, arg):
"""Checks if an argument triggers the true or false value."""
if arg[1] != '-':
return True
arg, sep, val = arg.partition('=')
return (
not sep or
val and val.lower() not in self.false_triggers
)
[docs]class OptionParameter(NamedParameter, ParameterWithValue,
ParameterWithSourceEquivalent):
"""A named parameter that takes an argument."""
[docs] def read_argument(self, ba, i):
if self.argument_name in ba.kwargs:
raise errors.DuplicateNamedArgument()
val = self.get_value(ba, i)
ba.kwargs[self.argument_name] = self.coerce_value(val)
def __str__(self):
if self.required:
fmt = '{0}{1}'
else:
fmt = '[{0}{1}]'
return fmt.format(self.display_name, self.format_type())
def split_int_rest(s):
for i, c, in enumerate(s):
if not c.isdigit():
return s[:i], s[i:]
[docs]class IntOptionParameter(OptionParameter):
"""A named parameter that takes an integer as argument. The short form
of it can be chained with the short form of other named parameters."""
[docs] def read_argument(self, ba, i):
if self.argument_name in ba.kwargs:
raise errors.DuplicateNamedArgument()
arg = ba.in_args[i]
if arg.startswith('--'):
super(IntOptionParameter, self).read_argument(ba, i)
return
arg = arg.lstrip('-')[1:]
if not arg:
super(IntOptionParameter, self).read_argument(ba, i)
return
val, rest = split_int_rest(arg)
ba.kwargs[self.argument_name] = self.coerce_value(val)
self.redispatch_short_arg(rest, ba, i)
[docs]class MultiParameter(ParameterWithValue):
"""Parameter that can collect multiple values."""
[docs] def get_collection(self, ba):
"""Return an object that new values will be appended to."""
raise NotImplementedError
[docs] def read_argument(self, ba, i):
val = self.coerce_value(ba.in_args[i])
self.get_collection(ba).append(val)
[docs]class EatAllPositionalParameter(MultiParameter):
"""Helper parameter that collects multiple values to be passed as
positional arguments to the callee."""
[docs] def get_collection(self, ba):
return ba.args
[docs]class EatAllOptionParameterArguments(EatAllPositionalParameter):
"""Helper parameter for .EatAllOptionParameter that adds the remaining
arguments as positional arguments for the function."""
def __init__(self, param, **kwargs):
super(EatAllOptionParameterArguments, self).__init__(
display_name='...', undocumented=False, **kwargs)
self.param = param
[docs]class IgnoreAllOptionParameterArguments(EatAllOptionParameterArguments):
"""Helper parameter for .EatAllOptionParameter that ignores the remaining
arguments."""
[docs] def read_argument(self, ba, i):
pass
[docs]class EatAllOptionParameter(NamedParameter):
"""Parameter that collects all remaining arguments as positional
arguments, even those which look like named arguments."""
extra_type = EatAllOptionParameterArguments
def __init__(self, **kwargs):
super(EatAllOptionParameter, self).__init__(**kwargs)
self.args_param = self.extra_type(self)
[docs] def read_argument(self, ba, i):
ba.post_name.append(ba.in_args[i])
ba.posarg_only = True
ba.sticky = self.args_param
[docs]class FallbackCommandParameter(EatAllOptionParameter):
"""Parameter that sets an alternative function when triggered. When used
as an argument other than the first all arguments are discarded."""
def __init__(self, func, **kwargs):
super(FallbackCommandParameter, self).__init__(**kwargs)
self.func = func
self.ignore_all = IgnoreAllOptionParameterArguments(self)
@util.property_once
[docs] def description(self):
try:
return self.func.helper.description
except AttributeError:
pass
[docs] def get_collection(self, ba):
return []
[docs] def read_argument(self, ba, i):
ba.args[:] = []
ba.kwargs.clear()
super(FallbackCommandParameter, self).read_argument(ba, i)
ba.func = self.func
if i:
ba.sticky = self.ignore_all
[docs]class AlternateCommandParameter(FallbackCommandParameter):
"""Parameter that sets an alternative function when triggered. When used
as an argument other than the first all arguments are discarded."""
[docs] def read_argument(self, ba, i):
if i:
raise errors.ArgsBeforeAlternateCommand(self)
return super(AlternateCommandParameter, self).read_argument(ba, i)
def parameter_converter(obj):
obj._clize__parameter_converter = True
return obj
def is_parameter_converter(obj):
return getattr(obj, '_clize__parameter_converter', False)
def unimplemented_parameter(argument_name, **kwargs):
raise ValueError(
"This converter cannot convert parameter {0!r},".format(argument_name)
)
@modifiers.autokwoargs
def use_class(
pos=unimplemented_parameter, varargs=unimplemented_parameter,
named=unimplemented_parameter, varkwargs=unimplemented_parameter,
kwargs={}):
return parameter_converter(
partial(_use_class, pos, varargs, named, varkwargs, kwargs))
@modifiers.autokwoargs
def use_mixin(cls, kwargs={}):
class _PosWithMixin(cls, PositionalParameter): pass
class _VarargsWithMixin(cls, ExtraPosArgsParameter): pass
class _NamedWithMixin(cls, OptionParameter): pass
return use_class(pos=_PosWithMixin, varargs=_VarargsWithMixin,
named=_NamedWithMixin,
kwargs=kwargs)
def _use_class(pos_cls, varargs_cls, named_cls, varkwargs_cls, kwargs,
param, annotations):
named = param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD)
aliases = [param.name]
default = util.UNSET
typ = util.identity
kwargs = dict(
kwargs,
argument_name=param.name,
undocumented=Parameter.UNDOCUMENTED in annotations,
)
if param.default is not param.empty:
if Parameter.REQUIRED not in annotations:
default = param.default
if default is not None:
typ = type(param.default)
if Parameter.REQUIRED in annotations:
kwargs['required'] = True
if Parameter.LAST_OPTION in annotations:
kwargs['last_option'] = True
set_coerce = False
for thing in annotations:
if isinstance(thing, Parameter):
return thing
elif callable(thing):
if is_parameter_converter(thing):
raise ValueError(
"A custom parameter converter must be the first element "
"of a parameter's annotation")
if set_coerce:
raise ValueError(
"Coercion function specified twice in annotation: "
"{0.__name__} {1.__name__}".format(typ, thing))
typ = thing
set_coerce = True
elif isinstance(thing, six.string_types):
if not named:
raise ValueError("Cannot give aliases for a positional "
"parameter.")
if len(thing.split()) > 1:
raise ValueError("Cannot have whitespace in aliases.")
if thing in aliases:
raise ValueError("Duplicate alias " + repr(thing))
aliases.append(thing)
elif isinstance(thing, ParameterFlag):
pass
else:
raise ValueError(thing)
kwargs['default'] = default
kwargs['typ'] = typ
if named:
kwargs['aliases'] = [
util.name_py2cli(alias, named)
for alias in aliases]
if param.kind == param.VAR_KEYWORD:
return varkwargs_cls(**kwargs)
return named_cls(**kwargs)
else:
kwargs['display_name'] = util.name_py2cli(param.name)
if param.kind == param.VAR_POSITIONAL:
return varargs_cls(**kwargs)
return pos_cls(**kwargs)
def pos_parameter(required=False, **kwargs):
return PositionalParameter(**kwargs)
def named_parameter(**kwargs):
if kwargs['default'] is False and kwargs['typ'] is bool:
del kwargs['default'], kwargs['typ']
return FlagParameter(value=True, false_value=False, **kwargs)
elif kwargs['typ'] is int:
return IntOptionParameter(**kwargs)
else:
return OptionParameter(**kwargs)
default_converter = use_class(
pos=pos_parameter, varargs=ExtraPosArgsParameter,
named=named_parameter,
)
def _develop_extras(params):
for param in params:
yield param
for subparam in _develop_extras(param.extras):
yield subparam
[docs]class CliSignature(object):
"""A collection of parameters that can be used to translate CLI arguments
to function arguments.
:param iterable parameters: The parameters to use.
.. attribute:: positional
List of positional parameters.
.. attribute:: alternate
List of parameters that initiate an alternate action.
.. attribute:: named
List of named parameters that aren't in `.alternate`.
.. attribute:: aliases
:annotation: = {}
Maps parameter names to `Parameter` instances.
.. attribute:: required
:annotation: = set()
A set of all required parameters.
"""
converter = default_converter
def __init__(self, parameters):
params = self.parameters = {}
pos = self.positional = []
named = self.named = []
alt = self.alternate = []
aliases = self.aliases = {}
required = self.required = set()
for param in _develop_extras(parameters):
required_ = getattr(param, 'required', False)
func = getattr(param, 'func', None)
aliases_ = getattr(param, 'aliases', None)
if required_:
required.add(param)
if aliases_ is not None:
for alias in aliases_:
existing = aliases.get(alias)
if existing is not None:
raise ValueError(
"Parameters {0.display_name} and {1.display_name} "
"use a duplicate alias {2!r}."
.format(existing, param, alias)
)
aliases[alias] = param
if func:
alt.append(param)
elif aliases_ is not None:
named.append(param)
else:
pos.append(param)
params[getattr(param, 'argument_name', param.display_name)] = param
param_cls = Parameter
"""The parameter class `.from_signature` will use to convert source
parameters to CLI parameters"""
@classmethod
[docs] def from_signature(cls, sig, extra=()):
"""Takes a signature object and returns an instance of this class
derived from it.
:param inspect.Signature sig: The signature object to use.
:param iterable extra: Extra parameter instances to include.
"""
return cls(
itertools.chain(
filter(lambda x: x is not Parameter.IGNORE,
(cls.convert_parameter(param)
for param in sig.parameters.values())
), extra))
@classmethod
[docs] def convert_parameter(cls, param):
"""Convert a python parameter to a CLI parameter"""
if param.annotation != param.empty:
annotations = util.maybe_iter(param.annotation)
else:
annotations = []
if Parameter.IGNORE in annotations:
return Parameter.IGNORE
for i, annotation in enumerate(annotations):
if getattr(annotation, '_clize__parameter_converter', False):
conv = annotation
annotations = annotations[:i] + annotations[i+1:]
break
else:
conv = cls.converter
return conv(param, annotations)
[docs] def read_arguments(self, args, name='unnamed'):
"""Returns a `.CliBoundArguments` instance for this CLI signature
bound to the given arguments.
:param sequence args: The CLI arguments, minus the script name.
:param str name: The script name.
"""
return CliBoundArguments(self, args, name)
def __str__(self):
return ' '.join(
str(p)
for p in itertools.chain(self.named, self.positional)
if not p.undocumented
)
class _SeekFallbackCommand(object):
"""Context manager that tries to seek a fallback command if an error was
raised."""
def __enter__(self):
pass
def __exit__(self, typ, exc, tb):
if exc is None:
return
try:
pos = exc.pos
ba = exc.ba
except AttributeError:
return
for i, arg in enumerate(ba.in_args[pos + 1:], pos +1):
param = ba.sig.aliases.get(arg, None)
if param in ba.sig.alternate:
try:
param.read_argument(ba, i)
except errors.ArgumentError:
continue
ba.unsatisfied.clear()
return True
[docs]class CliBoundArguments(object):
"""Command line arguments bound to a `.CliSignature` instance.
:param CliSignature sig: The signature to bind against.
:param sequence args: The CLI arguments, minus the script name.
:param str name: The script name.
.. attribute:: sig
The signature being bound to.
.. attribute:: in_args
The CLI arguments, minus the script name.
.. attribute:: name
The script name.
.. attribute:: args
:annotation: = []
List of arguments to pass to the target function.
.. attribute:: kwargs
:annotation: = {}
Mapping of named arguments to pass to the target function.
.. attribute:: func
:annotation: = None
If not `None`, replaces the target function.
.. attribute:: post_name
:annotation: = []
List of words to append to the script name when passed to the target
function.
The following attributes only exist while arguments are being processed:
.. attribute:: sticky
:annotation: = None
If not `None`, a parameter that will keep receiving positional
arguments.
.. attribute:: posarg_only
:annotation: = False
Arguments will always be processed as positional when this is set to
`True`.
.. attribute:: skip
:annotation: = 0
Amount of arguments to skip.
.. attribute:: unsatisfied
:annotation: = set(<required parameters>)
Required parameters that haven't yet been satisfied.
"""
def __init__(self, sig, args, name):
self.sig = sig
self.name = name
self.in_args = args
self.func = None
self.post_name = []
self.args = []
self.kwargs = {}
self.sticky = None
self.posarg_only = False
self.skip = 0
self.unsatisfied = set(self.sig.required)
posparam = iter(self.sig.positional)
with _SeekFallbackCommand():
for i, arg in enumerate(self.in_args):
if self.skip > 0:
self.skip -= 1
continue
with errors.SetArgumentErrorContext(pos=i, val=arg, ba=self):
if self.posarg_only or arg[0] != '-' or len(arg) < 2:
if self.sticky is not None:
param = self.sticky
else:
try:
param = next(posparam)
except StopIteration:
exc = errors.TooManyArguments(self.in_args[i:])
exc.__cause__ = None
raise exc
elif arg == '--':
self.posarg_only = True
continue
else:
if arg.startswith('--'):
name = arg.partition('=')[0]
else:
name = arg[:2]
try:
param = self.sig.aliases[name]
except KeyError:
raise errors.UnknownOption(name)
with errors.SetArgumentErrorContext(param=param):
param.read_argument(self, i)
param.apply_generic_flags(self)
if self.unsatisfied and not self.func:
unsatisfied = []
for p in self.unsatisfied:
with errors.SetArgumentErrorContext(param=p):
if p.unsatisfied(self):
unsatisfied.append(p)
if unsatisfied:
raise errors.MissingRequiredArguments(unsatisfied)
del self.sticky, self.posarg_only, self.skip, self.unsatisfied
def __iter__(self):
yield self.func
yield self.post_name
yield self.args
yield self.kwargs