1- """Test grouping logic for pytest-isolated."""
1+ """Test grouping logic for pytest-isolated.
2+
3+ Marker Precedence Rules
4+ -----------------------
5+ Following pytest's "closest marker wins" convention
6+ (``get_closest_marker`` returns function > class > module), the
7+ grouping logic resolves overlapping ``@pytest.mark.isolated`` markers
8+ as follows:
9+
10+ 1. **Explicit ``group`` always wins.**
11+ If the *closest* marker carries a ``group`` parameter (positional or
12+ keyword), that group name is used regardless of scope.
13+
14+ 2. **Function-level marker wins (closest scope).**
15+ A function decorated with ``@pytest.mark.isolated`` — even inside an
16+ already-isolated class or module — runs in its **own** subprocess
17+ (keyed by ``nodeid``). This matches pytest's standard precedence:
18+ the closest marker takes effect.
19+
20+ 3. **Class scope groups methods.**
21+ ``@pytest.mark.isolated`` on a class (without a function-level
22+ override) groups all its methods into one subprocess (keyed
23+ ``module::class``).
24+
25+ 4. **Module scope groups functions.**
26+ ``pytestmark = pytest.mark.isolated`` groups all functions and
27+ un-decorated class methods in the module into one subprocess
28+ (keyed by module path).
29+
30+ 5. **Timeout is a group-level concept.**
31+ The ``timeout`` parameter applies to the entire subprocess group.
32+ Use ``pytest-timeout`` for per-test timeouts within a group.
33+ """
234
335from __future__ import annotations
436
@@ -19,6 +51,11 @@ def _has_isolated_marker(obj: Any) -> bool:
1951 return any (getattr (m , "name" , None ) == "isolated" for m in markers )
2052
2153
54+ def _has_own_isolated_marker (item : pytest .Item ) -> bool :
55+ """Check if item has isolated marker directly on it (not inherited)."""
56+ return any (m .name == "isolated" for m in item .own_markers )
57+
58+
2259def pytest_collection_modifyitems (
2360 config : pytest .Config , items : list [pytest .Item ]
2461) -> None :
@@ -59,7 +96,7 @@ def pytest_collection_modifyitems(
5996 if not m and not run_all_isolated :
6097 continue
6198
62- # Get group from marker (positional arg, keyword arg, or default)
99+ # --- Step 1: explicit group from closest marker wins ---
63100 group = None
64101 if m :
65102 # Support @pytest.mark.isolated("groupname") - positional arg
@@ -69,23 +106,26 @@ def pytest_collection_modifyitems(
69106 elif "group" in m .kwargs :
70107 group = m .kwargs ["group" ]
71108
72- # Default grouping logic
109+ # --- Step 2: default grouping — closest scope wins ---
73110 if group is None :
74111 # If --isolated flag is used (no explicit marker), use unique nodeid
75112 if not m :
76113 group = item .nodeid
77- # Check if marker was applied to a class or module
78114 elif isinstance (item , pytest .Function ):
79- if item .cls is not None and _has_isolated_marker (item .cls ):
80- # Group by class name (module::class)
115+ # Closest wins: function-level marker takes priority
116+ if _has_own_isolated_marker (item ):
117+ # Function has its own @isolated → own subprocess
118+ group = item .nodeid
119+ elif item .cls is not None and _has_isolated_marker (item .cls ):
120+ # Class scope: group by class (module::class)
81121 parts = item .nodeid .split ("::" )
82122 group = "::" .join (parts [:2 ]) if len (parts ) >= 3 else item .nodeid
83123 elif _has_isolated_marker (item .module ):
84- # Group by module name (first part of nodeid)
124+ # Module scope: group by module path
85125 parts = item .nodeid .split ("::" )
86126 group = parts [0 ]
87127 else :
88- # Explicit marker on function uses unique nodeid
128+ # Marker on function only: own subprocess
89129 group = item .nodeid
90130 else :
91131 # Non-Function items use unique nodeid
0 commit comments