So - __doc__
apart this is tricky - and, due to more and more developers relying on the automatic parameter suggestions when coding, which is given by IDE introspection, it is really needed for any decorator that will add extra named parameters to a function.
I got to that in a project I am developing, and the solution is to create a new, dummy function, that will have the desired combined signature to be shown - and then using this new dummy function as the parameter to the @wraps
call,.
Here is my code - it is good enough, and so unrelated with the other project I will likely put it in a decorators Python package soon. For now:
def combine_signatures(func, wrapper=None):
"""Adds keyword-only parameters from wrapper to signature
Use this in place of `functools.wraps`
It works by creating a dummy function with the attrs of func, but with
extra, KEYWORD_ONLY parameters from 'wrapper'.
To be used in decorators that add new keyword parameters as
the "__wrapped__"
Usage:
def decorator(func):
@combine_signatures(func)
def wrapper(*args, new_parameter=None, **kwargs):
...
return func(*args, **kwargs)
"""
# TODO: move this into 'extradeco' independent package
from functools import partial, wraps
from inspect import signature, _empty as insp_empty, _ParameterKind as ParKind
from itertools import groupby
if wrapper is None:
return partial(combine_signatures, func)
sig_func = signature(func)
sig_wrapper = signature(wrapper)
pars_func = {group:list(params) for group, params in groupby(sig_func.parameters.values(), key=lambda p: p.kind)}
pars_wrapper = {group:list(params) for group, params in groupby(sig_wrapper.parameters.values(), key=lambda p: p.kind)}
def render_annotation(p):
return f"{':' + (repr(p.annotation) if not isinstance(p.annotation, type) else repr(p.annotation.__name__)) if p.annotation != insp_empty else ''}"
def render_params(p):
return f"{'=' + repr(p.default) if p.default != insp_empty else ''}"
def render_by_kind(groups, key):
parameters = groups.get(key, [])
return [f"{p.name}{render_annotation(p)}{render_params(p)}" for p in parameters]
pos_only = render_by_kind(pars_func, ParKind.POSITIONAL_ONLY)
pos_or_keyword = render_by_kind(pars_func, ParKind.POSITIONAL_OR_KEYWORD)
var_positional = [p for p in pars_func.get(ParKind.VAR_POSITIONAL,[])]
keyword_only = render_by_kind(pars_func, ParKind.KEYWORD_ONLY)
var_keyword = [p for p in pars_func.get(ParKind.VAR_KEYWORD,[])]
extra_parameters = render_by_kind(pars_wrapper, ParKind.KEYWORD_ONLY)
def opt(seq, value=None):
return ([value] if value else [', '.join(seq)]) if seq else []
annotations = func.__annotations__.copy()
for parameter in pars_wrapper.get(ParKind.KEYWORD_ONLY):
annotations[parameter.name] = parameter.annotation
param_spec = ', '.join([
*opt(pos_only),
*opt(pos_only, '/'),
*opt(pos_or_keyword),
*opt(keyword_only or extra_parameters, ('*' if not var_positional else f"*{var_positional[0].name}")),
*opt(keyword_only),
*opt(extra_parameters),
*opt(var_keyword, f"**{var_keyword[0].name}" if var_keyword else "")
])
declaration = f"def {func.__name__}({param_spec}): pass"
f_globals = func.__globals__
f_locals = {}
exec(declaration, f_globals, f_locals)
result = f_locals[func.__name__]
result.__qualname__ = func.__qualname__
result.__doc__ = func.__doc__
result.__annotations__ = annotations
return wraps(result)(wrapper)
Testing in interactive mode one gets this result:
IPython 7.8.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]: from terminedia.utils import combine_signatures
In [2]: def add_color(func):
...: @combine_signatures(func)
...: def wrapper(*args, color=None, **kwargs):
...: global context
...: context.color = color
...: return func(*args, **kw)
...: return wrapper
...:
In [3]: @add_color
...: def line(p1, p2):
...: pass
...:
In [4]: line
Out[4]: <function __main__.line(p1, p2, *, color=None)>
(As for doc strings, as in the question - once one have got all the wrapper and function data, it is a matter of text handling before pasting result.__doc__ = func.__doc__
. Since each project will have different styles for documenting parameters inside docstrings, it can't be done reliably in a 'one size fits all', but with some string splicing and testing it could be perfected for any given doc string style)