Skip to content

Commit

Permalink
Refactor internal scope handling by introducing Scope enum
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
nicoddemus committed Jul 31, 2021
1 parent 6247a95 commit e151f2e
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 173 deletions.
1 change: 1 addition & 0 deletions changelog/8913.trivial.rst
@@ -0,0 +1 @@
The private ``CallSpec2._arg2scopenum`` attribute has been removed after an internal refactoring.
237 changes: 115 additions & 122 deletions src/_pytest/fixtures.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/_pytest/mark/structures.py
Expand Up @@ -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]
Expand Down Expand Up @@ -450,7 +450,7 @@ def __call__( # type: ignore[override]
Callable[[Any], Optional[object]],
]
] = ...,
scope: Optional[_Scope] = ...,
scope: Optional[_ScopeName] = ...,
) -> MarkDecorator:
...

Expand Down
42 changes: 19 additions & 23 deletions src/_pytest/python.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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] = {}

Expand All @@ -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

Expand All @@ -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):
Expand All @@ -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))

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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.
Expand All @@ -1110,7 +1109,7 @@ def parametrize(
param_set.values,
param_id,
param_set.marks,
scopenum,
scope_,
param_index,
)
newcalls.append(newcallspec)
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions 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]
5 changes: 4 additions & 1 deletion src/_pytest/setuponly.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions src/_pytest/unittest.py
Expand Up @@ -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],
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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__}",
Expand Down
43 changes: 24 additions & 19 deletions testing/python/metafunc.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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)],
),
)

Expand All @@ -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:
Expand Down

0 comments on commit e151f2e

Please sign in to comment.