装饰器如何在不改变签名的情况下将变量传递给函数?

时间:2014-11-04 22:41:54

标签: python

首先我要承认,我想要做的事情可能被认为是从愚蠢到邪恶,但我想知道我是否可以用Python做到这一点。

假设我有一个函数装饰器,它接受定义变量的关键字参数,我想在包装函数中访问这些变量。我可能会这样做:

def more_vars(**extras):
    def wrapper(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            return f(extras, *args, **kwargs)
        return wrapped
    return wrapper

现在我可以这样做:

@more_vars(a='hello', b='world')
def test(deco_vars, x, y):
    print(deco_vars['a'], deco_vars['b'])
    print(x, y)

test(1, 2)
# Output:
# hello world
# 1 2

我不喜欢这样的事情是当你使用这个装饰器时,你必须改变函数的调用签名,除了在装饰器上拍打外,还要添加额外的变量。另外,如果你查看函数的帮助,你会看到一个额外的变量,你在调用函数时不希望使用它:

help(test)
# Output:
# Help on function test in module __main__:
#
# test(deco_vars, x, y)

这使得用户希望用3个参数调用该函数,但显然不会起作用。因此,您还必须向docstring添加一条消息,指示第一个参数不是界面的一部分,它只是一个实现细节,应该被忽略。不过,这有点像蹩脚。有没有办法在不将这些变量挂在全局范围内的情况下做到这一点?理想情况下,我希望它看起来如下:

@more_vars(a='hello', b='world')
def test(x, y):
    print(a, b)
    print(x, y)

test(1, 2)
# Output:
# hello world
# 1 2
help(test)
# Output:
# Help on function test in module __main__:
#
# test(x, y)

我满足于只有Python 3的解决方案。

4 个答案:

答案 0 :(得分:3)

你可以通过一些技巧来实现这一点,将传递给装饰器的变量插入到函数的局部变量中:

import sys
from functools import wraps
from types import FunctionType


def is_python3():
    return sys.version_info >= (3, 0)


def more_vars(**extras):
    def wrapper(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            fn_globals = {}
            fn_globals.update(globals())
            fn_globals.update(extras)
            if is_python3():
                func_code = '__code__'
            else:
                func_code = 'func_code'
            call_fn = FunctionType(getattr(f, func_code), fn_globals)
            return call_fn(*args, **kwargs)
        return wrapped
    return wrapper


@more_vars(a="hello", b="world")
def test(x, y):
    print("locals: {}".format(locals()))
    print("x: {}".format(x))
    print("y: {}".format(y))
    print("a: {}".format(a))
    print("b: {}".format(b))


if __name__ == "__main__":
    test(1, 2)

可以你这样做吗?当然! 应该你这样做?可能不是!

(可用代码here。)

答案 1 :(得分:1)

编辑:已编辑答案,以提高可读性。最新答案在最前面,原始答案在后面。

如果我理解得很好

  • 您希望在@more_vars装饰器中将新参数定义为关键字
  • 您想在修饰的功能中使用它们
  • 并且您希望它们对普通用户隐藏(公开的签名仍应是普通签名)

看看我的图书馆@with_partial中的makefun装饰器。它提供了开箱即用的功能:

from makefun import with_partial

@with_partial(a='hello', b='world')
def test(a, b, x, y):
    """Here is a doc"""
    print(a, b)
    print(x, y)

它会产生预期的输出,并相应地修改文档字符串:

test(1, 2)
help(test)

收益

hello world
1 2
Help on function test in module <...>:

test(x, y)
    <This function is equivalent to 'test(x, y, a=hello, b=world)', see original 'test' doc below.>
    Here is a doc

要回答您评论中的问题,makefun中的函数创建策略与著名的decorator库中的函数创建策略完全相同:compile + exec。这里没有魔术,但是decorator在现实应用中已经使用了多年了,因此非常可靠。请参见source code中的def _make

请注意,如果您出于某种原因要自己创建装饰器,则makefun库还提供了partial(f, *args, **kwargs)函数。


如果您希望手动执行此操作,则此解决方案应该可以按预期工作,它依靠makefun提供的wraps函数来修改公开的签名。

from makefun import wraps, remove_signature_parameters

def more_vars(**extras):
    def wrapper(f):
        # (1) capture the signature of the function to wrap and remove the invisible
        func_sig = signature(f)
        new_sig = remove_signature_parameters(func_sig, 'invisible_args')

        # (2) create a wrapper with the new signature
        @wraps(f, new_sig=new_sig)
        def wrapped(*args, **kwargs):
            # inject the invisible args again
            kwargs['invisible_args'] = extras
            return f(*args, **kwargs)

        return wrapped
    return wrapper

您可以测试它是否有效:

@more_vars(a='hello', b='world')
def test(x, y, invisible_args):
    a = invisible_args['a']
    b = invisible_args['b']
    print(a, b)
    print(x, y)

test(1, 2)
help(test)

如果使用decopatch删除无用的嵌套级别,甚至可以使装饰器定义更紧凑:

from decopatch import DECORATED
from makefun import wraps, remove_signature_parameters

@function_decorator
def more_vars(f=DECORATED, **extras):
    # (1) capture the signature of the function to wrap and remove the invisible
    func_sig = signature(f)
    new_sig = remove_signature_parameters(func_sig, 'invisible_args')

    # (2) create a wrapper with the new signature
    @wraps(f, new_sig=new_sig)
    def wrapped(*args, **kwargs):
        kwargs['invisible_args'] = extras
        return f(*args, **kwargs)

    return wrapped

最后,如果您不想依赖任何外部库,则最Python化的方法是创建一个函数工厂(但是您不能将其用作装饰器):

def make_test(a, b, name=None):
    def test(x, y):
        print(a, b)
        print(x, y)
    if name is not None:
        test.__name__ = name
    return test

test = make_test(a='hello', b='world')
test2 = make_test(a='hello', b='there', name='test2')

我是makefundecopatch的作者;)

答案 2 :(得分:0)

听起来你唯一的问题是help显示原始test的签名作为包装函数的签名,而你不想要它。

发生的唯一原因是wraps(或者更确切地说,update_wrapperwraps调用)明确地将其从包装中复制到包装器。

您可以准确决定自己的所作所为并且不想复制。如果你想要做的不同的事情很简单,那么只需要过滤默认的WRAPPER_ASSIGNMENTSWRAPPER_UPDATES之外的内容。如果您想更改其他内容,可能需要分叉update_wrapper并使用您自己的版本 - 但functools是其中一个模块,其中包含指向the source的顶部文档,因为它意味着用作可读的示例代码。

在您的情况下,它可能只是wraps(f, updated=[])的问题,或者您可能想做一些奇特的事情,比如使用inspect.signature来获取f的签名,然后修改它删除第一个参数,并在其周围明确构建一个包装器,以欺骗inspect模块。

答案 3 :(得分:0)

我已经找到了解决该问题的方法,尽管从大多数标准来看,该解决方案几乎肯定比问题本身更糟。通过巧妙地修饰修饰的函数的字节码,您可以将对给定名称的变量的所有引用重定向到可以为该函数动态创建的新闭包。此解决方案仅适用于标准CPython,而我仅使用3.7进行了测试。

import inspect

from dis import opmap, Bytecode
from types import FunctionType, CodeType

def more_vars(**vars):
    '''Decorator to inject more variables into a function.'''

    def wrapper(f):
        code = f.__code__
        new_freevars = code.co_freevars + tuple(vars.keys())
        new_globals = [var for var in code.co_names if var not in vars.keys()]
        new_locals = [var for var in code.co_varnames if var not in vars.keys()]
        payload = b''.join(
            filtered_bytecode(f, new_freevars, new_globals, new_locals))
        new_code = CodeType(code.co_argcount,
                            code.co_kwonlyargcount,
                            len(new_locals),
                            code.co_stacksize,
                            code.co_flags & ~inspect.CO_NOFREE,
                            payload,
                            code.co_consts,
                            tuple(new_globals),
                            tuple(new_locals),
                            code.co_filename,
                            code.co_name,
                            code.co_firstlineno,
                            code.co_lnotab,
                            code.co_freevars + tuple(vars.keys()),
                            code.co_cellvars)
        closure = tuple(get_cell(v) for (k, v) in vars.items())
        return FunctionType(new_code, f.__globals__, f.__name__, f.__defaults__,
                            (f.__closure__ or ()) + closure)
    return wrapper

def get_cell(val=None):
    '''Create a closure cell object with initial value.'''

    # If you know a better way to do this, I'd like to hear it.
    x = val
    def closure():
        return x  # pragma: no cover
    return closure.__closure__[0]

def filtered_bytecode(func, freevars, globals, locals):
    '''Get the bytecode for a function with adjusted closed variables

    Any references to globlas or locals in the bytecode which exist in the
    freevars are modified to reference the freevars instead.

    '''
    opcode_map = {
        opmap['LOAD_FAST']: opmap['LOAD_DEREF'],
        opmap['STORE_FAST']: opmap['STORE_DEREF'],
        opmap['LOAD_GLOBAL']: opmap['LOAD_DEREF'],
        opmap['STORE_GLOBAL']: opmap['STORE_DEREF']
    }
    freevars_map = {var: idx for (idx, var) in enumerate(freevars)}
    globals_map = {var: idx for (idx, var) in enumerate(globals)}
    locals_map = {var: idx for (idx, var) in enumerate(locals)}

    for instruction in Bytecode(func):
        if instruction.opcode not in opcode_map:
            yield bytes([instruction.opcode, instruction.arg or 0])
        elif instruction.argval in freevars_map:
            yield bytes([opcode_map[instruction.opcode],
                         freevars_map[instruction.argval]])
        elif 'GLOBAL' in instruction.opname:
            yield bytes([instruction.opcode,
                         globals_map[instruction.argval]])
        elif 'FAST' in instruction.opname:
            yield bytes([instruction.opcode,
                         locals_map[instruction.argval]])

这与我想要的完全一样:

In [1]: @more_vars(a='hello', b='world')
   ...: def test(x, y):
   ...:     print(a, b)
   ...:     print(x, y)
   ...:

In [2]: test(1, 2)
hello world
1 2

In [3]: help(test)
Help on function test in module __main__:

test(x, y)

几乎可以肯定,尚未准备好将其用于生产。如果不存在表现异常的边缘案例,甚至可能出现段错误,我都会感到惊讶。我可能会将此列为“教育好奇心”标题下。