From 3d652e539393ede031c2cb19371dbc2b40b362b0 Mon Sep 17 00:00:00 2001 From: GruffElixir <136691411+GruffElixir@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:57:44 +0530 Subject: [PATCH 1/4] Retain tmp paths for setup and teardown errors --- AUTHORS | 1 + changelog/14998.bugfix.rst | 1 + src/_pytest/tmpdir.py | 44 ++++++++++++++++++++++++------- testing/test_tmpdir.py | 54 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 changelog/14998.bugfix.rst diff --git a/AUTHORS b/AUTHORS index daa6a471eb9..0ded3545655 100644 --- a/AUTHORS +++ b/AUTHORS @@ -202,6 +202,7 @@ Greg Price Gregory Lee Grig Gheorghiu Grigorii Eremeev (budulianin) +GruffElixir Guido Wesdorp Guoqiang Zhang Hamza Mobeen diff --git a/changelog/14998.bugfix.rst b/changelog/14998.bugfix.rst new file mode 100644 index 00000000000..a1f06bb57ac --- /dev/null +++ b/changelog/14998.bugfix.rst @@ -0,0 +1 @@ +The ``tmp_path_retention_policy = "failed"`` setting now retains temporary directories when a test errors during fixture setup or teardown. diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 745a3c95670..62353386d94 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -37,6 +37,7 @@ tmppath_result_key = StashKey[dict[str, bool]]() +tmppath_path_key = StashKey[Path]() RetentionType = Literal["all", "failed", "none"] @@ -293,18 +294,15 @@ def tmp_path( as discussed in :ref:`temporary directory location and retention`. """ path = _mk_tmp(request, tmp_path_factory) + if tmp_path_factory._retention_policy == "failed": + request.node.stash[tmppath_path_key] = path yield path - # Remove the tmpdir if the policy is "failed" and the test passed. - policy = tmp_path_factory._retention_policy - result_dict = request.node.stash[tmppath_result_key] - - if policy == "failed" and result_dict.get("call", True): - # We do a "best effort" to remove files, but it might not be possible due to some leaked resource, - # permissions, etc, in which case we ignore it. - rmtree(path, ignore_errors=True) - - del request.node.stash[tmppath_result_key] + # The retention decision is made in pytest_runtest_makereport after the + # teardown report is available. This ensures setup and teardown errors are + # retained under the "failed" policy too. + if tmp_path_factory._retention_policy != "failed": + del request.node.stash[tmppath_result_key] def pytest_sessionfinish(session, exitstatus: int | ExitCode): @@ -343,4 +341,30 @@ def pytest_runtest_makereport( assert rep.when is not None empty: dict[str, bool] = {} item.stash.setdefault(tmppath_result_key, empty)[rep.when] = rep.passed + + if item.config._tmp_path_factory._retention_policy != "failed": + return rep + + tmp_path = item.stash.get(tmppath_path_key, None) + if tmp_path is None: + return rep + + if rep.when == "setup": + # A skipped fixture setup is not a failed test, so preserve the + # behavior from #10502 and remove its temporary directory. + if rep.skipped: + rmtree(tmp_path, ignore_errors=True) + elif rep.when == "teardown": + result_dict = item.stash[tmppath_result_key] + if ( + result_dict.get("setup", True) + and result_dict.get("call", True) + and rep.passed + ): + # We do a "best effort" to remove files, but it might not be + # possible due to some leaked resource, permissions, etc. + rmtree(tmp_path, ignore_errors=True) + del item.stash[tmppath_path_key] + del item.stash[tmppath_result_key] + return rep diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 0b33a74b926..fc47346b399 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -254,6 +254,60 @@ def test_fixt(fixt): base_dir = list(child.iterdir()) assert base_dir == [] + def test_policy_failed_keeps_dir_when_setup_or_teardown_fails( + self, pytester: Pytester + ) -> None: + p = pytester.makepyfile( + """ + import pytest + + def write_evidence(tmp_path, phase): + evidence = tmp_path / "debug-evidence.txt" + evidence.write_text(f"keep me: {phase}", encoding="utf-8") + + @pytest.fixture + def broken_during_setup(tmp_path): + write_evidence(tmp_path, "setup") + raise RuntimeError("setup failed") + + def test_setup_error(broken_during_setup): + pass + + def test_call_failure(tmp_path): + write_evidence(tmp_path, "call") + assert False + + @pytest.fixture + def broken_during_teardown(tmp_path): + write_evidence(tmp_path, "teardown") + yield + raise RuntimeError("teardown failed") + + def test_teardown_error(broken_during_teardown): + pass + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + tmp_path_retention_policy = "failed" + """ + ) + + reprec = pytester.inline_run(p) + reprec.assertoutcome(passed=1, failed=3) + + root = pytester._test_tmproot + evidence = { + path.read_text(encoding="utf-8") + for path in root.rglob("debug-evidence.txt") + } + assert evidence == { + "keep me: setup", + "keep me: call", + "keep me: teardown", + } + # issue #10502 def test_policy_all_keeps_dir_when_skipped_from_fixture( self, pytester: Pytester From ca34872a3a90ffe30c5f8712800d1a37068a8416 Mon Sep 17 00:00:00 2001 From: GruffElixir <136691411+GruffElixir@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:05:06 +0530 Subject: [PATCH 2/4] Avoid untyped config lookup in tmp path hook --- src/_pytest/tmpdir.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 62353386d94..f44dbfa5bc7 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -342,9 +342,6 @@ def pytest_runtest_makereport( empty: dict[str, bool] = {} item.stash.setdefault(tmppath_result_key, empty)[rep.when] = rep.passed - if item.config._tmp_path_factory._retention_policy != "failed": - return rep - tmp_path = item.stash.get(tmppath_path_key, None) if tmp_path is None: return rep From 890f4ce02a8199dc32b146850a857c33ede31aca Mon Sep 17 00:00:00 2001 From: Samartha Date: Sat, 12 Sep 2026 12:32:17 +0530 Subject: [PATCH 3/4] Defer tmp path cleanup after setup skips --- src/_pytest/tmpdir.py | 17 +++++++++++++---- testing/test_tmpdir.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index f44dbfa5bc7..167c474752d 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -38,6 +38,7 @@ tmppath_result_key = StashKey[dict[str, bool]]() tmppath_path_key = StashKey[Path]() +tmppath_setup_skipped_key = StashKey[bool]() RetentionType = Literal["all", "failed", "none"] @@ -348,14 +349,20 @@ def pytest_runtest_makereport( if rep.when == "setup": # A skipped fixture setup is not a failed test, so preserve the - # behavior from #10502 and remove its temporary directory. + # behavior from #10502 and mark its temporary directory for cleanup. if rep.skipped: - rmtree(tmp_path, ignore_errors=True) + item.stash[tmppath_setup_skipped_key] = True elif rep.when == "teardown": result_dict = item.stash[tmppath_result_key] + setup_skipped = item.stash.get(tmppath_setup_skipped_key, False) if ( - result_dict.get("setup", True) - and result_dict.get("call", True) + ( + setup_skipped + or ( + result_dict.get("setup", True) + and result_dict.get("call", True) + ) + ) and rep.passed ): # We do a "best effort" to remove files, but it might not be @@ -363,5 +370,7 @@ def pytest_runtest_makereport( rmtree(tmp_path, ignore_errors=True) del item.stash[tmppath_path_key] del item.stash[tmppath_result_key] + if setup_skipped: + del item.stash[tmppath_setup_skipped_key] return rep diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index fc47346b399..d89f6331c32 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -254,6 +254,42 @@ def test_fixt(fixt): base_dir = list(child.iterdir()) assert base_dir == [] + def test_policy_failed_defers_cleanup_until_dependent_teardown( + self, pytester: Pytester + ) -> None: + p = pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def resource(tmp_path): + evidence = tmp_path / "evidence.txt" + evidence.write_text("still here", encoding="utf-8") + yield tmp_path + assert evidence.read_text(encoding="utf-8") == "still here" + + @pytest.fixture + def unavailable(resource): + pytest.skip("optional service unavailable") + + def test_optional_service(unavailable): + pass + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + tmp_path_retention_policy = "failed" + """ + ) + + reprec = pytester.inline_run(p) + reprec.assertoutcome(skipped=1) + + root = pytester._test_tmproot + for child in root.iterdir(): + assert list(child.iterdir()) == [] + def test_policy_failed_keeps_dir_when_setup_or_teardown_fails( self, pytester: Pytester ) -> None: From 0cd6dc6a8583c94388b2a11faa4e3d1507fa36c0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:02:49 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/tmpdir.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 167c474752d..ecfee45553d 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -356,15 +356,9 @@ def pytest_runtest_makereport( result_dict = item.stash[tmppath_result_key] setup_skipped = item.stash.get(tmppath_setup_skipped_key, False) if ( - ( - setup_skipped - or ( - result_dict.get("setup", True) - and result_dict.get("call", True) - ) - ) - and rep.passed - ): + setup_skipped + or (result_dict.get("setup", True) and result_dict.get("call", True)) + ) and rep.passed: # We do a "best effort" to remove files, but it might not be # possible due to some leaked resource, permissions, etc. rmtree(tmp_path, ignore_errors=True)