Skip to content

Commit d97162e

Browse files
authored
Focus on pytest 8.4.0+ (#2)
* Focus on pytest 8.4.0+ - remove pytest 7 from the tox matrix - remove python 3.8 from the tox matrix and github actions
1 parent fe0d52e commit d97162e

13 files changed

Lines changed: 461 additions & 77 deletions

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
runs-on: ubuntu-latest
1414
strategy:
1515
matrix:
16-
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.8']
16+
python-version: ['3.9', '3.10', '3.11', '3.12']
1717
steps:
1818
- uses: actions/checkout@v4
1919

examples/conftest.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
11
"""Conftest for examples."""
2-
3-
# No special fixtures needed for basic load testing examples

examples/test_load_example.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@
99
1010
TEST_CODE:
1111
```python
12-
result = pytester.runpytest('--load-test', '-n', '2', '-v')
12+
result = run_with_timeout(pytester, '--load-test', '-n', '2', '-v')
13+
# Should complete successfully with interrupt
14+
assert result.ret == pytest.ExitCode.INTERRUPTED
1315
result.stdout.fnmatch_lines([
1416
'*Interrupted: Test session completed*',
1517
])
16-
assert result.ret == pytest.ExitCode.INTERRUPTED
1718
```
1819
"""
1920

2021
import pytest
2122

2223
from pytest_load_testing import stop_load_testing, weight
2324

24-
# Simple counter without shared state
25+
# Simple counter - note this won't work across xdist workers
26+
# but is sufficient for demonstration purposes
2527
_iteration_count = 0
2628

2729

@@ -31,8 +33,8 @@ def iteration_counter(request):
3133
global _iteration_count
3234
_iteration_count += 1
3335

34-
# Stop after 100 total test executions
35-
if _iteration_count >= 100:
36+
# Stop after 50 total test executions (reduced for faster testing)
37+
if _iteration_count >= 50:
3638
stop_load_testing(request, "Test session completed")
3739

3840

@@ -58,3 +60,6 @@ def test_admin_operations(iteration_counter):
5860
def test_health_check(iteration_counter):
5961
"""1% of requests - simulates health check."""
6062
assert True
63+
64+
65+
# Made with Bob

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ classifiers = [
3535
"License :: OSI Approved :: MIT License",
3636
]
3737
dependencies = [
38-
"pytest>=6.2.0",
38+
"pytest>=8.4.2",
3939
"pytest-xdist>=2.0.0",
4040
]
4141
[project.urls]

tests/conftest.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,67 @@
11
import pytest
2+
from _pytest.pytester import Pytester
23

34
pytest_plugins = "pytester"
45

6+
PYTESTER_TIMEOUT = 10
7+
58

69
@pytest.fixture(autouse=True)
710
def wide_terminal(monkeypatch):
811
"""Make all pytester tests use wider terminal for better output visibility."""
912
monkeypatch.setenv("COLUMNS", "120")
13+
14+
15+
@pytest.fixture
16+
def run_with_timeout():
17+
"""Fixture that provides a helper to run pytester with timeout
18+
19+
Returns:
20+
A callable that runs pytester.runpytest_subprocess with timeout handling
21+
"""
22+
23+
def _run(pytester, *args, timeout=PYTESTER_TIMEOUT, **kwargs):
24+
"""Run pytester with timeout and proper error handling.
25+
26+
Args:
27+
pytester: The pytester fixture
28+
*args: Arguments to pass to runpytest_subprocess
29+
timeout: Timeout in seconds (default: PYTESTER_TIMEOUT)
30+
**kwargs: Keyword arguments to pass to runpytest_subprocess
31+
32+
Returns:
33+
The result object from runpytest_subprocess
34+
35+
Raises:
36+
pytest.fail: If the subprocess times out
37+
"""
38+
try:
39+
return pytester.runpytest_subprocess(*args, timeout=timeout, **kwargs)
40+
except Pytester.TimeoutExpired as e:
41+
# Read stdout/stderr from pytester path
42+
stdout_path = pytester.path.joinpath("stdout")
43+
stderr_path = pytester.path.joinpath("stderr")
44+
45+
stdout = stdout_path.read_text() if stdout_path.exists() else "<no stdout>"
46+
stderr = stderr_path.read_text() if stderr_path.exists() else "<no stderr>"
47+
48+
# Truncate long output (first 100 + last 100 lines if > 200 lines)
49+
def truncate_output(text: str) -> str:
50+
lines = text.splitlines()
51+
if len(lines) > 200:
52+
first_100 = "\n".join(lines[:100])
53+
last_100 = "\n".join(lines[-100:])
54+
return f"{first_100}\n\n... ({len(lines) - 200} lines omitted) ...\n\n{last_100}"
55+
return text
56+
57+
stdout = truncate_output(stdout)
58+
stderr = truncate_output(stderr)
59+
60+
pytest.fail(
61+
f"Test timed out after {timeout} seconds - load test did not complete\n"
62+
f"Error: {e}\n"
63+
f"STDOUT:\n{stdout}\n"
64+
f"STDERR:\n{stderr}"
65+
)
66+
67+
return _run

tests/test_examples.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,13 @@ def test_missing_code(pytester):
118118
return test_missing_code
119119

120120
# Create test function that executes the test code
121-
def test_from_code(pytester):
121+
def test_from_code(pytester, run_with_timeout):
122122
# Copy example and conftest
123123
pytester.copy_example(f"examples/{file_name}")
124124
pytester.copy_example("examples/conftest.py")
125125

126126
# Execute the test code
127-
exec(test_code, {"pytester": pytester, "pytest": pytest})
127+
exec(test_code, {"pytester": pytester, "pytest": pytest, "run_with_timeout": run_with_timeout})
128128

129129
test_from_code.__name__ = test_name
130130
test_from_code.__doc__ = f"Auto-generated test for {file_name}"

tests/test_failure_tracking.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def test_dummy():
211211
assert "test_example.py::test_one" in scheduler.last_success_time
212212

213213

214-
def test_failure_tracking_integration(pytester):
214+
def test_failure_tracking_integration(pytester, run_with_timeout):
215215
"""Integration test: verify failure tracking works during actual test execution."""
216216
# Create a conftest that will capture the scheduler for inspection
217217
pytester.makeconftest("""
@@ -246,20 +246,23 @@ def pytest_sessionfinish(session):
246246
247247
@pytest.fixture(scope="session")
248248
def run_count(tmp_path_factory):
249-
counts_file = tmp_path_factory.mktemp("data") / "counts.json"
249+
root_tmp_dir = tmp_path_factory.getbasetemp().parent
250+
counts_file = root_tmp_dir / "counts.json"
250251
lock_file = counts_file.with_suffix('.lock')
251252
253+
# Initialize file with a lock
252254
with FileLock(str(lock_file)):
253255
if not counts_file.exists():
254256
counts_file.write_text(json.dumps({'failing': 0, 'passing': 0}))
255257
256258
class Counter:
257259
def __init__(self, file_path, lock_path):
258260
self.file = file_path
259-
self.lock = lock_path
261+
self.lock_path = lock_path
260262
261263
def increment(self, key):
262-
with FileLock(str(self.lock)):
264+
# Create new FileLock for each operation (xdist-safe)
265+
with FileLock(str(self.lock_path)):
263266
data = json.loads(self.file.read_text())
264267
data[key] += 1
265268
self.file.write_text(json.dumps(data))
@@ -286,7 +289,7 @@ def test_passing(request, run_count):
286289
assert True
287290
""")
288291

289-
result = pytester.runpytest("--load-test", "-n", "2", "-v")
292+
result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v")
290293

291294
# Verify tests ran and load testing stopped
292295
result.stdout.fnmatch_lines(

tests/test_fixture_lifecycle.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77

8-
def test_request_fixture_available(pytester):
8+
def test_request_fixture_available(pytester, run_with_timeout):
99
"""
1010
Test that the request fixture is available on each test execution.
1111
@@ -22,18 +22,17 @@ def test_request_fixture_available(pytester):
2222
def test_request_fixture_available(request):
2323
global count
2424
25+
count += 1
26+
if count >= 5:
27+
stop_load_testing(request, "Request fixture verified")
28+
2529
# The request fixture should be available
2630
assert request is not None
2731
assert hasattr(request, 'node')
2832
assert hasattr(request, 'session')
29-
30-
if count < 5:
31-
count += 1
32-
else:
33-
stop_load_testing(request, "Request fixture verified")
3433
""")
3534

36-
result = pytester.runpytest("--load-test", "-n", "2", "-v")
35+
result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v", timeout=5)
3736

3837
# Should stop gracefully
3938
result.stdout.fnmatch_lines(
@@ -44,7 +43,7 @@ def test_request_fixture_available(request):
4443
assert result.ret == pytest.ExitCode.INTERRUPTED
4544

4645

47-
def test_all_fixture_scopes(pytester):
46+
def test_all_fixture_scopes(pytester, run_with_timeout):
4847
"""Test that fixtures with different scopes provide correct data."""
4948
pytester.makepyfile("""
5049
import pytest
@@ -55,9 +54,11 @@ def test_all_fixture_scopes(pytester):
5554
5655
@pytest.fixture(scope="session")
5756
def fixture_counts(tmp_path_factory):
58-
counts_file = tmp_path_factory.mktemp("data") / "fixture_counts.json"
57+
root_tmp_dir = tmp_path_factory.getbasetemp().parent
58+
counts_file = root_tmp_dir / "fixture_counts.json"
5959
lock_file = counts_file.with_suffix('.lock')
6060
61+
# Initialize file with a lock
6162
with FileLock(str(lock_file)):
6263
if not counts_file.exists():
6364
counts_file.write_text(json.dumps({
@@ -69,17 +70,19 @@ def fixture_counts(tmp_path_factory):
6970
class Counter:
7071
def __init__(self, file_path, lock_path):
7172
self.file = file_path
72-
self.lock = lock_path
73+
self.lock_path = lock_path
7374
7475
def increment(self, key):
75-
with FileLock(str(self.lock)):
76+
# Create new FileLock for each operation (xdist-safe)
77+
with FileLock(str(self.lock_path)):
7678
data = json.loads(self.file.read_text())
7779
data[key] += 1
7880
self.file.write_text(json.dumps(data))
7981
return data[key]
8082
8183
def read(self):
82-
with FileLock(str(self.lock)):
84+
# Create new FileLock for each operation (xdist-safe)
85+
with FileLock(str(self.lock_path)):
8386
return json.loads(self.file.read_text())
8487
8588
counter = Counter(counts_file, lock_file)
@@ -90,20 +93,23 @@ def read(self):
9093
9194
@pytest.fixture(scope="session")
9295
def execution_counts(tmp_path_factory):
93-
counts_file = tmp_path_factory.mktemp("data") / "execution_counts.json"
96+
root_tmp_dir = tmp_path_factory.getbasetemp().parent
97+
counts_file = root_tmp_dir / "execution_counts.json"
9498
lock_file = counts_file.with_suffix('.lock')
9599
100+
# Initialize file with a lock
96101
with FileLock(str(lock_file)):
97102
if not counts_file.exists():
98103
counts_file.write_text(json.dumps({"test1": 0, "test2": 0}))
99104
100105
class Counter:
101106
def __init__(self, file_path, lock_path):
102107
self.file = file_path
103-
self.lock = lock_path
108+
self.lock_path = lock_path
104109
105110
def increment(self, key):
106-
with FileLock(str(self.lock)):
111+
# Create new FileLock for each operation (xdist-safe)
112+
with FileLock(str(self.lock_path)):
107113
data = json.loads(self.file.read_text())
108114
data[key] += 1
109115
self.file.write_text(json.dumps(data))
@@ -175,7 +181,7 @@ def test_function_fixture_reinitialization(request, function_fixture, execution_
175181
stop_load_testing(request, f"All fixture scopes verified (test1: {execution_count_test1}, test2: {execution_count_test2})")
176182
""")
177183

178-
result = pytester.runpytest("--load-test", "-n", "1", "-v")
184+
result = run_with_timeout(pytester, "--load-test", "-n", "1", "-v")
179185

180186
# Should stop gracefully
181187
result.stdout.fnmatch_lines(

0 commit comments

Comments
 (0)