# -*- coding: utf-8 -*-
import abc
import functools
from typing import Callable, Union
import types
__all__ = 'command', 'Option', 'Options', 'separate_opts', 'option', 'flag'
[docs]class Option:
"""An option that can be accepted by a command"""
def __init__(self, name, call_params=()):
self._name = name
self._params_stack = call_params
def __str__(self):
parts = ['-', str(self.name)]
for params in self._params_stack:
args = params[0]
kwargs = params[1]
params = list(str(arg) for arg in args)
params.extend(tuple(f'{key}={value}' for key, value in kwargs.items()))
parts.extend(('(', ','.join(params), ')'))
return ''.join(parts)
def __call__(self, *args, **kwargs):
if len(self._params_stack) == 2:
raise RuntimeError('Option already has filled parameters')
params = list(self._params_stack)
params.append((args, kwargs))
return Option(self.name, tuple(params))
def __neg__(self):
"""Allow so a user can optional use a dash when passing this as an argument"""
return self
@property
def called(self) -> bool:
return bool(self._params_stack)
@property
def params_stack(self) -> tuple:
return self._params_stack
@property
def name(self):
return self._name
[docs] def new(self):
"""Get a new option of this type"""
return Option(self.name)
OptionLike = Union[Option, int]
class OptionSpec:
def __init__(self, opt: Option, is_flag=True, help=''): # pylint: disable=redefined-builtin
self.option = opt
self.is_flag = is_flag
self.help = help
[docs]class Options:
"""Container storing options value, using this with separate_opts makes writing tools much
easier"""
def __init__(self):
self._opts = {} # typing.MutableMapping[str, Option]
def __str__(self):
return ' '.join((str(val) for val in self._opts.values()))
def __contains__(self, item):
if isinstance(item, Option):
return item.name in self._opts
if isinstance(item, str):
return item in self._opts
return False
def add(self, name, value):
self._opts[name] = value
def pop(self, opt: Option, *default):
if not isinstance(opt, (Option, int)):
raise TypeError(f"Unsupported option type '{opt}'")
if len(default) > 1:
raise ValueError('Can only pass at most one default')
if isinstance(opt, Option):
name = opt.name
else:
name = opt
return self._opts.pop(name, default[0] if default else None)
def update(self, other):
for name, opt in other._opts.items(): # pylint: disable=protected-access
if name not in self._opts:
self._opts[name] = opt
else:
raise ValueError(f"Option '{name}' alreday specified!")
[docs]def separate_opts(*args) -> [Options, list]:
"""Separate out the options from the other arguments"""
opts = Options()
rest = []
for arg in args:
if isinstance(arg, Option):
params_stack = arg.params_stack
value = True
if params_stack and params_stack[0] and params_stack[0][0]:
value = params_stack[0][0][0]
opts.add(arg.name, value)
elif isinstance(arg, Options):
opts.update(arg)
else:
rest.append(arg)
return opts, rest
# region Commands
def command(pass_options=False):
def wrapper(func):
if isinstance(func, Command):
func.pass_options = pass_options
return func
return Command(func, pass_options)
return wrapper
class CommandLike(metaclass=abc.ABCMeta):
"""Base class for command like things"""
@abc.abstractmethod
def __call__(self, *args, **kwargs):
"""Call this command"""
def __or__(self, other: Callable):
"""Pipe the result of this call into a callable
:param other: the callable to pipe the results to
"""
result = self.__call__()
return result.__or__(other)
class Command(CommandLike):
"""Class representing a command"""
def __init__(self, func: callable, pass_options=False):
self.func = func
self.pass_options = pass_options
self.accepts = {}
# Use partial just to create a new function instance so we can set the docstring on this
# instance only
self.__call__ = types.MethodType(functools.partial(Command.__call__), self)
self._create_docstring()
@property
def name(self) -> str:
return self.func.__name__
def add_opt(self, spec: OptionSpec):
"""Add an option that this command accepts"""
self.accepts[spec.option.name] = spec
self._create_docstring()
def __call__(self, *args, **kwargs): # pylint: disable=method-hidden
"""Execute this command directly"""
if self.pass_options:
options, rest = separate_opts(*args)
return self.func(options, *rest, **kwargs)
return self.func(*args, **kwargs)
def __sub__(self, other: Option):
"""Start an execution of this command by supplying options"""
builder = CommandBuilder(self)
return builder - other
def _create_docstring(self):
doc = [self.func.__doc__ or f'Call {self.name}']
if self.accepts:
doc.append('FLAGS')
for name, spec in sorted(self.accepts.items(), key=lambda pair: str(pair[0])):
doc.append(f'\t-{name}\t{spec.help}')
self.__call__.__func__.__doc__ = '\n'.join(doc) # pylint: disable=no-member
class CommandBuilder(CommandLike):
"""Used when a command is being built by options being passed to it"""
def __init__(self, cmd: Command):
self.command = cmd
self.options = Options()
def execute(self, *args, **kwargs):
return self.command(self.options, *args, **kwargs)
def __call__(self, *args, **kwargs):
return self.execute(*args, **kwargs)
def __sub__(self, other: Option):
unsupported = ValueError(f'Command does not accept option: {other}')
if isinstance(other, Option):
# We have an option instance so it could be an option with a value or the
# arguments are actually meant for the command
try:
spec = self.command.accepts[other.name]
except KeyError:
raise unsupported from None
else:
params_stack = list(other.params_stack)
if spec.is_flag:
self.options.add(other.name, True)
if params_stack:
# Assume the parameters are for the command, but make sure only one
assert len(params_stack) == 1, \
f"Flag '{other.name}' does not take any parameters"
else:
option_params = params_stack.pop(0) # tuple of (args, kwargs)
assert not option_params[1], "Can't supply kwargs to an option"
value = True
if option_params[0]:
value = option_params[0][0]
self.options.add(other.name, value)
# Now the arguments of the option have been dealt with, see if we should execute
if params_stack:
args, kwargs = params_stack.pop()
return self.execute(*args, **kwargs)
# Carry on
return self
raise unsupported
def __str__(self) -> str:
return ' '.join((self.command.name, str(self.options)))
# endregion
[docs]def option(opt: Option, help=''): # pylint: disable=redefined-builtin
"""Decorator for defining an option taken by a command function"""
spec = OptionSpec(opt, is_flag=False, help=help)
def attach(func):
if isinstance(func, Command):
cmd = func
else:
cmd = Command(func)
cmd.add_opt(spec)
return cmd
return attach
[docs]def flag(opt: OptionLike, help=''): # pylint: disable=redefined-builtin
"""Decorator for defining an option taken by a command function"""
opt = _make_option(opt)
spec = OptionSpec(opt, is_flag=True, help=help)
def attach(func):
if isinstance(func, Command):
cmd = func
else:
cmd = Command(func)
cmd.add_opt(spec)
return cmd
return attach
def _make_option(opt: OptionLike) -> Option:
if isinstance(opt, Option):
return opt
return Option(opt)