From e151f2ec0d8ed2e63a2c4b8841fdc61a33b2b3f5 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 15 Jul 2021 12:36:06 -0300 Subject: [PATCH] Refactor internal scope handling by introducing Scope enum This replaces the internal use of string literals and "scopenum" to a proper Scope enum, which also centralizes ordering, getting higher and lower scopes, etc. Another benefit is that it helps the type checker by introducing a proper type, and improves readability. Added a trivial changelog as courtesy for plugin authors that used the removed attribute. This is _mostly_ an internal change, however due to historical reasons, the API of the following _internal_ objects has changed slightly: * `CallSpec2`: `_arg2scopenum` renamed to `_arg2scope` for consistency. * `FixtureRequest`: Previously contained a `scope: str` attribute. Changed attribute to `_scope: Scope`, with a read-only property `scope -> str`. * `SubRequest.__init__` parameter changed from `_Scope` to `Scope`. --- changelog/8913.trivial.rst | 1 + src/_pytest/fixtures.py | 237 ++++++++++++++++----------------- src/_pytest/mark/structures.py | 4 +- src/_pytest/python.py | 42 +++--- src/_pytest/scope.py | 89 +++++++++++++ src/_pytest/setuponly.py | 5 +- src/_pytest/unittest.py | 11 +- testing/python/metafunc.py | 43 +++--- testing/test_scope.py | 39 ++++++ 9 files changed, 298 insertions(+), 173 deletions(-) create mode 100644 changelog/8913.trivial.rst create mode 100644 src/_pytest/scope.py create mode 100644 testing/test_scope.py diff --git a/changelog/8913.trivial.rst b/changelog/8913.trivial.rst new file mode 100644 index 00000000000..0d971c475a5 --- /dev/null +++ b/changelog/8913.trivial.rst @@ -0,0 +1 @@ +The private ``CallSpec2._arg2scopenum`` attribute has been removed after an internal refactoring. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 347d490032a..79b7c877f46 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -5,6 +5,7 @@ import warnings from collections import defaultdict from collections import deque +from contextlib import suppress from pathlib import Path from types import TracebackType from typing import Any @@ -62,20 +63,21 @@ from _pytest.outcomes import TEST_OUTCOME from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath +from _pytest.scope import HIGH_SCOPES +from _pytest.scope import Scope from _pytest.stash import StashKey + if TYPE_CHECKING: from typing import Deque from typing import NoReturn - from typing_extensions import Literal + from _pytest.scope import _ScopeName from _pytest.main import Session from _pytest.python import CallSpec2 from _pytest.python import Function from _pytest.python import Metafunc - _Scope = Literal["session", "package", "module", "class", "function"] - # The value of the fixture -- return/yield of the fixture function (type variable). FixtureValue = TypeVar("FixtureValue") @@ -104,10 +106,10 @@ ] -@attr.s(frozen=True) +@attr.s(frozen=True, auto_attribs=True) class PseudoFixtureDef(Generic[FixtureValue]): - cached_result = attr.ib(type="_FixtureCachedResult[FixtureValue]") - scope = attr.ib(type="_Scope") + cached_result: "_FixtureCachedResult[FixtureValue]" + _scope: Scope def pytest_sessionstart(session: "Session") -> None: @@ -130,19 +132,19 @@ def get_scope_package(node, fixturedef: "FixtureDef[object]"): def get_scope_node( - node: nodes.Node, scope: "_Scope" + node: nodes.Node, scope: Scope ) -> Optional[Union[nodes.Item, nodes.Collector]]: import _pytest.python - if scope == "function": + if scope is Scope.Function: return node.getparent(nodes.Item) - elif scope == "class": + elif scope is Scope.Class: return node.getparent(_pytest.python.Class) - elif scope == "module": + elif scope is Scope.Module: return node.getparent(_pytest.python.Module) - elif scope == "package": + elif scope is Scope.Package: return node.getparent(_pytest.python.Package) - elif scope == "session": + elif scope is Scope.Session: return node.getparent(_pytest.main.Session) else: assert_never(scope) @@ -166,7 +168,7 @@ def add_funcarg_pseudo_fixture_def( return # Collect funcargs of all callspecs into a list of values. arg2params: Dict[str, List[object]] = {} - arg2scope: Dict[str, _Scope] = {} + arg2scope: Dict[str, Scope] = {} for callspec in metafunc._calls: for argname, argvalue in callspec.funcargs.items(): assert argname not in callspec.params @@ -175,8 +177,8 @@ def add_funcarg_pseudo_fixture_def( callspec.indices[argname] = len(arg2params_list) arg2params_list.append(argvalue) if argname not in arg2scope: - scopenum = callspec._arg2scopenum.get(argname, scopenum_function) - arg2scope[argname] = scopes[scopenum] + scope = callspec._arg2scope.get(argname, Scope.Function) + arg2scope[argname] = scope callspec.funcargs.clear() # Register artificial FixtureDef's so that later at test execution @@ -189,10 +191,12 @@ def add_funcarg_pseudo_fixture_def( # node related to the scope. scope = arg2scope[argname] node = None - if scope != "function": + if scope is not Scope.Function: node = get_scope_node(collector, scope) if node is None: - assert scope == "class" and isinstance(collector, _pytest.python.Module) + assert scope is Scope.Class and isinstance( + collector, _pytest.python.Module + ) # Use module-level collector for class-scope (for now). node = collector if node is None: @@ -238,10 +242,10 @@ def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: _Key = Tuple[object, ...] -def get_parametrized_fixture_keys(item: nodes.Item, scopenum: int) -> Iterator[_Key]: +def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_Key]: """Return list of keys for all parametrized arguments which match the specified scope.""" - assert scopenum < scopenum_function # function + assert scope is not Scope.Function try: callspec = item.callspec # type: ignore[attr-defined] except AttributeError: @@ -252,67 +256,71 @@ def get_parametrized_fixture_keys(item: nodes.Item, scopenum: int) -> Iterator[_ # sort this so that different calls to # get_parametrized_fixture_keys will be deterministic. for argname, param_index in sorted(cs.indices.items()): - if cs._arg2scopenum[argname] != scopenum: + if cs._arg2scope[argname] != scope: continue - if scopenum == 0: # session + if scope is Scope.Session: key: _Key = (argname, param_index) - elif scopenum == 1: # package + elif scope is Scope.Package: key = (argname, param_index, item.path.parent) - elif scopenum == 2: # module + elif scope is Scope.Module: key = (argname, param_index, item.path) - elif scopenum == 3: # class + elif scope is Scope.Class: item_cls = item.cls # type: ignore[attr-defined] key = (argname, param_index, item.path, item_cls) + else: + assert_never(scope) yield key # Algorithm for sorting on a per-parametrized resource setup basis. -# It is called for scopenum==0 (session) first and performs sorting +# It is called for Session scope first and performs sorting # down to the lower scopes such as to minimize number of "high scope" # setups and teardowns. def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: - argkeys_cache: Dict[int, Dict[nodes.Item, Dict[_Key, None]]] = {} - items_by_argkey: Dict[int, Dict[_Key, Deque[nodes.Item]]] = {} - for scopenum in range(scopenum_function): + argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]] = {} + items_by_argkey: Dict[Scope, Dict[_Key, Deque[nodes.Item]]] = {} + for scope in HIGH_SCOPES: d: Dict[nodes.Item, Dict[_Key, None]] = {} - argkeys_cache[scopenum] = d + argkeys_cache[scope] = d item_d: Dict[_Key, Deque[nodes.Item]] = defaultdict(deque) - items_by_argkey[scopenum] = item_d + items_by_argkey[scope] = item_d for item in items: - keys = dict.fromkeys(get_parametrized_fixture_keys(item, scopenum), None) + keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None) if keys: d[item] = keys for key in keys: item_d[key].append(item) items_dict = dict.fromkeys(items, None) - return list(reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, 0)) + return list( + reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, Scope.Session) + ) def fix_cache_order( item: nodes.Item, - argkeys_cache: Dict[int, Dict[nodes.Item, Dict[_Key, None]]], - items_by_argkey: Dict[int, Dict[_Key, "Deque[nodes.Item]"]], + argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]], + items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]], ) -> None: - for scopenum in range(scopenum_function): - for key in argkeys_cache[scopenum].get(item, []): - items_by_argkey[scopenum][key].appendleft(item) + for scope in HIGH_SCOPES: + for key in argkeys_cache[scope].get(item, []): + items_by_argkey[scope][key].appendleft(item) def reorder_items_atscope( items: Dict[nodes.Item, None], - argkeys_cache: Dict[int, Dict[nodes.Item, Dict[_Key, None]]], - items_by_argkey: Dict[int, Dict[_Key, "Deque[nodes.Item]"]], - scopenum: int, + argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]], + items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]], + scope: Scope, ) -> Dict[nodes.Item, None]: - if scopenum >= scopenum_function or len(items) < 3: + if scope is Scope.Function or len(items) < 3: return items ignore: Set[Optional[_Key]] = set() items_deque = deque(items) items_done: Dict[nodes.Item, None] = {} - scoped_items_by_argkey = items_by_argkey[scopenum] - scoped_argkeys_cache = argkeys_cache[scopenum] + scoped_items_by_argkey = items_by_argkey[scope] + scoped_argkeys_cache = argkeys_cache[scope] while items_deque: no_argkey_group: Dict[nodes.Item, None] = {} slicing_argkey = None @@ -338,7 +346,7 @@ def reorder_items_atscope( break if no_argkey_group: no_argkey_group = reorder_items_atscope( - no_argkey_group, argkeys_cache, items_by_argkey, scopenum + 1 + no_argkey_group, argkeys_cache, items_by_argkey, scope.next_lower() ) for item in no_argkey_group: items_done[item] = None @@ -437,14 +445,18 @@ def __init__(self, pyfuncitem, *, _ispytest: bool = False) -> None: self._pyfuncitem = pyfuncitem #: Fixture for which this request is being performed. self.fixturename: Optional[str] = None - #: Scope string, one of "function", "class", "module", "session". - self.scope: _Scope = "function" + self._scope = Scope.Function self._fixture_defs: Dict[str, FixtureDef[Any]] = {} fixtureinfo: FuncFixtureInfo = pyfuncitem._fixtureinfo self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy() self._arg2index: Dict[str, int] = {} self._fixturemanager: FixtureManager = pyfuncitem.session._fixturemanager + @property + def scope(self) -> "_ScopeName": + """Scope string, one of "function", "class", "module", "package", "session".""" + return self._scope.value + @property def fixturenames(self) -> List[str]: """Names of all active fixtures in this request.""" @@ -455,7 +467,7 @@ def fixturenames(self) -> List[str]: @property def node(self): """Underlying collection node (depends on current request scope).""" - return self._getscopeitem(self.scope) + return self._getscopeitem(self._scope) def _getnextfixturedef(self, argname: str) -> "FixtureDef[Any]": fixturedefs = self._arg2fixturedefs.get(argname, None) @@ -598,8 +610,7 @@ def _get_active_fixturedef( except FixtureLookupError: if argname == "request": cached_result = (self, [0], None) - scope: _Scope = "function" - return PseudoFixtureDef(cached_result, scope) + return PseudoFixtureDef(cached_result, Scope.Function) raise # Remove indent to prevent the python3 exception # from leaking into the call. @@ -628,7 +639,7 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None: # (latter managed by fixturedef) argname = fixturedef.argname funcitem = self._pyfuncitem - scope = fixturedef.scope + scope = fixturedef._scope try: param = funcitem.callspec.getparam(argname) except (AttributeError, ValueError): @@ -675,16 +686,15 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None: param_index = funcitem.callspec.indices[argname] # If a parametrize invocation set a scope it will override # the static scope defined with the fixture function. - paramscopenum = funcitem.callspec._arg2scopenum.get(argname) - if paramscopenum is not None: - scope = scopes[paramscopenum] + with suppress(KeyError): + scope = funcitem.callspec._arg2scope[argname] subrequest = SubRequest( self, scope, param, param_index, fixturedef, _ispytest=True ) # Check if a higher-level scoped fixture accesses a lower level one. - subrequest._check_scope(argname, self.scope, scope) + subrequest._check_scope(argname, self._scope, scope) try: # Call the fixture function. fixturedef.execute(request=subrequest) @@ -700,19 +710,18 @@ def _schedule_finalizers( def _check_scope( self, argname: str, - invoking_scope: "_Scope", - requested_scope: "_Scope", + invoking_scope: Scope, + requested_scope: Scope, ) -> None: if argname == "request": return - if scopemismatch(invoking_scope, requested_scope): + if invoking_scope > requested_scope: # Try to report something helpful. - lines = self._factorytraceback() + text = "\n".join(self._factorytraceback()) fail( - "ScopeMismatch: You tried to access the %r scoped " - "fixture %r with a %r scoped request object, " - "involved factories\n%s" - % ((requested_scope, argname, invoking_scope, "\n".join(lines))), + f"ScopeMismatch: You tried to access the {requested_scope.value} scoped " + f"fixture {argname} with a {invoking_scope.value} scoped request object, " + f"involved factories:\n{text}", pytrace=False, ) @@ -730,17 +739,21 @@ def _factorytraceback(self) -> List[str]: lines.append("%s:%d: def %s%s" % (p, lineno + 1, factory.__name__, args)) return lines - def _getscopeitem(self, scope: "_Scope") -> Union[nodes.Item, nodes.Collector]: - if scope == "function": + def _getscopeitem( + self, scope: Union[Scope, "_ScopeName"] + ) -> Union[nodes.Item, nodes.Collector]: + if isinstance(scope, str): + scope = Scope(scope) + if scope is Scope.Function: # This might also be a non-function Item despite its attribute name. node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem - elif scope == "package": + elif scope is Scope.Package: # FIXME: _fixturedef is not defined on FixtureRequest (this class), # but on FixtureRequest (a subclass). node = get_scope_package(self._pyfuncitem, self._fixturedef) # type: ignore[attr-defined] else: node = get_scope_node(self._pyfuncitem, scope) - if node is None and scope == "class": + if node is None and scope is Scope.Class: # Fallback to function item itself. node = self._pyfuncitem assert node, 'Could not obtain a node for scope "{}" for function {!r}'.format( @@ -759,7 +772,7 @@ class SubRequest(FixtureRequest): def __init__( self, request: "FixtureRequest", - scope: "_Scope", + scope: Scope, param: Any, param_index: int, fixturedef: "FixtureDef[object]", @@ -772,7 +785,7 @@ def __init__( if param is not NOTSET: self.param = param self.param_index = param_index - self.scope = scope + self._scope = scope self._fixturedef = fixturedef self._pyfuncitem = request._pyfuncitem self._fixture_defs = request._fixture_defs @@ -801,29 +814,6 @@ def _schedule_finalizers( super()._schedule_finalizers(fixturedef, subrequest) -scopes: List["_Scope"] = ["session", "package", "module", "class", "function"] -scopenum_function = scopes.index("function") - - -def scopemismatch(currentscope: "_Scope", newscope: "_Scope") -> bool: - return scopes.index(newscope) > scopes.index(currentscope) - - -def scope2index(scope: str, descr: str, where: Optional[str] = None) -> int: - """Look up the index of ``scope`` and raise a descriptive value error - if not defined.""" - strscopes: Sequence[str] = scopes - try: - return strscopes.index(scope) - except ValueError: - fail( - "{} {}got an unexpected scope value '{}'".format( - descr, f"from {where} " if where else "", scope - ), - pytrace=False, - ) - - @final class FixtureLookupError(LookupError): """Could not return a requested fixture (missing or invalid).""" @@ -955,10 +945,10 @@ def _teardown_yield_fixture(fixturefunc, it) -> None: def _eval_scope_callable( - scope_callable: "Callable[[str, Config], _Scope]", + scope_callable: "Callable[[str, Config], _ScopeName]", fixture_name: str, config: Config, -) -> "_Scope": +) -> "_ScopeName": try: # Type ignored because there is no typing mechanism to specify # keyword arguments, currently. @@ -989,7 +979,7 @@ def __init__( baseid: Optional[str], argname: str, func: "_FixtureFunc[FixtureValue]", - scope: "Union[_Scope, Callable[[str, Config], _Scope]]", + scope: Union[Scope, "_ScopeName", Callable[[str, Config], "_ScopeName"], None], params: Optional[Sequence[object]], unittest: bool = False, ids: Optional[ @@ -1004,17 +994,16 @@ def __init__( self.has_location = baseid is not None self.func = func self.argname = argname - if callable(scope): - scope_ = _eval_scope_callable(scope, argname, fixturemanager.config) - else: - scope_ = scope - self.scopenum = scope2index( - # TODO: Check if the `or` here is really necessary. - scope_ or "function", # type: ignore[unreachable] - descr=f"Fixture '{func.__name__}'", - where=baseid, - ) - self.scope = scope_ + if scope is None: + scope = Scope.Function + elif callable(scope): + scope = _eval_scope_callable(scope, argname, fixturemanager.config) + + if isinstance(scope, str): + scope = Scope.from_user( + scope, descr=f"Fixture '{func.__name__}'", where=baseid + ) + self._scope = scope self.params: Optional[Sequence[object]] = params self.argnames: Tuple[str, ...] = getfuncargnames( func, name=argname, is_method=unittest @@ -1024,6 +1013,11 @@ def __init__( self.cached_result: Optional[_FixtureCachedResult[FixtureValue]] = None self._finalizers: List[Callable[[], object]] = [] + @property + def scope(self) -> "_ScopeName": + """Scope string, one of "function", "class", "module", "package", "session".""" + return self._scope.value + def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) @@ -1126,7 +1120,7 @@ def pytest_fixture_setup( fixdef = request._get_active_fixturedef(argname) assert fixdef.cached_result is not None result, arg_cache_key, exc = fixdef.cached_result - request._check_scope(argname, request.scope, fixdef.scope) + request._check_scope(argname, request._scope, fixdef._scope) kwargs[argname] = result fixturefunc = resolve_fixture_function(fixturedef, request) @@ -1195,18 +1189,17 @@ def result(*args, **kwargs): @final @attr.s(frozen=True) class FixtureFunctionMarker: - scope = attr.ib(type="Union[_Scope, Callable[[str, Config], _Scope]]") - params = attr.ib(type=Optional[Tuple[object, ...]], converter=_params_converter) - autouse = attr.ib(type=bool, default=False) - ids = attr.ib( - type=Union[ - Tuple[Union[None, str, float, int, bool], ...], - Callable[[Any], Optional[object]], - ], + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = attr.ib() + params: Optional[Tuple[object, ...]] = attr.ib(converter=_params_converter) + autouse: bool = attr.ib(default=False) + ids: Union[ + Tuple[Union[None, str, float, int, bool], ...], + Callable[[Any], Optional[object]], + ] = attr.ib( default=None, converter=_ensure_immutable_ids, ) - name = attr.ib(type=Optional[str], default=None) + name: Optional[str] = attr.ib(default=None) def __call__(self, function: FixtureFunction) -> FixtureFunction: if inspect.isclass(function): @@ -1238,7 +1231,7 @@ def __call__(self, function: FixtureFunction) -> FixtureFunction: def fixture( fixture_function: FixtureFunction, *, - scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = ..., + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., params: Optional[Iterable[object]] = ..., autouse: bool = ..., ids: Optional[ @@ -1256,7 +1249,7 @@ def fixture( def fixture( fixture_function: None = ..., *, - scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = ..., + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., params: Optional[Iterable[object]] = ..., autouse: bool = ..., ids: Optional[ @@ -1273,7 +1266,7 @@ def fixture( def fixture( fixture_function: Optional[FixtureFunction] = None, *, - scope: "Union[_Scope, Callable[[str, Config], _Scope]]" = "function", + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = "function", params: Optional[Iterable[object]] = None, autouse: bool = False, ids: Optional[ @@ -1552,15 +1545,15 @@ def merge(otherlist: Iterable[str]) -> None: arg2fixturedefs[argname] = fixturedefs merge(fixturedefs[-1].argnames) - def sort_by_scope(arg_name: str) -> int: + def sort_by_scope(arg_name: str) -> Scope: try: fixturedefs = arg2fixturedefs[arg_name] except KeyError: - return scopes.index("function") + return Scope.Function else: - return fixturedefs[-1].scopenum + return fixturedefs[-1]._scope - fixturenames_closure.sort(key=sort_by_scope) + fixturenames_closure.sort(key=sort_by_scope, reverse=True) return initialnames, fixturenames_closure, arg2fixturedefs def pytest_generate_tests(self, metafunc: "Metafunc") -> None: diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index e40a605bdc5..76dff5e082c 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -400,7 +400,7 @@ def store_mark(obj, mark: Mark) -> None: # Typing for builtin pytest marks. This is cheating; it gives builtin marks # special privilege, and breaks modularity. But practicality beats purity... if TYPE_CHECKING: - from _pytest.fixtures import _Scope + from _pytest.scope import _ScopeName class _SkipMarkDecorator(MarkDecorator): @overload # type: ignore[override,misc] @@ -450,7 +450,7 @@ def __call__( # type: ignore[override] Callable[[Any], Optional[object]], ] ] = ..., - scope: Optional[_Scope] = ..., + scope: Optional[_ScopeName] = ..., ) -> MarkDecorator: ... diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 8e8d2f38d83..e44320ddf49 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -72,12 +72,13 @@ from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import parts from _pytest.pathlib import visit +from _pytest.scope import Scope from _pytest.warning_types import PytestCollectionWarning from _pytest.warning_types import PytestUnhandledCoroutineWarning if TYPE_CHECKING: from typing_extensions import Literal - from _pytest.fixtures import _Scope + from _pytest.scope import _ScopeName def pytest_addoption(parser: Parser) -> None: @@ -896,7 +897,7 @@ def __init__(self, metafunc: "Metafunc") -> None: self._idlist: List[str] = [] self.params: Dict[str, object] = {} # Used for sorting parametrized resources. - self._arg2scopenum: Dict[str, int] = {} + self._arg2scope: Dict[str, Scope] = {} self.marks: List[Mark] = [] self.indices: Dict[str, int] = {} @@ -906,7 +907,7 @@ def copy(self) -> "CallSpec2": cs.params.update(self.params) cs.marks.extend(self.marks) cs.indices.update(self.indices) - cs._arg2scopenum.update(self._arg2scopenum) + cs._arg2scope.update(self._arg2scope) cs._idlist = list(self._idlist) return cs @@ -927,7 +928,7 @@ def setmulti2( valset: Iterable[object], id: str, marks: Iterable[Union[Mark, MarkDecorator]], - scopenum: int, + scope: Scope, param_index: int, ) -> None: for arg, val in zip(argnames, valset): @@ -941,7 +942,7 @@ def setmulti2( else: # pragma: no cover assert False, f"Unhandled valtype for arg: {valtype_for_arg}" self.indices[arg] = param_index - self._arg2scopenum[arg] = scopenum + self._arg2scope[arg] = scope self._idlist.append(id) self.marks.extend(normalize_mark_list(marks)) @@ -999,7 +1000,7 @@ def parametrize( Callable[[Any], Optional[object]], ] ] = None, - scope: "Optional[_Scope]" = None, + scope: "Optional[_ScopeName]" = None, *, _param_mark: Optional[Mark] = None, ) -> None: @@ -1055,8 +1056,6 @@ def parametrize( It will also override any fixture-function defined scope, allowing to set a dynamic scope using test context or configuration. """ - from _pytest.fixtures import scope2index - argnames, parameters = ParameterSet._for_parametrize( argnames, argvalues, @@ -1072,8 +1071,12 @@ def parametrize( pytrace=False, ) - if scope is None: - scope = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect) + if scope is not None: + scope_ = Scope.from_user( + scope, descr=f"parametrize() call in {self.function.__name__}" + ) + else: + scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect) self._validate_if_using_arg_names(argnames, indirect) @@ -1093,10 +1096,6 @@ def parametrize( if _param_mark and _param_mark._param_ids_from and generated_ids is None: object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) - scopenum = scope2index( - scope, descr=f"parametrize() call in {self.function.__name__}" - ) - # Create the new calls: if we are parametrize() multiple times (by applying the decorator # more than once) then we accumulate those calls generating the cartesian product # of all calls. @@ -1110,7 +1109,7 @@ def parametrize( param_set.values, param_id, param_set.marks, - scopenum, + scope_, param_index, ) newcalls.append(newcallspec) @@ -1263,7 +1262,7 @@ def _find_parametrized_scope( argnames: Sequence[str], arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]], indirect: Union[bool, Sequence[str]], -) -> "fixtures._Scope": +) -> Scope: """Find the most appropriate scope for a parametrized call based on its arguments. When there's at least one direct argument, always use "function" scope. @@ -1281,17 +1280,14 @@ def _find_parametrized_scope( if all_arguments_are_fixtures: fixturedefs = arg2fixturedefs or {} used_scopes = [ - fixturedef[0].scope + fixturedef[0]._scope for name, fixturedef in fixturedefs.items() if name in argnames ] - if used_scopes: - # Takes the most narrow scope from used fixtures. - for scope in reversed(fixtures.scopes): - if scope in used_scopes: - return scope + # Takes the most narrow scope from used fixtures. + return min(used_scopes, default=Scope.Function) - return "function" + return Scope.Function def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) -> str: diff --git a/src/_pytest/scope.py b/src/_pytest/scope.py new file mode 100644 index 00000000000..4f37a0f3be6 --- /dev/null +++ b/src/_pytest/scope.py @@ -0,0 +1,89 @@ +""" +Scope definition and related utilities. + +Those are defined here, instead of in the 'fixtures' module because +their use is spread across many other pytest modules, and centralizing it in 'fixtures' +would cause circular references. + +Also this makes the module light to import, as it should. +""" +from enum import Enum +from functools import total_ordering +from typing import Optional +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing_extensions import Literal + + _ScopeName = Literal["session", "package", "module", "class", "function"] + + +@total_ordering +class Scope(Enum): + """ + Represents one of the possible fixture scopes in pytest. + + Scopes are ordered from lower to higher, that is: + + ->>> higher ->>> + + Function < Class < Module < Package < Session + + <<<- lower <<<- + """ + + # Scopes need to be listed from lower to higher. + Function: "_ScopeName" = "function" + Class: "_ScopeName" = "class" + Module: "_ScopeName" = "module" + Package: "_ScopeName" = "package" + Session: "_ScopeName" = "session" + + def next_lower(self) -> "Scope": + """Return the next lower scope.""" + index = _SCOPE_INDICES[self] + if index == 0: + raise ValueError(f"{self} is the lower-most scope") + return _ALL_SCOPES[index - 1] + + def next_higher(self) -> "Scope": + """Return the next higher scope.""" + index = _SCOPE_INDICES[self] + if index == len(_SCOPE_INDICES) - 1: + raise ValueError(f"{self} is the upper-most scope") + return _ALL_SCOPES[index + 1] + + def __lt__(self, other: "Scope") -> bool: + self_index = _SCOPE_INDICES[self] + other_index = _SCOPE_INDICES[other] + return self_index < other_index + + @classmethod + def from_user( + cls, scope_name: "_ScopeName", descr: str, where: Optional[str] = None + ) -> "Scope": + """ + Given a scope name from the user, return the equivalent Scope enum. Should be used + whenever we want to convert a user provided scope name to its enum object. + + If the scope name is invalid, construct a user friendly message and call pytest.fail. + """ + from _pytest.outcomes import fail + + try: + return Scope(scope_name) + except ValueError: + fail( + "{} {}got an unexpected scope value '{}'".format( + descr, f"from {where} " if where else "", scope_name + ), + pytrace=False, + ) + + +_ALL_SCOPES = list(Scope) +_SCOPE_INDICES = {scope: index for index, scope in enumerate(_ALL_SCOPES)} + + +# Ordered list of scopes which can contain many tests (in practice all except Function). +HIGH_SCOPES = [x for x in Scope if x is not Scope.Function] diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 9a1dd751716..531131ce726 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -9,6 +9,7 @@ from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureDef from _pytest.fixtures import SubRequest +from _pytest.scope import Scope def pytest_addoption(parser: Parser) -> None: @@ -64,7 +65,9 @@ def _show_fixture_action(fixturedef: FixtureDef[object], msg: str) -> None: tw = config.get_terminal_writer() tw.line() - tw.write(" " * 2 * fixturedef.scopenum) + # Use smaller indentation the higher the scope: Session = 0, Package = 1, etc. + scope_indent = list(reversed(Scope)).index(fixturedef._scope) + tw.write(" " * 2 * scope_indent) tw.write( "{step} {scope} {fixture}".format( step=msg.ljust(8), # align the output to TEARDOWN diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 17fccc26867..26ea22f33f9 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -29,13 +29,12 @@ from _pytest.python import Function from _pytest.python import PyCollector from _pytest.runner import CallInfo +from _pytest.scope import Scope if TYPE_CHECKING: import unittest import twisted.trial.unittest - from _pytest.fixtures import _Scope - _SysExcInfoType = Union[ Tuple[Type[BaseException], BaseException, types.TracebackType], Tuple[None, None, None], @@ -102,7 +101,7 @@ def _inject_setup_teardown_fixtures(self, cls: type) -> None: "setUpClass", "tearDownClass", "doClassCleanups", - scope="class", + scope=Scope.Class, pass_self=False, ) if class_fixture: @@ -113,7 +112,7 @@ def _inject_setup_teardown_fixtures(self, cls: type) -> None: "setup_method", "teardown_method", None, - scope="function", + scope=Scope.Function, pass_self=True, ) if method_fixture: @@ -125,7 +124,7 @@ def _make_xunit_fixture( setup_name: str, teardown_name: str, cleanup_name: Optional[str], - scope: "_Scope", + scope: Scope, pass_self: bool, ): setup = getattr(obj, setup_name, None) @@ -141,7 +140,7 @@ def cleanup(*args): pass @pytest.fixture( - scope=scope, + scope=scope.value, autouse=True, # Use a unique name to speed up lookup. name=f"_unittest_{setup_name}_fixture_{obj.__qualname__}", diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index ccf9c1d9c24..fc0082eb6b9 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -26,6 +26,7 @@ from _pytest.pytester import Pytester from _pytest.python import _idval from _pytest.python import idmaker +from _pytest.scope import Scope class TestMetafunc: @@ -142,16 +143,16 @@ def test_find_parametrized_scope(self) -> None: @attr.s class DummyFixtureDef: - scope = attr.ib() + _scope = attr.ib() fixtures_defs = cast( Dict[str, Sequence[fixtures.FixtureDef[object]]], dict( - session_fix=[DummyFixtureDef("session")], - package_fix=[DummyFixtureDef("package")], - module_fix=[DummyFixtureDef("module")], - class_fix=[DummyFixtureDef("class")], - func_fix=[DummyFixtureDef("function")], + session_fix=[DummyFixtureDef(Scope.Session)], + package_fix=[DummyFixtureDef(Scope.Package)], + module_fix=[DummyFixtureDef(Scope.Module)], + class_fix=[DummyFixtureDef(Scope.Class)], + func_fix=[DummyFixtureDef(Scope.Function)], ), ) @@ -160,29 +161,33 @@ class DummyFixtureDef: def find_scope(argnames, indirect): return _find_parametrized_scope(argnames, fixtures_defs, indirect=indirect) - assert find_scope(["func_fix"], indirect=True) == "function" - assert find_scope(["class_fix"], indirect=True) == "class" - assert find_scope(["module_fix"], indirect=True) == "module" - assert find_scope(["package_fix"], indirect=True) == "package" - assert find_scope(["session_fix"], indirect=True) == "session" + assert find_scope(["func_fix"], indirect=True) == Scope.Function + assert find_scope(["class_fix"], indirect=True) == Scope.Class + assert find_scope(["module_fix"], indirect=True) == Scope.Module + assert find_scope(["package_fix"], indirect=True) == Scope.Package + assert find_scope(["session_fix"], indirect=True) == Scope.Session - assert find_scope(["class_fix", "func_fix"], indirect=True) == "function" - assert find_scope(["func_fix", "session_fix"], indirect=True) == "function" - assert find_scope(["session_fix", "class_fix"], indirect=True) == "class" - assert find_scope(["package_fix", "session_fix"], indirect=True) == "package" - assert find_scope(["module_fix", "session_fix"], indirect=True) == "module" + assert find_scope(["class_fix", "func_fix"], indirect=True) == Scope.Function + assert find_scope(["func_fix", "session_fix"], indirect=True) == Scope.Function + assert find_scope(["session_fix", "class_fix"], indirect=True) == Scope.Class + assert ( + find_scope(["package_fix", "session_fix"], indirect=True) == Scope.Package + ) + assert find_scope(["module_fix", "session_fix"], indirect=True) == Scope.Module # when indirect is False or is not for all scopes, always use function - assert find_scope(["session_fix", "module_fix"], indirect=False) == "function" + assert ( + find_scope(["session_fix", "module_fix"], indirect=False) == Scope.Function + ) assert ( find_scope(["session_fix", "module_fix"], indirect=["module_fix"]) - == "function" + == Scope.Function ) assert ( find_scope( ["session_fix", "module_fix"], indirect=["session_fix", "module_fix"] ) - == "module" + == Scope.Module ) def test_parametrize_and_id(self) -> None: diff --git a/testing/test_scope.py b/testing/test_scope.py new file mode 100644 index 00000000000..09ee1343a80 --- /dev/null +++ b/testing/test_scope.py @@ -0,0 +1,39 @@ +import re + +import pytest +from _pytest.scope import Scope + + +def test_ordering() -> None: + assert Scope.Session > Scope.Package + assert Scope.Package > Scope.Module + assert Scope.Module > Scope.Class + assert Scope.Class > Scope.Function + + +def test_next_lower() -> None: + assert Scope.Session.next_lower() is Scope.Package + assert Scope.Package.next_lower() is Scope.Module + assert Scope.Module.next_lower() is Scope.Class + assert Scope.Class.next_lower() is Scope.Function + + with pytest.raises(ValueError, match="Function is the lower-most scope"): + Scope.Function.next_lower() + + +def test_next_higher() -> None: + assert Scope.Function.next_higher() is Scope.Class + assert Scope.Class.next_higher() is Scope.Module + assert Scope.Module.next_higher() is Scope.Package + assert Scope.Package.next_higher() is Scope.Session + + with pytest.raises(ValueError, match="Session is the upper-most scope"): + Scope.Session.next_higher() + + +def test_from_user() -> None: + assert Scope.from_user("module", "for parametrize", "some::id") is Scope.Module + + expected_msg = "for parametrize from some::id got an unexpected scope value 'foo'" + with pytest.raises(pytest.fail.Exception, match=re.escape(expected_msg)): + Scope.from_user("foo", "for parametrize", "some::id") # type:ignore[arg-type]