Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/update-pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.PAT }}
commit-message: "chore: update pre-commit hooks"
title: "chore: update pre-commit hooks"
body: |
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ def test_two():
pass
```

### Marker Precedence

When `@pytest.mark.isolated` appears at multiple scopes, the **closest marker wins** (following pytest's standard `get_closest_marker` convention):

1. **Explicit `group` always wins.** `@pytest.mark.isolated(group="name")` uses that group regardless of scope.
1. **Function > class > module.** A function-level `@pytest.mark.isolated` breaks out of any class or module group into its own subprocess.
1. **Class > module.** A class marker groups its methods together, even inside a module with `pytestmark`.

## Configuration

### Command Line
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_isolated/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""pytest-isolated: Run pytest tests in isolated subprocesses."""

__version__ = "0.4.3"
__version__ = "0.4.4"
56 changes: 48 additions & 8 deletions src/pytest_isolated/grouping.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,36 @@
"""Test grouping logic for pytest-isolated."""
"""Test grouping logic for pytest-isolated.

Marker Precedence Rules
-----------------------
Following pytest's "closest marker wins" convention
(``get_closest_marker`` returns function > class > module), the
grouping logic resolves overlapping ``@pytest.mark.isolated`` markers
as follows:

1. **Explicit ``group`` always wins.**
If the *closest* marker carries a ``group`` parameter (positional or
keyword), that group name is used regardless of scope.

2. **Function-level marker wins (closest scope).**
A function decorated with ``@pytest.mark.isolated`` — even inside an
already-isolated class or module — runs in its **own** subprocess
(keyed by ``nodeid``). This matches pytest's standard precedence:
the closest marker takes effect.

3. **Class scope groups methods.**
``@pytest.mark.isolated`` on a class (without a function-level
override) groups all its methods into one subprocess (keyed
``module::class``).

4. **Module scope groups functions.**
``pytestmark = pytest.mark.isolated`` groups all functions and
un-decorated class methods in the module into one subprocess
(keyed by module path).

5. **Timeout is a group-level concept.**
The ``timeout`` parameter applies to the entire subprocess group.
Use ``pytest-timeout`` for per-test timeouts within a group.
"""

from __future__ import annotations

Expand All @@ -19,6 +51,11 @@ def _has_isolated_marker(obj: Any) -> bool:
return any(getattr(m, "name", None) == "isolated" for m in markers)


def _has_own_isolated_marker(item: pytest.Item) -> bool:
"""Check if item has isolated marker directly on it (not inherited)."""
return any(m.name == "isolated" for m in item.own_markers)


def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
Expand All @@ -43,7 +80,7 @@ def pytest_collection_modifyitems(
if not m and not run_all_isolated:
continue

# Get group from marker (positional arg, keyword arg, or default)
# --- Step 1: explicit group from closest marker wins ---
group = None
if m:
# Support @pytest.mark.isolated("groupname") - positional arg
Expand All @@ -53,23 +90,26 @@ def pytest_collection_modifyitems(
elif "group" in m.kwargs:
group = m.kwargs["group"]

# Default grouping logic
# --- Step 2: default grouping — closest scope wins ---
if group is None:
# If --isolated flag is used (no explicit marker), use unique nodeid
if not m:
group = item.nodeid
# Check if marker was applied to a class or module
elif isinstance(item, pytest.Function):
if item.cls is not None and _has_isolated_marker(item.cls):
# Group by class name (module::class)
# Closest wins: function-level marker takes priority
if _has_own_isolated_marker(item):
# Function has its own @isolated → own subprocess
group = item.nodeid
elif item.cls is not None and _has_isolated_marker(item.cls):
Comment thread
dyollb marked this conversation as resolved.
# Class scope: group by class (module::class)
parts = item.nodeid.split("::")
group = "::".join(parts[:2]) if len(parts) >= 3 else item.nodeid
elif _has_isolated_marker(item.module):
# Group by module name (first part of nodeid)
# Module scope: group by module path
parts = item.nodeid.split("::")
group = parts[0]
else:
# Explicit marker on function uses unique nodeid
# Marker on function only: own subprocess
group = item.nodeid
else:
# Non-Function items use unique nodeid
Expand Down
Loading