Skip to content

Commit 9ed34d4

Browse files
committed
New feature: @cases_fixture. Fixes #15.
Added related test. New dependency: decorator.
1 parent 1d870d2 commit 9ed34d4

File tree

6 files changed

+423
-27
lines changed

6 files changed

+423
-27
lines changed

ci_tools/requirements-conda.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pandoc
55
pypandoc
66

77
# --- to install
8+
decorator
89

910
# --- to run the tests
1011
# (pip) pytest$PYTEST_VERSION

pytest_cases/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
except ImportError:
66
pass
77

8-
from pytest_cases.main import cases_data, CaseDataGetter, unfold_expected_err, extract_cases_from_module, THIS_MODULE
8+
from pytest_cases.main import cases_data, CaseDataGetter, cases_fixture, \
9+
unfold_expected_err, extract_cases_from_module, THIS_MODULE
910

1011
__all__ = [
1112
# the 2 submodules
1213
'main', 'case_funcs',
1314
# all symbols imported above
14-
'cases_data', 'CaseData', 'CaseDataGetter', 'unfold_expected_err', 'extract_cases_from_module',
15+
'cases_data', 'CaseData', 'CaseDataGetter', 'cases_fixture', 'unfold_expected_err', 'extract_cases_from_module',
1516
'case_name', 'Given', 'ExpectedNormal', 'ExpectedError',
1617
'test_target', 'case_tags', 'THIS_MODULE', 'cases_generator', 'MultipleStepsCaseData'
1718
]

pytest_cases/decorator_hack.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import sys
2+
3+
from itertools import chain
4+
5+
from decorator import FunctionMaker
6+
from inspect import isgeneratorfunction
7+
8+
try: # python 3.3+
9+
from inspect import signature
10+
except ImportError:
11+
from funcsigs import signature
12+
13+
try:
14+
from decorator import iscoroutinefunction
15+
except ImportError:
16+
try:
17+
from inspect import iscoroutinefunction
18+
except ImportError:
19+
# let's assume there are no coroutine functions in old Python
20+
def iscoroutinefunction(f):
21+
return False
22+
23+
24+
class MyFunctionMaker(FunctionMaker):
25+
"""
26+
Overrides FunctionMaker so that additional arguments can be inserted in the resulting signature.
27+
"""
28+
29+
def refresh_signature(self):
30+
"""Update self.signature and self.shortsignature based on self.args,
31+
self.varargs, self.varkw"""
32+
allargs = list(self.args)
33+
allshortargs = list(self.args)
34+
if self.varargs:
35+
allargs.append('*' + self.varargs)
36+
allshortargs.append('*' + self.varargs)
37+
elif self.kwonlyargs:
38+
allargs.append('*') # single star syntax
39+
for a in self.kwonlyargs:
40+
allargs.append('%s=None' % a)
41+
allshortargs.append('%s=%s' % (a, a))
42+
if self.varkw:
43+
allargs.append('**' + self.varkw)
44+
allshortargs.append('**' + self.varkw)
45+
self.signature = ', '.join(allargs)
46+
self.shortsignature = ', '.join(allshortargs)
47+
48+
@classmethod
49+
def create(cls, obj, body, evaldict, defaults=None,
50+
doc=None, module=None, addsource=True, add_args=(), del_args=(), **attrs):
51+
"""
52+
Create a function from the strings name, signature and body.
53+
evaldict is the evaluation dictionary. If addsource is true an
54+
attribute __source__ is added to the result. The attributes attrs
55+
are added, if any.
56+
57+
If add_args is not empty, these arguments will be prepended to the
58+
positional arguments.
59+
60+
If del_args is not empty, these arguments will be removed from signature
61+
"""
62+
if isinstance(obj, str): # "name(signature)"
63+
name, rest = obj.strip().split('(', 1)
64+
signature = rest[:-1] # strip a right parens
65+
func = None
66+
else: # a function
67+
name = None
68+
signature = None
69+
func = obj
70+
self = cls(func, name, signature, defaults, doc, module)
71+
ibody = '\n'.join(' ' + line for line in body.splitlines())
72+
caller = evaldict.get('_call_') # when called from `decorate`
73+
if caller and iscoroutinefunction(caller):
74+
body = ('async def %(name)s(%(signature)s):\n' + ibody).replace(
75+
'return', 'return await')
76+
else:
77+
body = 'def %(name)s(%(signature)s):\n' + ibody
78+
79+
# Handle possible signature changes
80+
sig_modded = False
81+
if len(add_args) > 0:
82+
# prepend them as positional args - hence the reversed()
83+
for arg in reversed(add_args):
84+
if arg not in self.args:
85+
self.args = [arg] + self.args
86+
sig_modded = True
87+
else:
88+
# the argument already exists in the wrapped
89+
# function, nothing to do.
90+
pass
91+
92+
if len(del_args) > 0:
93+
# remove the args
94+
for to_remove in del_args:
95+
for where_field in ('args', 'varargs', 'varkw', 'defaults', 'kwonlyargs', 'kwonlydefaults'):
96+
a = getattr(self, where_field, None)
97+
if a is not None and to_remove in a:
98+
try:
99+
# list
100+
a.remove(to_remove)
101+
except AttributeError:
102+
# dict-like
103+
del a[to_remove]
104+
finally:
105+
sig_modded = True
106+
107+
if sig_modded:
108+
self.refresh_signature()
109+
110+
# make the function
111+
func = self.make(body, evaldict, addsource, **attrs)
112+
113+
if sig_modded:
114+
# delete this annotation otherwise inspect.signature
115+
# will wrongly return the signature of func.__wrapped__
116+
# instead of the signature of func
117+
func.__wrapped_with_addargs__ = func.__wrapped__
118+
del func.__wrapped__
119+
120+
return func
121+
122+
123+
def _extract_additional_args(f_sig, add_args_names, args, kwargs, put_all_in_kwargs=False):
124+
"""
125+
Processes the arguments received by our caller so that at the end, args
126+
and kwargs only contain what is needed by f (according to f_sig). All
127+
additional arguments are returned separately, in order described by
128+
`add_args_names`. If some names in `add_args_names` are present in `f_sig`,
129+
then the arguments will appear both in the additional arguments and in
130+
*args, **kwargs.
131+
132+
In the end, only *args can possibly be modified by the procedure (by removing
133+
from it all additional arguments that were not in f_sig and were prepended).
134+
135+
So the result is a tuple (add_args, args)
136+
137+
:return: a tuple (add_args, args) where `add_args` are the values of
138+
arguments named in `add_args_names` in the same order ; and `args` is
139+
the positional arguments to send to the wrapped function together with
140+
kwargs (args now only contains the positional args that are required by
141+
f, without the extra ones)
142+
"""
143+
# -- first extract (and remove) the 'truly' additional ones (the ones not in the signature)
144+
add_args = [None] * len(add_args_names)
145+
for i, arg_name in enumerate(add_args_names):
146+
if arg_name not in f_sig.parameters:
147+
# remove this argument from the args and put it in the right place
148+
add_args[i] = args[0]
149+
args = args[1:]
150+
151+
# -- then copy the ones that already exist in the signature. Thanks,inspect pkg!
152+
bound = f_sig.bind(*args, **kwargs)
153+
for i, arg_name in enumerate(add_args_names):
154+
if arg_name in f_sig.parameters:
155+
add_args[i] = bound.arguments[arg_name]
156+
157+
# -- finally move args to kwargs of needed
158+
if put_all_in_kwargs:
159+
args = tuple()
160+
kwargs = {arg_name: bound.arguments[arg_name] for arg_name in f_sig.parameters}
161+
162+
return add_args, args, kwargs
163+
164+
165+
def _wrap_caller_for_additional_args(func, caller, additional_args, removed_args):
166+
"""
167+
This internal function wraps the caller so as to handle all cases
168+
(if some additional args are already present in the signature or not)
169+
so as to ensure a consistent caller signature.
170+
171+
Note: as of today if removed_args is not empty, positional args can not be correctly handled so all arguments
172+
are passed as kwargs to the wrapper
173+
174+
:return: a new caller wrapping the caller, to be used in `decorate`
175+
"""
176+
f_sig = signature(func)
177+
178+
# We will create a caller above the original caller in order to check
179+
# if additional_args are already present in the signature or not, and
180+
# act accordingly
181+
original_caller = caller
182+
183+
# If we have to remove the parameters, the behaviour and signatures will be a bit different
184+
# First modify the signature so that we remove the parameters that have to be.
185+
if len(removed_args) > 0:
186+
# new_params = OrderedDict(((k, v) for k, v in f_sig.parameters.items() if k not in removed_args))
187+
new_params = (v for k, v in f_sig.parameters.items() if k not in removed_args)
188+
f_sig = f_sig.replace(parameters=new_params)
189+
190+
# -- then create the appropriate function signature according to
191+
# wrapped function signature assume that original_caller has all
192+
# additional args as first positional arguments, in order
193+
if not isgeneratorfunction(original_caller):
194+
def caller(f, *args, **kwargs):
195+
# Retrieve the values for additional args.
196+
add_args, args, kwargs = _extract_additional_args(f_sig, additional_args,
197+
args, kwargs,
198+
put_all_in_kwargs=(len(removed_args) > 0))
199+
200+
# Call the original caller
201+
# IMPORTANT : args and kwargs are passed without the double-star here!
202+
return original_caller(f, *add_args, args=args, kwargs=kwargs)
203+
else:
204+
def caller(f, *args, **kwargs):
205+
# Retrieve the value for additional args.
206+
add_args, args, kwargs = _extract_additional_args(f_sig, additional_args,
207+
args, kwargs,
208+
put_all_in_kwargs=(len(removed_args) > 0))
209+
210+
# Call the original caller
211+
# IMPORTANT : args and kwargs are passed without the double-star here!
212+
for res in original_caller(f, *add_args, args=args, kwargs=kwargs):
213+
yield res
214+
215+
return caller
216+
217+
218+
def my_decorate(func, caller, extras=(), additional_args=(), removed_args=(), pytest_place_as=True):
219+
"""
220+
decorate(func, caller) decorates a function using a caller.
221+
If the caller is a generator function, the resulting function
222+
will be a generator function.
223+
224+
You can provide additional arguments with `additional_args`. In that case
225+
the caller's signature should be
226+
227+
`caller(f, <additional_args_in_order>, *args, **kwargs)`.
228+
229+
`*args, **kwargs` will always contain the arguments required by the inner
230+
function `f`. If `additional_args` contains argument names that are already
231+
present in `func`, they will be present both in <additional_args_in_order>
232+
AND in `*args, **kwargs` so that it remains easy for the `caller` both to
233+
get the additional arguments' values directly, and to call `f` with the
234+
right arguments.
235+
236+
Note: as of today if removed_args is not empty, positional args can not be correctly handled so all arguments
237+
are passed as kwargs to the wrapper
238+
239+
"""
240+
if len(additional_args) > 0:
241+
# wrap the caller so as to handle all cases
242+
# (if some additional args are already present in the signature or not)
243+
# so as to ensure a consistent caller signature
244+
caller = _wrap_caller_for_additional_args(func, caller, additional_args, removed_args)
245+
246+
evaldict = dict(_call_=caller, _func_=func)
247+
es = ''
248+
for i, extra in enumerate(extras):
249+
ex = '_e%d_' % i
250+
evaldict[ex] = extra
251+
es += ex + ', '
252+
253+
if '3.5' <= sys.version < '3.6':
254+
# with Python 3.5 isgeneratorfunction returns True for all coroutines
255+
# however we know that it is NOT possible to have a generator
256+
# coroutine in python 3.5: PEP525 was not there yet
257+
generatorcaller = isgeneratorfunction(
258+
caller) and not iscoroutinefunction(caller)
259+
else:
260+
generatorcaller = isgeneratorfunction(caller)
261+
if generatorcaller:
262+
fun = MyFunctionMaker.create(
263+
func, "for res in _call_(_func_, %s%%(shortsignature)s):\n"
264+
" yield res" % es, evaldict,
265+
add_args=additional_args, del_args=removed_args, __wrapped__=func)
266+
else:
267+
fun = MyFunctionMaker.create(
268+
func, "return _call_(_func_, %s%%(shortsignature)s)" % es,
269+
evaldict, add_args=additional_args, del_args=removed_args, __wrapped__=func)
270+
if hasattr(func, '__qualname__'):
271+
fun.__qualname__ = func.__qualname__
272+
273+
# With this hack our decorator will be ordered correctly by pytest https://github.com/pytest-dev/pytest/issues/4429
274+
if pytest_place_as:
275+
fun.place_as = func
276+
277+
return fun

0 commit comments

Comments
 (0)