Skip to content

Commit d064174

Browse files
authored
Merge pull request #11394 from ales-erjavec/temp-cleanup-ignore-errors
2 parents 95640b8 + 2928750 commit d064174

File tree

5 files changed

+131
-20
lines changed

5 files changed

+131
-20
lines changed

news/11394.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Ignore errors in temporary directory cleanup (show a warning instead).

src/pip/_internal/utils/misc.py

+49-15
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,33 +124,66 @@ 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:
136-
"""On Windows, the files in .svn are read-only, so when rmtree() tries to
137-
remove them, an exception is thrown. We catch that here, remove the
138-
read-only attribute, and hopefully continue without problems."""
157+
"""
158+
`rmtree` error handler to 'force' a file remove (i.e. like `rm -f`).
159+
160+
* If a file is readonly then it's write flag is set and operation is
161+
retried.
162+
163+
* `onerror` is the original callback from `rmtree(... onerror=onerror)`
164+
that is chained at the end if the "rm -f" still fails.
165+
"""
139166
try:
140-
has_attr_readonly = not (os.stat(path).st_mode & stat.S_IWRITE)
167+
st_mode = os.stat(path).st_mode
141168
except OSError:
142169
# it's equivalent to os.path.exists
143170
return
144171

145-
if has_attr_readonly:
172+
if not st_mode & stat.S_IWRITE:
146173
# convert to read/write
147-
os.chmod(path, stat.S_IWRITE)
148-
# use the original function to repeat the operation
149-
func(path)
150-
return
151-
else:
152-
raise
174+
try:
175+
os.chmod(path, st_mode | stat.S_IWRITE)
176+
except OSError:
177+
pass
178+
else:
179+
# use the original function to repeat the operation
180+
try:
181+
func(path)
182+
return
183+
except OSError:
184+
pass
185+
186+
onexc(func, path, exc_info)
153187

154188

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

src/pip/_internal/utils/temp_dir.py

+51-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,20 @@
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 (
9+
Any,
10+
Callable,
11+
Dict,
12+
Generator,
13+
List,
14+
Optional,
15+
Tuple,
16+
Type,
17+
TypeVar,
18+
Union,
19+
)
820

921
from pip._internal.utils.misc import enum, rmtree
1022

@@ -106,6 +118,7 @@ def __init__(
106118
delete: Union[bool, None, _Default] = _default,
107119
kind: str = "temp",
108120
globally_managed: bool = False,
121+
ignore_cleanup_errors: bool = True,
109122
):
110123
super().__init__()
111124

@@ -128,6 +141,7 @@ def __init__(
128141
self._deleted = False
129142
self.delete = delete
130143
self.kind = kind
144+
self.ignore_cleanup_errors = ignore_cleanup_errors
131145

132146
if globally_managed:
133147
assert _tempdir_manager is not None
@@ -170,7 +184,42 @@ def cleanup(self) -> None:
170184
self._deleted = True
171185
if not os.path.exists(self._path):
172186
return
173-
rmtree(self._path)
187+
188+
errors: List[BaseException] = []
189+
190+
def onerror(
191+
func: Callable[[str], Any],
192+
path: str,
193+
exc_info: Tuple[Type[BaseException], BaseException, Any],
194+
) -> None:
195+
"""Log a warning for a `rmtree` error and continue"""
196+
exc_val = "\n".join(traceback.format_exception_only(*exc_info[:2]))
197+
exc_val = exc_val.rstrip() # remove trailing new line
198+
if func in (os.unlink, os.remove, os.rmdir):
199+
logger.debug(
200+
"Failed to remove a temporary file '%s' due to %s.\n",
201+
path,
202+
exc_val,
203+
)
204+
else:
205+
logger.debug("%s failed with %s.", func.__qualname__, exc_val)
206+
errors.append(exc_info[1])
207+
208+
if self.ignore_cleanup_errors:
209+
try:
210+
# first try with tenacity; retrying to handle ephemeral errors
211+
rmtree(self._path, ignore_errors=False)
212+
except OSError:
213+
# last pass ignore/log all errors
214+
rmtree(self._path, onexc=onerror)
215+
if errors:
216+
logger.warning(
217+
"Failed to remove contents in a temporary directory '%s'.\n"
218+
"You can safely remove it manually.",
219+
self._path,
220+
)
221+
else:
222+
rmtree(self._path)
174223

175224

176225
class AdjacentTempDirectory(TempDirectory):

tests/unit/test_utils.py

+7-3
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

+23
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)