From 3730be81ed1455eeddd5e1faf867e7cce449219e Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Wed, 22 Oct 2025 17:06:47 -0400 Subject: [PATCH 1/3] make PYTEST_CURRENT_TEST thread-safe --- .gitignore | 2 +- changelog/13837.improvement.rst | 1 + src/_pytest/runner.py | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 changelog/13837.improvement.rst diff --git a/.gitignore b/.gitignore index c4557b33a1c..e6fb8d83504 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ include/ *.class *.orig *~ -.hypothesis/ # autogenerated src/_pytest/_version.py @@ -51,6 +50,7 @@ coverage.xml .vscode __pycache__/ .python-version +.claude # generated by pip pip-wheel-metadata/ diff --git a/changelog/13837.improvement.rst b/changelog/13837.improvement.rst new file mode 100644 index 00000000000..69bb66bd2b3 --- /dev/null +++ b/changelog/13837.improvement.rst @@ -0,0 +1 @@ +Pytest setting :ref:`PYTEST_CURRENT_TEST ` internally is now thread-safe. diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index ec08025d897..ac9f6f663e5 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -208,7 +208,13 @@ def _update_current_test_var( value = value.replace("\x00", "(null)") os.environ[var_name] = value else: - os.environ.pop(var_name) + # under multithreading, this may have already been popped by another thread. + # Note that os.environ inherits from MutableMapping and therefore .pop(var_name, None) + # is not atomic or thread-safe, unlike e.g. popping from a builtin dict. + try: + os.environ.pop(var_name) + except KeyError: + pass def pytest_report_teststatus(report: BaseReport) -> tuple[str, str, str] | None: From dff52324ec2ad75abd850eb95229a11c0b0a655d Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Wed, 22 Oct 2025 17:10:40 -0400 Subject: [PATCH 2/3] nocover --- src/_pytest/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index ac9f6f663e5..700a7fd3e0b 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -213,7 +213,7 @@ def _update_current_test_var( # is not atomic or thread-safe, unlike e.g. popping from a builtin dict. try: os.environ.pop(var_name) - except KeyError: + except KeyError: # pragma: no cover # can be removed when #13768 is farther along pass From 093a05573441b95ac30a116ddd16481d09b3f707 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Thu, 23 Oct 2025 15:52:48 -0400 Subject: [PATCH 3/3] track item: thread mapping for PYTEST_CURRENT_TEST --- src/_pytest/main.py | 20 ++++++++++++++++++++ src/_pytest/runner.py | 17 ++++++++++------- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index b1eb22f1f61..e697923984e 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -16,6 +16,8 @@ import os from pathlib import Path import sys +import threading +from threading import Thread from typing import final from typing import Literal from typing import overload @@ -581,6 +583,8 @@ def __init__(self, config: Config) -> None: self._initial_parts: list[CollectionArgument] = [] self._collection_cache: dict[nodes.Collector, CollectReport] = {} self.items: list[nodes.Item] = [] + # track the thread in which each item started execution in + self._item_to_thread: dict[nodes.Item, Thread] = {} self._bestrelpathcache: dict[Path, str] = _bestrelpath_cache(config.rootpath) @@ -643,6 +647,22 @@ def startpath(self) -> Path: """ return self.config.invocation_params.dir + def _readable_thread_id(self, thread: Thread) -> int: + # Returns a 0-indexed id of `thread`, corresponding to the order in + # which we saw that thread start executing tests. The main thread always + # has value 0, so non-main threads start at 1, even if the first thread + # we saw was a non-main thread, or even if we never saw the main thread + # execute tests. + + # relying on item_to_thread to be sorted for stable ordering + threads = list(self._item_to_thread.values()) + assert thread in threads + + if thread is threading.main_thread(): + return 0 + + return threads.index(thread) + 1 + def _node_location_to_relpath(self, node_path: Path) -> str: # bestrelpath is a quite slow function. return self._bestrelpathcache[node_path] diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 700a7fd3e0b..9085cf4f54f 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -8,6 +8,7 @@ import dataclasses import os import sys +import threading import types from typing import cast from typing import final @@ -127,6 +128,7 @@ def runtestprotocol( # This only happens if the item is re-run, as is done by # pytest-rerunfailures. item._initrequest() # type: ignore[attr-defined] + item.session._item_to_thread[item] = threading.current_thread() rep = call_and_report(item, "setup", log) reports = [rep] if rep.passed: @@ -201,20 +203,21 @@ def _update_current_test_var( If ``when`` is None, delete ``PYTEST_CURRENT_TEST`` from the environment. """ + thread = item.session._item_to_thread[item] + readable_id = item.session._readable_thread_id(thread) + # main thread (aka humanid == 0) gets the plain var. Other threads + # append their id. var_name = "PYTEST_CURRENT_TEST" + if readable_id != 0: + var_name += f"_THREAD_{readable_id}" + if when: value = f"{item.nodeid} ({when})" # don't allow null bytes on environment variables (see #2644, #2957) value = value.replace("\x00", "(null)") os.environ[var_name] = value else: - # under multithreading, this may have already been popped by another thread. - # Note that os.environ inherits from MutableMapping and therefore .pop(var_name, None) - # is not atomic or thread-safe, unlike e.g. popping from a builtin dict. - try: - os.environ.pop(var_name) - except KeyError: # pragma: no cover # can be removed when #13768 is farther along - pass + os.environ.pop(var_name) def pytest_report_teststatus(report: BaseReport) -> tuple[str, str, str] | None: