import argparse
import builtins
import json
import re
from enum import Enum
from functools import update_wrapper
from inspect import Parameter as BaseParameter
from cached_property import cached_property
from .exc import CommandError
from .util import invert_string, is_mapping, is_sequence, is_type
EMPTY = BaseParameter.empty
KEYWORD_ONLY = BaseParameter.KEYWORD_ONLY
POSITIONAL_ONLY = BaseParameter.POSITIONAL_ONLY
POSITIONAL_OR_KEYWORD = BaseParameter.POSITIONAL_OR_KEYWORD
VAR_KEYWORD = BaseParameter.VAR_KEYWORD
VAR_POSITIONAL = BaseParameter.VAR_POSITIONAL
class POSITIONAL_PLACEHOLDER:
"""Used as a placeholder for positionals."""
class Parameter:
"""Wrapper for :class:`inspect.Parameter`.
Adds convenience methods for our typical use cases.
"""
empty = EMPTY
KEYWORD_ONLY = KEYWORD_ONLY
POSITIONAL_ONLY = POSITIONAL_ONLY
POSITIONAL_OR_KEYWORD = POSITIONAL_OR_KEYWORD
VAR_KEYWORD = VAR_KEYWORD
VAR_POSITIONAL = VAR_POSITIONAL
def __init__(self, parameter):
self.parameter = parameter
@cached_property
def is_positional(self):
kind = self.parameter.kind
default = self.parameter.default
return (kind is POSITIONAL_ONLY) or (
kind is POSITIONAL_OR_KEYWORD and default is EMPTY
)
@cached_property
def is_var_positional(self):
return self.parameter.kind is VAR_POSITIONAL
@cached_property
def is_var_keyword(self):
return self.parameter.kind is VAR_KEYWORD
@cached_property
def is_optional(self):
kind = self.parameter.kind
default = self.parameter.default
return (
(kind is POSITIONAL_OR_KEYWORD) or (kind is KEYWORD_ONLY)
) and default is not EMPTY
@cached_property
def is_required_keyword_only(self):
kind = self.parameter.kind
default = self.parameter.default
return kind is KEYWORD_ONLY and default is EMPTY
@cached_property
def is_bool(self):
return isinstance(self.parameter.default, bool)
def __getattr__(self, name):
"""Proxy to wrapped :class:`inspect.Parameter`."""
return getattr(self.parameter, name)
[docs]class ArgConfig:
"""Configuration for an arg.
This can be used as a function parameter annotation to explicitly
configure an arg, overriding default behavior.
Args:
container (type): Container type to collect args into. If this
is passed, or if the ``default`` value is a container type,
values for this arg will be collected into a container of
the appropriate type.
type (type): The arg's type. By default, a positional arg will
be parsed as ``str`` and an optional/keyword arg will be
parsed as the type of its default value (or as ``str`` if
the default value is ``None``). If a ``container`` is
specified, or if the ``default`` value for the arg is a
container, the ``type`` will be applied to the container's
values.
choices (sequence): A sequence of allowed choices for the arg.
help (str): Help string for the arg.
inverse_help (str): Inverse help string for the arg (for the
``--no-xyz`` variation of boolean args).
short_option (str): Short command line option.
long_option (str): Long command line option.
inverse_short_option (str): Inverse short option for boolean
args.
inverse_long_option (str): Inverse long option for boolean args.
action (Action): ``argparse`` Action.
nargs (int|str): Number of command line args to consume.
mutual_exclusion_group (str): Name of mutual exclusion group to
add this arg to.
envvar(str): Environment variable containing a default value to
use when the arg isn't specified on the command line.
default: Default value for positional args.
.. note:: For convenience, regular dicts can be used to annotate
args instead; they will be converted to instances of this class
automatically.
"""
short_option_regex = re.compile(r"-\w")
long_option_regex = re.compile(r"--\w+(-\w+)*")
def __init__(
self,
*,
container=None,
type=None,
choices=None,
help=None,
inverse_help=None,
short_option=None,
long_option=None,
no_inverse=False,
inverse_short_option=None,
inverse_long_option=None,
inverse_option=None, # XXX: Temporary alias for inverse_long_option
action=None,
nargs=None,
mutual_exclusion_group=None,
envvar=None,
default=EMPTY,
):
if short_option is not None:
if not self.short_option_regex.fullmatch(short_option):
raise CommandError(
f'Expected short option with form -x, not "{short_option}"'
)
if long_option is not None:
if not self.long_option_regex.fullmatch(long_option):
raise CommandError(
f'Expected long option with form --option, not "{long_option}"'
)
self.container = container
self.type = type
self.choices = choices
self.help = help
self.inverse_help = inverse_help
self.short_option = short_option
self.long_option = long_option
self.no_inverse = no_inverse
self.inverse_short_option = inverse_short_option
self.inverse_long_option = inverse_long_option or inverse_option
self.action = action
self.nargs = nargs
self.mutual_exclusion_group = mutual_exclusion_group
self.envvar = envvar
self.default = default
def __repr__(self):
type_name = self.type.__name__ if self.type is not None else "None"
options = (
self.short_option,
self.long_option,
self.inverse_short_option,
self.inverse_long_option,
)
options = (option for option in options if option)
options = ", ".join(options)
return f"arg<{type_name}>({options})"
arg = ArgConfig
[docs]class Arg:
"""Encapsulates an arg belonging to a command.
Attributes:
command (Command): Command this arg belongs to.
parameter (Parameter): Function parameter this arg is derived
from.
name (str): Normalized arg name.
container (type): Container type to collect args into. If this
is passed, or if the ``default`` value is a container type,
values for this arg will be collected into a container of
the appropriate type.
type (type): The arg's type. By default, a positional arg will
be parsed as ``str`` and an optional/keyword arg will be
parsed as the type of its default value (or as ``str`` if
the default value is ``None``). If a ``container`` is
specified, or if the ``default`` value for the arg is a
container, the ``type`` will be applied to the container's
values.
positional (bool): An arg will automatically be considered
positional if it doesn't have a default value, so this
doesn't usually need to be passed explicitly. It can be
used to force an arg that would normally be optional to
be positional.
default (object): Default value for the arg.
choices (sequence): A sequence of allowed choices for the arg.
help (str): Help string for the arg.
inverse_help (str): Inverse help string for the arg (for the
``--no-xyz`` variation of boolean args).
short_option (str): Short command line option.
long_option (str): Long command line option.
inverse_short_option (str): Inverse short option for boolean
args.
inverse_long_option (str): Inverse long option for boolean args.
action (Action): ``argparse`` Action.
nargs (int|str): Number of command line args to consume.
mutual_exclusion_group (str): Name of mutual exclusion group to
add this arg to.
envvar(str): Environment variable containing a default value to
use when the arg isn't specified on the command line.
"""
def __init__(
self,
*,
command,
parameter,
name,
container,
type,
positional,
default,
choices,
help,
inverse_help,
short_option,
long_option,
no_inverse,
inverse_short_option,
inverse_long_option,
action,
nargs,
mutual_exclusion_group,
envvar,
):
is_keyword_only = parameter.kind is KEYWORD_ONLY and default is EMPTY
is_var_positional = parameter.kind is VAR_POSITIONAL
if default is EMPTY:
is_positional = not (is_var_positional or is_keyword_only)
is_optional = False
else:
is_positional = False
is_optional = True
if positional is not None:
is_positional = positional
metavar = name.upper().replace("-", "_")
if container and len(name) > 1 and name.endswith("s"):
metavar = metavar[:-1]
if container is None:
if is_mapping(default) or is_sequence(default):
container = default.__class__
elif is_var_positional:
container = tuple
if type is None:
if is_type(choices, Enum):
type = choices
elif container is not None:
if default and default is not EMPTY:
type = default[0].__class__
else:
type = str
elif default not in (None, EMPTY):
type = default.__class__
else:
type = str
if isinstance(type, builtins.type):
is_bool = issubclass(type, bool)
is_bool_or = issubclass(type, bool_or)
is_enum_bool_or = is_bool_or and issubclass(type, Enum)
is_enum = issubclass(type, Enum)
else:
is_bool = False
is_bool_or = False
is_enum_bool_or = False
is_enum = False
if is_bool:
type = None
metavar = None
elif is_bool_or:
type = type.type
if not choices:
if is_enum:
choices = type
elif is_enum_bool_or:
choices = type.type
if is_positional or is_var_positional:
options = (short_option, long_option, inverse_long_option)
options = tuple(option for option in options if option is not None)
if options:
raise CommandError(
f"Positional args cannot be specified with "
f"options: {', '.join(options)}"
)
if action is None:
if container:
if is_bool_or:
action = BoolOrContainerAction.make(container, type)
# XXX: Type conversion handled in action
type = str
else:
action = ContainerAction.make(container)
elif is_bool:
action = "store_true"
elif is_bool_or:
action = BoolOrAction
if nargs is None:
if is_positional:
if container:
nargs = "+"
elif is_optional:
nargs = "?"
elif is_var_positional:
nargs = "*"
elif is_bool_or:
if container:
nargs = "*"
else:
nargs = "?"
elif is_optional:
if container:
nargs = "*"
options = tuple(opt for opt in (short_option, long_option) if opt is not None)
all_options = options
if no_inverse:
inverse_options = ()
else:
inverse_options = tuple(
opt
for opt in (inverse_short_option, inverse_long_option)
if opt is not None
)
all_options += inverse_options
if is_var_positional and default is EMPTY:
default = ()
self.command = command
self.parameter = parameter
self.is_positional = is_positional
self.is_var_positional = is_var_positional
self.is_optional = is_optional
self.takes_value = is_positional or (is_optional and not is_bool)
self.dest = parameter.name
self.name = name
self.metavar = metavar
self.container = container
self.type = type
self.is_bool = is_bool
self.is_bool_or = is_bool_or
self.default = default
self.choices = choices
self.help = help
self.inverse_help = inverse_help
self.short_option = short_option
self.long_option = long_option
self.options = options
self.no_inverse = no_inverse
self.inverse_short_option = inverse_short_option
self.inverse_long_option = inverse_long_option
self.inverse_options = inverse_options
self.all_options = all_options
self.action = action
self.nargs = nargs
self.mutual_exclusion_group = mutual_exclusion_group
self.envvar = envvar
@cached_property
def add_argument_args(self, *, _type_wrapper_cache={}):
args = self.options
if self.is_optional and not self.is_bool:
if self.type not in _type_wrapper_cache:
type = lambda v: (None if v == "" else self.type(v)) # noqa: E731
type = update_wrapper(type, self.type)
_type_wrapper_cache[self.type] = type
type = _type_wrapper_cache[self.type]
else:
type = self.type
kwargs = {
"action": self.action,
"choices": self.choices,
"dest": self.dest,
"help": self.help,
"metavar": self.metavar,
"nargs": self.nargs,
"type": type,
}
kwargs = {k: v for k, v in kwargs.items() if v is not None}
if self.is_positional and self.is_optional:
kwargs["default"] = POSITIONAL_PLACEHOLDER
return args, kwargs
@cached_property
def add_argument_inverse_args(self):
if not (self.is_bool or self.is_bool_or) or self.no_inverse:
return None
args = self.inverse_options
_, kwargs = self.add_argument_args
if self.inverse_help:
inverse_help = self.inverse_help
elif self.help:
inverse_help = invert_string(self.help)
else:
inverse_help = self.help
kwargs = kwargs.copy()
kwargs["action"] = "store_false"
kwargs["help"] = inverse_help
if self.is_bool_or:
kwargs.pop("metavar")
kwargs.pop("nargs")
kwargs.pop("type")
return args, kwargs
[docs] def convert_value(self, value: str):
"""Convert string value to this arg's type."""
if not isinstance(value, str):
return value
if self.is_bool or self.is_bool_or:
if value in ("1", "true"):
return True
elif value in ("0", "false"):
return False
if self.is_bool:
raise ValueError("Bool value must be one of 1, true, 0, or false")
converter = self.add_argument_args[1]["type"]
value = converter(value)
return value
def __str__(self):
kind = "Positional" if self.is_positional else "Optional"
has_default = self.default not in (EMPTY, None)
default = f"[={self.default}]" if has_default else ""
if self.is_bool:
type = "flag"
elif self.is_bool_or:
type = f"flag|{self.type.__name__}"
elif self.type is None:
type = None
else:
type = self.type.__name__
return f"{kind} arg: {self.name}{default}: type={type}"
class HelpArg(Arg):
def __init__(self, *, command):
parameter = Parameter(
BaseParameter("help", POSITIONAL_OR_KEYWORD, default=False),
)
super().__init__(
command=command,
parameter=parameter,
name="help",
container=None,
type=bool,
positional=None,
default=False,
choices=None,
help=None,
inverse_help=None,
short_option="-h",
long_option="--help",
no_inverse=True,
inverse_short_option=None,
inverse_long_option=None,
action=None,
nargs=None,
mutual_exclusion_group=None,
envvar=None,
)
[docs]class bool_or:
"""Used to indicate that an arg can be a flag or an option.
Use like this::
@command
def local(config, cmd, hide: {'type': bool_or} = False):
"Run the specified command, possibly hiding its output.
If ``hide=True``, *all* output will be hidden. It can also
be set to one of "stdout" or "stderr" to hide just the
specified output stream.
"
.. note:: The default inner type for ``bool_or`` is ``str``.
``bool_or(str)`` is equivalent to ``bool_or``.
Allows for this::
run local --hide all # Hide everything
run local --hide # Hide everything with less effort
run local --hide stdout # Hide stdout only
run local --no-hide # Don't hide anything
This can also be combined with the ``container`` option like so::
@command
def fetch(fields: {'container': dict, type: bool_or(int)}):
"Get data from somewhere and show the specified fields.
If ``fields=True``, all fields will be shown with their
original names. If fields are specified, only those fields
will be shown, with their names mapped. For example,
``fields`` could be::
{'givenName': 'first_name', 'sn': 'last_name'}
"
.. note:: When combined with ``container``, the type passed to
``bool_or`` is applied to the *values* in the container rather
than being applied to the literal strings passed on the command
line.
"""
type = str
def __new__(cls, type, *, _type_cache={}):
if type not in _type_cache:
name = f"BoolOr{type.__name__.title()}"
_type_cache[type] = builtins.type(name, (cls,), {"type": type})
return _type_cache[type]
def add_items_to_container(
container_type,
item_type,
existing_items,
new_items,
option_string,
):
"""Return a new container with existing plus new items."""
items = []
if is_mapping(existing_items):
if existing_items is not None:
items.extend(existing_items.items())
for value in new_items:
try:
name, value = value.split(":", 1)
except ValueError:
raise CommandError(
f"Bad format for {option_string}; "
f"expected `name:<value>` but got `{value}`"
)
value = item_type(value)
items.append((name, value))
return container_type(items)
elif is_sequence(existing_items):
if existing_items is not None:
items.extend(existing_items)
items.extend(item_type(value) for value in new_items)
else:
raise ValueError(f"Not a mapping or sequence: {existing_items!r}")
return container_type(items)
class BoolOrAction(argparse.Action):
def __call__(self, parser, namespace, value, option_string=None):
if value is None:
value = True
setattr(namespace, self.dest, value)
class BoolOrContainerAction(argparse.Action):
@classmethod
def make(cls, container_type, item_type):
return type(
"BoolOrContainerAction",
(cls,),
{
"container_type": container_type,
"item_type": item_type,
},
)
def __call__(self, parser, namespace, value, option_string=None):
if value == []:
setattr(namespace, self.dest, True)
else:
existing_items = getattr(namespace, self.dest)
if isinstance(existing_items, bool):
# XXX: The default value is False
existing_items = self.container_type()
items = add_items_to_container(
self.container_type,
self.item_type,
existing_items,
value,
option_string,
)
setattr(namespace, self.dest, items)
class ContainerAction(argparse.Action):
@classmethod
def make(cls, container_type):
return type("ContainerAction", (cls,), {"container_type": container_type})
def __call__(self, parser, namespace, values, option_string=None):
items = add_items_to_container(
self.container_type,
self.type,
getattr(namespace, self.dest, self.container_type()),
values,
option_string,
)
setattr(namespace, self.dest, items)
def json_value(string):
"""Convert string to JSON if possible; otherwise, return as is."""
try:
string = json.loads(string)
except ValueError:
pass
return string