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

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 

7 

8try: # python 3.3+ 

9 from inspect import signature 

10except ImportError: 

11 from funcsigs import signature # noqa 

12 

13try: 

14 from typing import Union, Callable, List, Set, Tuple, Any, Sequence, Optional, Iterable # noqa 

15except ImportError: 

16 pass 

17 

18try: 

19 from _pytest.mark.structures import MarkDecorator, Mark # noqa 

20except ImportError: 

21 pass 

22 

23from .common_pytest_marks import get_pytest_marks_on_function, markdecorators_as_tuple, PYTEST53_OR_GREATER, \ 

24 markdecorators_to_markinfos 

25 

26 

27class Lazy(object): 

28 """ 

29 All lazy items should inherit from this for good pytest compliance (ids, marks, etc.) 

30 """ 

31 __slots__ = () 

32 

33 _field_names = () 

34 """Subclasses should fill this variable to get an automatic __eq__ and __repr__.""" 

35 

36 # @abstractmethod 

37 def get_id(self): 

38 """Return the id to use by pytest""" 

39 raise NotImplementedError() 

40 

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() 

45 

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 

48 

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() 

53 

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 

60 

61 def __repr__(self): 

62 """Default repr method based on the _field_names""" 

63 

64 return "%s(%s)" % (self.__class__.__name__, ", ".join("%s=%r" % (k, getattr(self, k)) 

65 for k in self._field_names)) 

66 

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() 

71 

72 @classmethod 

73 def copy_from(cls, obj): 

74 """Subclasses should override this""" 

75 raise NotImplementedError() 

76 

77 def clone(self): 

78 """Clones self based on copy_from""" 

79 return type(self).copy_from(self) 

80 

81 

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 

106 

107 

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) 

118 

119 

120# noinspection PyPep8Naming 

121class _LazyValue(Lazy): 

122 """ 

123 A reference to a value getter, to be used in `parametrize`. 

124 

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. 

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 

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' 

142 

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 

153 

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 

165 

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

169 

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 

176 

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

182 

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) 

189 

190 return self_marks + valuegetter_marks 

191 else: 

192 return valuegetter_marks 

193 

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 

203 

204 # unwrap and handle partial functions 

205 vg = _unwrap(self.valuegetter) 

206 

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__ 

211 

212 def get(self, request_or_item): 

213 """ 

214 Calls the underlying value getter function `self.valuegetter` and returns the result. 

215 

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. 

219 

220 See https://github.com/smarie/python-pytest-cases/issues/149 

221 and https://github.com/smarie/python-pytest-cases/issues/143 

222 

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) 

226 

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) 

232 

233 return self.cached_value 

234 

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 

237 

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. 

240 

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 

256 

257 # get node context information 

258 node = get_test_node(request_or_item) 

259 

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") 

262 

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 

265 

266 def as_lazy_tuple(self, nb_params): 

267 return LazyTuple(self, nb_params) 

268 

269 def as_lazy_items_list(self, nb_params): 

270 return [v for v in self.as_lazy_tuple(nb_params)] 

271 

272 

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' 

284 

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) 

291 

292 # noinspection PyMissingConstructor 

293 def __init__(self, 

294 host, # type: LazyTuple 

295 item # type: int 

296 ): 

297 self.host = host 

298 self.item = item 

299 

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

303 

304 def __repr__(self): 

305 """Override the inherited method to avoid infinite recursion""" 

306 

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 

312 

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

318 

319 def get_id(self): 

320 return "%s[%s]" % (self.host.get_id(), self.item) 

321 

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

324 

325 See _LazyValue.get for details 

326 

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) 

330 

331 

332class LazyTuple(Lazy): 

333 """ 

334 A wrapper representing a lazy_value used as a tuple = for several argvalues at once. 

335 

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) 

338 

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. 

341 

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__ 

347 

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) 

355 

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 

363 

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

367 

368 def __len__(self): 

369 return self.theoretical_size 

370 

371 def get_id(self): 

372 """return the id to use by pytest""" 

373 return self._lazyvalue.get_id() 

374 

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 

378 

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) 

382 

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 

385 

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. 

388 

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) 

396 

397 @property 

398 def cached_value(self): 

399 return self._lazyvalue.cached_value 

400 

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) 

413 

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

426 

427 

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 

434 

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 

442 

443 class _LazyValueBase(fake_base, object): 

444 

445 __slots__ = () 

446 

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 

452 

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) 

456 

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) 

461 

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

465 

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) 

473 

474 class LazyTupleItem(_LazyTupleItem, _LazyValueBase): 

475 """Same than _LazyTupleItem but inherits from int so that pytest calls str(o) for the id""" 

476 

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) 

484 

485 

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`. 

492 

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. 

496 

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. 

499 

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. 

503 

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) 

509 

510 

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 

518 

519 

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 

531 

532 

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. 

538 

539 :param request_or_item: the context of this call: either a pytest request or item 

540 """ 

541 

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 

551 

552 

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 

557 

558 :param request_or_item: 

559 :return: 

560 """ 

561 try: 

562 return request_or_item.node 

563 except AttributeError: 

564 return request_or_item