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/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 ec08025d897..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,7 +203,14 @@ 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)