Skip to content

Commit a83b359

Browse files
authored
Refactor internal scope handling by introducing Scope enum
PR #8913
1 parent ef5d81a commit a83b359

File tree

9 files changed

+298
-173
lines changed

9 files changed

+298
-173
lines changed

changelog/8913.trivial.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The private ``CallSpec2._arg2scopenum`` attribute has been removed after an internal refactoring.

src/_pytest/fixtures.py

Lines changed: 115 additions & 122 deletions
Large diffs are not rendered by default.

src/_pytest/mark/structures.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ def store_mark(obj, mark: Mark) -> None:
400400
# Typing for builtin pytest marks. This is cheating; it gives builtin marks
401401
# special privilege, and breaks modularity. But practicality beats purity...
402402
if TYPE_CHECKING:
403-
from _pytest.fixtures import _Scope
403+
from _pytest.scope import _ScopeName
404404

405405
class _SkipMarkDecorator(MarkDecorator):
406406
@overload # type: ignore[override,misc]
@@ -450,7 +450,7 @@ def __call__( # type: ignore[override]
450450
Callable[[Any], Optional[object]],
451451
]
452452
] = ...,
453-
scope: Optional[_Scope] = ...,
453+
scope: Optional[_ScopeName] = ...,
454454
) -> MarkDecorator:
455455
...
456456

src/_pytest/python.py

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,13 @@
7272
from _pytest.pathlib import ImportPathMismatchError
7373
from _pytest.pathlib import parts
7474
from _pytest.pathlib import visit
75+
from _pytest.scope import Scope
7576
from _pytest.warning_types import PytestCollectionWarning
7677
from _pytest.warning_types import PytestUnhandledCoroutineWarning
7778

7879
if TYPE_CHECKING:
7980
from typing_extensions import Literal
80-
from _pytest.fixtures import _Scope
81+
from _pytest.scope import _ScopeName
8182

8283

8384
def pytest_addoption(parser: Parser) -> None:
@@ -896,7 +897,7 @@ def __init__(self, metafunc: "Metafunc") -> None:
896897
self._idlist: List[str] = []
897898
self.params: Dict[str, object] = {}
898899
# Used for sorting parametrized resources.
899-
self._arg2scopenum: Dict[str, int] = {}
900+
self._arg2scope: Dict[str, Scope] = {}
900901
self.marks: List[Mark] = []
901902
self.indices: Dict[str, int] = {}
902903

@@ -906,7 +907,7 @@ def copy(self) -> "CallSpec2":
906907
cs.params.update(self.params)
907908
cs.marks.extend(self.marks)
908909
cs.indices.update(self.indices)
909-
cs._arg2scopenum.update(self._arg2scopenum)
910+
cs._arg2scope.update(self._arg2scope)
910911
cs._idlist = list(self._idlist)
911912
return cs
912913

@@ -927,7 +928,7 @@ def setmulti2(
927928
valset: Iterable[object],
928929
id: str,
929930
marks: Iterable[Union[Mark, MarkDecorator]],
930-
scopenum: int,
931+
scope: Scope,
931932
param_index: int,
932933
) -> None:
933934
for arg, val in zip(argnames, valset):
@@ -941,7 +942,7 @@ def setmulti2(
941942
else: # pragma: no cover
942943
assert False, f"Unhandled valtype for arg: {valtype_for_arg}"
943944
self.indices[arg] = param_index
944-
self._arg2scopenum[arg] = scopenum
945+
self._arg2scope[arg] = scope
945946
self._idlist.append(id)
946947
self.marks.extend(normalize_mark_list(marks))
947948

@@ -999,7 +1000,7 @@ def parametrize(
9991000
Callable[[Any], Optional[object]],
10001001
]
10011002
] = None,
1002-
scope: "Optional[_Scope]" = None,
1003+
scope: "Optional[_ScopeName]" = None,
10031004
*,
10041005
_param_mark: Optional[Mark] = None,
10051006
) -> None:
@@ -1055,8 +1056,6 @@ def parametrize(
10551056
It will also override any fixture-function defined scope, allowing
10561057
to set a dynamic scope using test context or configuration.
10571058
"""
1058-
from _pytest.fixtures import scope2index
1059-
10601059
argnames, parameters = ParameterSet._for_parametrize(
10611060
argnames,
10621061
argvalues,
@@ -1072,8 +1071,12 @@ def parametrize(
10721071
pytrace=False,
10731072
)
10741073

1075-
if scope is None:
1076-
scope = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
1074+
if scope is not None:
1075+
scope_ = Scope.from_user(
1076+
scope, descr=f"parametrize() call in {self.function.__name__}"
1077+
)
1078+
else:
1079+
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
10771080

10781081
self._validate_if_using_arg_names(argnames, indirect)
10791082

@@ -1093,10 +1096,6 @@ def parametrize(
10931096
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
10941097
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
10951098

1096-
scopenum = scope2index(
1097-
scope, descr=f"parametrize() call in {self.function.__name__}"
1098-
)
1099-
11001099
# Create the new calls: if we are parametrize() multiple times (by applying the decorator
11011100
# more than once) then we accumulate those calls generating the cartesian product
11021101
# of all calls.
@@ -1110,7 +1109,7 @@ def parametrize(
11101109
param_set.values,
11111110
param_id,
11121111
param_set.marks,
1113-
scopenum,
1112+
scope_,
11141113
param_index,
11151114
)
11161115
newcalls.append(newcallspec)
@@ -1263,7 +1262,7 @@ def _find_parametrized_scope(
12631262
argnames: Sequence[str],
12641263
arg2fixturedefs: Mapping[str, Sequence[fixtures.FixtureDef[object]]],
12651264
indirect: Union[bool, Sequence[str]],
1266-
) -> "fixtures._Scope":
1265+
) -> Scope:
12671266
"""Find the most appropriate scope for a parametrized call based on its arguments.
12681267
12691268
When there's at least one direct argument, always use "function" scope.
@@ -1281,17 +1280,14 @@ def _find_parametrized_scope(
12811280
if all_arguments_are_fixtures:
12821281
fixturedefs = arg2fixturedefs or {}
12831282
used_scopes = [
1284-
fixturedef[0].scope
1283+
fixturedef[0]._scope
12851284
for name, fixturedef in fixturedefs.items()
12861285
if name in argnames
12871286
]
1288-
if used_scopes:
1289-
# Takes the most narrow scope from used fixtures.
1290-
for scope in reversed(fixtures.scopes):
1291-
if scope in used_scopes:
1292-
return scope
1287+
# Takes the most narrow scope from used fixtures.
1288+
return min(used_scopes, default=Scope.Function)
12931289

1294-
return "function"
1290+
return Scope.Function
12951291

12961292

12971293
def _ascii_escaped_by_config(val: Union[str, bytes], config: Optional[Config]) -> str:

src/_pytest/scope.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Scope definition and related utilities.
3+
4+
Those are defined here, instead of in the 'fixtures' module because
5+
their use is spread across many other pytest modules, and centralizing it in 'fixtures'
6+
would cause circular references.
7+
8+
Also this makes the module light to import, as it should.
9+
"""
10+
from enum import Enum
11+
from functools import total_ordering
12+
from typing import Optional
13+
from typing import TYPE_CHECKING
14+
15+
if TYPE_CHECKING:
16+
from typing_extensions import Literal
17+
18+
_ScopeName = Literal["session", "package", "module", "class", "function"]
19+
20+
21+
@total_ordering
22+
class Scope(Enum):
23+
"""
24+
Represents one of the possible fixture scopes in pytest.
25+
26+
Scopes are ordered from lower to higher, that is:
27+
28+
->>> higher ->>>
29+
30+
Function < Class < Module < Package < Session
31+
32+
<<<- lower <<<-
33+
"""
34+
35+
# Scopes need to be listed from lower to higher.
36+
Function: "_ScopeName" = "function"
37+
Class: "_ScopeName" = "class"
38+
Module: "_ScopeName" = "module"
39+
Package: "_ScopeName" = "package"
40+
Session: "_ScopeName" = "session"
41+
42+
def next_lower(self) -> "Scope":
43+
"""Return the next lower scope."""
44+
index = _SCOPE_INDICES[self]
45+
if index == 0:
46+
raise ValueError(f"{self} is the lower-most scope")
47+
return _ALL_SCOPES[index - 1]
48+
49+
def next_higher(self) -> "Scope":
50+
"""Return the next higher scope."""
51+
index = _SCOPE_INDICES[self]
52+
if index == len(_SCOPE_INDICES) - 1:
53+
raise ValueError(f"{self} is the upper-most scope")
54+
return _ALL_SCOPES[index + 1]
55+
56+
def __lt__(self, other: "Scope") -> bool:
57+
self_index = _SCOPE_INDICES[self]
58+
other_index = _SCOPE_INDICES[other]
59+
return self_index < other_index
60+
61+
@classmethod
62+
def from_user(
63+
cls, scope_name: "_ScopeName", descr: str, where: Optional[str] = None
64+
) -> "Scope":
65+
"""
66+
Given a scope name from the user, return the equivalent Scope enum. Should be used
67+
whenever we want to convert a user provided scope name to its enum object.
68+
69+
If the scope name is invalid, construct a user friendly message and call pytest.fail.
70+
"""
71+
from _pytest.outcomes import fail
72+
73+
try:
74+
return Scope(scope_name)
75+
except ValueError:
76+
fail(
77+
"{} {}got an unexpected scope value '{}'".format(
78+
descr, f"from {where} " if where else "", scope_name
79+
),
80+
pytrace=False,
81+
)
82+
83+
84+
_ALL_SCOPES = list(Scope)
85+
_SCOPE_INDICES = {scope: index for index, scope in enumerate(_ALL_SCOPES)}
86+
87+
88+
# Ordered list of scopes which can contain many tests (in practice all except Function).
89+
HIGH_SCOPES = [x for x in Scope if x is not Scope.Function]

src/_pytest/setuponly.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from _pytest.config.argparsing import Parser
1010
from _pytest.fixtures import FixtureDef
1111
from _pytest.fixtures import SubRequest
12+
from _pytest.scope import Scope
1213

1314

1415
def pytest_addoption(parser: Parser) -> None:
@@ -64,7 +65,9 @@ def _show_fixture_action(fixturedef: FixtureDef[object], msg: str) -> None:
6465

6566
tw = config.get_terminal_writer()
6667
tw.line()
67-
tw.write(" " * 2 * fixturedef.scopenum)
68+
# Use smaller indentation the higher the scope: Session = 0, Package = 1, etc.
69+
scope_indent = list(reversed(Scope)).index(fixturedef._scope)
70+
tw.write(" " * 2 * scope_indent)
6871
tw.write(
6972
"{step} {scope} {fixture}".format(
7073
step=msg.ljust(8), # align the output to TEARDOWN

src/_pytest/unittest.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,12 @@
2929
from _pytest.python import Function
3030
from _pytest.python import PyCollector
3131
from _pytest.runner import CallInfo
32+
from _pytest.scope import Scope
3233

3334
if TYPE_CHECKING:
3435
import unittest
3536
import twisted.trial.unittest
3637

37-
from _pytest.fixtures import _Scope
38-
3938
_SysExcInfoType = Union[
4039
Tuple[Type[BaseException], BaseException, types.TracebackType],
4140
Tuple[None, None, None],
@@ -102,7 +101,7 @@ def _inject_setup_teardown_fixtures(self, cls: type) -> None:
102101
"setUpClass",
103102
"tearDownClass",
104103
"doClassCleanups",
105-
scope="class",
104+
scope=Scope.Class,
106105
pass_self=False,
107106
)
108107
if class_fixture:
@@ -113,7 +112,7 @@ def _inject_setup_teardown_fixtures(self, cls: type) -> None:
113112
"setup_method",
114113
"teardown_method",
115114
None,
116-
scope="function",
115+
scope=Scope.Function,
117116
pass_self=True,
118117
)
119118
if method_fixture:
@@ -125,7 +124,7 @@ def _make_xunit_fixture(
125124
setup_name: str,
126125
teardown_name: str,
127126
cleanup_name: Optional[str],
128-
scope: "_Scope",
127+
scope: Scope,
129128
pass_self: bool,
130129
):
131130
setup = getattr(obj, setup_name, None)
@@ -141,7 +140,7 @@ def cleanup(*args):
141140
pass
142141

143142
@pytest.fixture(
144-
scope=scope,
143+
scope=scope.value,
145144
autouse=True,
146145
# Use a unique name to speed up lookup.
147146
name=f"_unittest_{setup_name}_fixture_{obj.__qualname__}",

testing/python/metafunc.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from _pytest.pytester import Pytester
2727
from _pytest.python import _idval
2828
from _pytest.python import idmaker
29+
from _pytest.scope import Scope
2930

3031

3132
class TestMetafunc:
@@ -142,16 +143,16 @@ def test_find_parametrized_scope(self) -> None:
142143

143144
@attr.s
144145
class DummyFixtureDef:
145-
scope = attr.ib()
146+
_scope = attr.ib()
146147

147148
fixtures_defs = cast(
148149
Dict[str, Sequence[fixtures.FixtureDef[object]]],
149150
dict(
150-
session_fix=[DummyFixtureDef("session")],
151-
package_fix=[DummyFixtureDef("package")],
152-
module_fix=[DummyFixtureDef("module")],
153-
class_fix=[DummyFixtureDef("class")],
154-
func_fix=[DummyFixtureDef("function")],
151+
session_fix=[DummyFixtureDef(Scope.Session)],
152+
package_fix=[DummyFixtureDef(Scope.Package)],
153+
module_fix=[DummyFixtureDef(Scope.Module)],
154+
class_fix=[DummyFixtureDef(Scope.Class)],
155+
func_fix=[DummyFixtureDef(Scope.Function)],
155156
),
156157
)
157158

@@ -160,29 +161,33 @@ class DummyFixtureDef:
160161
def find_scope(argnames, indirect):
161162
return _find_parametrized_scope(argnames, fixtures_defs, indirect=indirect)
162163

163-
assert find_scope(["func_fix"], indirect=True) == "function"
164-
assert find_scope(["class_fix"], indirect=True) == "class"
165-
assert find_scope(["module_fix"], indirect=True) == "module"
166-
assert find_scope(["package_fix"], indirect=True) == "package"
167-
assert find_scope(["session_fix"], indirect=True) == "session"
164+
assert find_scope(["func_fix"], indirect=True) == Scope.Function
165+
assert find_scope(["class_fix"], indirect=True) == Scope.Class
166+
assert find_scope(["module_fix"], indirect=True) == Scope.Module
167+
assert find_scope(["package_fix"], indirect=True) == Scope.Package
168+
assert find_scope(["session_fix"], indirect=True) == Scope.Session
168169

169-
assert find_scope(["class_fix", "func_fix"], indirect=True) == "function"
170-
assert find_scope(["func_fix", "session_fix"], indirect=True) == "function"
171-
assert find_scope(["session_fix", "class_fix"], indirect=True) == "class"
172-
assert find_scope(["package_fix", "session_fix"], indirect=True) == "package"
173-
assert find_scope(["module_fix", "session_fix"], indirect=True) == "module"
170+
assert find_scope(["class_fix", "func_fix"], indirect=True) == Scope.Function
171+
assert find_scope(["func_fix", "session_fix"], indirect=True) == Scope.Function
172+
assert find_scope(["session_fix", "class_fix"], indirect=True) == Scope.Class
173+
assert (
174+
find_scope(["package_fix", "session_fix"], indirect=True) == Scope.Package
175+
)
176+
assert find_scope(["module_fix", "session_fix"], indirect=True) == Scope.Module
174177

175178
# when indirect is False or is not for all scopes, always use function
176-
assert find_scope(["session_fix", "module_fix"], indirect=False) == "function"
179+
assert (
180+
find_scope(["session_fix", "module_fix"], indirect=False) == Scope.Function
181+
)
177182
assert (
178183
find_scope(["session_fix", "module_fix"], indirect=["module_fix"])
179-
== "function"
184+
== Scope.Function
180185
)
181186
assert (
182187
find_scope(
183188
["session_fix", "module_fix"], indirect=["session_fix", "module_fix"]
184189
)
185-
== "module"
190+
== Scope.Module
186191
)
187192

188193
def test_parametrize_and_id(self) -> None:

0 commit comments

Comments
 (0)