"""
The core interface and constructs for python-args.
"""
import contextlib
import inspect
import threading
# A sentinel value to know if a default value of a kwarg was provided.
_unset = object()
# The current python-args call being executed.
# Allows us to set run-time parameters and maintain additional information
# about the call.
# TODO: Make this a local variable that is passed through the call chain
_current_call = threading.local()
class Call:
"""Contains information about an executing python-args call.
Values can only be set temporarily using set()
"""
def __init__(self):
# True if we are running partial mode. Partial mode allows
# us to only run python-arg decorators that can be bound
# to the current arguments.
self.is_partial = False
# True if we are running pre_func mode. The pre_func mode
# will make python-args run everything up until the actual
# wrapped function.
self.is_pre_func = False
# These arguments are set during parametrization to give the
# user access to the current parametrized arg name, the current
# value, the index of the value with respect to all values, and
# all arg values that are being parametrized
self.parametrize_arg = None
self.parametrize_arg_val = None
self.parametrize_arg_index = None
self.parametrize_arg_vals = None
def set(self, **kwargs):
"""Temporarily set attributes of the call"""
@contextlib.contextmanager
def temporary_set():
orig = {key: getattr(self, key) for key in kwargs}
for key, value in kwargs.items():
setattr(self, key, value)
yield
for key, value in orig.items():
setattr(self, key, value)
return temporary_set()
[docs]def call():
"""Obtains the current python-args call
The current call is always set by the __call__ method of
the Args class when invoking running.
If called outside of a python-args call, will return None
"""
if not hasattr(_current_call, 'val'):
return None
return _current_call.val
[docs]class BindError(TypeError):
"""When a function can't be bound to arguments
For example, if @args.defaults or @args.validators
have functions that take arguments with names other than
the function they are decorating, a BindError
could be thrown if the called function does not have all
necessary arguments.
"""
def _parse_args(func, args=None, kwargs=None, extra=False, partial=False):
"""
Parses the arguments to the function and returns a dictionary of
arguments.
Args:
func (function): The function over which arguments are parsed.
args (list): A list of postiional arguments to parse.
kwargs (dict): A dictionary of keyword arguments to parse.
extra (bool, default=False): Parse extra keyword arguments and
include them in the results.
partial (bool, default=False): Allow partial arguments to be
parsed.
Raises:
BindError: If there are not enough arguments to bind to
the function and partial=False.
"""
args = args or []
kwargs = kwargs or {}
sig = inspect.signature(func)
# Bind all args and kwargs to the main function.
bind = sig.bind if not partial else sig.bind_partial
try:
bound = bind(
*args, **{k: v for k, v in kwargs.items() if k in sig.parameters}
)
except TypeError as exc:
msg = (
f'Cannot bind arguments args={args} kwargs={kwargs}'
f' to function "{func}" - {exc}.'
)
raise BindError(msg) from exc
bound.apply_defaults()
# We currently allow defaults, validators, and contexts
# to take in arbitrary arguments, even if they are not part
# of the function declaration. This is facilitated by
# adding any additional keyword arguments to the call arguments.
# We may later be more strict about this.
if extra:
for label, value in kwargs.items():
if label not in sig.parameters:
bound.arguments[label] = value
return bound.arguments
[docs]class Lazy:
"""A base class for lazy utilties
Tracks a call chain that can later be lazily evaulated.
For example, @arg.val is a lazy utility that can
obtain the value of an argument (@arg.val('arg_name'))
and then apply chained operations (@arg.val('arg_name').strip() to
strip a string for example).
"""
def __init__(self):
self._call_chain = []
def __getattribute__(self, name):
if name.startswith('_'):
return object.__getattribute__(self, name)
else:
self._call_chain.append((name, None, None))
return self
def __call__(self, *args, **kwargs):
if self._call_chain:
self._call_chain[-1] = (self._call_chain[-1][0], args, kwargs)
else:
# If the lazy object is called before any attributes are
# accessed, the return of the lazy object will be called
# directly
self._call_chain.append((None, args, kwargs))
return self
def _call(self, **call_args):
raise NotImplementedError
def _load(self, **call_args):
"""Load the lazy object and return the chained result."""
val = self._call(**call_args)
for name, args, kwargs in self._call_chain:
if name is None:
# If the name is none, the call was applied directly
# on the return value
val = val()
else:
val = getattr(val, name)
if (args, kwargs) != (None, None):
# If the args/kwargs are none, an attribute
# was accessed. Otherwise a method was called
val = val(*args, **kwargs)
return val
[docs]class func(Lazy):
"""For lazy calling of a function.
All python-args decorator functions are wrapped in this. The
func class lazily evaluates these functions and dynamically
binds arguments.
This class can still be used directly in other scenarios, although
it is never required to use it directly in python-args decorators,
it can be used by other python-args utilities (like arg.init).
"""
def __init__(self, wraps, default=_unset):
super().__init__()
self._wraps = wraps
self._func = wraps._func if isinstance(wraps, func) else wraps
self._default = default
def _call(self, **call_args):
"""Call and return function with args"""
try:
call_args = _parse_args(self._func, kwargs=call_args)
except BindError:
if self._default is not _unset:
return self._default
raise
if isinstance(self._wraps, Lazy):
return load(self._wraps, **call_args)
else:
return self._wraps(**call_args)
[docs]class val(func):
"""A shortcut that lazily returns the value of an argument."""
def __init__(self, arg, default=_unset):
self._arg = arg
super().__init__(eval(f'lambda {arg}: {arg}'), default=default)
[docs]class first(Lazy):
"""
Obtain the result of the first lazy method that can be executed
without binding errors.
Examples:
Assign ``arg`` to a function result if the function can be bound,
otherwise assign it to the value of ``arg2``::
@arg.defaults(arg=arg.first(arg.func(...), arg.val('arg2')))
def my_func(arg):
...
Assign ``arg`` to the value of ``arg1``, ``arg2``, or ``arg3``.
As shown below, passing in a string is the same for passing in
an `arg.val`::
@arg.defaults(arg=arg.first('arg1', 'arg2', 'arg3'))
def my_func(arg):
...
Similarly, passing in a function is the same for passing in
an `arg.func`::
@arg.defaults(arg=arg.first(lambda a: 'a', lambda b: 'b'))
def my_func(arg):
...
Assign ``arg`` to the value of ``arg2`` or ``arg3``. Default it to the
value of "nothing" if none of those argument exist::
@arg.defaults(arg=arg.first('arg1', 'arg2', default='nothing'))
def my_func(arg):
...
"""
def __init__(self, *lazy_vals, default=_unset):
def _get_lazy_val(lazy_val):
if isinstance(lazy_val, Lazy):
return lazy_val
elif isinstance(lazy_val, str):
return val(lazy_val)
elif callable(lazy_val):
return func(lazy_val)
else:
raise TypeError(
f'"{lazy_val}" must be a string, function, or arg.Lazy'
' object'
)
lazy_vals = [_get_lazy_val(lazy_val) for lazy_val in lazy_vals]
assert all(isinstance(lazy_val, Lazy) for lazy_val in lazy_vals)
self._lazy_vals = lazy_vals
self._default = default
super().__init__()
def _call(self, **call_args):
"""Return first loadable value"""
for lazy_val in self._lazy_vals:
try:
return load(lazy_val, **call_args)
except BindError:
pass
if self._default is not _unset:
return self._default
else:
msg = (
f'Cannot bind arguments kwargs={call_args}'
f' to anything in arg.first({self._lazy_vals})'
)
raise BindError(msg)
[docs]class init(Lazy):
"""For lazy initialization of a class.
Args and keyword arguments can also be lazily loaded with
arg.func, arg.init, arg.val or any lazy python-args utilities.
"""
def __init__(self, class_, *args, **kwargs):
super().__init__()
self._class = class_
self._args = args
self._kwargs = kwargs
def _call(self, **call_args):
class_args = [
load(a, **call_args) if isinstance(a, Lazy) else a
for a in self._args
]
class_kwargs = {
l: load(v, **call_args) if isinstance(v, Lazy) else v
for l, v in self._kwargs.items()
}
return self._class(*class_args, **class_kwargs)
[docs]def load(lazy, **call_args):
"""Loads a lazy object with arguments.
We cannot override __call__ on lazy objects since lazy objects
are chainable. So we must go through this interface to evaluate them.
"""
# Any non-lazy objects are assumed to be lazy functions
if not isinstance(lazy, Lazy):
lazy = func(lazy)
return lazy._load(**call_args)
@contextlib.contextmanager
def _suppress_bind_errors_in_partial_call():
"""
When dynamically binding arguments to contexts, validators, defaults,
etc, suppress errors when inside of the special partial context
"""
try:
yield
except BindError:
if not call().is_partial:
raise
[docs]class Args:
"""
The primary Args class that orchestrates running of ``python-args``
decorators.
Responsible for parsing call args and orchestrating various
run modes. Can be used to construct other top-level ``python-args``
decorators that interface with the library.
"""
def __init__(self, wraps):
# The main underlying function that is wrapped. If we are wrapping
# another Args object (such as when stacking decorators), pull
# the _func from the underlying Args object so that we always
# have a reference to it.
self._func = wraps if not isinstance(wraps, Args) else wraps._func
self._wraps = wraps
@property
def func(self):
return self._func
@property
def partial(self):
"""
Return a partial version of the Args where validators, contexts,
and defaults can run against partial arguments.
"""
return contexts(func(call).set(is_partial=True))(self)
@property
def pre_func(self):
"""
Return a version of the Args where everything before the main
function runs.
"""
return contexts(func(call).set(is_pre_func=True))(self)
def _call(self, call_args):
"""
Call the wrapped function and returns the result.
Ignore calling if we are only running pre_func routines.
"""
if isinstance(self._wraps, Args):
return self._wraps._call(call_args)
elif not call().is_pre_func:
# We have reached the end of the Args chain. Ignore
# running the function if we are in pre_func mode. Otherwise
# parse the final arguments against the function and call.
# We re-parse the arguments so that we can throw a nicer
# BindError if anything is going on with our arguments
assert self._wraps == self._func
call_args = _parse_args(self.func, kwargs=call_args)
return self._func(**call_args)
def __call__(self, *args, **kwargs):
"""
The entry point for the python-args call chain. Any nested
decorators will start here.
"""
if hasattr(_current_call, 'val'):
raise AssertionError('Can only call Args class once in chain.')
_current_call.val = Call()
try:
call_args = _parse_args(
self.func, args, kwargs, partial=True, extra=True
)
return self._call(call_args)
finally:
delattr(_current_call, 'val')
class Validators(Args):
def __init__(self, wraps, validators):
self._validators = validators
super().__init__(wraps)
def _call(self, kwargs):
for validator_func in self._validators:
with _suppress_bind_errors_in_partial_call():
load(validator_func, **kwargs)
return super()._call(kwargs)
[docs]def validators(*validation_funcs):
"""
Run validators over arguments.
Args:
*validation_funcs (List[func]): Functions that validate arguments.
Argument names of the calling function are used to determine
which validators to run.
Examples:
Validate an email address is correct:::
def validate_email(email):
if email.not_valid_address():
raise ValueError('Email is invalid')
@arg.validators(validate_email)
def my_func(email):
# A ValueError will be raised when calling with an invalid email
"""
def decorator(wraps):
return Validators(wraps, validators=validation_funcs)
return decorator
class Contexts(Args):
def __init__(self, wraps, contexts):
self._contexts = contexts
super().__init__(wraps)
def _call(self, call_args):
context_args = {}
with contextlib.ExitStack() as context_stack:
for context_key, context_func in self._contexts.items():
with _suppress_bind_errors_in_partial_call():
resource = context_stack.enter_context(
load(context_func, **{**call_args, **context_args})
)
# The call arguments to the current call are expanded when
# entering named contexts. Named
# context managers are those that assign a resource to
# a name (i.e. @arg.contexts(name=context_manager)).
# If not named, the context key is just the reference
# to the context function.
if isinstance(context_key, str):
context_args[context_key] = resource
return super()._call({**call_args, **context_args})
[docs]def contexts(*context_managers, **named_context_managers):
"""
Enter contexts based on arguments.
Args:
*context_managers (List[contextmanager]): A list of context managers
that will be entered before calling the function. Context managers
can take arguments that the function takes.
**named_context_managers (Dict[contextmanagers]): Naming a context
manager will assign the context manager resource to the argument
name, allowing the function (or other args decorators) to use
it.
"""
def decorator(wraps):
return Contexts(
wraps,
contexts={
**{mgr: mgr for mgr in context_managers},
**named_context_managers,
},
)
return decorator
class Defaults(Args):
def __init__(self, wraps, defaults):
self._defaults = defaults
super().__init__(wraps)
def _call(self, call_args):
default_args = {}
# Apply processing for defaults
for label, default_func in self._defaults.items():
with _suppress_bind_errors_in_partial_call():
default_args[label] = load(
default_func, **{**call_args, **default_args}
)
return super()._call({**call_args, **default_args})
[docs]def defaults(**default_funcs):
"""
Process default values to function arguments.
Args:
**default_funcs (Dict[func]): A mapping of argument names to
functions that should process those arguments before
they are provided to the function call.
Examples:
Pre-process a string argument so that it is always uppercase::
@arg.defaults(arg_name=lambda arg_name: arg_name.upper())
def my_func(arg_name):
# arg_name will be the uppercase version when my_func is called
"""
def decorator(wraps):
return Defaults(wraps, defaults=default_funcs)
return decorator
class Parametrize(Args):
def __init__(self, wraps, parametrize):
# We currently only support parametrizing exactly one argument
assert len(parametrize) == 1
self._parametrize_arg = list(parametrize)[0]
self._parametrize_func = parametrize[self._parametrize_arg]
super().__init__(wraps)
def _call(self, call_args):
with _suppress_bind_errors_in_partial_call():
arg_vals = load(self._parametrize_func, **call_args)
results = []
for arg_index, arg_val in enumerate(arg_vals):
with call().set(
parametrize_arg=self._parametrize_arg,
parametrize_arg_val=arg_val,
parametrize_arg_vals=arg_vals,
parametrize_arg_index=arg_index,
):
results.append(
super()._call(
{**call_args, **{self._parametrize_arg: arg_val}}
)
)
return results
# If we are in partial mode and couldn't bind, keep trying to
# run partially
return super()._call(call_args)
[docs]def parametrize(**parametrize_funcs):
"""
Parametrize a function's arguments.
Args:
**parametrize_funcs (Dict[func]): A mapping of argument names to
functions that return iterables. The argument will
be parametrized over the iterable, calling the underlying
function for each element.
Returns:
list: Parametrized functions return a list of all results.
Examples:
This is an example of parametrizing a function that doubles a value::
@arg.parametrize(val=arg.val('vals'))
def double(val):
return val * 2
assert double(vals=[1, 2, 3]) == [2, 4, 6]
"""
def decorator(wraps):
return Parametrize(wraps, parametrize=parametrize_funcs)
return decorator
[docs]def s(*arg_decorators):
"""
Creates a ``python-args`` class from multiple decorators. Useful
for creating higher-level methods of running functions.
Args:
*arg_decorators (List[`Args`]): A list of ``python-args``
decorators (``@arg.validators``, ``@arg.contexts``, etc)
Returns:
`Args`: An `Args` class with the appropriate decorators applied.
If no ``arg_decorators`` are provided, will return an `Args`
class with no decorators.
"""
def decorator(wraps):
for arg_decorator in reversed(arg_decorators):
wraps = arg_decorator(wraps)
if not isinstance(wraps, Args):
wraps = Args(wraps)
return wraps
return decorator