簡體   English   中英

在修飾函數中強制執行僅關鍵字 arguments

[英]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將強制執行裝飾 function 的調用簽名。 顯然,這不是wraps所做的。 有沒有辦法做到這一點?

這並不能真正回答問題。 但它是一個可行的解決方案,說明了所需的行為,並且無論如何都太長了,無法發表評論。

也許這是一個壞/愚蠢的主意。 我不確定。

我想要的基本上是一個可選的關鍵字參數:

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

但是如果self.default is None怎么辦? 一般來說,這應該是允許的。 但是,某些方法,例如Foo.foo要求this_kwarg不是None ,即如果是,它們將失敗。 所以基本上我正在以一種很好的“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 ,它們都不能工作。)

我認為裝飾器將是一種優雅且 Python 式的方式來實現這一點,而無需大量重復代碼。 問題是Foo.fooFoo.barFoo.baz等都有不同的調用簽名。 除了this_kwarg ,其中一些方法可能有 1 個或多個(可能是任意數量)位置和/或關鍵字 arguments。

那么,也許最好的解決方案是:

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')
        ...

這實際上是我最初的解決方案。 然后我嘗試用裝飾器來做這件事並遇到了我描述的問題。 我認為 function 調用簽名是否可以在裝飾器中強制執行的問題是一個有趣的問題。

哇,這比我預期的要棘手得多。 我很想看看是否有人提出了一個更簡單、更清潔的解決方案,但我認為這可以滿足您的需求嗎?

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 失敗,則引發錯誤。

此代碼應在 position 或關鍵字參數中工作。

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,則返回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})")

Output:

============================================================================== 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 ===========================================================================

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM