Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ include/
*.class
*.orig
*~
.hypothesis/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this ignore rule is duplicated farther down the file

# autogenerated
src/_pytest/_version.py
Expand Down Expand Up @@ -51,6 +50,7 @@ coverage.xml
.vscode
__pycache__/
.python-version
.claude

# generated by pip
pip-wheel-metadata/
Expand Down
1 change: 1 addition & 0 deletions changelog/13837.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pytest setting :ref:`PYTEST_CURRENT_TEST <pytest current test env>` internally is now thread-safe.
20 changes: 20 additions & 0 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not invent own thread numbering schemes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the envvars be named PYTEST_CURRENT_TEST_THREAD_{thread.ident}instead, eg. PYTEST_CURRENT_TEST_THREAD_8422586432? My thinking was that a more human readable approach would be nice, and also that way someone could tell how many threads are active. Happy to just use thread.ident though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should require a "pytest thread" name for a thread run to be picked no guessing, no random long integers - the env names should be something people can deal with

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(i considered picking the threading thread name but their default is not ux friendly for env vars)


def _node_location_to_relpath(self, node_path: Path) -> str:
# bestrelpath is a quite slow function.
return self._bestrelpathcache[node_path]
Expand Down
9 changes: 9 additions & 0 deletions src/_pytest/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import dataclasses
import os
import sys
import threading
import types
from typing import cast
from typing import final
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading