From f65e98a9dc74f383688b22080f1f1d03d3442a82 Mon Sep 17 00:00:00 2001 From: ulofiai Date: Fri, 7 Aug 2026 09:37:09 +0800 Subject: [PATCH] gh-132581: Report why the execution environment was altered Signed-off-by: ulofiai --- Lib/test/libregrtest/main.py | 2 +- Lib/test/libregrtest/result.py | 11 ++++++++++- Lib/test/libregrtest/results.py | 18 +++++++++++++++++- Lib/test/libregrtest/run_workers.py | 3 ++- Lib/test/libregrtest/save_env.py | 2 +- Lib/test/libregrtest/single.py | 6 ++++-- Lib/test/libregrtest/utils.py | 8 +++++--- Lib/test/support/__init__.py | 17 ++++++++++++++--- Lib/test/test_regrtest.py | 13 +++++++++---- ...6-08-06-18-20-00.gh-issue-132581.envchg.rst | 4 ++++ 10 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/Tests/2026-08-06-18-20-00.gh-issue-132581.envchg.rst diff --git a/Lib/test/libregrtest/main.py b/Lib/test/libregrtest/main.py index 8773e9df73263b7..fa2a6761396468e 100644 --- a/Lib/test/libregrtest/main.py +++ b/Lib/test/libregrtest/main.py @@ -448,7 +448,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 7e6c7fa4cc5507a..6c4946ba2429907 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 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 892d204700179f6..9246febc8e592de 100644 --- a/Lib/test/libregrtest/utils.py +++ b/Lib/test/libregrtest/utils.py @@ -141,7 +141,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: @@ -165,7 +166,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: @@ -524,7 +526,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 74d3794289bf69f..13d60f31a685702 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -1550,14 +1550,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 @@ -1577,7 +1588,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 6d30d267cd5ad29..b4de490b579acb7 100644 --- a/Lib/test/test_regrtest.py +++ b/Lib/test/test_regrtest.py @@ -716,9 +716,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: @@ -807,7 +811,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``).