[英]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.bar
, Foo.baz
等,如果參數是None
,它們都不能工作。)
我認為裝飾器將是一種優雅且 Python 式的方式來實現這一點,而無需大量重復代碼。 問題是Foo.foo
、 Foo.bar
、 Foo.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
才能工作的生產代碼,所以我仍然懷疑您是否真的需要執行此操作的代碼 - 在這里更廣泛的設計中可能存在一些反模式。 但我想,什么都可以做。
閱讀您的問題和評論后,我對您的問題的理解是您正在按以下順序搜索價值。
this_kwarg
存在,請使用它。self.default
。此代碼應在 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)
...
代碼邏輯匹配上述搜索順序:
this_kwarg
不是 None,則返回func(this_kwarg)
。 (第 18 行)this_kwarg
為 None,請嘗試 return func(self.default)
。 (第 17 行)this_kwarg
和self.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.