"""
Function reflection and transformation utilities.
This module provides a collection of decorators and helper functions for working
with Python callables. It focuses on manipulating function attributes, dynamically
adapting call signatures, and implementing reusable pre/post-processing logic.
The module contains the following main components:
* :func:`fassign` - Assign arbitrary attributes to a function
* :func:`frename` - Rename a function by updating its ``__name__``
* :func:`fcopy` - Create a wrapped copy of a function
* :func:`args_iter` - Iterate over positional and keyword arguments with indices
* :func:`sigsupply` - Attach a supplemental signature to callables
* :func:`dynamic_call` - Enable flexible argument handling for callables
* :func:`static_call` - Restore the original function from a dynamic wrapper
* :func:`pre_process` - Apply argument pre-processing before function execution
* :func:`post_process` - Apply return-value post-processing
* :func:`raising` - Convert returned exceptions into raised exceptions
* :func:`warning_` - Convert returned warnings into emitted warnings
* :func:`freduce` - Turn a binary operation into a variadic reduction
* :func:`get_callable_hint` - Build a ``typing.Callable`` hint from annotations
Example::
>>> @fassign(author='hbutils')
... def add(a, b):
... return a + b
>>> add.author
'hbutils'
>>>
>>> dynamic_call(lambda x, y: x ** y)(2, 3, 4)
8
>>>
>>> @freduce(init=0)
... def plus(a, b):
... return a + b
>>> plus(1, 2, 3)
6
.. note::
Some utilities (e.g., :func:`dynamic_call`) require inspectable signatures. For
builtin callables without signatures, use :func:`sigsupply` to provide one.
"""
import warnings
from functools import wraps
from inspect import signature, Parameter, Signature
from itertools import chain
from typing import Callable, TypeVar, Union, Type, get_type_hints, Any, Generator, Tuple, Dict, Optional
from ..design import SingletonMark, decolize
__all__ = [
'fassign', 'frename', 'fcopy',
'args_iter', 'sigsupply',
'dynamic_call', 'static_call',
'pre_process', 'post_process',
'raising', 'warning_',
'freduce',
'get_callable_hint',
]
_FuncType = TypeVar('_FuncType', bound=Callable[..., Any])
_ElementType = TypeVar("_ElementType")
[docs]
def fassign(**assigns: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Do assignments to function attributes.
This decorator allows you to assign arbitrary attributes to a function object.
It's useful for adding metadata or custom properties to functions.
:param assigns: Keyword arguments representing attribute names and their values to assign.
:type assigns: Any
:return: A decorator function that assigns the specified attributes to the target function.
:rtype: Callable
Examples::
>>> @fassign(__name__='fff')
>>> def func(a, b):
>>> return a + b
>>> func.__name__
'fff'
"""
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
for k, v in assigns.items():
setattr(func, k, v)
return func
return _decorator
[docs]
def frename(new_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Rename the given function.
This decorator changes the ``__name__`` attribute of a function to the specified new name.
:param new_name: New name of function.
:type new_name: str
:return: Decorator to rename the function.
:rtype: Callable
Examples::
>>> @frename('fff')
>>> def func(a, b):
>>> return a + b
>>> func.__name__
'fff'
"""
return fassign(__name__=new_name)
[docs]
def fcopy(func: _FuncType) -> _FuncType:
"""
Make a copy of given function.
Creates a new function that wraps the original function, effectively creating a copy
with the same behavior but a different identity. The wrapper preserves the original
function's metadata using ``functools.wraps``.
:param func: Function to be copied.
:type func: Callable
:return: Copied function.
:rtype: Callable
Examples::
>>> def func(a, b):
... return a + b
>>> nfunc = fcopy(func)
>>> nfunc(1, 2)
3
>>> nfunc is func
False
"""
@wraps(func)
def _new_func(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return _new_func # type: ignore[return-value]
[docs]
def args_iter(*args: Any, **kwargs: Any) -> Generator[Tuple[Union[int, str], Any], None, None]:
"""
Iterate all the arguments with index and value.
This generator function yields (index, value) pairs for all arguments.
For positional arguments, indices are integers starting from 0.
For keyword arguments, indices are strings representing the argument names.
Keyword arguments are yielded in sorted order by key.
:param args: Positional arguments to iterate over.
:type args: Tuple[Any]
:param kwargs: Keyword arguments to iterate over.
:type kwargs: Dict[str, Any]
:yield: Tuples of (index, value) where index is int for positional args and str for keyword args.
:rtype: Generator[Tuple[Union[int, str], Any], None, None]
Examples::
>>> for index, value in args_iter(1, 2, 3, a=1, b=2, c=3):
... print(index, value)
0 1
1 2
2 3
a 1
b 2
c 3
"""
for _index, _item in chain(enumerate(args), sorted(kwargs.items())):
yield _index, _item
_SIG_WRAPPED = '__sig_wrapped__'
_DYNAMIC_WRAPPED = '__dynamic_wrapped__'
[docs]
def sigsupply(func: Callable[..., Any], sfunc: Callable[..., Any]) -> Callable[..., Any]:
"""
Supply a signature for builtin functions or methods.
This function provides a workaround for builtin functions that don't have inspectable
signatures. It attaches a supplemental function's signature to the builtin function,
allowing it to be processed by :func:`dynamic_call` and other signature-dependent operations.
:param func: Original function, can be a native function or builtin function.
:type func: Callable
:param sfunc: Supplemental function with a valid signature. Its implementation doesn't matter,
only its signature is used.
:type sfunc: Callable
:return: The original function if it already has a signature, or a wrapped version with
the supplemental signature attached.
:rtype: Callable
Examples::
>>> dynamic_call(max)([1, 2, 3]) # no sigsupply
ValueError: no signature found for builtin <built-in function max>
>>> dynamic_call(sigsupply(max, lambda x: None))([1, 2, 3]) # use it as func(x) when builtin
3
"""
if getattr(func, _SIG_WRAPPED, None):
return func
try:
signature(func, follow_wrapped=False)
except ValueError:
@wraps(func)
def _new_func(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
setattr(_new_func, _SIG_WRAPPED, sfunc)
return _new_func
else:
return func
def _getsignature(func: Callable[..., Any]) -> Signature:
"""
Get the signature of a function, considering supplemental signatures.
This internal helper retrieves the signature from either the function itself
or from a supplemental function attached via :func:`sigsupply`.
:param func: Function to get signature from.
:type func: Callable
:return: The function's signature.
:rtype: inspect.Signature
"""
sfunc = getattr(func, _SIG_WRAPPED, func)
return signature(sfunc, follow_wrapped=False)
[docs]
@decolize
def dynamic_call(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorate function to support dynamic calling with flexible arguments.
This decorator makes a function accept any number of arguments, automatically
filtering them based on the function's signature. Extra positional arguments
are ignored unless the function has *args, and extra keyword arguments are
ignored unless the function has **kwargs.
:param func: Original function to be decorated.
:type func: Callable
:return: Decorated function that supports dynamic calling.
:rtype: Callable
Examples::
>>> dynamic_call(lambda x, y: x ** y)(2, 3) # 8
8
>>> dynamic_call(lambda x, y: x ** y)(2, 3, 4) # 8, 3rd is ignored
8
>>> dynamic_call(lambda x, y, t, *args: (args, (t, x, y)))(1, 2, 3, 4, 5) # ((4, 5), (3, 1, 2))
((4, 5), (3, 1, 2))
>>> dynamic_call(lambda x, y: (x, y))(y=2, x=1) # (1, 2), keyword supported
(1, 2)
>>> dynamic_call(lambda x, y, **kwargs: (kwargs, x, y))(1, k=2, y=3) # ({'k': 2}, 1, 3)
({'k': 2}, 1, 3)
.. note::
Simple :func:`dynamic_call` **cannot support builtin functions because they do not have
python signatures**. If you need to deal with builtin functions, you can use :func:`sigsupply`
to add a signature onto the function when necessary.
"""
if _is_dynamic_call(func):
return func
enable_args, args_count = False, 0
enable_kwargs, kwargs_set = False, set()
for name, param in _getsignature(func).parameters.items():
if param.kind in {Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD}:
args_count += 1
if param.kind in (Parameter.KEYWORD_ONLY, Parameter.POSITIONAL_OR_KEYWORD):
kwargs_set |= {name}
if param.kind == Parameter.VAR_POSITIONAL:
enable_args = True
if param.kind == Parameter.VAR_KEYWORD:
enable_kwargs = True
def _get_args(*args: Any) -> Tuple[Any, ...]:
return args if enable_args else args[:args_count]
def _get_kwargs(**kwargs: Any) -> Dict[str, Any]:
return kwargs if enable_kwargs else {key: value for key, value in kwargs.items() if key in kwargs_set}
@wraps(func)
def _new_func(*args: Any, **kwargs: Any) -> Any:
return func(*_get_args(*args), **_get_kwargs(**kwargs))
setattr(_new_func, _DYNAMIC_WRAPPED, func)
return _new_func
def _is_dynamic_call(func: Callable[..., Any]) -> bool:
"""
Check if a function has been wrapped by dynamic_call.
:param func: Function to check.
:type func: Callable
:return: True if the function is wrapped by dynamic_call, False otherwise.
:rtype: bool
"""
return not not getattr(func, _DYNAMIC_WRAPPED, None)
[docs]
@decolize
def static_call(func: Callable[..., Any], static_ok: bool = True) -> Callable[..., Any]:
"""
Convert a dynamic-call function back to its original static form.
This function unwraps a function that has been decorated with :func:`dynamic_call`,
returning the original function. It's the inverse operation of :func:`dynamic_call`.
:param func: Given dynamic function to convert.
:type func: Callable
:param static_ok: Allow given function to be already static, default is ``True``.
:type static_ok: bool
:return: Original static function.
:rtype: Callable
:raises TypeError: If ``static_ok`` is False and the function is already static.
"""
if not static_ok and not _is_dynamic_call(func):
raise TypeError("Given callable is already static.")
return getattr(func, _DYNAMIC_WRAPPED, func)
[docs]
def pre_process(processor: Callable[..., Any]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Create a decorator that pre-processes function arguments.
This decorator applies a processor function to the arguments before passing them
to the original function. The processor can transform both positional and keyword
arguments.
:param processor: Pre-processor function that transforms arguments.
:type processor: Callable
:return: Function decorator that applies pre-processing.
:rtype: Callable
Examples::
>>> @pre_process(lambda x, y: (-x, (x + 2) * y))
>>> def plus(a, b):
>>> return a + b
>>>
>>> plus(1, 2) # 5, 5 = -1 + (1 + 2) * 2
5
.. note::
The processor can return:
- A tuple of ``(args_list, kwargs_dict)`` for both positional and keyword arguments
- A tuple/list for positional arguments only
- A dict for keyword arguments only
- A single value which will be passed as the first positional argument
"""
_processor = dynamic_call(processor)
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def _new_func(*args: Any, **kwargs: Any) -> Any:
pargs = _processor(*args, **kwargs)
if isinstance(pargs, tuple) and len(pargs) == 2 \
and isinstance(pargs[0], (list, tuple)) \
and isinstance(pargs[1], (dict,)):
args_, kwargs_ = tuple(pargs[0]), dict(pargs[1])
elif isinstance(pargs, (tuple, list)):
args_, kwargs_ = tuple(pargs), {}
elif isinstance(pargs, (dict,)):
args_, kwargs_ = (), dict(pargs)
else:
args_, kwargs_ = (pargs,), {}
return func(*args_, **kwargs_)
return _new_func
return _decorator
[docs]
def post_process(processor: Callable[..., Any]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Create a decorator that post-processes function return values.
This decorator applies a processor function to the return value of the original
function before returning it to the caller.
:param processor: Post-processor function that transforms the return value.
:type processor: Callable
:return: Function decorator that applies post-processing.
:rtype: Callable
Examples::
>>> @post_process(lambda x: -x)
>>> def plus(a, b):
>>> return a + b
>>>
>>> plus(1, 2) # -3
-3
"""
processor = dynamic_call(processor)
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def _new_func(*args: Any, **kwargs: Any) -> Any:
_result = func(*args, **kwargs)
return processor(_result)
return _new_func
return _decorator
def _is_throwable(err: Any) -> bool:
"""
Check if an object is a throwable exception.
:param err: Object to check.
:type err: Any
:return: True if the object is an exception instance or exception class.
:rtype: bool
"""
return isinstance(err, BaseException) or (isinstance(err, type) and issubclass(err, BaseException))
def _post_for_raising(ret: Any) -> Any:
"""
Post-processor helper that raises exceptions if the return value is throwable.
:param ret: Return value to check and potentially raise.
:type ret: Any
:return: The original return value if it's not an exception.
:rtype: Any
:raises BaseException: If ret is a throwable exception.
"""
if _is_throwable(ret):
raise ret
else:
return ret
[docs]
def raising(func: Union[Callable[..., Any], BaseException, Type[BaseException]]) -> Callable[..., Any]:
"""
Decorate function to raise exceptions instead of returning them.
This decorator transforms functions that return exception objects into functions
that raise those exceptions. It can also be used directly with exception classes
or instances to create raising callables.
:param func: Function that returns exceptions, or an exception class/instance.
:type func: Union[Callable, BaseException, Type[BaseException]]
:return: Decorated function that raises exceptions.
:rtype: Callable
Examples::
>>> raising(RuntimeError)() # Raises RuntimeError
RuntimeError
>>> raising(lambda x: ValueError('value error - %s' % (repr(x), )))(1) # Raises ValueError
ValueError: value error - 1
"""
if _is_throwable(func):
return raising(dynamic_call(lambda: func))
else:
return post_process(_post_for_raising)(func)
def _is_warning(w: Any) -> bool:
"""
Check if an object is a warning.
:param w: Object to check.
:type w: Any
:return: True if the object is a warning instance, warning class, or warning string.
:rtype: bool
"""
return isinstance(w, (Warning, str)) or (isinstance(w, type) and issubclass(w, Warning))
def _warn(w: Union[Warning, Type[Warning], str]) -> Union[Warning, str]:
"""
Convert a warning class to a warning instance.
:param w: Warning class, instance, or string.
:type w: Union[Warning, Type[Warning], str]
:return: Warning instance or string.
:rtype: Union[Warning, str]
"""
return w() if _is_warning(w) and isinstance(w, type) and issubclass(w, Warning) else w
def _post_for_warning(ret: Any) -> Any:
"""
Post-processor helper that issues warnings if the return value is a warning.
:param ret: Return value to check and potentially warn about.
:type ret: Any
:return: None if a warning was issued, otherwise the original return value.
:rtype: Any
"""
_matched = False
if _is_warning(ret):
_matched, _w, args_, kwargs_ = True, ret, (), {}
elif isinstance(ret, tuple) and len(ret) >= 1 and _is_warning(ret[0]):
_w, ret = ret[0], ret[1:]
if len(ret) == 1:
if isinstance(ret[0], tuple):
_matched, args_, kwargs_ = True, ret[0], {}
elif isinstance(ret[0], dict):
_matched, args_, kwargs_ = True, (), ret[0]
elif len(ret) == 2:
if isinstance(ret[0], tuple) and isinstance(ret[1], dict):
_matched, args_, kwargs_ = True, ret[0], ret[1]
if not _matched:
return ret
else:
# noinspection PyUnboundLocalVariable
warnings.warn(_warn(_w), *args_, **kwargs_)
[docs]
def warning_(func: Union[Callable[..., Any], Warning, Type[Warning], str]) -> Callable[..., Any]:
"""
Decorate function to issue warnings instead of returning them.
This decorator transforms functions that return warning objects into functions
that issue those warnings using the warnings module. It can also be used directly
with warning classes, instances, or strings to create warning callables.
:param func: Function that returns warnings, or a warning class/instance/string.
:type func: Union[Callable, Warning, Type[Warning], str]
:return: Decorated function that issues warnings.
:rtype: Callable
Examples::
>>> warning_(RuntimeWarning)() # Issues RuntimeWarning
>>> warning_(lambda x: Warning('value warning - %s' % (repr(x), )))(1) # Issues Warning
"""
if _is_warning(func):
return warning_(dynamic_call(lambda: func))
else:
return post_process(_post_for_warning)(func)
NO_INITIAL = SingletonMark("no_initial")
[docs]
def freduce(init: Any = NO_INITIAL, pass_kwargs: bool = True) -> Callable[[Callable[..., _ElementType]], Callable[..., _ElementType]]:
"""
Make a binary function reducible over multiple arguments.
This decorator transforms a binary function into a variadic function that applies
the binary operation repeatedly (reduction). Similar to functools.reduce but as
a decorator with more flexibility.
:param init: Initial value or generator function. If ``NO_INITIAL``, the first argument
is used as the initial value. Can be a value or a callable that returns a value.
:type init: Any
:param pass_kwargs: Whether to pass keyword arguments to the initial function and wrapped function.
:type pass_kwargs: bool
:return: Decorator for the original binary function.
:rtype: Callable
:raises SyntaxError: If no initial value is provided and no arguments are passed to the function.
Examples::
>>> @freduce(init=0)
>>> def plus(a, b):
>>> return a + b
>>>
>>> plus() # 0
0
>>> plus(1) # 1
1
>>> plus(1, 2) # 3
3
>>> plus(1, 2, 3, 4) # 10
10
"""
if init is NO_INITIAL:
init_func = None
else:
init_func = dynamic_call(init if hasattr(init, '__call__') else (lambda: init))
def _decorator(func: Callable[..., _ElementType]) -> Callable[..., _ElementType]:
func = dynamic_call(func)
@wraps(func)
def _new_func(*args: Any, **kwargs: Any) -> _ElementType:
if not pass_kwargs and kwargs:
warnings.warn(SyntaxWarning(
"Key-word arguments detected but will not be passed due to the pass_kwargs setting - {kwargs}.".format(
kwargs=repr(kwargs))))
kwargs = kwargs if pass_kwargs else {}
if init_func is None:
if not args:
raise SyntaxError(
"No less than 1 argument expected in function {func} but 0 found.".format(func=repr(func)))
current = args[0]
args = args[1:]
else:
current = init_func(**kwargs)
for arg in args:
current = func(current, arg, **kwargs)
return current
return _new_func
return _decorator
[docs]
def get_callable_hint(f: Callable[..., Any]) -> Any:
"""
Get the type hint of a callable as a Callable type annotation.
This function extracts type hints from a callable and returns a Callable type
annotation that represents the function's signature. If the function has only
positional parameters, it returns a specific Callable type; otherwise, it returns
Callable[..., Any].
:param f: Callable object to extract type hints from.
:type f: Callable
:return: Type hint representing the callable's signature.
:rtype: type
Examples::
>>> def f1(x: float, y: str) -> int:
... pass
>>> get_callable_hint(f1) # Callable[[float, str], int]
typing.Callable[[float, str], int]
>>>
>>> def f2(x: float, y: str, *, z: int):
... pass
>>> get_callable_hint(f2) # Callable[..., Any]
typing.Callable[..., typing.Any]
"""
count, ponly = 0, True
for key, value in signature(f).parameters.items():
if value.kind in {Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD}:
count += 1
else:
ponly = False
_type_hints = get_type_hints(f)
_return_type = _type_hints.get('return', Any)
if ponly:
_items = [_type_hints.get(key, Any) for key in signature(f).parameters.keys()]
return Callable[[*_items], _return_type]
else:
return Callable[..., _return_type]