https://github.com/python/cpython/commit/0a8373276c992b7cb6b8ecfb209d736819bf9ade
commit: 0a8373276c992b7cb6b8ecfb209d736819bf9ade
branch: 3.14
author: Miss Islington (bot) <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-18T11:30:41+03:00
summary:

[3.14] gh-132581: Report why the execution environment was altered (GH-155294) 
(GH-155987)

The list of tests which altered the execution environment now includes the
reasons -- an unraisable exception, a modified sys.path, leaked temporary
files, etc -- one per line.

The final result no longer repeats the same state twice, like
"ENV CHANGED then ENV CHANGED".
(cherry picked from commit 350fc64fbd0c8ebb6452041b9d09511e7971f524)

Co-authored-by: Serhiy Storchaka <[email protected]>

files:
A Misc/NEWS.d/next/Tests/2026-08-06-18-20-00.gh-issue-132581.envchg.rst
M Lib/test/libregrtest/main.py
M Lib/test/libregrtest/result.py
M Lib/test/libregrtest/results.py
M Lib/test/libregrtest/run_workers.py
M Lib/test/libregrtest/save_env.py
M Lib/test/libregrtest/single.py
M Lib/test/libregrtest/utils.py
M Lib/test/support/__init__.py
M Lib/test/test_regrtest.py

diff --git a/Lib/test/libregrtest/main.py b/Lib/test/libregrtest/main.py
index d8b9605ea498432..d26e0e113ced2dc 100644
--- a/Lib/test/libregrtest/main.py
+++ b/Lib/test/libregrtest/main.py
@@ -446,7 +446,7 @@ def run_tests_sequentially(self, runtests: RunTests) -> 
None:
 
     def get_state(self) -> str:
         state = self.results.get_state(self.fail_env_changed)
-        if self.first_state:
+        if self.first_state and self.first_state != state:
             state = f'{self.first_state} then {state}'
         return state
 
diff --git a/Lib/test/libregrtest/result.py b/Lib/test/libregrtest/result.py
index daf7624366ee207..5324a46bb8cb1c5 100644
--- a/Lib/test/libregrtest/result.py
+++ b/Lib/test/libregrtest/result.py
@@ -100,6 +100,9 @@ class TestResult:
     # partial coverage in a worker run; not used by sequential in-process runs
     covered_lines: list[Location] | None = None
 
+    # short descriptions of how the test altered the execution environment
+    env_changed_reasons: list[str] | None = None
+
     def is_failed(self, fail_env_changed: bool) -> bool:
         if self.state == State.ENV_CHANGED:
             return fail_env_changed
@@ -175,9 +178,15 @@ def __str__(self) -> str:
     def has_meaningful_duration(self):
         return State.has_meaningful_duration(self.state)
 
-    def set_env_changed(self):
+    def set_env_changed(self, *reasons):
         if self.state is None or self.state == State.PASSED:
             self.state = State.ENV_CHANGED
+        if reasons:
+            if self.env_changed_reasons is None:
+                self.env_changed_reasons = []
+            for reason in reasons:
+                if reason not in self.env_changed_reasons:
+                    self.env_changed_reasons.append(reason)
 
     def must_stop(self, fail_fast: bool, fail_env_changed: bool) -> bool:
         if State.must_stop(self.state):
diff --git a/Lib/test/libregrtest/results.py b/Lib/test/libregrtest/results.py
index a35934fc2c9ca82..ea5fee334215417 100644
--- a/Lib/test/libregrtest/results.py
+++ b/Lib/test/libregrtest/results.py
@@ -30,6 +30,8 @@ def __init__(self) -> None:
         self.skipped: TestList = []
         self.resource_denied: TestList = []
         self.env_changed: TestList = []
+        # test name => how the test altered the execution environment
+        self.env_changed_reasons: dict[TestName, list[str]] = {}
         self.run_no_tests: TestList = []
         self.rerun: TestList = []
         self.rerun_results: list[TestResult] = []
@@ -107,6 +109,9 @@ def accumulate_result(self, result: TestResult, runtests: 
RunTests) -> None:
                 self.good.append(test_name)
             case State.ENV_CHANGED:
                 self.env_changed.append(test_name)
+                if result.env_changed_reasons:
+                    self.env_changed_reasons[test_name] = \
+                        result.env_changed_reasons
                 self.rerun_results.append(result)
             case State.SKIPPED:
                 self.skipped.append(test_name)
@@ -254,7 +259,18 @@ def display_result(self, tests: TestTuple, quiet: bool, 
print_slowest: bool) ->
                 print()
                 count_text = count(len(tests_list), count_text)
                 print(title_format.format(count_text))
-                printlist(tests_list)
+                if tests_list is self.env_changed:
+                    # List every test and every reason on a separate line.
+                    for test_name in sorted(tests_list):
+                        reasons = self.env_changed_reasons.get(test_name)
+                        if reasons:
+                            print(f"    {test_name}:")
+                            for reason in reasons:
+                                print(f"        {reason}")
+                        else:
+                            print(f"    {test_name}")
+                else:
+                    printlist(tests_list)
 
         if self.good and not quiet:
             print()
diff --git a/Lib/test/libregrtest/run_workers.py 
b/Lib/test/libregrtest/run_workers.py
index 4458e160705bca7..1c94a15a1868bb6 100644
--- a/Lib/test/libregrtest/run_workers.py
+++ b/Lib/test/libregrtest/run_workers.py
@@ -386,7 +386,8 @@ def _runtest(self, test_name: TestName) -> 
MultiprocessResult:
                    f'Warning -- {test_name} leaked temporary files '
                    f'({len(tmp_files)}): {", ".join(sorted(tmp_files))}')
             stdout += msg
-            result.set_env_changed()
+            result.set_env_changed(
+                f"leaked temporary files: {', '.join(sorted(tmp_files))}")
 
         return MultiprocessResult(result, stdout)
 
diff --git a/Lib/test/libregrtest/save_env.py b/Lib/test/libregrtest/save_env.py
index 138465012a252c5..708ab53b66c6de9 100644
--- a/Lib/test/libregrtest/save_env.py
+++ b/Lib/test/libregrtest/save_env.py
@@ -347,7 +347,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
             current = get()
             # Check for changes to the resource's value
             if current != original:
