Skip to content

Commit 26bb8f0

Browse files
author
Sylvain MARIE
committed
lazy_value parameters are now cached by pytest node id only. So plugins can access the value without triggering an extra function call, but a new call is triggered for each pytest node, so as to prevent mutable object leakage across tests. Fixed #149 while ensuring no regression for #143.
1 parent 765e136 commit 26bb8f0

File tree

7 files changed

+149
-71
lines changed

7 files changed

+149
-71
lines changed

docs/api_reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ def lazy_value(valuegetter: Callable[[], Any],
313313

314314
A reference to a value getter (an argvalue-providing callable), to be used in [`@parametrize`](#parametrize).
315315

316-
A `lazy_value` is the same thing than a function-scoped fixture, except that the value getter function is not a fixture and therefore can neither be parametrized nor depend on fixtures. It should have no mandatory argument.
316+
A `lazy_value` is the same thing than a function-scoped fixture, except that the value getter function is not a fixture and therefore can neither be parametrized nor depend on fixtures. It should have no mandatory argument. The underlying function will be called exactly once per test node.
317317

318318
By default the associated id is the name of the `valuegetter` callable, but a specific `id` can be provided otherwise. Note that this `id` does not take precedence over custom `ids` or `idgen` passed to `@parametrize`.
319319

pytest_cases/common_pytest_lazy_values.py

Lines changed: 72 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# License: 3-clause BSD, <https://github.com/smarie/python-pytest-cases/blob/master/LICENSE>
55
from distutils.version import LooseVersion
66
from functools import partial
7+
import weakref
78

89
try: # python 3.3+
910
from inspect import signature
@@ -35,7 +36,7 @@ def get_id(self):
3536
raise NotImplementedError()
3637

3738
# @abstractmethod
38-
def get(self):
39+
def get(self, request):
3940
"""Return the value to use by pytest"""
4041
raise NotImplementedError()
4142

@@ -123,14 +124,21 @@ class _LazyValue(Lazy):
123124
124125
A `lazy_value` is the same thing than a function-scoped fixture, except that the value getter function is not a
125126
fixture and therefore can neither be parametrized nor depend on fixtures. It should have no mandatory argument.
127+
128+
The `self.get(request)` method can be used to get the value for the current pytest context. This value will
129+
be cached so that plugins can call it several time without triggering new calls to the underlying function.
130+
So the underlying function will be called exactly once per test node.
131+
132+
See https://github.com/smarie/python-pytest-cases/issues/149
133+
and https://github.com/smarie/python-pytest-cases/issues/143
126134
"""
127135
if pytest53:
128-
__slots__ = 'valuegetter', '_id', '_marks', 'retrieved', 'value'
136+
__slots__ = 'valuegetter', '_id', '_marks', 'cached_value_context', 'cached_value'
129137
_field_names = __slots__
130138
else:
131139
# we can not define __slots__ since we'll extend int in a subclass
132140
# see https://docs.python.org/3/reference/datamodel.html?highlight=__slots__#notes-on-using-slots
133-
_field_names = 'valuegetter', '_id', '_marks', 'retrieved', 'value'
141+
_field_names = 'valuegetter', '_id', '_marks', 'cached_value_context', 'cached_value'
134142

135143
@classmethod
136144
def copy_from(cls,
@@ -139,9 +147,9 @@ def copy_from(cls,
139147
"""Creates a copy of this _LazyValue"""
140148
new_obj = cls(valuegetter=obj.valuegetter, id=obj._id, marks=obj._marks)
141149
# make sure the copy will not need to retrieve the result if already done
142-
new_obj.retrieved = obj.retrieved
143-
if new_obj.retrieved:
144-
new_obj.value = obj.value
150+
if obj.has_cached_value():
151+
new_obj.cached_value_context = obj.cached_value_context
152+
new_obj.cached_value = obj.cached_value
145153
return new_obj
146154

147155
# noinspection PyMissingConstructor
@@ -156,8 +164,8 @@ def __init__(self,
156164
self._marks = marks
157165
else:
158166
self._marks = (marks, )
159-
self.retrieved = False
160-
self.value = None
167+
self.cached_value_context = None
168+
self.cached_value = None
161169

162170
def get_marks(self, as_decorators=False):
163171
"""
@@ -192,22 +200,32 @@ def get_id(self):
192200
else:
193201
return vg.__name__
194202

195-
def get(self):
196-
""" Call the underlying value getter, then return the result value (not self). With a cache mechanism """
197-
if not self.retrieved:
198-
# retrieve
199-
self.value = self.valuegetter()
200-
self.retrieved = True
203+
def get(self, request):
204+
"""
205+
Calls the underlying value getter function `self.valuegetter` and returns the result.
206+
207+
This result is cached to ensure that the underlying getter function is called exactly once for each
208+
pytest node. Note that we do not cache across calls to preserve the pytest spirit of "no leakage
209+
across test nodes" especially when the value is mutable.
210+
211+
See https://github.com/smarie/python-pytest-cases/issues/149
212+
and https://github.com/smarie/python-pytest-cases/issues/143
213+
"""
214+
if self.cached_value_context is None or self.cached_value_context() is not request.node:
215+
# retrieve the value by calling the function
216+
self.cached_value = self.valuegetter()
217+
# remember the pytest context of the call with a weak reference to avoir gc issues
218+
self.cached_value_context = weakref.ref(request.node)
219+
220+
return self.cached_value
201221

202-
return self.value
222+
def has_cached_value(self):
223+
"""Return True if there is a cached value in self.value, but with no guarantee that it corresponds to the
224+
current request"""
225+
return self.cached_value_context is not None
203226

204227
def as_lazy_tuple(self, nb_params):
205-
res = LazyTuple(self, nb_params)
206-
if self.retrieved:
207-
# make sure the tuple will not need to retrieve the result if already done
208-
res.retrieved = True
209-
res.value = self.value
210-
return res
228+
return LazyTuple(self, nb_params)
211229

212230
def as_lazy_items_list(self, nb_params):
213231
return [v for v in self.as_lazy_tuple(nb_params)]
@@ -244,15 +262,15 @@ def __repr__(self):
244262
"""Override the inherited method to avoid infinite recursion"""
245263
vals_to_display = (
246264
('item', self.item), # item number first for easier debug
247-
('tuple', self.host.value if self.host.retrieved else self.host.valuegetter), # lazy value tuple or retrieved tuple
265+
('tuple', self.host.cached_value if self.host.has_cached_value() else self.host._lazyvalue), # lazy value tuple or cached tuple
248266
)
249267
return "%s(%s)" % (self.__class__.__name__, ", ".join("%s=%r" % (k, v) for k, v in vals_to_display))
250268

251269
def get_id(self):
252270
return "%s[%s]" % (self.host.get_id(), self.item)
253271

254-
def get(self):
255-
return self.host.force_getitem(self.item)
272+
def get(self, request):
273+
return self.host.force_getitem(self.item, request)
256274

257275

258276
class LazyTuple(Lazy):
@@ -268,70 +286,68 @@ class LazyTuple(Lazy):
268286
In all other cases (when @parametrize is used on a test function), pytest unpacks the tuple so it directly
269287
manipulates the underlying LazyTupleItem instances.
270288
"""
271-
__slots__ = 'valuegetter', 'theoretical_size', 'retrieved', 'value'
289+
__slots__ = '_lazyvalue', 'theoretical_size'
272290
_field_names = __slots__
273291

274292
@classmethod
275293
def copy_from(cls,
276294
obj # type: LazyTuple
277295
):
278-
new_obj = cls(valueref=obj.value, theoretical_size=obj.theoretical_size)
279-
# make sure the copy will not need to retrieve the result if already done
280-
new_obj.retrieved = obj.retrieved
281-
if new_obj.retrieved:
282-
new_obj.value = obj.value
283-
return new_obj
296+
# clone the inner lazy value
297+
value_copy = obj._lazyvalue.clone()
298+
return cls(valueref=value_copy, theoretical_size=obj.theoretical_size)
284299

285300
# noinspection PyMissingConstructor
286301
def __init__(self,
287-
valueref, # type: Union[LazyValue, Sequence]
302+
valueref, # type: _LazyValue
288303
theoretical_size # type: int
289304
):
290-
self.valuegetter = valueref
305+
self._lazyvalue = valueref
291306
self.theoretical_size = theoretical_size
292-
self.retrieved = False
293-
self.value = None
294307

295308
def __len__(self):
296309
return self.theoretical_size
297310

298311
def get_id(self):
299312
"""return the id to use by pytest"""
300-
return self.valuegetter.get_id()
313+
return self._lazyvalue.get_id()
301314

302-
def get(self):
303-
""" Call the underlying value getter, then return the result tuple (not self). With a cache mechanism """
304-
if not self.retrieved:
305-
# retrieve
306-
self.value = self.valuegetter.get()
307-
self.retrieved = True
308-
return self.value
315+
def get(self, request):
316+
""" Call the underlying value getter, then return the result tuple value (not self). """
317+
return self._lazyvalue.get(request)
318+
319+
def has_cached_value(self):
320+
return self._lazyvalue.has_cached_value()
321+
322+
@property
323+
def cached_value(self):
324+
return self._lazyvalue.cached_value
309325

310326
def __getitem__(self, item):
311327
"""
312328
Getting an item in the tuple with self[i] does *not* retrieve the value automatically, but returns
313329
a facade (a LazyTupleItem), so that pytest can store this item independently wherever needed, without
314330
yet calling the value getter.
315331
"""
316-
if self.retrieved:
332+
if self._lazyvalue.has_cached_value():
317333
# this is never called by pytest, but keep it for debugging
318-
return self.value[item]
334+
return self._lazyvalue.cached_value[item]
319335
elif item >= self.theoretical_size:
320336
raise IndexError(item)
321337
else:
322338
# do not retrieve yet: return a facade
323339
return LazyTupleItem(self, item)
324340

325-
def force_getitem(self, item):
341+
def force_getitem(self, item, request):
326342
""" Call the underlying value getter, then return self[i]. """
327-
argvalue = self.get()
343+
argvalue = self.get(request)
328344
try:
329345
return argvalue[item]
330346
except TypeError as e:
331347
raise ValueError("(lazy_value) The parameter value returned by `%r` is not compliant with the number"
332348
" of argnames in parametrization (%s). A %s-tuple-like was expected. "
333349
"Returned lazy argvalue is %r and argvalue[%s] raised %s: %s"
334-
% (self.valuegetter, self.theoretical_size, self.theoretical_size,
350+
% (self._lazyvalue, self.theoretical_size, self.theoretical_size,
335351
argvalue, item, e.__class__, e))
336352

337353

@@ -402,6 +418,7 @@ def lazy_value(valuegetter, # type: Callable[[], Any]
402418
403419
A `lazy_value` is the same thing than a function-scoped fixture, except that the value getter function is not a
404420
fixture and therefore can neither be parametrized nor depend on fixtures. It should have no mandatory argument.
421+
The underlying function will be called exactly once per test node.
405422
406423
By default the associated id is the name of the `valuegetter` callable, but a specific `id` can be provided
407424
otherwise. Note that this `id` does not take precedence over custom `ids` or `idgen` passed to @parametrize.
@@ -439,15 +456,19 @@ def is_lazy(argval):
439456
return False
440457

441458

442-
def get_lazy_args(argval):
443-
""" Possibly calls the lazy values contained in argval if needed, before returning it"""
459+
def get_lazy_args(argval, request):
460+
"""
461+
Possibly calls the lazy values contained in argval if needed, before returning it.
462+
Since the lazy values cache their result to ensure that their underlying function is called only once
463+
per test node, the `request` argument here is mandatory.
464+
"""
444465

445466
try:
446467
_is_lazy = is_lazy(argval)
447468
except: # noqa
448469
return argval
449470
else:
450471
if _is_lazy:
451-
return argval.get()
472+
return argval.get(request)
452473
else:
453474
return argval

pytest_cases/fixture_core2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,12 +469,12 @@ def _map_arguments(*_args, **_kwargs):
469469
for p_names, fixture_param_value in zip(params_names_or_name_combinations, _params):
470470
if len(p_names) == 1:
471471
# a single parameter for that generated fixture (@pytest.mark.parametrize with a single name)
472-
_kwargs[p_names[0]] = get_lazy_args(fixture_param_value)
472+
_kwargs[p_names[0]] = get_lazy_args(fixture_param_value, request)
473473
else:
474474
# several parameters for that generated fixture (@pytest.mark.parametrize with several names)
475475
# unpack all of them and inject them in the kwargs
476476
for old_p_name, old_p_value in zip(p_names, fixture_param_value):
477-
_kwargs[old_p_name] = get_lazy_args(old_p_value)
477+
_kwargs[old_p_name] = get_lazy_args(old_p_value, request)
478478

479479
return _args, _kwargs
480480

pytest_cases/fixture_parametrize_plus.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,21 @@ def _fixture_product(fixtures_dest,
8383
if len(all_names) < 1:
8484
raise ValueError("Empty fixture products are not permitted")
8585

86-
def _tuple_generator(all_fixtures):
86+
def _tuple_generator(request, all_fixtures):
8787
for i in range(_tuple_size):
8888
fix_at_pos_i = f_names[i]
8989
if fix_at_pos_i is None:
9090
# fixed value
9191
# note: wouldnt it be almost as efficient but more readable to *always* call handle_lazy_args?
92-
yield get_lazy_args(fixtures_or_values[i]) if has_lazy_vals else fixtures_or_values[i]
92+
yield get_lazy_args(fixtures_or_values[i], request) if has_lazy_vals else fixtures_or_values[i]
9393
else:
9494
# fixture value
9595
yield all_fixtures[fix_at_pos_i]
9696

9797
# then generate the body of our product fixture. It will require all of its dependent fixtures
98-
@with_signature("(%s)" % ', '.join(all_names))
99-
def _new_fixture(**all_fixtures):
100-
return tuple(_tuple_generator(all_fixtures))
98+
@with_signature("(request, %s)" % ', '.join(all_names))
99+
def _new_fixture(request, **all_fixtures):
100+
return tuple(_tuple_generator(request, all_fixtures))
101101

102102
_new_fixture.__name__ = name
103103

pytest_cases/plugin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ def pytest_runtest_setup(item):
6161

6262
# now item.funcargs exists so we can handle it
6363
if hasattr(item, "funcargs"):
64-
item.funcargs = {argname: get_lazy_args(argvalue) for argname, argvalue in item.funcargs.items()}
64+
item.funcargs = {argname: get_lazy_args(argvalue, item._request)
65+
for argname, argvalue in item.funcargs.items()}
6566

6667

6768
# @pytest.hookimpl(tryfirst=True, hookwrapper=True)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from distutils.version import LooseVersion
2+
3+
import pytest
4+
5+
from pytest_cases import parametrize, lazy_value, fixture, is_lazy
6+
7+
8+
def x():
9+
return []
10+
11+
12+
@parametrize("y", [0, 1])
13+
@parametrize("x", [lazy_value(x)])
14+
@pytest.mark.skipif(LooseVersion(pytest.__version__) < LooseVersion('3.0.0'),
15+
reason="request.getfixturevalue is not available in pytest 2")
16+
def test_foo(x, y, my_cache_verifier):
17+
print(x, y)
18+
# make sure the cache works correctly: different requests trigger different calls
19+
assert x == ['added_by_fixture']
20+
x.append("added_by_test")
21+
22+
23+
@fixture
24+
def my_cache_verifier(request):
25+
x = request.getfixturevalue('x')
26+
assert is_lazy(x)
27+
x = x.get(request)
28+
x.append('added_by_fixture')
29+
yield
30+
x = request.getfixturevalue('x')
31+
assert is_lazy(x)
32+
x = x.get(request)
33+
assert x == ['added_by_fixture', "added_by_test"]

0 commit comments

Comments
 (0)