Skip to content

Commit 286f5b8

Browse files
committed
make PYTEST_CURRENT_TEST thread-safe
1 parent 184f5f1 commit 286f5b8

File tree

4 files changed

+75
-2
lines changed

4 files changed

+75
-2
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ include/
1616
*.class
1717
*.orig
1818
*~
19-
.hypothesis/
2019

2120
# autogenerated
2221
src/_pytest/_version.py
@@ -51,6 +50,7 @@ coverage.xml
5150
.vscode
5251
__pycache__/
5352
.python-version
53+
.claude
5454

5555
# generated by pip
5656
pip-wheel-metadata/

changelog/13837.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Pytest setting :ref:`PYTEST_CURRENT_TEST <pytest current test env>` internally is now thread-safe.

src/_pytest/runner.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,13 @@ def _update_current_test_var(
208208
value = value.replace("\x00", "(null)")
209209
os.environ[var_name] = value
210210
else:
211-
os.environ.pop(var_name)
211+
# under multithreading, this may have already been popped by another thread.
212+
# Note that os.environ inherits from MutableMapping and therefore .pop(var_name, None)
213+
# is not atomic or thread-safe, unlike e.g. popping from a builtin dict.
214+
try:
215+
os.environ.pop(var_name)
216+
except KeyError:
217+
pass
212218

213219

214220
def pytest_report_teststatus(report: BaseReport) -> tuple[str, str, str] | None:
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from _pytest.pytester import Pytester
4+
5+
6+
# a conftest that runs tests in a threadpool. You can think of this as "like pytest-xdist,
7+
# but using threads instead of processes". This is a nice conftest for flushing out
8+
# concurrency bugs in pytest itself.
9+
threaded_conftest = """
10+
import sys
11+
from queue import Queue, Empty
12+
from concurrent.futures import ThreadPoolExecutor
13+
14+
# make thread switches more common
15+
sys.setswitchinterval(0.000001)
16+
n = 3
17+
18+
def pytest_runtestloop(session):
19+
queue = Queue()
20+
for item in session.items:
21+
queue.put(item)
22+
23+
def worker():
24+
try:
25+
item = queue.get_nowait()
26+
except Empty:
27+
return
28+
29+
while item is not None:
30+
try:
31+
next_item = queue.get_nowait()
32+
except Empty:
33+
next_item = None
34+
35+
item.config.hook.pytest_runtest_protocol(
36+
item=item, nextitem=next_item
37+
)
38+
item = next_item
39+
40+
with ThreadPoolExecutor() as executor:
41+
futures = [executor.submit(worker) for _ in range(n)]
42+
for future in futures:
43+
future.result()
44+
return True
45+
"""
46+
47+
48+
def test_concurrent(pytester: Pytester) -> None:
49+
pytester.makeconftest(threaded_conftest)
50+
pytester.makepyfile(
51+
"""
52+
import pytest
53+
54+
def do_work():
55+
# arbitrary moderately-expensive work
56+
for x in range(500):
57+
_y = x**x
58+
59+
def test_1(): do_work()
60+
def test_2(): do_work()
61+
def test_3(): do_work()
62+
def test_4(): do_work()
63+
"""
64+
)
65+
result = pytester.runpytest()
66+
result.assert_outcomes(passed=4)

0 commit comments

Comments
 (0)