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

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/7a2d6e0b42275e76b1c05d5422189fb4caad4bd1
commit: 7a2d6e0b42275e76b1c05d5422189fb4caad4bd1
branch: 3.13
author: Miss Islington (bot) <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-18T11:30:15+03:00
summary:

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

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 09deea889a9ccdb..b6dd5634d59cda3 100644
--- a/Lib/test/libregrtest/main.py
+++ b/Lib/test/libregrtest/main.py
@@ -424,7 +424,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 7553efe5e8abeb1..6484abc97221e5b 100644
--- a/Lib/test/libregrtest/result.py
+++ b/Lib/test/libregrtest/result.py
@@ -99,6 +99,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
@@ -157,9 +160,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 9eda926966dc7ed..7fbc17747a4ac5d 100644
--- a/Lib/test/libregrtest/results.py
+++ b/Lib/test/libregrtest/results.py
@@ -29,6 +29,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] = []
@@ -101,6 +103,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)
@@ -224,7 +229,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 3c6d13215fd79d7..3c6c03017653896 100644
--- a/Lib/test/libregrtest/run_workers.py
+++ b/Lib/test/libregrtest/run_workers.py
@@ -377,7 +377,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 02492124a612dd5..697f84b6b082f71 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 75fe0d7ad3b4e89..1e427bd23ab442b 100644
--- a/Lib/test/libregrtest/single.py
+++ b/Lib/test/libregrtest/single.py
@@ -146,7 +146,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)")
 
@@ -165,6 +166,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:
@@ -223,7 +225,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 2bf01df5bb73083..b646222d02f6214 100644
--- a/Lib/test/libregrtest/utils.py
+++ b/Lib/test/libregrtest/utils.py
@@ -133,7 +133,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:
@@ -157,7 +158,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:
@@ -541,7 +543,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 51c7d2e86927206..b82c9355b484a64 100644
--- a/Lib/test/support/__init__.py
+++ b/Lib/test/support/__init__.py
@@ -1424,14 +1424,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
@@ -1451,7 +1462,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 bb675bfbe335c59..56f5fd8c6b2d8ce 100644
--- a/Lib/test/test_regrtest.py
+++ b/Lib/test/test_regrtest.py
@@ -702,9 +702,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:
@@ -793,7 +797,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.