Skip to content

Commit 37a83e3

Browse files
authored
prevent redundant test execution (#35)
* fix issue 33 * crespo review --------- Co-authored-by: Bryn Lloyd <12702862+dyollb@users.noreply.github.com>
1 parent 5256011 commit 37a83e3

File tree

5 files changed

+476
-10
lines changed

5 files changed

+476
-10
lines changed

.github/workflows/update-pre-commit.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
- name: Create Pull Request
3535
uses: peter-evans/create-pull-request@v8
3636
with:
37-
token: ${{ secrets.GITHUB_TOKEN }}
37+
token: ${{ secrets.PAT }}
3838
commit-message: "chore: update pre-commit hooks"
3939
title: "chore: update pre-commit hooks"
4040
body: |

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ def test_two():
141141
pass
142142
```
143143

144+
### Marker Precedence
145+
146+
When `@pytest.mark.isolated` appears at multiple scopes, the **closest marker wins** (following pytest's standard `get_closest_marker` convention):
147+
148+
1. **Explicit `group` always wins.** `@pytest.mark.isolated(group="name")` uses that group regardless of scope.
149+
1. **Function > class > module.** A function-level `@pytest.mark.isolated` breaks out of any class or module group into its own subprocess.
150+
1. **Class > module.** A class marker groups its methods together, even inside a module with `pytestmark`.
151+
144152
## Configuration
145153

146154
### Command Line

src/pytest_isolated/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""pytest-isolated: Run pytest tests in isolated subprocesses."""
22

3-
__version__ = "0.4.3"
3+
__version__ = "0.4.4"

src/pytest_isolated/grouping.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,36 @@
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

335
from __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+
2259
def 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

Comments
 (0)