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

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
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]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.