Coverage for src/pytest_cases/common_pytest_lazy_values.py: 78%
208 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-09-26 21:52 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2024-09-26 21:52 +0000
1# Authors: Sylvain MARIE <sylvain.marie@se.com>
2# + All contributors to <https://github.com/smarie/python-pytest-cases>
3#
4# License: 3-clause BSD, <https://github.com/smarie/python-pytest-cases/blob/master/LICENSE>
5from functools import partial
6import weakref
8try: # python 3.3+
9 from inspect import signature
10except ImportError:
11 from funcsigs import signature # noqa
13try:
14 from typing import Union, Callable, List, Set, Tuple, Any, Sequence, Optional, Iterable # noqa
15except ImportError:
16 pass
18try:
19 from _pytest.mark.structures import MarkDecorator, Mark # noqa
20except ImportError:
21 pass
23from .common_pytest_marks import get_pytest_marks_on_function, markdecorators_as_tuple, PYTEST53_OR_GREATER, \
24 markdecorators_to_markinfos
27class Lazy(object):
28 """
29 All lazy items should inherit from this for good pytest compliance (ids, marks, etc.)
30 """
31 __slots__ = ()
33 _field_names = ()
34 """Subclasses should fill this variable to get an automatic __eq__ and __repr__."""
36 # @abstractmethod
37 def get_id(self):
38 """Return the id to use by pytest"""
39 raise NotImplementedError()
41 # @abstractmethod
42 def get(self, request_or_item):
43 """Return the actual value to use by pytest in the given context"""
44 raise NotImplementedError()
46 def __str__(self):
47 """in pytest<5.3 we inherit from int so that str(v) is called by pytest _idmaker to get the id
49 In later pytest this is extremely convenient to have this string representation
50 for example to use in pytest-harvest results tables, so we still keep it.
51 """
52 return self.get_id()
54 def __eq__(self, other):
55 """Default equality method based on the _field_names"""
56 try:
57 return all(getattr(self, k) == getattr(other, k) for k in self._field_names)
58 except Exception: # noqa
59 return False
61 def __repr__(self):
62 """Default repr method based on the _field_names"""
64 return "%s(%s)" % (self.__class__.__name__, ", ".join("%s=%r" % (k, getattr(self, k))
65 for k in self._field_names))
67 @property
68 def __name__(self):
69 """for pytest >= 5.3 we override this so that pytest uses it for id"""
70 return self.get_id()
72 @classmethod
73 def copy_from(cls, obj):
74 """Subclasses should override this"""
75 raise NotImplementedError()
77 def clone(self):
78 """Clones self based on copy_from"""
79 return type(self).copy_from(self)
82def _unwrap(obj):
83 """A light copy of _pytest.compat.get_real_func. In our case
84 we do not wish to unwrap the partial nor handle pytest fixture
85 Note: maybe from inspect import unwrap could do the same?
86 """
87 start_obj = obj
88 for _ in range(100): 88 ↛ 101line 88 didn't jump to line 101, because the loop on line 88 didn't complete
89 # __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function
90 # to trigger a warning if it gets called directly instead of by pytest: we don't
91 # want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774)
92 # new_obj = getattr(obj, "__pytest_wrapped__", None)
93 # if isinstance(new_obj, _PytestWrapper):
94 # obj = new_obj.obj
95 # break
96 new_obj = getattr(obj, "__wrapped__", None)
97 if new_obj is None: 97 ↛ 99line 97 didn't jump to line 99, because the condition on line 97 was never false
98 break
99 obj = new_obj
100 else:
101 raise ValueError("could not find real function of {start}\nstopped at {current}".format(
102 start=repr(start_obj), current=repr(obj)
103 )
104 )
105 return obj
108def partial_to_str(partialfun):
109 """Return a string representation of a partial function, to use in lazy_value ids"""
110 strwds = ", ".join("%s=%s" % (k, v) for k, v in partialfun.keywords.items())
111 if len(partialfun.args) > 0: 111 ↛ 116line 111 didn't jump to line 116, because the condition on line 111 was never false
112 strargs = ', '.join(str(i) for i in partialfun.args)
113 if len(partialfun.keywords) > 0: 113 ↛ 114line 113 didn't jump to line 114, because the condition on line 113 was never true
114 strargs = "%s, %s" % (strargs, strwds)
115 else:
116 strargs = strwds
117 return "%s(%s)" % (partialfun.func.__name__, strargs)
120# noinspection PyPep8Naming
121class _LazyValue(Lazy):
122 """
123 A reference to a value getter, to be used in `parametrize`.
125 A `lazy_value` is the same thing than a function-scoped fixture, except that the value getter function is not a
126 fixture and therefore can neither be parametrized nor depend on fixtures. It should have no mandatory argument.
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.
132 See https://github.com/smarie/python-pytest-cases/issues/149
133 and https://github.com/smarie/python-pytest-cases/issues/143
134 """
135 if PYTEST53_OR_GREATER: 135 ↛ 141line 135 didn't jump to line 141, because the condition on line 135 was never false
136 __slots__ = 'valuegetter', '_id', '_marks', 'cached_value_context', 'cached_value'
137 _field_names = __slots__
138 else:
139 # we can not define __slots__ since we'll extend int in a subclass
140 # see https://docs.python.org/3/reference/datamodel.html?highlight=__slots__#notes-on-using-slots
141 _field_names = 'valuegetter', '_id', '_marks', 'cached_value_context', 'cached_value'
143 @classmethod
144 def copy_from(cls,
145 obj # type: _LazyValue
146 ):
147 """Creates a copy of this _LazyValue"""
148 new_obj = cls(valuegetter=obj.valuegetter, id=obj._id, marks=obj._marks)
149 # make sure the copy will not need to retrieve the result if already done
150 new_obj.cached_value_context = obj.cached_value_context
151 new_obj.cached_value = obj.cached_value
152 return new_obj
154 # noinspection PyMissingConstructor
155 def __init__(self,
156 valuegetter, # type: Callable[[], Any]
157 id=None, # type: str # noqa
158 marks=None, # type: Union[MarkDecorator, Iterable[MarkDecorator]]
159 ):
160 self.valuegetter = valuegetter
161 self._id = id
162 self._marks = markdecorators_as_tuple(marks)
163 self.cached_value_context = None
164 self.cached_value = None
166 def __hash__(self):
167 """Provide a minimal hash representing the class, valuegetter, id and marks"""
168 return hash((self.__class__, self.valuegetter, self._id, self._marks))
170 def get_marks(self,
171 as_decorators=False # type: bool
172 ):
173 # type: (...) -> Union[Tuple[Mark, ...], Tuple[MarkDecorator, ...]]
174 """
175 Overrides default implementation to return the marks that are on the case function
177 :param as_decorators: when True, the marks (MarkInfo) will be transformed into MarkDecorators before being
178 returned
179 :return:
180 """
181 valuegetter_marks = tuple(get_pytest_marks_on_function(self.valuegetter, as_decorators=as_decorators))
183 if self._marks:
184 if as_decorators: 184 ↛ 188line 184 didn't jump to line 188, because the condition on line 184 was never false
185 # self_marks = markinfos_to_markdecorators(self._marks, function_marks=True)
186 self_marks = self._marks
187 else:
188 self_marks = markdecorators_to_markinfos(self._marks)
190 return self_marks + valuegetter_marks
191 else:
192 return valuegetter_marks
194 def get_id(self):
195 """The id to use in pytest"""
196 if self._id is not None:
197 return self._id
198 else:
199 # default is the __name__ of the value getter
200 _id = getattr(self.valuegetter, '__name__', None)
201 if _id is not None:
202 return _id
204 # unwrap and handle partial functions
205 vg = _unwrap(self.valuegetter)
207 if isinstance(vg, partial): 207 ↛ 210line 207 didn't jump to line 210, because the condition on line 207 was never false
208 return partial_to_str(vg)
209 else:
210 return vg.__name__
212 def get(self, request_or_item):
213 """
214 Calls the underlying value getter function `self.valuegetter` and returns the result.
216 This result is cached to ensure that the underlying getter function is called exactly once for each
217 pytest node. Note that we do not cache across calls to preserve the pytest spirit of "no leakage
218 across test nodes" especially when the value is mutable.
220 See https://github.com/smarie/python-pytest-cases/issues/149
221 and https://github.com/smarie/python-pytest-cases/issues/143
223 :param request_or_item: the context of this call: either a pytest request or test node item.
224 """
225 node = get_test_node(request_or_item)
227 if not self.has_cached_value(node=node):
228 # retrieve the value by calling the function
229 self.cached_value = self.valuegetter()
230 # remember the pytest context of the call with a weak reference to avoir gc issues
231 self.cached_value_context = weakref.ref(node)
233 return self.cached_value
235 def has_cached_value(self, request_or_item=None, node=None, raise_if_no_context=True):
236 """Return True if there is a cached value in self.value correnponding to the given request
238 A degraded query "is there a cached value" (whatever the context) can be performed by not passing any
239 request, item or node, and switching `raise_if_no_context` to False.
241 :param request_or_item: the pytest request or item
242 :param node: the pytest node if it already known.
243 :param raise_if_no_context: a boolean indicating if an error should be raised if `request_or_item` and `node`
244 are both None. Default is `True`.
245 """
246 if node is None:
247 # can we get that context information from the request/item ?
248 if request_or_item is None:
249 if raise_if_no_context:
250 raise ValueError("No request, item or node was provided: I can not tell if there is a "
251 "cached value for your context. Switch `raise_if_no_context=False` if"
252 " you wish to get a degraded answer.")
253 else:
254 # degraded answer: just tell if the cache was populated at least once
255 return self.cached_value_context is not None
257 # get node context information
258 node = get_test_node(request_or_item)
260 elif request_or_item is not None: 260 ↛ 261line 260 didn't jump to line 261, because the condition on line 260 was never true
261 raise ValueError("Only one of `request_or_item` and `node` should be provided")
263 # True if there is a cached value context that is the same as the context of the request
264 return self.cached_value_context is not None and self.cached_value_context() is node
266 def as_lazy_tuple(self, nb_params):
267 return LazyTuple(self, nb_params)
269 def as_lazy_items_list(self, nb_params):
270 return [v for v in self.as_lazy_tuple(nb_params)]
273class _LazyTupleItem(Lazy):
274 """
275 An item in a Lazy Tuple
276 """
277 if PYTEST53_OR_GREATER: 277 ↛ 283line 277 didn't jump to line 283, because the condition on line 277 was never false
278 __slots__ = 'host', 'item'
279 _field_names = __slots__
280 else:
281 # we can not define __slots__ since we'll extend int in a subclass
282 # see https://docs.python.org/3/reference/datamodel.html?highlight=__slots__#notes-on-using-slots
283 _field_names = 'host', 'item'
285 @classmethod
286 def copy_from(cls,
287 obj # type: _LazyTupleItem
288 ):
289 """Creates a copy of this _LazyTupleItem"""
290 return cls(host=obj.host, item=obj.item)
292 # noinspection PyMissingConstructor
293 def __init__(self,
294 host, # type: LazyTuple
295 item # type: int
296 ):
297 self.host = host
298 self.item = item
300 def __hash__(self):
301 """Provide a minimal hash representing the class, host and item number"""
302 return hash((self.__class__, self.host, self.item))
304 def __repr__(self):
305 """Override the inherited method to avoid infinite recursion"""
307 # lazy value tuple or cached tuple
308 if self.host.has_cached_value(raise_if_no_context=False): 308 ↛ 309line 308 didn't jump to line 309, because the condition on line 308 was never true
309 tuple_to_represent = self.host.cached_value
310 else:
311 tuple_to_represent = self.host._lazyvalue # noqa
313 vals_to_display = (
314 ('item', self.item), # item number first for easier debug
315 ('tuple', tuple_to_represent),
316 )
317 return "%s(%s)" % (self.__class__.__name__, ", ".join("%s=%r" % (k, v) for k, v in vals_to_display))
319 def get_id(self):
320 return "%s[%s]" % (self.host.get_id(), self.item)
322 def get(self, request_or_item):
323 """ Call the underlying value getter if needed (cache), then return the result tuple item value (not self).
325 See _LazyValue.get for details
327 :param request_or_item: the context of this call: either a pytest request or test node item.
328 """
329 return self.host.force_getitem(self.item, request_or_item)
332class LazyTuple(Lazy):
333 """
334 A wrapper representing a lazy_value used as a tuple = for several argvalues at once.
336 Its `.get()` method caches the tuple obtained from the value getter, so that it is not called several times (once
337 for each LazyTupleItem)
339 It is only used directly by pytest when a lazy_value is used in a @ parametrize to decorate a fixture.
340 Indeed in that case pytest does not unpack the tuple, we do it in our custom @fixture.
342 In all other cases (when @parametrize is used on a test function), pytest unpacks the tuple so it directly
343 manipulates the underlying LazyTupleItem instances.
344 """
345 __slots__ = '_lazyvalue', 'theoretical_size'
346 _field_names = __slots__
348 @classmethod
349 def copy_from(cls,
350 obj # type: LazyTuple
351 ):
352 # clone the inner lazy value
353 value_copy = obj._lazyvalue.clone()
354 return cls(valueref=value_copy, theoretical_size=obj.theoretical_size)
356 # noinspection PyMissingConstructor
357 def __init__(self,
358 valueref, # type: _LazyValue
359 theoretical_size # type: int
360 ):
361 self._lazyvalue = valueref
362 self.theoretical_size = theoretical_size
364 def __hash__(self):
365 """Provide a minimal hash representing the class, lazy value, and theoretical size"""
366 return hash((self.__class__, self._lazyvalue, self.theoretical_size))
368 def __len__(self):
369 return self.theoretical_size
371 def get_id(self):
372 """return the id to use by pytest"""
373 return self._lazyvalue.get_id()
375 def get(self, request_or_item):
376 """ Call the underlying value getter if needed (cache), then return the result tuple value (not self).
377 See _LazyValue.get for details
379 :param request_or_item: the context of this call: either a pytest request or test node item.
380 """
381 return self._lazyvalue.get(request_or_item)
383 def has_cached_value(self, request_or_item=None, node=None, raise_if_no_context=True):
384 """Return True if there is a cached value correnponding to the given request
386 A degraded query "is there a cached value" (whatever the context) can be performed by not passing any
387 request, item or node, and switching `raise_if_no_context` to False.
389 :param request_or_item: the pytest request or item
390 :param node: the pytest node if it already known.
391 :param raise_if_no_context: a boolean indicating if an error should be raised if `request_or_item` and `node`
392 are both None. Default is `True`.
393 """
394 return self._lazyvalue.has_cached_value(request_or_item=request_or_item, node=node,
395 raise_if_no_context=raise_if_no_context)
397 @property
398 def cached_value(self):
399 return self._lazyvalue.cached_value
401 def __getitem__(self, item):
402 """
403 Getting an item in the tuple with self[i] does *not* retrieve the value automatically, but returns
404 a facade (a LazyTupleItem), so that pytest can store this item independently wherever needed, without
405 yet calling the value getter.
406 """
407 if item >= self.theoretical_size:
408 raise IndexError(item)
409 else:
410 # note: do not use the cache here since we do not know the context.
411 # return a facade than will be able to use the cache of the tuple
412 return LazyTupleItem(self, item)
414 def force_getitem(self, item, request):
415 """ Call the underlying value getter, then return self[i]. """
416 # Note: this will use the cache correctly if needed
417 argvalue = self.get(request)
418 try:
419 return argvalue[item]
420 except TypeError as e:
421 raise ValueError("(lazy_value) The parameter value returned by `%r` is not compliant with the number"
422 " of argnames in parametrization (%s). A %s-tuple-like was expected. "
423 "Returned lazy argvalue is %r and argvalue[%s] raised %s: %s"
424 % (self._lazyvalue, self.theoretical_size, self.theoretical_size,
425 argvalue, item, e.__class__, e))
428if PYTEST53_OR_GREATER: 428 ↛ 441line 428 didn't jump to line 441, because the condition on line 428 was never false
429 # in the latest versions of pytest, the default _idmaker returns the value of __name__ if it is available,
430 # even if an object is not a class nor a function. So we do not need to use any special trick with our
431 # lazy objects
432 class LazyValue(_LazyValue):
433 pass
435 class LazyTupleItem(_LazyTupleItem):
436 pass
437else:
438 # in this older version of pytest, the default _idmaker does *not* return the value of __name__ for
439 # objects that are not functions not classes. However it *does* return str(obj) for objects that are
440 # instances of bool, int or float. So that's why we make our lazy objects inherit from int.
441 fake_base = int
443 class _LazyValueBase(fake_base, object):
445 __slots__ = ()
447 def __new__(cls, *args, **kwargs):
448 """ Inheriting from int is a bit hard in python: we have to override __new__ """
449 obj = fake_base.__new__(cls, 111111) # noqa
450 cls.__init__(obj, *args, **kwargs) # noqa
451 return obj
453 def __getattribute__(self, item):
454 """Map all default attribute and method access to the ones in object, not in int"""
455 return object.__getattribute__(self, item)
457 def __repr__(self):
458 """Magic methods are not intercepted by __getattribute__ and need to be overridden manually.
459 We do not need all of them by at least override this one for easier debugging"""
460 return object.__repr__(self)
462 class LazyValue(_LazyValue, _LazyValueBase):
463 """Same than _LazyValue but inherits from int so that pytest calls str(o) for the id.
464 Note that we do it afterwards so that _LazyValue remains "pure" - pytest-harvest needs to reuse it"""
466 def clone(self, remove_int_base=False):
467 if not remove_int_base:
468 # return a type(self) (LazyValue or subclass)
469 return _LazyValue.clone(self)
470 else:
471 # return a _LazyValue without the int base from _LazyValueBase
472 return _LazyValue.copy_from(self)
474 class LazyTupleItem(_LazyTupleItem, _LazyValueBase):
475 """Same than _LazyTupleItem but inherits from int so that pytest calls str(o) for the id"""
477 def clone(self, remove_int_base=False):
478 if not remove_int_base:
479 # return a type(self) (LazyTupleItem or subclass)
480 return _LazyTupleItem.clone(self)
481 else:
482 # return a _LazyTupleItem without the int base from _LazyValueBase
483 return _LazyTupleItem.copy_from(self)
486def lazy_value(valuegetter, # type: Callable[[], Any]
487 id=None, # type: str # noqa
488 marks=() # type: Union[MarkDecorator, Iterable[MarkDecorator]]
489 ):
490 """
491 Creates a reference to a value getter, to be used in `parametrize`.
493 A `lazy_value` is the same thing than a function-scoped fixture, except that the value getter function is not a
494 fixture and therefore can neither be parametrized nor depend on fixtures. It should have no mandatory argument.
495 The underlying function will be called exactly once per test node.
497 By default the associated id is the name of the `valuegetter` callable, but a specific `id` can be provided
498 otherwise. Note that this `id` does not take precedence over custom `ids` or `idgen` passed to @parametrize.
500 Note that a `lazy_value` can be included in a `pytest.param` without problem. In that case the id defined by
501 `pytest.param` will take precedence over the one defined in `lazy_value` if any. The marks, however,
502 will all be kept wherever they are defined.
504 :param valuegetter: a callable without mandatory arguments
505 :param id: an optional id. Otherwise `valuegetter.__name__` will be used by default
506 :param marks: optional marks. `valuegetter` marks will also be preserved.
507 """
508 return LazyValue(valuegetter, id=id, marks=marks)
511def is_lazy_value(argval):
512 """ Return True if `argval` is the *immediate* output of `lazy_value()` """
513 try:
514 # note: we use the private and not public class here on purpose
515 return isinstance(argval, _LazyValue)
516 except Exception: # noqa
517 return False
520def is_lazy(argval):
521 """
522 Return True if `argval` is the outcome of processing a `lazy_value` through `@parametrize`
523 As opposed to `is_lazy_value`, this encompasses lazy tuples that are created when parametrizing several argnames
524 with the same `lazy_value()`.
525 """
526 try:
527 # note: we use the private and not public classes here on purpose
528 return isinstance(argval, (_LazyValue, LazyTuple, _LazyTupleItem))
529 except Exception: # noqa
530 return False
533def get_lazy_args(argval, request_or_item):
534 """
535 Possibly calls the lazy values contained in argval if needed, before returning it.
536 Since the lazy values cache their result to ensure that their underlying function is called only once
537 per test node, the `request` argument here is mandatory.
539 :param request_or_item: the context of this call: either a pytest request or item
540 """
542 try:
543 _is_lazy = is_lazy(argval)
544 except: # noqa
545 return argval
546 else:
547 if _is_lazy:
548 return argval.get(request_or_item)
549 else:
550 return argval
553def get_test_node(request_or_item):
554 """
555 Return the test node, typically a _pytest.Function.
556 Provided arg may be the node already, or the pytest request
558 :param request_or_item:
559 :return:
560 """
561 try:
562 return request_or_item.node
563 except AttributeError:
564 return request_or_item