Skip to content

Commit c7f94e3

Browse files
authored
Merge pull request #178 from ngoldbaum/fix-single-core
2 parents 9bb1215 + 51c177f commit c7f94e3

6 files changed

Lines changed: 116 additions & 29 deletions

File tree

.github/workflows/main.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,27 @@ jobs:
5151
- name: Check manifest
5252
run: |
5353
check-manifest -u -v
54+
55+
test-single-cpu:
56+
runs-on: ubuntu-latest
57+
name: Run tests on a simulated single-CPU system
58+
steps:
59+
- uses: actions/checkout@v5
60+
- uses: actions/setup-python@v6
61+
with:
62+
python-version: '3.14'
63+
64+
- name: Setup uv
65+
uses: astral-sh/setup-uv@v7
66+
with:
67+
activate-environment: true
68+
69+
- name: Install dependencies
70+
run: |
71+
uv sync --locked --group test
72+
73+
- name: Run tests pinned to a single CPU
74+
# Regression coverage for issue #177: --parallel-threads=auto must
75+
# degrade gracefully when only one CPU is available.
76+
run: |
77+
taskset -c 0 python -m pytest tests/

MANIFEST.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
include tests/conftest.py
1+
recursive-include tests *.py
22
exclude pytest_run_parallel.egg-info
33
recursive-exclude * *.egg-info
44
recursive-include docs *.bat

tests/_helpers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from pytest_run_parallel.cpu_detection import get_logical_cpus
2+
3+
4+
def passing_status(threads):
5+
# Pytest reports "PARALLEL PASSED" only when the resolved worker count is
6+
# greater than 1. On single-CPU systems --parallel-threads=auto resolves
7+
# to 1, so the status degrades to "PASSED" (issue #177).
8+
if threads == "auto":
9+
n = get_logical_cpus() or 1
10+
else:
11+
n = int(threads)
12+
return "PARALLEL PASSED" if n > 1 else "PASSED"

tests/test_cpu_detection.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,50 @@ def test_auto_detect_cpus(num_parallel_threads):
158158
"*::test_auto_detect_cpus PARALLEL PASSED*",
159159
]
160160
)
161+
162+
163+
def test_auto_detect_single_cpu(
164+
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
165+
) -> None:
166+
# Regression test for issue #177: on a single-CPU system,
167+
# --parallel-threads=auto must degrade gracefully to 1 thread without
168+
# erroring out, and tests should still run (just sequentially).
169+
monkeypatch.setattr("pytest_run_parallel.utils.get_logical_cpus", lambda: 1)
170+
171+
pytester.makepyfile("""
172+
def test_single_cpu(num_parallel_threads):
173+
assert num_parallel_threads == 1
174+
""")
175+
176+
result = pytester.runpytest("--parallel-threads=auto", "-v")
177+
178+
result.stdout.fnmatch_lines(
179+
[
180+
"*::test_single_cpu PASSED*",
181+
]
182+
)
183+
assert "PARALLEL PASSED" not in result.stdout.str()
184+
assert result.ret == 0
185+
186+
187+
def test_auto_detect_no_cpu_info(
188+
pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
189+
) -> None:
190+
# Regression test for issue #177: when every CPU-detection path returns
191+
# None, --parallel-threads=auto must still fall back to 1 rather than
192+
# crashing.
193+
monkeypatch.setattr("pytest_run_parallel.utils.get_logical_cpus", lambda: None)
194+
195+
pytester.makepyfile("""
196+
def test_no_cpu_info(num_parallel_threads):
197+
assert num_parallel_threads == 1
198+
""")
199+
200+
result = pytester.runpytest("--parallel-threads=auto", "-v")
201+
202+
result.stdout.fnmatch_lines(
203+
[
204+
"*::test_no_cpu_info PASSED*",
205+
]
206+
)
207+
assert result.ret == 0

tests/test_thread_index.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import pytest
2+
from _helpers import passing_status
3+
4+
from pytest_run_parallel.cpu_detection import get_logical_cpus
25

36

47
def test_thread_index_single_thread(pytester: pytest.Pytester) -> None:
@@ -26,12 +29,15 @@ def test_thread_index(thread_index, num_parallel_threads):
2629

2730
result.stdout.fnmatch_lines(
2831
[
29-
"*::test_thread_index PARALLEL PASSED*",
32+
f"*::test_thread_index {passing_status('auto')}*",
3033
]
3134
)
3235

3336

3437
def test_thread_index_changes_between_tests(pytester: pytest.Pytester) -> None:
38+
if (get_logical_cpus() or 1) < 2:
39+
pytest.skip("requires more than one CPU to compare thread indexes")
40+
3541
# thread_comp is checking if the thread_indexes are equal between threads.
3642
# should fail since thread_indexes should not match.
3743
# test can be improved, since this cannot check if every thread has a
@@ -60,7 +66,7 @@ def test_iteration_index(iteration_index):
6066

6167
result.stdout.fnmatch_lines(
6268
[
63-
"*::test_iteration_index PARALLEL PASSED*",
69+
f"*::test_iteration_index {passing_status('auto')}*",
6470
]
6571
)
6672

@@ -92,6 +98,6 @@ def test_iteration_index(iteration_index, num_iterations):
9298

9399
result.stdout.fnmatch_lines(
94100
[
95-
"*::test_iteration_index PARALLEL PASSED*",
101+
f"*::test_iteration_index {passing_status('auto')}*",
96102
]
97103
)

tests/test_tmp_path.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import pytest
2+
from _helpers import passing_status
23

