gh-132581: Report why the execution environment was altered (GH-155294)

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/350fc64fbd0c8ebb6452041b9d09511e7971f524
commit: 350fc64fbd0c8ebb6452041b9d09511e7971f524
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-18T10:07:47+03:00
summary:

gh-132581: Report why the execution environment was altered (GH-155294)

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".

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 7391056627cd068..db2e9acb850f107 100644
--- a/Lib/test/libregrtest/main.py
+++ b/Lib/test/libregrtest/main.py
@@ -453,7 +453,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 a146a439d9d3eca..c6db78e0882ba31 100644
--- a/Lib/test/libregrtest/run_workers.py
+++ b/Lib/test/libregrtest/run_workers.py
@@ -393,7 +393,8 @@ def _runtest(self, test_name: TestName,
                    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 6393036118010e6..4149210fa173109 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 f6c13227dbbcdf7..dfec7a59b02583b 100644
--- a/Lib/test/libregrtest/utils.py
+++ b/Lib/test/libregrtest/utils.py
@@ -142,7 +142,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:
@@ -166,7 +167,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:
@@ -525,7 +527,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 3f2caebd21e336c..da116355eeea154 100644
--- a/Lib/test/support/__init__.py
+++ b/Lib/test/support/__init__.py
@@ -1563,14 +1563,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
@@ -1590,7 +1601,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 f05008bf8bc2c01..f4baa965b3f632b 100644
--- a/Lib/test/test_regrtest.py
+++ b/Lib/test/test_regrtest.py
@@ -743,9 +743,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:
@@ -834,7 +838,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.