Skip to content

Commit 660dafb

Browse files
committed
Ignore errors in temporary directory cleanup
pip should not exit with an error when it fails to cleanup temporary files after it has already successfully installed packages.
1 parent 593b85f commit 660dafb

File tree

4 files changed

+94
-13
lines changed

4 files changed

+94
-13
lines changed

src/pip/_internal/utils/misc.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import sys
1212
import sysconfig
1313
import urllib.parse
14+
from functools import partial
1415
from io import StringIO
1516
from itertools import filterfalse, tee, zip_longest
1617
from types import TracebackType
@@ -123,15 +124,35 @@ def get_prog() -> str:
123124
# Retry every half second for up to 3 seconds
124125
# Tenacity raises RetryError by default, explicitly raise the original exception
125126
@retry(reraise=True, stop=stop_after_delay(3), wait=wait_fixed(0.5))
126-
def rmtree(dir: str, ignore_errors: bool = False) -> None:
127+
def rmtree(
128+
dir: str,
129+
ignore_errors: bool = False,
130+
onexc: Optional[Callable[[Any, Any, Any], Any]] = None,
131+
) -> None:
132+
if ignore_errors:
133+
onexc = _onerror_ignore
134+
elif onexc is None:
135+
onexc = _onerror_reraise
127136
if sys.version_info >= (3, 12):
128-
shutil.rmtree(dir, ignore_errors=ignore_errors, onexc=rmtree_errorhandler)
137+
shutil.rmtree(dir, onexc=partial(rmtree_errorhandler, onexc=onexc))
129138
else:
130-
shutil.rmtree(dir, ignore_errors=ignore_errors, onerror=rmtree_errorhandler)
139+
shutil.rmtree(dir, onerror=partial(rmtree_errorhandler, onexc=onexc))
140+
141+
142+
def _onerror_ignore(*_args: Any) -> None:
143+
pass
144+
145+
146+
def _onerror_reraise(*_args: Any) -> None:
147+
raise
131148

132149

133150
def rmtree_errorhandler(
134-
func: Callable[..., Any], path: str, exc_info: Union[ExcInfo, BaseException]
151+
func: Callable[..., Any],
152+
path: str,
153+
exc_info: Union[ExcInfo, BaseException],
154+
*,
155+
onexc: Callable[..., Any] = _onerror_reraise,
135156
) -> None:
136157
"""On Windows, the files in .svn are read-only, so when rmtree() tries to
137158
remove them, an exception is thrown. We catch that here, remove the
@@ -146,10 +167,13 @@ def rmtree_errorhandler(
146167
# convert to read/write
147168
os.chmod(path, stat.S_IWRITE)
148169
# use the original function to repeat the operation
149-
func(path)
150-
return
151-
else:
152-
raise
170+
try:
171+
func(path)
172+
return
173+
except OSError:
174+
pass
175+
176+
onexc(func, path, exc_info)
153177

154178

155179
def display_path(path: str) -> str:

src/pip/_internal/utils/temp_dir.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import logging
44
import os.path
55
import tempfile
6+
import traceback
67
from contextlib import ExitStack, contextmanager
7-
from typing import Any, Dict, Generator, Optional, TypeVar, Union
8+
from typing import Any, Callable, Dict, Generator, Optional, Tuple, Type, TypeVar, Union
89

910
from pip._internal.utils.misc import enum, rmtree
1011

@@ -106,6 +107,7 @@ def __init__(
106107
delete: Union[bool, None, _Default] = _default,
107108
kind: str = "temp",
108109
globally_managed: bool = False,
110+
ignore_cleanup_errors: bool = True,
109111
):
110112
super().__init__()
111113

@@ -128,6 +130,7 @@ def __init__(
128130
self._deleted = False
129131
self.delete = delete
130132
self.kind = kind
133+
self.ignore_cleanup_errors = ignore_cleanup_errors
131134

132135
if globally_managed:
133136
assert _tempdir_manager is not None
@@ -170,7 +173,34 @@ def cleanup(self) -> None:
170173
self._deleted = True
171174
if not os.path.exists(self._path):
172175
return
173-
rmtree(self._path)
176+
177+
def onerror(
178+
func: Callable[[str], Any],
179+
path: str,
180+
exc_info: Tuple[Type[BaseException], BaseException, Any],
181+
) -> None:
182+
"""Log a warning for a `rmtree` error and continue"""
183+
exc_val = "\n".join(traceback.format_exception_only(*exc_info[:2]))
184+
exc_val = exc_val.rstrip() # remove trailing new line
185+
if func in (os.unlink, os.remove, os.rmdir):
186+
logging.warning(
187+
"Failed to remove a temporary file '%s' due to %s.\n"
188+
"You can safely remove it manually.",
189+
path,
190+
exc_val,
191+
)
192+
else:
193+
logging.warning("%s failed with %s.", func.__qualname__, exc_val)
194+
195+
if self.ignore_cleanup_errors:
196+
try:
197+
# first try with tenacity; retrying to handle ephemeral errors
198+
rmtree(self._path, ignore_errors=False)
199+
except OSError:
200+
# last pass ignore/log all errors
201+
rmtree(self._path, onexc=onerror)
202+
else:
203+
rmtree(self._path)
174204

175205

176206
class AdjacentTempDirectory(TempDirectory):

tests/unit/test_utils.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,13 @@ def test_rmtree_errorhandler_reraises_error(tmpdir: Path) -> None:
257257
except RuntimeError:
258258
# Make sure the handler reraises an exception
259259
with pytest.raises(RuntimeError, match="test message"):
260-
# Argument 3 to "rmtree_errorhandler" has incompatible type "None"; expected
261-
# "Tuple[Type[BaseException], BaseException, TracebackType]"
262-
rmtree_errorhandler(mock_func, path, None) # type: ignore[arg-type]
260+
# Argument 3 to "rmtree_errorhandler" has incompatible type
261+
# "Union[Tuple[Type[BaseException], BaseException, TracebackType],
262+
# Tuple[None, None, None]]"; expected "Tuple[Type[BaseException],
263+
# BaseException, TracebackType]"
264+
rmtree_errorhandler(
265+
mock_func, path, sys.exc_info() # type: ignore[arg-type]
266+
)
263267

264268
mock_func.assert_not_called()
265269

tests/unit/test_utils_temp_dir.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import tempfile
55
from pathlib import Path
66
from typing import Any, Iterator, Optional, Union
7+
from unittest import mock
78

89
import pytest
910

@@ -274,3 +275,25 @@ def test_tempdir_registry_lazy(should_delete: bool) -> None:
274275
registry.set_delete("test-for-lazy", should_delete)
275276
assert os.path.exists(path)
276277
assert os.path.exists(path) == (not should_delete)
278+
279+
280+
def test_tempdir_cleanup_ignore_errors() -> None:
281+
os_unlink = os.unlink
282+
283+
# mock os.unlink to fail with EACCES for a specific filename to simulate
284+
# how removing a loaded exe/dll behaves.
285+
def unlink(name: str, *args: Any, **kwargs: Any) -> None:
286+
if "bomb" in name:
287+
raise PermissionError(name)
288+
else:
289+
os_unlink(name)
290+
291+
with mock.patch("os.unlink", unlink):
292+
with TempDirectory(ignore_cleanup_errors=True) as tmp_dir:
293+
path = tmp_dir.path
294+
with open(os.path.join(path, "bomb"), "a"):
295+
pass
296+
297+
filename = os.path.join(path, "bomb")
298+
assert os.path.isfile(filename)
299+
os.unlink(filename)

0 commit comments

Comments
 (0)