Skip to content

Commit

Permalink
Fixtures in case files can now be automatically imported using the **…
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
Sylvain MARIE committed Mar 24, 2021
1 parent c7b942b commit 97e155c
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 24 deletions.
4 changes: 3 additions & 1 deletion docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ CaseFilter(filter_function: Callable)
filter: Callable = None,
ids: Union[Callable, Iterable[str]] = None,
idstyle: Union[str, Callable] = None,
scope: str = "function"
scope: str = "function",
import_fixtures: bool = False
)
```

Expand Down Expand Up @@ -287,6 +288,7 @@ argvalues = get_parametrize_args(host_class_or_module_of_f, cases_funs)

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

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

### `get_current_case_id`

Expand Down
6 changes: 5 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,11 @@ test_generators.py::test_foo[simple_generator-who=there] PASSED [100%]
#### Cases requiring fixtures
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).
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.
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).
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).
!!! warning "Use `@fixture` instead of `@pytest.fixture`"
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).
Expand Down
96 changes: 76 additions & 20 deletions pytest_cases/case_parametrizer_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import functools
from importlib import import_module
from inspect import getmembers, isfunction, ismethod
from inspect import getmembers, ismodule
import re
from warnings import warn

Expand All @@ -17,11 +17,13 @@
pass

from .common_mini_six import string_types
from .common_others import get_code_first_line, AUTO, qname, funcopy, needs_binding
from .common_others import get_code_first_line, AUTO, qname, funcopy, needs_binding, get_function_host, \
in_same_module, get_host_module
from .common_pytest_marks import copy_pytest_marks, make_marked_parameter_value, remove_pytest_mark, filter_marks, \
get_param_argnames_as_list
from .common_pytest_lazy_values import lazy_value, LazyTupleItem
from .common_pytest import safe_isclass, MiniMetafunc, is_fixture, get_fixture_name, inject_host, add_fixture_params
from .common_pytest import safe_isclass, MiniMetafunc, is_fixture, get_fixture_name, inject_host, add_fixture_params, \
list_all_fixtures_in

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

# Finally apply parametrization - note that we need to call the private method so that fixture are created in
# the right module (not here)
Expand Down Expand Up @@ -289,6 +295,7 @@ def get_parametrize_args(host_class_or_module, # type: Union[Type, ModuleType
cases_funs, # type: List[Callable]
prefix, # type: str
scope="function", # type: str
import_fixtures=False, # type: bool
debug=False # type: bool
):
# type: (...) -> List[Union[lazy_value, fixture_ref]]
Expand All @@ -304,16 +311,22 @@ def get_parametrize_args(host_class_or_module, # type: Union[Type, ModuleType
:param host_class_or_module: host of the parametrization target. A class or a module.
:param cases_funs: a list of case functions, returned typically by `get_all_cases`
:param prefix:
:param scope:
:param import_fixtures: experimental feature. Turn this to True in order to automatically import all fixtures
defined in the cases module into the current module.
:param debug: a boolean flag, turn it to True to print debug messages.
:return:
"""
return [c for _f in cases_funs for c in case_to_argvalues(host_class_or_module, _f, prefix, scope, debug)]
return [c for _f in cases_funs for c in case_to_argvalues(host_class_or_module, _f, prefix, scope, import_fixtures,
debug)]


def case_to_argvalues(host_class_or_module, # type: Union[Type, ModuleType]
case_fun, # type: Callable
prefix, # type: str
scope, # type: str
import_fixtures=False, # type: bool
debug=False # type: bool
):
# type: (...) -> Tuple[lazy_value]
Expand All @@ -328,6 +341,8 @@ def case_to_argvalues(host_class_or_module, # type: Union[Type, ModuleType]
Otherwise, `case_fun` represents a single case: in that case a single `lazy_value` is returned.
:param case_fun:
:param import_fixtures: experimental feature. Turn this to True in order to automatically import all fixtures
defined in the cases module into the current module.
:return:
"""
# get the id from the case function either added by the @case decorator, or default one.
Expand Down Expand Up @@ -371,7 +386,8 @@ def case_to_argvalues(host_class_or_module, # type: Union[Type, ModuleType]

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

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

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

# for checks
orig_name = true_case_func.__name__
orig_case = true_case_func

# destination
target_in_class = safe_isclass(target_host)
fix_cases_dct = _get_fixture_cases(target_host) # get our "storage unit" in this module
fix_cases_dct, imported_fixtures_list = _get_fixture_cases(target_host) # get our "storage unit" in this module

# shortcut if the case fixture is already known/registered in target host
try:
Expand All @@ -439,9 +458,29 @@ def get_or_create_case_fixture(case_id, # type: str
# not yet known there. Create a new symbol in the target host :
# we need a "free" fixture name, and a "free" symbol name
existing_fixture_names = []
for n, symb in getmembers(target_host, lambda f: isfunction(f) or ismethod(f)):
if is_fixture(symb):
existing_fixture_names.append(get_fixture_name(symb))
# -- fixtures in target module or class should not be overridden
existing_fixture_names += list_all_fixtures_in(target_host, recurse_to_module=False)
# -- are there fixtures in source module or class ? should not be overridden too
if not in_same_module(target_host, true_case_func_host):
fixtures_in_cases_module = list_all_fixtures_in(true_case_func_host, recurse_to_module=False)
if len(fixtures_in_cases_module) > 0:
# EXPERIMENTAL we can try to import the fixtures into current module
if import_fixtures:
from_module = get_host_module(true_case_func_host)
if from_module not in imported_fixtures_list:
for f in list_all_fixtures_in(true_case_func_host, recurse_to_module=False, return_names=False):
f_name = get_fixture_name(f)
if (f_name in existing_fixture_names) or (f.__name__ in existing_fixture_names):
raise ValueError("Cannot import fixture %r from %r as it would override an existing symbol in "
"%r. Please set `@parametrize_with_cases(import_fixtures=False)`"
"" % (f, from_module, target_host))
target_host_module = target_host if not target_in_class else get_host_module(target_host)
setattr(target_host_module, f.__name__, f)

imported_fixtures_list.append(from_module)

# Fix the problem with "case_foo(foo)" leading to the generated fixture having the same name
existing_fixture_names += fixtures_in_cases_module

def name_changer(name, i):
return name + '_' * i
Expand Down Expand Up @@ -499,18 +538,35 @@ def name_changer(name, i):
return fix_name, case_marks


def _get_fixture_cases(module # type: ModuleType
def _get_fixture_cases(module_or_class # type: Union[ModuleType, Type]
):
"""
Returns our 'storage unit' in a module, used to remember the fixtures created from case functions.
Returns our 'storage unit' in a module or class, used to remember the fixtures created from case functions.
That way we can reuse fixtures already created for cases, in a given module/class.
In addition, the host module of the class, or the module itself, is used to store a list of modules
from where we imported fixtures already. This relates to the EXPERIMENTAL `import_fixtures=True` param.
"""
try:
cache = module._fixture_cases
except AttributeError:
cache = dict()
module._fixture_cases = cache
return cache
if ismodule(module_or_class):
# module: everything is stored in the same place
try:
cache, imported_fixtures_list = module_or_class._fixture_cases
except AttributeError:
cache = dict()
imported_fixtures_list = []
module_or_class._fixture_cases = (cache, imported_fixtures_list)
else:
# class: on class only the fixtures dict is stored
try:
cache = module_or_class._fixture_cases
except AttributeError:
cache = dict()
module_or_class._fixture_cases = cache

# grab the imported fixtures list from the module host
_, imported_fixtures_list = _get_fixture_cases(get_host_module(module_or_class))

return cache, imported_fixtures_list


def import_default_cases_module(f):
Expand Down
16 changes: 14 additions & 2 deletions pytest_cases/common_others.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ def __exit__(self, exc_type, exc_val, exc_tb):
"""Marker for automatic defaults"""


def get_host_module(a):
"""get the host module of a, or a if it is already a module"""
if inspect.ismodule(a):
return a
else:
return import_module(a.__module__)


def in_same_module(a, b):
"""Compare the host modules of a and b"""
return get_host_module(a) == get_host_module(b)


def get_function_host(func, fallback_to_module=True):
"""
Returns the module or class where func is defined. Approximate method based on qname but "good enough"
Expand All @@ -228,8 +241,7 @@ def get_function_host(func, fallback_to_module=True):
raise

if host is None:
host = import_module(func.__module__)
# assert func in host
host = get_host_module(func)

return host

Expand Down
27 changes: 27 additions & 0 deletions pytest_cases/common_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
# License: 3-clause BSD, <https://github.com/smarie/python-pytest-cases/blob/master/LICENSE>
from __future__ import division

import inspect
import sys
from importlib import import_module

from makefun import add_signature_parameters, wraps

Expand Down Expand Up @@ -86,6 +88,31 @@ def is_fixture(fixture_fun # type: Any
return False


def list_all_fixtures_in(cls_or_module, return_names=True, recurse_to_module=False):
"""
Returns a list containing all fixture names (or symbols if `return_names=False`)
in the given class or module.
Note that `recurse_to_module` can be used so that the fixtures in the parent
module of a class are listed too.
:param cls_or_module:
:param return_names:
:param recurse_to_module:
:return:
"""
res = [get_fixture_name(symb) if return_names else symb
for n, symb in inspect.getmembers(cls_or_module, lambda f: inspect.isfunction(f) or inspect.ismethod(f))
if is_fixture(symb)]

if recurse_to_module and not inspect.ismodule(cls_or_module):
# TODO currently this only works for a single level of nesting, we should use __qualname__ (py3) or .im_class
host = import_module(cls_or_module.__module__)
res += list_all_fixtures_in(host, recurse_to_module=True, return_names=return_names)

return res


def safe_isclass(obj # type: object
):
# type: (...) -> bool
Expand Down
24 changes: 24 additions & 0 deletions pytest_cases/tests/cases/issues/test_issue_193.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import pytest_cases


from .test_issue_193_cases import case_two_positive_ints, case_two_positive_ints2


@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints, debug=True, import_fixtures=True)
def test_bar(x):
assert x is not None


@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints2, debug=True, import_fixtures=True)
def test_bar(x):
assert x is not None


@pytest_cases.parametrize_with_cases("x", debug=True, import_fixtures=True)
def test_foo(x):
assert x is not None


@pytest_cases.parametrize_with_cases("x", debug=True, import_fixtures=True)
def test_bar(x):
assert x is not None
15 changes: 15 additions & 0 deletions pytest_cases/tests/cases/issues/test_issue_193_bis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# We make sure that two files requiring the same cases files and importing fixtures can work concurrently
import pytest_cases


from .test_issue_193_cases import case_two_positive_ints, case_two_positive_ints2


@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints, debug=True, import_fixtures=True)
def test_bar(x):
assert x is not None


@pytest_cases.parametrize_with_cases("x", cases=case_two_positive_ints2, debug=True, import_fixtures=True)
def test_bar(x):
assert x is not None
16 changes: 16 additions & 0 deletions pytest_cases/tests/cases/issues/test_issue_193_cases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import pytest_cases


@pytest_cases.fixture
def two_positive_ints():
return 1, 2


def case_two_positive_ints(two_positive_ints):
""" Inputs are two positive integers """
return two_positive_ints


def case_two_positive_ints2(two_positive_ints):
""" Inputs are two positive integers """
return two_positive_ints

0 comments on commit 97e155c

Please sign in to comment.