-                support.environment_altered = True
+                support.set_environment_altered(f"{name} was modified")
                 restore(original)
                 if not self.quiet and not self.pgo:
                     print_warning(
diff --git a/Lib/test/libregrtest/single.py b/Lib/test/libregrtest/single.py
index d0759d2626989d6..d7a1c6bea202a65 100644
--- a/Lib/test/libregrtest/single.py
+++ b/Lib/test/libregrtest/single.py
@@ -173,7 +173,8 @@ def test_func():
         remove_testfn(test_name, runtests.verbose)
 
     if gc.garbage:
-        support.environment_altered = True
+        support.set_environment_altered(
+            f"{len(gc.garbage)} uncollectable object(s)")
         print_warning(f"{test_name} created {len(gc.garbage)} "
                       f"uncollectable object(s)")
 
@@ -194,6 +195,7 @@ def _runtest_env_changed_exc(result: TestResult, runtests: 
RunTests,
     # Reset the environment_altered flag to detect if a test altered
     # the environment
     support.environment_altered = False
+    support.environment_altered_reasons.clear()
 
     pgo = runtests.pgo
     if pgo:
@@ -261,7 +263,7 @@ def _runtest_env_changed_exc(result: TestResult, runtests: 
RunTests,
         return
 
     if support.environment_altered:
-        result.set_env_changed()
+        result.set_env_changed(*support.environment_altered_reasons)
     # Don't override the state if it was already set (REFLEAK or ENV_CHANGED)
     if result.state is None:
         result.state = State.PASSED
diff --git a/Lib/test/libregrtest/utils.py b/Lib/test/libregrtest/utils.py
index 7da50c79d59e4b7..68ea34e21af0140 100644
--- a/Lib/test/libregrtest/utils.py
+++ b/Lib/test/libregrtest/utils.py
@@ -132,7 +132,8 @@ def print_warning(msg: str) -> None:
 
 def regrtest_unraisable_hook(unraisable) -> None:
     global orig_unraisablehook
-    support.environment_altered = True
+    support.set_environment_altered(
+        f"unraisable exception ({unraisable.exc_type.__name__})")
     support.print_warning("Unraisable exception")
     old_stderr = sys.stderr
     try:
@@ -156,7 +157,8 @@ def setup_unraisable_hook() -> None:
 
 def regrtest_threading_excepthook(args) -> None:
     global orig_threading_excepthook
-    support.environment_altered = True
+    support.set_environment_altered(
+        f"uncaught thread exception ({args.exc_type.__name__})")
     support.print_warning(f"Uncaught thread exception: 
{args.exc_type.__name__}")
     old_stderr = sys.stderr
     try:
@@ -497,7 +499,7 @@ def remove_testfn(test_name: TestName, verbose: int) -> 
None:
 
     if verbose:
         print_warning(f"{test_name} left behind {kind} {name!r}")
-        support.environment_altered = True
+        support.set_environment_altered(f"left behind {kind} {name!r}")
 
     try:
         import stat
diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py
index dcd76bd165c8626..64924a4b5a7eefc 100644
--- a/Lib/test/support/__init__.py
+++ b/Lib/test/support/__init__.py
@@ -1468,14 +1468,25 @@ def print_warning(msg):
 # to cleanup threads.
 environment_altered = False
 
+# Short descriptions of what was altered, e.g. "unraisable exception".
+# They are reported by regrtest together with the name of the test.
+environment_altered_reasons = []
+
+
+def set_environment_altered(reason):
+    """Set the environment_altered flag and record why it was set."""
+    global environment_altered
+    environment_altered = True
+    if reason not in environment_altered_reasons:
+        environment_altered_reasons.append(reason)
+
+
 def reap_children():
     """Use this function at the end of test_main() whenever sub-processes
     are started.  This will help ensure that no extra children (zombies)
     stick around to hog resources and create problems when looking
     for refleaks.
     """
-    global environment_altered
-
     # Need os.waitpid(-1, os.WNOHANG): Windows is not supported
     if not (hasattr(os, 'waitpid') and hasattr(os, 'WNOHANG')):
         return
@@ -1495,7 +1506,7 @@ def reap_children():
             break
 
         print_warning(f"reap_children() reaped child process {pid}")
-        environment_altered = True
+        set_environment_altered("reaped child process")
 
 
 @contextlib.contextmanager
diff --git a/Lib/test/test_regrtest.py b/Lib/test/test_regrtest.py
index 42cc1c2f7c7eaa9..b578f81c075c942 100644
--- a/Lib/test/test_regrtest.py
+++ b/Lib/test/test_regrtest.py
@@ -705,9 +705,13 @@ def list_regex(line_format, tests):
             self.check_line(output, regex)
 
         if env_changed:
-            regex = list_regex(r'%s test%s altered the execution environment '
-                               r'\(env changed\)',
-                               env_changed)
+            # Every test is listed on a separate line, followed by the
+            # reasons why the environment was altered, one per line.
+            count = len(env_changed)
+            regex = (r'%s test%s altered the execution environment '
+                     r'\(env changed\):\n' % (count, plural(count)))
+            regex += ''.join(r'    %s:?\n(?:        .*\n)*' % re.escape(name)
+                             for name in sorted(env_changed))
             self.check_line(output, regex)
 
         if omitted:
@@ -796,7 +800,8 @@ def list_regex(line_format, tests):
         state = ', '.join(state)
         if rerun is not None:
             new_state = 'SUCCESS' if rerun.success else 'FAILURE'
-            state = f'{state} then {new_state}'
+            if new_state != state:
+                state = f'{state} then {new_state}'
         self.check_line(output, f'Result: {state}', full=True)
 
     def parse_random_seed(self, output: str) -> str:
diff --git 
a/Misc/NEWS.d/next/Tests/2026-08-06-18-20-00.gh-issue-132581.envchg.rst 
b/Misc/NEWS.d/next/Tests/2026-08-06-18-20-00.gh-issue-132581.envchg.rst
new file mode 100644
index 000000000000000..ff71f7a90a021b6
--- /dev/null
+++ b/Misc/NEWS.d/next/Tests/2026-08-06-18-20-00.gh-issue-132581.envchg.rst
@@ -0,0 +1,4 @@
+The list of tests which altered the execution environment now includes the
+reasons why the environment was considered altered, for example an unraisable
+exception or a modified :data:`sys.path`.  The final result no longer repeats
+the same state twice (like ``ENV CHANGED then ENV CHANGED``).

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to