在装饰函数中强制使用关键字参数

Enforcing keyword-only arguments in decorated functions

我有一个 class 有几个方法需要存在特定参数,但出于不同的原因。

通常,参数将作为属性附加到实例,在这种情况下不需要传递参数。但是,如果缺少该属性(或 None),则可以选择将此参数作为仅关键字参数传递:

import functools

class Foo:
    def __init__(self, this_kwarg_default=None):
        self.default = this_kwarg_default
    
    @staticmethod
    def require_this_kwarg(reason):
        def enforced(func):
            @functools.wraps(func)
            def wrapped(self, *args, this_kwarg=None, **kwargs):
                if this_kwarg is None:
                    this_kwarg = self.default
                if this_kwarg is None:
                    raise TypeError(f'You need to pass this kwarg, {reason}!')
                return func(self, *args, this_kwarg=this_kwarg, **kwargs)
        
            return wrapped
        return enforced

    require_this_kwarg = require_this_kwarg.__func__

    @require_this_kwarg('because I said so')
    def foo(self, this_kwarg=None):
        print(f'This kwarg is {str(this_kwarg)}')

大多数情况下,这会产生所需的行为。

>>> myfoo = Foo(42)
>>> myfoo.foo()
This kwarg is 42
>>> myfoo.foo(this_kwarg=4)
This kwarg is 4
>>> yourfoo = Foo()
>>> yourfoo.foo()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "dec.py", line 15, in wrapped
    raise TypeError(f'You need to pass this kwarg, {reason}!')
TypeError: You need to pass this kwarg, because I said so!

但是如果传递任何位置参数,我会得到一些意想不到的行为:

>>> myfoo.foo(4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "dec.py", line 16, in wrapped
    return func(self, *args, this_kwarg=this_kwarg, **kwargs)
TypeError: foo() got multiple values for argument 'this_kwarg'

这很有意义,然后定义 Foo.foo 以将 this_kwarg 作为仅关键字参数:

@require_this_kwarg('because I said so')
def foo(self, *, this_kwarg=None):
    print(f'This kwarg is {str(this_kwarg)}')

然而...

>>> myfoo.foo(4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "dec.py", line 16, in wrapped
    return func(self, *args, this_kwarg=this_kwarg, **kwargs)
TypeError: foo() takes 1 positional argument but 2 positional arguments (and 1 keyword-only argument) were given

在这种情况下,期望的行为是引发 TypeError: foo() takes 0 positional arguments but 1 was given,就像没有使用装饰器时预期的那样。

我希望 functools.wraps 会强制执行修饰函数的调用签名。显然,这不是 wraps 所做的。有什么办法可以做到这一点吗?

这并没有真正回答问题;但它是一个可行的解决方案,说明了所需的行为,而且对于评论来说太长了。

也许这是一个 bad/stupid 的想法。我不确定。

我想要的基本上是一个可选的关键字参数:

def foo(self, this_kwarg=None):
    if this_kwarg is None:
        this_kwarg = self.default
    ...

但是如果self.default is None呢?一般来说,这是应该被允许的。然而,某些方法如 Foo.foo 要求此 this_kwarg 不是 None,即如果是,它们将失败。所以基本上我正在寻找 descriptive/informative 以一种很好的 'pythonic' 方式处理错误。

一种解决方法是像这样实现 Foo.foo

def foo(self, this_kwarg=None):
    if this_kwarg is None:
        this_kwarg = self.default
    if this_kwarg is None:
        raise TypeError('This kwarg cannot be `None`, because I said so!')
    ...

但是我必须将这段代码添加到每个有此要求的方法中。 (假设我有其他几种方法 Foo.barFoo.baz 等,如果参数为 None,所有这些方法都不起作用。)

我认为装饰器将是一种优雅且 pythonic 的方式来实现它,而无需大量重复代码。问题是 Foo.fooFoo.barFoo.baz 等都有不同的调用签名。除了 this_kwarg,其中一些方法可能有 1 个或多个(可能是任意数量)位置 and/or 关键字参数。

也许,那么,最好的解决办法是:

class Foo:
    def __init__(self, default=None):
        self.default = default
    
    def require_this_kwarg(self, this_kwarg, reason):
        if this_kwarg is None:
            this_kwarg = self.default
        if this_kwarg is None:
            raise TypeError(f'This kwarg cannot be `None`, because {reason}!')
        return this_kwarg

    def foo(self, *args, this_kwarg=None, **kwargs):
        this_kwarg = self.require_this_kwarg(this_kwarg, 'I said so')
        ...

这实际上是我最初的解决方案。然后我尝试用装饰器 & 运行 来解决我描述的问题。我认为函数调用签名是否可以在装饰器内部强制执行是一个有趣的问题。

哇,这比我预期的要棘手得多。我很想知道是否有人提出了更简单、更清洁的解决方案,但我认为这可以满足您的需求?

from inspect import getfullargspec
import functools


class Foo:
    def __init__(self, x_default):
        self.default = x_default

    @staticmethod
    def require_x(reason):
        def enforced(func):
            @functools.wraps(func)
            def wrapped(self, *args, **kwargs):
                argspec = getfullargspec(func)
                while True:
                    if 'x' in kwargs:
                        # it's explicitly there, so it will have a value
                        if kwargs['x'] is None:
                            kwargs['x'] = self.default
                        break
                    elif argspec.varargs is None:
                        # there are no varargs to eat up positional arguments
                        if 'x' in argspec.args[:len(args)+1]:
                            # x will get a value from args, offset by one for self
                            if args[argspec.args.index('x') - 1] is None:
                                args = tuple(a if n != argspec.args.index('x') - 1 else self.default
                                             for n, a in enumerate(args))
                            break
                        elif argspec.defaults is not None and 'x' in argspec.args[-len(argspec.defaults):]:
                            # x will get a value from a default
                            if argspec.defaults[argspec.args[-len(argspec.defaults):].index('x')] is None:
                                kwargs['x'] = self.default
                            break
                    elif 'x' in argspec.kwonlydefaults:
                        if argspec.kwonlydefaults['x'] is None:
                            kwargs['x'] = self.default
                        break
                    raise TypeError(f'{func.__name__} needs a value for x, {reason}.')

                func(self, *args, **kwargs)

            return wrapped

        return enforced

    require_x = require_x.__func__

我不喜欢需要 inspect 才能工作的生产代码,所以我仍然怀疑您是否真的需要执行此操作的代码 - 在更广泛的设计在这里。但是我想什么都可以做。

看完你的问题和评论后,我对你的问题的理解是你在按以下顺序搜索价值。

  1. 如果this_kwarg存在,使用它。
  2. 如果#1 失败,使用 self.default
  3. 如果#2 失败,引发错误。

这段代码应该在位置或关键字参数中工作。

import functools

class Foo:
    def __init__(self, this_kwarg_default=None):
        self.default = this_kwarg_default
    
    @staticmethod
    def require_this_kwarg(reason):
        def enforced(func):
            @functools.wraps(func)
            def wrapped(self, this_kwarg=None):
                def _process(k=self.default):
                    if k is None:
                        raise TypeError(f'You need to pass this kwarg, {reason}!')
                    return func(self, k)
                if this_kwarg is None:
                    return _process()
                return _process(this_kwarg)
            return wrapped
        return enforced

    require_this_kwarg = require_this_kwarg.__func__

    @require_this_kwarg('because I said so')
    def foo(self, this_kwarg=None):
        print(f'This kwarg is {str(this_kwarg)}')

核心逻辑在下面的代码中

                    ...
 12                 def _process(k=self.default):
 13                     if k is None:
 14                         raise TypeError(f'You need to pass this kwarg, {reason}!')
 15                     return func(self, k)
 16                 if this_kwarg is None:
 17                     return _process()
 18                 return _process(this_kwarg)
                    ...

代码逻辑符合上述搜索顺序:

  1. 如果 this_kwarg 不是 None,return func(this_kwarg)。 (第 18 行)
  2. 如果 this_kwarg 是 None,请尝试 return func(self.default)。 (第 17 行)
  3. 如果 this_kwargself.default 都是 None,则引发错误。 (第 14 行)

测试

import pytest

@pytest.mark.parametrize("default_val", [None, 1, "this_kwarg_default=1"])
@pytest.mark.parametrize("call_val", [None, 3, "this_kwarg=3"])
def test_foo(default_val, call_val):
    print("parametr are: ", default_val, call_val)
    f = eval(f"Foo({default_val})")
    eval(f"f.foo({call_val})")

输出:

============================================================================== test session starts ===============================================================================
platform darwin -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /Users/kz2249/tmp/st/tests
collected 9 items                                                                                                                                                                

test_example.py parametr are:  None None
Fparametr are:  1 None
This kwarg is 1

.parametr are:  this_kwarg_default=1 None
This kwarg is 1

.parametr are:  None 3
This kwarg is 3

.parametr are:  1 3
This kwarg is 3

.parametr are:  this_kwarg_default=1 3
This kwarg is 3

.parametr are:  None this_kwarg=3
This kwarg is 3

.parametr are:  1 this_kwarg=3
This kwarg is 3

.parametr are:  this_kwarg_default=1 this_kwarg=3
This kwarg is 3

.

==================================================================================== FAILURES ====================================================================================
______________________________________________________________________________ test_foo[None-None] _______________________________________________________________________________

default_val = None, call_val = None

    @pytest.mark.parametrize("default_val", [None, 1, "this_kwarg_default=1"])
    @pytest.mark.parametrize("call_val", [None, 3, "this_kwarg=3"])
    def test_foo(default_val, call_val):
        print("parametr are: ", default_val, call_val)
        f = eval(f"Foo({default_val})")
>       eval(f"f.foo({call_val})")

test_example.py:36: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
<string>:1: in <module>
    ???
test_example.py:19: in wrapped
    return wrap()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

k = None

    def wrap(k=self.default):
        if k is None:
>           raise TypeError(f'You need to pass this kwarg, {reason}!')
E           TypeError: You need to pass this kwarg, because I said so!

test_example.py:16: TypeError
============================================================================ short test summary info =============================================================================
FAILED test_example.py::test_foo[None-None] - TypeError: You need to pass this kwarg, because I said so!
========================================================================== 1 failed, 8 passed in 0.10s ===========================================================================