Skip to content

Commit 97e155c

Browse files
author
Sylvain MARIE
committed
Fixtures in case files can now be automatically imported using the **experimental** @parametrize_with_cases(import_fixtures=True). Fixes #193
Also, Fixed an issue where a case transformed into a fixture, with the same name as the fixture it requires, would lead to a `pytest` fixture recursion.
1 parent c7b942b commit 97e155c

8 files changed

+180
-24
lines changed

docs/api_reference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ CaseFilter(filter_function: Callable)
246246
filter: Callable = None,
247247
ids: Union[Callable, Iterable[str]] = None,
248248
idstyle: Union[str, Callable] = None,
249-
scope: str = "function"
249+
scope: str = "function",
250+
import_fixtures: bool = False
250251
)
251252
```
252253

@@ -287,6 +288,7 @@ argvalues = get_parametrize_args(host_class_or_module_of_f, cases_funs)
287288

288289
- `scope`: The scope of the union fixture to create if `fixture_ref`s are found in the argvalues
289290

291+
- `import_fixtures`: experimental feature. Turn this to `True` in order to automatically import all fixtures defined in the cases module into the current module.
290292

291293
### `get_current_case_id`
292294

docs/index.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,11 @@ test_generators.py::test_foo[simple_generator-who=there] PASSED [100%]
370370
371371
#### Cases requiring fixtures
372372
373-
Cases can use fixtures the same way as [test functions do](https://docs.pytest.org/en/stable/fixture.html#fixtures-as-function-arguments): simply add the fixture names as arguments in their signature and make sure the fixture exists either in the same module, or in a [`conftest.py`](https://docs.pytest.org/en/stable/fixture.html?highlight=conftest.py#conftest-py-sharing-fixture-functions) file in one of the parent packages. See [`pytest` documentation on sharing fixtures](https://docs.pytest.org/en/stable/fixture.html?highlight=conftest.py#conftest-py-sharing-fixture-functions).
373+
Cases can use fixtures the same way as [test functions do](https://docs.pytest.org/en/stable/fixture.html#fixtures-as-function-arguments): simply add the fixture names as arguments in their signature and make sure the fixture exists or is imported either in the module where `@parametrize_with_cases` is used, or in a [`conftest.py`](https://docs.pytest.org/en/stable/fixture.html?highlight=conftest.py#conftest-py-sharing-fixture-functions) file in one of the parent packages.
374+
375+
See [`pytest` documentation on sharing fixtures](https://docs.pytest.org/en/stable/fixture.html?highlight=conftest.py#conftest-py-sharing-fixture-functions)and this [blog](https://gist.github.com/peterhurford/09f7dcda0ab04b95c026c60fa49c2a68).
376+
377+
You can use the **experimental** `@parametrize_with_cases(import_fixtures=True)` argument to perform the import automatically for you, see [API reference](./api_reference.md#parametrize_with_cases).
374378
375379
!!! warning "Use `@fixture` instead of `@pytest.fixture`"
376380
If a fixture is used by *some* of your cases only, then you *should* use the `@fixture` decorator from pytest-cases instead of the standard `@pytest.fixture`. Otherwise you fixture will be setup/teardown for all cases even those not requiring it. See [`@fixture` doc](./api_reference.md#fixture).

pytest_cases/case_parametrizer_new.py

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import functools
99
from importlib import import_module
10-
from inspect import getmembers, isfunction, ismethod
10+
from inspect import getmembers, ismodule
1111
import re
1212
from warnings import warn
1313

@@ -17,11 +17,13 @@
1717
pass
1818

1919
from .common_mini_six import string_types
20-
from .common_others import get_code_first_line, AUTO, qname, funcopy, needs_binding
20+
from .common_others import get_code_first_line, AUTO, qname, funcopy, needs_binding, get_function_host, \
21+
in_same_module, get_host_module
2122
from .common_pytest_marks import copy_pytest_marks, make_marked_parameter_value, remove_pytest_mark, filter_marks, \
2223
get_param_argnames_as_list
2324
from .common_pytest_lazy_values import lazy_value, LazyTupleItem
24-
from .common_pytest import safe_isclass, MiniMetafunc, is_fixture, get_fixture_name, inject_host, add_fixture_params
25+
from .common_pytest import safe_isclass, MiniMetafunc, is_fixture, get_fixture_name, inject_host, add_fixture_params, \
26+
list_all_fixtures_in
2527

2628
from . import fixture
2729
from .case_funcs import matches_tag_query, is_case_function, is_case_class, CASE_PREFIX_FUN, copy_case_info, \
@@ -62,7 +64,8 @@ def parametrize_with_cases(argnames, # type: Union[str, List[str]
6264
idstyle=None, # type: Union[str, Callable]
6365
# idgen=_IDGEN, # type: Union[str, Callable]
6466
debug=False, # type: bool
65-
scope="function" # type: str
67+
scope="function", # type: str
68+
import_fixtures=False # type: bool
6669
):
6770
# type: (...) -> Callable[[Callable], Callable]
6871
"""
@@ -116,6 +119,8 @@ def parametrize_with_cases(argnames, # type: Union[str, List[str]
116119
transformed into fixtures. As opposed to `ids`, a callable provided here will receive a `ParamAlternative`
117120
object indicating which generated fixture should be used. See `@parametrize` for details.
118121
:param scope: the scope of the union fixture to create if `fixture_ref`s are found in the argvalues
122+
:param import_fixtures: experimental feature. Turn this to True in order to automatically import all fixtures
123+
defined in the cases module into the current module.
119124
:param debug: a boolean flag to debug what happens behind the scenes
120125
:return:
121126
"""
@@ -139,7 +144,8 @@ def _apply_parametrization(f, host_class_or_module):
139144
# Transform the various case functions found into `lazy_value` (for case functions not requiring fixtures)
140145
# or `fixture_ref` (for case functions requiring fixtures - for them we create associated case fixtures in
141146
# `host_class_or_module`)
142-
argvalues = get_parametrize_args(host_class_or_module, cases_funs, prefix=prefix, debug=debug, scope=scope)
147+
argvalues = get_parametrize_args(host_class_or_module, cases_funs, prefix=prefix,
148+
import_fixtures=import_fixtures, debug=debug, scope=scope)
143149

144150
# Finally apply parametrization - note that we need to call the private method so that fixture are created in
145151
# the right module (not here)
@@ -289,6 +295,7 @@ def get_parametrize_args(host_class_or_module, # type: Union[Type, ModuleType
289295
cases_funs, # type: List[Callable]
290296
prefix, # type: str
291297
scope="function", # type: str
298+
import_fixtures=False, # type: bool
292299
debug=False # type: bool
293300
):
294301
# type: (...) -> List[Union[lazy_value, fixture_ref]]
@@ -304,16 +311,22 @@ def get_parametrize_args(host_class_or_module, # type: Union[Type, ModuleType
304311
305312
:param host_class_or_module: host of the parametrization target. A class or a module.
306313
:param cases_funs: a list of case functions, returned typically by `get_all_cases`
314+
:param prefix:
315+
:param scope:
316+
:param import_fixtures: experimental feature. Turn this to True in order to automatically import all fixtures
317+
defined in the cases module into the current module.
307318
:param debug: a boolean flag, turn it to True to print debug messages.
308319
:return:
309320
"""
310-
return [c for _f in cases_funs for c in case_to_argvalues(host_class_or_module, _f, prefix, scope, debug)]
321+
return [c for _f in cases_funs for c in case_to_argvalues(host_class_or_module, _f, prefix, scope, import_fixtures,
322+
debug)]
311323

312324

313325
def case_to_argvalues(host_class_or_module, # type: Union[Type, ModuleType]
314326
case_fun, # type: Callable
315327
prefix, # type: str
316328
scope, # type: str
329+
import_fixtures=False, # type: bool
317330
debug=False # type: bool
318331
):
319332
# type: (...) -> Tuple[lazy_value]
@@ -328,6 +341,8 @@ def case_to_argvalues(host_class_or_module, # type: Union[Type, ModuleType]
328341
Otherwise, `case_fun` represents a single case: in that case a single `lazy_value` is returned.
329342
330343
:param case_fun:
344+
:param import_fixtures: experimental feature. Turn this to True in order to automatically import all fixtures
345+
defined in the cases module into the current module.
331346
:return:
332347
"""
333348
# get the id from the case function either added by the @case decorator, or default one.
@@ -371,7 +386,8 @@ def case_to_argvalues(host_class_or_module, # type: Union[Type, ModuleType]
371386

372387
# create or reuse a fixture in the host (pytest collector: module or class) of the parametrization target
373388
fix_name, remaining_marks = get_or_create_case_fixture(case_id, case_fun, host_class_or_module,
374-
meta.fixturenames_not_in_sig, scope, debug)
389+
meta.fixturenames_not_in_sig, scope,
390+
import_fixtures=import_fixtures, debug=debug)
375391

376392
# reference that case fixture, and preserve the case id in the associated id whatever the generated fixture name
377393
argvalues = fixture_ref(fix_name, id=case_id)
@@ -387,6 +403,7 @@ def get_or_create_case_fixture(case_id, # type: str
387403
target_host, # type: Union[Type, ModuleType]
388404
add_required_fixtures, # type: Iterable[str]
389405
scope, # type: str
406+
import_fixtures=False, # type: bool
390407
debug=False # type: bool
391408
):
392409
# type: (...) -> Tuple[str, Tuple[MarkInfo]]
@@ -407,6 +424,8 @@ def get_or_create_case_fixture(case_id, # type: str
407424
:param case_fun:
408425
:param target_host:
409426
:param add_required_fixtures:
427+
:param import_fixtures: experimental feature. Turn this to True in order to automatically import all fixtures
428+
defined in the cases module into the current module.
410429
:param debug:
411430
:return: the newly created fixture name, and the remaining marks not applied
412431
"""
@@ -417,15 +436,15 @@ def get_or_create_case_fixture(case_id, # type: str
417436

418437
# source: detect a functools.partial wrapper created by us because of a host class
419438
true_case_func, case_in_class = _get_original_case_func(case_fun)
420-
# case_host = case_fun.host_class if case_in_class else import_module(case_fun.__module__)
439+
true_case_func_host = get_function_host(true_case_func)
421440

422441
# for checks
423442
orig_name = true_case_func.__name__
424443
orig_case = true_case_func
425444

426445
# destination
427446
target_in_class = safe_isclass(target_host)
428-
fix_cases_dct = _get_fixture_cases(target_host) # get our "storage unit" in this module
447+
fix_cases_dct, imported_fixtures_list = _get_fixture_cases(target_host) # get our "storage unit" in this module
429448

430449
# shortcut if the case fixture is already known/registered in target host
431450
try:
@@ -439,9 +458,29 @@ def get_or_create_case_fixture(case_id, # type: str
439458
# not yet known there. Create a new symbol in the target host :
440459
# we need a "free" fixture name, and a "free" symbol name
441460
existing_fixture_names = []
442-
for n, symb in getmembers(target_host, lambda f: isfunction(f) or ismethod(f)):
443-
if is_fixture(symb):
444-
existing_fixture_names.append(get_fixture_name(symb))
461+
# -- fixtures in target module or class should not be overridden
462+
existing_fixture_names += list_all_fixtures_in(target_host, recurse_to_module=False)
463+
# -- are there fixtures in source module or class ? should not be overridden too
464+
if not in_same_module(target_host, true_case_func_host):
465+
fixtures_in_cases_module = list_all_fixtures_in(true_case_func_host, recurse_to_module=False)
466+
if len(fixtures_in_cases_module) > 0:
467+
# EXPERIMENTAL we can try to import the fixtures into current module
468+
if import_fixtures:
469+
from_module = get_host_module(true_case_func_host)
470+
if from_module not in imported_fixtures_list:
471+
for f in list_all_fixtures_in(true_case_func_host, recurse_to_module=False, return_names=False):
472+
f_name = get_fixture_name(f)
473+
if (f_name in existing_fixture_names) or (f.__name__ in existing_fixture_names):
474+
raise ValueError("Cannot import fixture %r from %r as it would override an existing symbol in "
475+
"%r. Please set `@parametrize_with_cases(import_fixtures=False)`"
476+
"" % (f, from_module, target_host))
477+
target_host_module = target_host if not target_in_class else get_host_module(target_host)
478+
setattr(target_host_module, f.__name__, f)
479+
480+
imported_fixtures_list.append(from_module)
481+
482+
# Fix the problem with "case_foo(foo)" leading to the generated fixture having the same name
483+
existing_fixture_names += fixtures_in_cases_module
445484

446485
def name_changer(name, i):
447486
return name + '_' * i
@@ -499,18 +538,35 @@ def name_changer(name, i):
499538
return fix_name, case_marks
500539

501540

502-
def _get_fixture_cases(module # type: ModuleType
541+
def _get_fixture_cases(module_or_class # type: Union[ModuleType, Type]
503542
):
504543
"""
505-
Returns our 'storage unit' in a module, used to remember the fixtures created from case functions.
544+
Returns our 'storage unit' in a module or class, used to remember the fixtures created from case functions.
506545
That way we can reuse fixtures already created for cases, in a given module/class.
546+
547+
In addition, the host module of the class, or the module itself, is used to store a list of modules
548+
from where we imported fixtures already. This relates to the EXPERIMENTAL `import_fixtures=True` param.
507549
"""
508-
try:
509-
cache = module._fixture_cases
510-
except AttributeError:
511-
cache = dict()
512-
module._fixture_cases = cache
513-
return cache
550+
if ismodule(module_or_class):
551+
# module: everything is stored in the same place
552+
try:
553+
cache, imported_fixtures_list = module_or_class._fixture_cases
554+
except AttributeError:
555+
cache = dict()
556+
imported_fixtures_list = []
557+
module_or_class._fixture_cases = (cache, imported_fixtures_list)
558+
else:
559+
# class: on class only the fixtures dict is stored
560+
try:
561+
cache = module_or_class._fixture_cases
562+
except AttributeError:
563+
cache = dict()
564+
module_or_class._fixture_cases = cache
565+
566+
# grab the imported fixtures list from the module host
567+
_, imported_fixtures_list = _get_fixture_cases(get_host_module(module_or_class))
568+
569+
return cache, imported_fixtures_list
514570

515571

516572
def import_default_cases_module(f):

pytest_cases/common_others.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,19 @@ def __exit__(self, exc_type, exc_val, exc_tb):
211211
"""Marker for automatic defaults"""
212212

213213

214+
def get_host_module(a):
215+
"""get the host module of a, or a if it is already a module"""
216+
if inspect.ismodule(a):
217+
return a
218+
else:
219+
return import_module(a.__module__)
220+
221+
222+
def in_same_module(a, b):
223+
"""Compare the host modules of a and b"""
224+
return get_host_module(a) == get_host_module(b)
225+
226+
214227
def get_function_host(func, fallback_to_module=True):
215228
"""
216229
Returns the module or class where func is defined. Approximate method based on qname but "good enough"
@@ -228,8 +241,7 @@ def get_function_host(func, fallback_to_module=True):
228241
raise
229242

230243
if host is None:
231-
host = import_module(func.__module__)
232-
# assert func in host
244+
host = get_host_module(func)
233245

234246
return host
235247

pytest_cases/common_pytest.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
# License: 3-clause BSD, <https://github.com/smarie/python-pytest-cases/blob/master/LICENSE>
55
from __future__ import division
66

7+
import inspect
78
import sys
9+
from importlib import import_module
810

911
from makefun import add_signature_parameters, wraps
1012

@@ -86,6 +88,31 @@ def is_fixture(fixture_fun # type: Any
8688
return False
8789

8890

91+
def list_all_fixtures_in(cls_or_module, return_names=True, recurse_to_module=False):
92+
"""
93+
Returns a list containing all fixture names (or symbols if `return_names=False`)
94+
in the given class or module.
95+
96+
Note that `recurse_to_module` can be used so that the fixtures in the parent
97+
module of a class are listed too.
98+
99+
:param cls_or_module:
100+
:param return_names:
101+
:param recurse_to_module:
102+
:return:
103+
"""
104+
res = [get_fixture_name(symb) if return_names else symb
105+
for n, symb in inspect.getmembers(cls_or_module, lambda f: inspect.isfunction(f) or inspect.ismethod(f))
106+
if is_fixture(symb)]
107+
108+
if recurse_to_module and not inspect.ismodule(cls_or_module):
109+
# TODO currently this only works for a single level of nesting, we should use __qualname__ (py3) or .im_class
110+
host = import_module(cls_or_module.__module__)
111+
res += list_all_fixtures_in(host, recurse_to_module=True, return_names=return_names)
112+
113+
return res
114+
115+
89116
def safe_isclass(obj # type: object
90117
):
91118
# type: (...) -> bool
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pytest_cases
2+
3+
4+
from .test_issue_193_cases import case_two_positive_ints, case_two_positive_ints2
5+
6+
7+
@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints, debug=True, import_fixtures=True)
8+
def test_bar(x):
9+
assert x is not None
10+
11+
12+
@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints2, debug=True, import_fixtures=True)
13+
def test_bar(x):
14+
assert x is not None
15+
16+
17+
@pytest_cases.parametrize_with_cases("x", debug=True, import_fixtures=True)
18+
def test_foo(x):
19+
assert x is not None
20+
21+
22+
@pytest_cases.parametrize_with_cases("x", debug=True, import_fixtures=True)
23+
def test_bar(x):
24+
assert x is not None
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# We make sure that two files requiring the same cases files and importing fixtures can work concurrently
2+
import pytest_cases
3+
4+
5+
from .test_issue_193_cases import case_two_positive_ints, case_two_positive_ints2
6+
7+
8+
@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints, debug=True, import_fixtures=True)
9+
def test_bar(x):
10+
assert x is not None
11+
12+
13+
@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints2, debug=True, import_fixtures=True)
14+
def test_bar(x):
15+
assert x is not None
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pytest_cases
2+
3+
4+
@pytest_cases.fixture
5+
def two_positive_ints():
6+
return 1, 2
7+
8+
9+
def case_two_positive_ints(two_positive_ints):
10+
""" Inputs are two positive integers """
11+
return two_positive_ints
12+
13+
14+
def case_two_positive_ints2(two_positive_ints):
15+
""" Inputs are two positive integers """
16+
return two_positive_ints

0 commit comments

Comments
 (0)