保留装饰函数的签名
- 2025-02-10 08:57:00
- admin 原创
- 52
问题描述:
假设我编写了一个装饰器,它的功能非常通用。例如,它可能将所有参数转换为特定类型、执行日志记录、实现记忆等。
以下是一个例子:
def args_as_ints(f):
def g(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
return g
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x*y + 2*z
>>> funny_function("3", 4.0, z="5")
22
到目前为止一切都很好。但是有一个问题。修饰函数不保留原始函数的文档:
>>> help(funny_function)
Help on function g in module __main__:
g(*args, **kwargs)
幸运的是,有一个解决方法:
def args_as_ints(f):
def g(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
g.__name__ = f.__name__
g.__doc__ = f.__doc__
return g
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x*y + 2*z
这次,函数名称和文档是正确的:
>>> help(funny_function)
Help on function funny_function in module __main__:
funny_function(*args, **kwargs)
Computes x*y + 2*z
但还有一个问题:函数签名是错误的。“args, *kwargs”信息几乎毫无用处。
该怎么办?我能想到两个简单但有缺陷的解决方法:
1——在文档字符串中包含正确的签名:
def funny_function(x, y, z=3):
"""funny_function(x, y, z=3) -- computes x*y + 2*z"""
return x*y + 2*z
由于重复,这很糟糕。签名仍然无法在自动生成的文档中正确显示。很容易更新函数而忘记更改文档字符串,或者输入错误。[是的,我知道文档字符串已经重复了函数主体。请忽略这一点;funny_function 只是一个随机示例。 ]
2——不使用装饰器,或者对每个特定签名使用专用装饰器:
def funny_functions_decorator(f):
def g(x, y, z=3):
return f(int(x), int(y), z=int(z))
g.__name__ = f.__name__
g.__doc__ = f.__doc__
return g
对于具有相同签名的一组函数来说,这很好用,但总的来说没用。正如我在开头所说的,我希望能够完全通用地使用装饰器。
我正在寻找一个完全通用且自动化的解决方案。
所以问题是:在创建装饰函数签名之后,有没有办法编辑它?
否则,我可以编写一个装饰器来提取函数签名并在构造装饰函数时使用该信息而不是“kwargs,*kwargs”吗?我如何提取该信息?我应该如何使用 exec 构造装饰函数?
还有其他方法吗?
解决方案 1:
安装装饰器模块:
$ pip install decorator
调整定义
args_as_ints()
:
import decorator
@decorator.decorator
def args_as_ints(f, *args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x*y + 2*z
print funny_function("3", 4.0, z="5")
# 22
help(funny_function)
# Help on function funny_function in module __main__:
#
# funny_function(x, y, z=3)
# Computes x*y + 2*z
Python 3.4+
functools.wraps()
从 Python 3.4 开始, stdlib保留了签名:
import functools
def args_as_ints(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return func(*args, **kwargs)
return wrapper
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x*y + 2*z
print(funny_function("3", 4.0, z="5"))
# 22
help(funny_function)
# Help on function funny_function in module __main__:
#
# funny_function(x, y, z=3)
# Computes x*y + 2*z
functools.wraps()
至少从 Python 2.5 开始可用,但它没有保留签名:
help(funny_function)
# Help on function funny_function in module __main__:
#
# funny_function(*args, **kwargs)
# Computes x*y + 2*z
注意:*args, **kwargs
而不是x, y, z=3
。
解决方案 2:
functools
这个问题可以通过 Python 的标准库和函数来解决functools.wraps
,该函数旨在“更新包装函数,使其看起来像包装函数”。但它的行为取决于 Python 版本,如下所示。应用到问题中的示例,代码将如下所示:
from functools import wraps
def args_as_ints(f):
@wraps(f)
def g(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
return g
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x*y + 2*z
在 Python 3 中执行时,将产生以下内容:
>>> funny_function("3", 4.0, z="5")
22
>>> help(funny_function)
Help on function funny_function in module __main__:
funny_function(x, y, z=3)
Computes x*y + 2*z
它唯一的缺点是,在 Python 2 中,它不会更新函数的参数列表。在 Python 2 中执行时,它将产生:
>>> help(funny_function)
Help on function funny_function in module __main__:
funny_function(*args, **kwargs)
Computes x*y + 2*z
解决方案 3:
有一个带有装饰器的装饰器模块decorator
,您可以使用:
@decorator
def args_as_ints(f, *args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
然后保留方法的签名和帮助:
>>> help(funny_function)
Help on function funny_function in module __main__:
funny_function(x, y, z=3)
Computes x*y + 2*z
编辑:JF Sebastian 指出我没有修改args_as_ints
功能——现在已经修复。
解决方案 4:
看一下装饰器模块——特别是装饰器decorator,它解决了这个问题。
解决方案 5:
第二种选择:
安装 wrapt 模块:
$ easy_install wrapt
wrapt 有奖金,保留班级签名。
import wrapt
import inspect
@wrapt.decorator
def args_as_ints(wrapped, instance, args, kwargs):
if instance is None:
if inspect.isclass(wrapped):
# Decorator was applied to a class.
return wrapped(*args, **kwargs)
else:
# Decorator was applied to a function or staticmethod.
return wrapped(*args, **kwargs)
else:
if inspect.isclass(instance):
# Decorator was applied to a classmethod.
return wrapped(*args, **kwargs)
else:
# Decorator was applied to an instancemethod.
return wrapped(*args, **kwargs)
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x * y + 2 * z
>>> funny_function(3, 4, z=5))
# 22
>>> help(funny_function)
Help on function funny_function in module __main__:
funny_function(x, y, z=3)
Computes x*y + 2*z
解决方案 6:
正如上面jfs 的回答中所说;如果您关心外观方面的签名(help
,和inspect.signature
),那么使用functools.wraps
就完全没问题。
如果您关心签名的行为(特别是TypeError
在参数不匹配的情况下),functools.wraps
则不保留它。您应该使用decorator
,或者我对其核心引擎的概括,名为makefun
。
from makefun import wraps
def args_as_ints(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("wrapper executes")
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return func(*args, **kwargs)
return wrapper
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x*y + 2*z
print(funny_function("3", 4.0, z="5"))
# wrapper executes
# 22
help(funny_function)
# Help on function funny_function in module __main__:
#
# funny_function(x, y, z=3)
# Computes x*y + 2*z
funny_function(0)
# observe: no "wrapper executes" is printed! (with functools it would)
# TypeError: funny_function() takes at least 2 arguments (1 given)
另请参阅有关此帖子functools.wraps
。
解决方案 7:
from inspect import signature
def args_as_ints(f):
def g(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
sig = signature(f)
g.__signature__ = sig
g.__doc__ = f.__doc__
g.__annotations__ = f.__annotations__
g.__name__ = f.__name__
return g
@args_as_ints
def funny_function(x, y, z=3):
"""Computes x*y + 2*z"""
return x*y + 2*z
>>> funny_function("3", 4.0, z="5")
22
我想添加这个答案(因为这首先出现在谷歌中)。inspect 模块能够获取函数的签名,以便可以将其保存在装饰器中。但这还不是全部。如果您想修改签名,可以这样做:
from inspect import signature, Parameter, _ParameterKind
def foo(a: int, b: int) -> int:
return a + b
sig = signature(foo)
sig._parameters = dict(sig.parameters)
sig.parameters['c'] = Parameter(
'c', _ParameterKind.POSITIONAL_OR_KEYWORD,
annotation=int
)
foo.__signature__ = sig
>>> help(foo)
Help on function foo in module __main__:
foo(a: int, b: int, c: int) -> int
为什么要改变函数的签名?
对函数和方法进行充分的文档记录非常有用。如果您使用该*args, **kwargs
语法,然后从 kwargs 中弹出参数以用于装饰器中的其他用途,则该关键字参数将无法正确记录,因此会修改函数的签名。
解决方案 8:
def args_as_ints(f):
def g(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
g.__name__ = f.__name__
g.__doc__ = f.__doc__
return g
这修复了名称和文档。为了保留函数签名,wrap
与在完全相同的位置使用g.__name__ = f.__name__, g.__doc__ = f.__doc__
。
本身wraps
就是一个装饰器。我们将闭包(内部函数)传递给该装饰器,它将修复元数据。但是,如果我们只将内部函数传递给wraps
,它就不知道从哪里复制元数据。它需要知道哪个函数的元数据需要保护。它需要知道原始函数。
def args_as_ints(f):
def g(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
g=wraps(f)(g)
return g
wraps(f)
将返回一个函数,该函数将接受g
作为其参数。它将返回闭包,并将其赋值给g
,然后我们将其返回。
解决方案 9:
正如已经回答的那样,functools.wraps
这是您要走的路。这有时会带来缺点,在使用typeshed的 IDE 类型检查器/linters 中,即使是简单函数的 IDE 中的类型提示也会发生变化,更改为不太好读的_Wrapped
类型。您需要再次转换或覆盖该类型提示,例如使用与之一起使用的装饰器的签名。
@wraps(int)
def foo(x: str):
return int(x)
reveal_type(foo)
# foo: _Wrapped[(x: ConvertibleToInt = ..., /), int, (x: str), int]
函数签名越长,就越丑陋。
是用来设置本例中以及其他属性的指示器_Wrapped
。更准确地说,但取决于python版本:以及来自的所有条目。functools.update_wrapper
`foo.__wrapped__ = intint
foo'__module__', '__name__', '__qualname__', '__doc__', '__annotations__'
__dict__`
有时知道这一点很好,但有时却不方便,而且您想保留原始签名。
实际上有两种不同的情况 1)保持签名相同 2)注入额外参数。
对于 1),您可以使用绑定的 Callable TypeVar 或新的 3.12+ 语法:
Python <3.12
from typing import TypeVar, Callable
from functools import wraps
CT = TypeVar("CT", bound=Callable[..., Any])
def args_as_ints(f : CT) -> CT:
"""
This tells the type-checker that the returned
value is equivalent to the input value.
"""
@wraps(f)
def g(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
return g
...
Python 3.12+
from typing import Any, Callable
from functools import wraps
# Any is narrowed by the int from funny_function.
def args_as_ints[T: Callable[..., Any]](func: T) -> T:
@wraps(func)
def wrapper(*args, **kwargs):
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return func(*args, **kwargs)
return wrapper
...
reveal_type(funny_function)
# funny_function" is "(x: Any, y: Any, z: Any = 3) -> int"
2)扩展签名
保持类型提示相同的另一种方法是使用稍微不同的装饰器,并使用ParamSpec和functools.update_wrapper
from typing import TypeVar, Callable, ParamSpec, Concatenate
from functools import update_wrapper
T = TypeVar("T")
P = ParamSpec("P")
def args_as_ints(f : Callable[P, T]): # -> Callable[Concatenate[int, P], T]:
# In this case you can also replace it with "int" to be explicit and not use
# the signature of f, i.e. args: int
def g(special_value=1, *args: P.args, **kwargs: P.kwargs) -> T:
args = [int(x) for x in args]
kwargs = dict((k, int(v)) for k, v in kwargs.items())
return f(*args, **kwargs)
update_wrapper(g, f)
return g
ConvertibleToInt : TypeAlias = Any # you can do this properly
@args_as_ints
def funny_function(x : "ConvertibleToInt",
y: "ConvertibleToInt",
z: "ConvertibleToInt"=3) -> int:
"""Computes x*y + 2*z"""
return x*y + 2*z
reveal_type(funny_function)
# "funny_function" is "(special_value: int = ... x: Any, y: Any, z: Any = ...) -> int"
在运行时这相当于@wraps
。
但是有一个优点和一个缺点:
优点:这是正确的显式类型,它有助于理解代码,并且可以避免错误。
缺点:您可能会丢失默认参数,因为您会看到z
不再有默认值。带有的显式注释-> Callable[Concatenate[int, P], T]:
会删除第一个参数名为的信息special_value
。隐式类型/签名跟踪可以保留此信息,并且不会使用_Wrapped
。当您在装饰器中注释或返回类型时,您会失去隐式跟踪。f