3-
parallel_threads = [
4-
(1, "PASSED"), # no parallel threads
5-
("auto", "PARALLEL PASSED"), # parallel threads
6-
]
4+
parallel_threads = [1, "auto"]
75

86

9-
@pytest.mark.parametrize("parallel, passing", parallel_threads)
10-
def test_tmp_path_is_empty(pytester: pytest.Pytester, parallel, passing):
7+
@pytest.mark.parametrize("parallel", parallel_threads)
8+
def test_tmp_path_is_empty(pytester: pytest.Pytester, parallel):
119
# ensures tmp_path is empty for each thread
1210
# test from (gh-109)
1311
pytester.makepyfile("""
@@ -26,13 +24,13 @@ def test_tmp_path(tmp_path):
2624

2725
result.stdout.fnmatch_lines(
2826
[
29-
f"*::test_tmp_path {passing}*",
27+
f"*::test_tmp_path {passing_status(parallel)}*",
3028
]
3129
)
3230

3331

34-
@pytest.mark.parametrize("parallel, passing", parallel_threads)
35-
def test_tmp_path_read_write(pytester: pytest.Pytester, parallel, passing):
32+
@pytest.mark.parametrize("parallel", parallel_threads)
33+
def test_tmp_path_read_write(pytester: pytest.Pytester, parallel):
3634
# ensures we can read/write in each tmp_path
3735
pytester.makepyfile("""
3836
def test_tmp_path(tmp_path):
@@ -47,13 +45,13 @@ def test_tmp_path(tmp_path):
4745

4846
result.stdout.fnmatch_lines(
4947
[
50-
f"*::test_tmp_path {passing}*",
48+
f"*::test_tmp_path {passing_status(parallel)}*",
5149
]
5250
)
5351

5452

55-
@pytest.mark.parametrize("parallel, passing", parallel_threads)
56-
def test_tmp_path_delete(pytester: pytest.Pytester, parallel, passing):
53+
@pytest.mark.parametrize("parallel", parallel_threads)
54+
def test_tmp_path_delete(pytester: pytest.Pytester, parallel):
5755
# ensures we can delete files in each tmp_path
5856
pytester.makepyfile("""
5957
def test_tmp_path(tmp_path):
@@ -73,13 +71,13 @@ def test_tmp_path(tmp_path):
7371

7472
result.stdout.fnmatch_lines(
7573
[
76-
f"*::test_tmp_path {passing}*",
74+
f"*::test_tmp_path {passing_status(parallel)}*",
7775
]
7876
)
7977

8078

81-
@pytest.mark.parametrize("parallel, passing", parallel_threads)
82-
def test_tmpdir_is_empty(pytester: pytest.Pytester, parallel, passing):
79+
@pytest.mark.parametrize("parallel", parallel_threads)
80+
def test_tmpdir_is_empty(pytester: pytest.Pytester, parallel):
8381
# ensures tmpdir is empty for each thread
8482
pytester.makepyfile("""
8583
def test_tmpdir(tmpdir):
@@ -94,13 +92,13 @@ def test_tmpdir(tmpdir):
9492

9593
result.stdout.fnmatch_lines(
9694
[
97-
f"*::test_tmpdir {passing}*",
95+
f"*::test_tmpdir {passing_status(parallel)}*",
9896
]
9997
)
10098

10199

102-
@pytest.mark.parametrize("parallel, passing", parallel_threads)
103-
def test_tmpdir_read_write(pytester: pytest.Pytester, parallel, passing):
100+
@pytest.mark.parametrize("parallel", parallel_threads)
101+
def test_tmpdir_read_write(pytester: pytest.Pytester, parallel):
104102
# ensures we can read/write in each tmpdir
105103
pytester.makepyfile("""
106104
def test_tmpdir(tmpdir):
@@ -115,13 +113,13 @@ def test_tmpdir(tmpdir):
115113

116114
result.stdout.fnmatch_lines(
117115
[
118-
f"*::test_tmpdir {passing}*",
116+
f"*::test_tmpdir {passing_status(parallel)}*",
119117
]
120118
)
121119

122120

123-
@pytest.mark.parametrize("parallel, passing", parallel_threads)
124-
def test_tmpdir_delete(pytester: pytest.Pytester, parallel, passing):
121+
@pytest.mark.parametrize("parallel", parallel_threads)
122+
def test_tmpdir_delete(pytester: pytest.Pytester, parallel):
125123
# ensures we can delete files in each tmpdir
126124
pytester.makepyfile("""
127125
def test_tmpdir(tmpdir):
@@ -140,13 +138,13 @@ def test_tmpdir(tmpdir):
140138

141139
result.stdout.fnmatch_lines(
142140
[
143-
f"*::test_tmpdir {passing}*",
141+
f"*::test_tmpdir {passing_status(parallel)}*",
144142
]
145143
)
146144

147145

148-
@pytest.mark.parametrize("parallel, passing", parallel_threads)
149-
def test_tmp_path_tmpdir(pytester: pytest.Pytester, parallel, passing):
146+
@pytest.mark.parametrize("parallel", parallel_threads)
147+
def test_tmp_path_tmpdir(pytester: pytest.Pytester, parallel):
150148
# ensures tmp_path and tmpdir can be used at the same time
151149
pytester.makepyfile("""
152150
def test_both(tmp_path, tmpdir):
@@ -159,6 +157,6 @@ def test_both(tmp_path, tmpdir):
159157

160158
result.stdout.fnmatch_lines(
161159
[
162-
f"*::test_both {passing}*",
160+
f"*::test_both {passing_status(parallel)}*",
163161
]
164162
)

0 commit comments

Comments
 (0)