From bb7680c97ba605c8e984fc5db8998f06d59ab5ce Mon Sep 17 00:00:00 2001 From: pmic1 <87313639+pmic1@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:10:25 -0300 Subject: [PATCH 1/5] test: add regression tests for tmp_path retention (#14998) --- testing/test_tmpdir.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 0b33a74b926..6452868e9e5 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -197,6 +197,55 @@ def test_2(tmp_path): assert len(test_dir) == 1 assert test_dir[0].name == "test_20" + + def test_policy_failed_keeps_setup_and_teardown_errors( + self, pytester: Pytester + ) -> None: + """Regression test for #14998.""" + + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + tmp_path_retention_policy = "failed" + """ + ) + + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def setup_error(tmp_path): + (tmp_path / "evidence.txt").write_text("setup") + raise RuntimeError("setup failed") + + @pytest.fixture + def teardown_error(tmp_path): + (tmp_path / "evidence.txt").write_text("teardown") + yield + raise RuntimeError("teardown failed") + + def test_setup(setup_error): + pass + + def test_call(tmp_path): + (tmp_path / "evidence.txt").write_text("call") + assert False + + def test_teardown(teardown_error): + pass + """ + ) + + result = pytester.runpytest("--basetemp=retained") + result.assert_outcomes(passed=1, failed=1, errors=2) + + retained = pytester.path / "retained" + + assert (retained / "test_setup0" / "evidence.txt").exists() + assert (retained / "test_call0" / "evidence.txt").exists() + assert (retained / "test_teardown0" / "evidence.txt").exists() + def test_policy_failed_removes_basedir_when_all_passed( self, pytester: Pytester ) -> None: From c9c769833e93a7f076f54f880a623e9b34810344 Mon Sep 17 00:00:00 2001 From: pmic1 <87313639+pmic1@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:03:30 -0300 Subject: [PATCH 2/5] fix: preserve tmp_path on setup and teardown failures (#14998) --- src/_pytest/tmpdir.py | 37 +++++++++++++++++++++++++------------ testing/test_tmpdir.py | 4 +--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 616a09d1b27..afc55b76192 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"] @@ -292,18 +293,11 @@ def tmp_path( as discussed in :ref:`temporary directory location and retention`. """ path = _mk_tmp(request, tmp_path_factory) - 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) + # Store the path so cleanup can happen after the teardown report. + request.node.stash[tmppath_path_key] = path - del request.node.stash[tmppath_result_key] + yield path def pytest_sessionfinish(session, exitstatus: int | ExitCode): @@ -340,6 +334,25 @@ def pytest_runtest_makereport( ) -> Generator[None, TestReport, TestReport]: rep = yield assert rep.when is not None - empty: dict[str, bool] = {} - item.stash.setdefault(tmppath_result_key, empty)[rep.when] = rep.passed + + # Record whether each phase failed. + result_dict = item.stash.setdefault(tmppath_result_key, {}) + result_dict[rep.when] = rep.failed + + # Wait until teardown has finished before deciding whether the temporary directory should be retained. + if rep.when == "teardown": + path = item.stash.get(tmppath_path_key, None) + + if path is not None: + tmp_path_factory: TempPathFactory = item.config._tmp_path_factory # type: ignore[attr-defined] + policy = tmp_path_factory._retention_policy + + # Retain the directory if any phase failed. Otherwise, remove it under the "failed" policy. + if policy == "failed" and not any(result_dict.values()): + rmtree(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 6452868e9e5..a0bc3385901 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -197,12 +197,10 @@ def test_2(tmp_path): assert len(test_dir) == 1 assert test_dir[0].name == "test_20" - def test_policy_failed_keeps_setup_and_teardown_errors( self, pytester: Pytester ) -> None: """Regression test for #14998.""" - pytester.makepyprojecttoml( """ [tool.pytest.ini_options] @@ -244,7 +242,7 @@ def test_teardown(teardown_error): assert (retained / "test_setup0" / "evidence.txt").exists() assert (retained / "test_call0" / "evidence.txt").exists() - assert (retained / "test_teardown0" / "evidence.txt").exists() + assert (retained / "test_teardown0" / "evidence.txt").exists() def test_policy_failed_removes_basedir_when_all_passed( self, pytester: Pytester From 7c2aa08edd183699aca5c67291a3ebd7cfe41cc7 Mon Sep 17 00:00:00 2001 From: pmic1 <87313639+pmic1@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:42 -0300 Subject: [PATCH 3/5] add changelog and author entry for #14998 --- AUTHORS | 1 + changelog/14998.bugfix.rst | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 changelog/14998.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 1d1af001595..14b02e3fb36 100644 --- a/AUTHORS +++ b/AUTHORS @@ -383,6 +383,7 @@ Omri Golan Ondřej Súkup Onkesh Bansal Oscar Benjamin +Panayiotis Michalos Paneendra Kumar Parth Patel Patrick Hayes diff --git a/changelog/14998.bugfix.rst b/changelog/14998.bugfix.rst new file mode 100644 index 00000000000..9f9810e455a --- /dev/null +++ b/changelog/14998.bugfix.rst @@ -0,0 +1,2 @@ +Fixed ``tmp_path_retention_policy="failed"`` incorrectly removing temporary +directories when fixture setup or teardown raises an error. \ No newline at end of file From 28a9496156a0f5fb788606cf2a49d587902c8cb6 Mon Sep 17 00:00:00 2001 From: pmic1 <87313639+pmic1@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:14:57 -0300 Subject: [PATCH 4/5] fix changelog formatting for #14998 --- changelog/14998.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/14998.bugfix.rst b/changelog/14998.bugfix.rst index 9f9810e455a..31d63d17a68 100644 --- a/changelog/14998.bugfix.rst +++ b/changelog/14998.bugfix.rst @@ -1,2 +1,2 @@ Fixed ``tmp_path_retention_policy="failed"`` incorrectly removing temporary -directories when fixture setup or teardown raises an error. \ No newline at end of file +directories when fixture setup or teardown raises an error. From 9db686b39dbf05b16e7b0c68f47dc4d20eb119d8 Mon Sep 17 00:00:00 2001 From: pmic1 <87313639+pmic1@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:41:22 -0300 Subject: [PATCH 5/5] test: specify UTF-8 encoding for regression test --- testing/test_tmpdir.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a0bc3385901..fd0ea0bcb47 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -214,12 +214,12 @@ def test_policy_failed_keeps_setup_and_teardown_errors( @pytest.fixture def setup_error(tmp_path): - (tmp_path / "evidence.txt").write_text("setup") + (tmp_path / "evidence.txt").write_text("setup", encoding='utf-8') raise RuntimeError("setup failed") @pytest.fixture def teardown_error(tmp_path): - (tmp_path / "evidence.txt").write_text("teardown") + (tmp_path / "evidence.txt").write_text("teardown", encoding='utf-8') yield raise RuntimeError("teardown failed") @@ -227,7 +227,7 @@ def test_setup(setup_error): pass def test_call(tmp_path): - (tmp_path / "evidence.txt").write_text("call") + (tmp_path / "evidence.txt").write_text("call", encoding='utf-8') assert False def test_teardown(teardown_error):