Skip to content

Commit 7a74e13

Browse files
authored
Add PersistentOnErrorTemporaryDirectory ctx manager (#49)
Is derived from `tempfile.TemporaryDirectory`, but it does not delete the directory if an exception occurs. This is useful for debugging or when you want to inspect the content of the directory after an error.
1 parent b927205 commit 7a74e13

3 files changed

Lines changed: 137 additions & 1 deletion

File tree

changelog.d/49.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add new context manager :class:`~docbuild.utils.contextmgr.PersistentOnErrorTemporaryDirectory`.
2+
It is derived from :class:`tempfile.TemporaryDirectory`and has a similar behavior, but it does not delete the temporary directory on exit if an exception occurs.

src/docbuild/utils/contextmgr.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,21 @@
33
from collections.abc import Callable, Iterator
44
from contextlib import AbstractContextManager, contextmanager
55
from dataclasses import dataclass
6+
import logging
7+
from pathlib import Path
8+
import shutil
9+
import tempfile
610
import time
11+
from types import TracebackType
12+
import weakref as _weakref
13+
14+
# Type aliases for exception handling
15+
type ExcType = type[BaseException] | None
16+
type ExcVal = BaseException | None
17+
type ExcTback = TracebackType | None
18+
19+
# Logging
20+
log = logging.getLogger(__name__)
721

822

923
@dataclass
@@ -53,3 +67,63 @@ def wrapper() -> Iterator[TimerData]:
5367
data.elapsed = data.end - data.start
5468

5569
return wrapper
70+
71+
72+
class PersistentOnErrorTemporaryDirectory(tempfile.TemporaryDirectory):
73+
"""Delete temporary directory only if no exception occurs.
74+
75+
It is similar to :class:`tempfile.TemporaryDirectory`, but it does not
76+
delete the directory if an exception occurs. This is useful for debugging
77+
or when you want to inspect the contents of the directory after an error.
78+
79+
.. code-block:: python
80+
81+
with PersistentOnErrorTemporaryDirectory() as temp_dir:
82+
# Do something with the temporary directory, it's a Path object
83+
84+
Optional arguments:
85+
:param suffix: A str suffix for the directory name. (see mkdtemp)
86+
:param prefix: A str prefix for the directory name. (see mkdtemp)
87+
:param dir: A directory to create this temp dir in. (see mkdtemp)
88+
"""
89+
90+
def __init__(
91+
self,
92+
suffix: str | None = None,
93+
prefix: str | None = None,
94+
dir: str | Path | None = None, # noqa: A002
95+
) -> None:
96+
# Call the parent constructor. We don't need the
97+
# `ignore_cleanup_errors` flag as we implement our own cleanup.
98+
super().__init__(suffix=suffix, prefix=prefix, dir=dir)
99+
100+
def __enter__(self) -> Path:
101+
"""Enter the runtime context and create the temporary directory.
102+
103+
:returns: Path to the created temporary directory.
104+
"""
105+
# The parent __enter__ returns a string, so we override it
106+
# to return a Path object for consistency with your original class.
107+
return Path(self.name)
108+
109+
def __exit__(self, exc_type: ExcType, exc_val: ExcVal, exc_tb: ExcTback) -> None:
110+
"""Exit the runtime context and delete the directory if no exception occurred.
111+
112+
:param exc_type: Exception type, if any.
113+
:param exc_val: Exception instance, if any.
114+
:param exc_tb: Traceback, if any.
115+
"""
116+
# CRITICAL: We must always detach the finalizer. If we don't,
117+
# and an error occurred, the directory would still be deleted
118+
# upon garbage collection, which is not what we want.
119+
self._finalizer.detach()
120+
121+
if exc_type is None:
122+
# No exception occurred in the `with` block, so we clean up.
123+
try:
124+
shutil.rmtree(self.name)
125+
126+
except OSError as e:
127+
# Your custom logging is more informative than the parent's
128+
# `ignore_errors=True`, so we replicate it here.
129+
log.exception('Failed to delete temp dir %s: %s', self.name, e)

tests/utils/test_contextmgr.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import math
2+
from pathlib import Path
23
import time
4+
from unittest.mock import patch
35

46
import pytest
57

6-
from docbuild.utils.contextmgr import make_timer
8+
import docbuild.utils.contextmgr as contextmgr
9+
from docbuild.utils.contextmgr import PersistentOnErrorTemporaryDirectory, make_timer
710

811

912
def test_timer_has_correct_attributes():
@@ -64,3 +67,60 @@ def test_timer_for_nan_as_default():
6467
assert timer_data.start > 0
6568
assert math.isnan(timer_data.end)
6669
assert math.isnan(timer_data.elapsed)
70+
71+
72+
# ----
73+
@pytest.fixture
74+
def fake_temp_path() -> str:
75+
return '/mock/temp/dir'
76+
77+
78+
def test_temp_dir_deleted_on_success(fake_temp_path: str) -> None:
79+
"""Ensure the directory is deleted if no exception occurs."""
80+
with (
81+
patch.object(contextmgr.tempfile,
82+
'mkdtemp',
83+
return_value=fake_temp_path,
84+
),
85+
patch.object(contextmgr.shutil, 'rmtree') as mock_rmtree,
86+
):
87+
with PersistentOnErrorTemporaryDirectory() as temp_path:
88+
assert temp_path == Path(fake_temp_path)
89+
90+
mock_rmtree.assert_called_once_with(fake_temp_path)
91+
92+
93+
def test_temp_dir_preserved_on_exception(fake_temp_path: str) -> None:
94+
"""Ensure the directory is preserved if an exception occurs."""
95+
with (
96+
patch.object(contextmgr.tempfile, 'mkdtemp', return_value=fake_temp_path),
97+
patch.object(contextmgr.shutil, 'rmtree') as mock_rmtree,
98+
):
99+
with pytest.raises(RuntimeError):
100+
with PersistentOnErrorTemporaryDirectory() as temp_path:
101+
assert temp_path == Path(fake_temp_path)
102+
raise RuntimeError('Simulated failure')
103+
104+
mock_rmtree.assert_not_called()
105+
106+
107+
def test_temp_dir_deletion_failure_is_logged(fake_temp_path: str) -> None:
108+
"""Ensure that an OSError during directory deletion is logged."""
109+
mock_error = OSError('Permission denied')
110+
111+
with (
112+
patch.object(contextmgr.tempfile, 'mkdtemp', return_value=fake_temp_path),
113+
patch.object(contextmgr.shutil, 'rmtree', side_effect=mock_error)
114+
as mock_rmtree,
115+
patch.object(contextmgr.log, 'exception') as mock_log_exception,
116+
):
117+
# The __exit__ method should catch the OSError and not re-raise it.
118+
with PersistentOnErrorTemporaryDirectory() as temp_path:
119+
assert temp_path == Path(fake_temp_path)
120+
121+
# Verify that rmtree was called, which triggered the error
122+
mock_rmtree.assert_called_once_with(fake_temp_path)
123+
# Verify that the exception was logged with the correct message.
124+
mock_log_exception.assert_called_once_with(
125+
'Failed to delete temp dir %s: %s', fake_temp_path, mock_error
126+
)

0 commit comments

Comments
 (0)