From 683a96acda4d8c583ec7299a324742d93976e456 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 29 Jun 2026 17:54:37 +0000 Subject: [PATCH] test(#5196): add _safe_replace retry/backoff coverage (auto-fix) Adds the test the project's contribution rules require for non-trivial logic (AGENTS.md). Covers: posix passthrough (1 call, no sleep), Windows retry-then-succeed, reraise after exhausting 5 attempts (no silent success on a save path), no-retry on non-PermissionError, and the 50/100/200/400ms backoff sequence. Production _safe_replace unchanged. --- tests/test_issue5196_windows_safe_replace.py | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_issue5196_windows_safe_replace.py diff --git a/tests/test_issue5196_windows_safe_replace.py b/tests/test_issue5196_windows_safe_replace.py new file mode 100644 index 00000000..26ca2da1 --- /dev/null +++ b/tests/test_issue5196_windows_safe_replace.py @@ -0,0 +1,61 @@ +# tests/test_issue5196_windows_safe_replace.py +from unittest import mock +from api import models + +def test_safe_replace_passthrough_off_windows(): + calls = [] + with mock.patch.object(models.os, "name", "posix"), \ + mock.patch.object(models.os, "replace", lambda s, d: calls.append((s, d))), \ + mock.patch.object(models.time, "sleep") as slept: + models._safe_replace("a", "b") + assert len(calls) == 1 and not slept.called + +def test_safe_replace_retries_then_succeeds_on_windows(): + n = {"c": 0} + def flaky(s, d): + n["c"] += 1 + if n["c"] <= 2: + raise PermissionError("WinError 5") + with mock.patch.object(models.os, "name", "nt"), \ + mock.patch.object(models.os, "replace", flaky), \ + mock.patch.object(models.time, "sleep", lambda x: None): + models._safe_replace("a", "b") + assert n["c"] == 3 + +def test_safe_replace_reraises_after_exhausting_retries(): + n = {"c": 0} + def always(s, d): + n["c"] += 1 + raise PermissionError("WinError 5") + with mock.patch.object(models.os, "name", "nt"), \ + mock.patch.object(models.os, "replace", always), \ + mock.patch.object(models.time, "sleep", lambda x: None): + import pytest + with pytest.raises(PermissionError): + models._safe_replace("a", "b") + assert n["c"] == 5 # no silent success on a save path + +def test_safe_replace_does_not_retry_non_permission_errors(): + n = {"c": 0} + def oserr(s, d): + n["c"] += 1 + raise OSError("disk full") + with mock.patch.object(models.os, "name", "nt"), \ + mock.patch.object(models.os, "replace", oserr), \ + mock.patch.object(models.time, "sleep", lambda x: None): + import pytest + with pytest.raises(OSError): + models._safe_replace("a", "b") + assert n["c"] == 1 + +def test_safe_replace_backoff_sequence(): + delays = [] + def always(s, d): + raise PermissionError("x") + with mock.patch.object(models.os, "name", "nt"), \ + mock.patch.object(models.os, "replace", always), \ + mock.patch.object(models.time, "sleep", lambda x: delays.append(round(x, 3))): + import pytest + with pytest.raises(PermissionError): + models._safe_replace("a", "b") + assert delays == [0.05, 0.1, 0.2, 0.4]