Skip to content

Commit a2d8650

Browse files
author
Dan Davis
committed
Fix issue pypa#7280 - capture error and warn, but retry as normal
- TempDirectory() tries to delete the directory as normal - if cleanup fails on Windows due to EACCES, warn about virus scanner - This is a more specific error than previous error, but does not change the behavior when a user is attempting to pip uninstall in a system directory - This changes the messaging, but risks leaving the directory behind. - Leaving the directory behind, however, is what is already happening - The fix for pypa#7479 gives the Virus scanner more time to finish its work, but there is still a possibility for a race condition that leaves the impression that there is an error in pip itself.
1 parent 08f61a9 commit a2d8650

File tree

6 files changed

+210
-2
lines changed

6 files changed

+210
-2
lines changed

news/7280.bugfix

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
When pip is installing or removing a package and is unable to remove a temporary directory on Windows,
2+
this is likely due to a Virus Scan operation. This fix warns the user and continues rather than
3+
preventing the operation. pip still retries the removal, so this happens quite rarely in most Windows
4+
environments.

src/pip/_internal/utils/temp_dir.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import itertools
55
import logging
66
import os.path
7+
import sys
78
import tempfile
89
from contextlib import contextmanager
910

@@ -124,7 +125,16 @@ def cleanup(self):
124125
"""
125126
self._deleted = True
126127
if os.path.exists(self._path):
127-
rmtree(self._path)
128+
try:
129+
rmtree(self._path)
130+
except OSError as e:
131+
if sys.platform == 'win32' and e.errno == errno.EACCES:
132+
logger.warning(
133+
"%s (virus scanner may be holding it)."
134+
"cannot remove '%s'",
135+
e.strerror, e.filename)
136+
else:
137+
raise
128138

129139

130140
class AdjacentTempDirectory(TempDirectory):

tests/lib/filesystem.py

+97
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Helpers for filesystem-dependent tests.
22
"""
3+
import multiprocessing
34
import os
45
import socket
56
import subprocess
67
import sys
8+
import traceback
79
from functools import partial
810
from itertools import chain
911

@@ -34,6 +36,101 @@ def make_unreadable_file(path):
3436
subprocess.check_call(args)
3537

3638

39+
if sys.platform == 'win32':
40+
def lock_action(f):
41+
pass
42+
else:
43+
def lock_action(f):
44+
pass
45+
46+
47+
def external_file_opener(conn):
48+
"""
49+
This external process is run with multiprocessing.
50+
It waits for a path from the parent, opens it, and then wait for another
51+
message before closing it.
52+
53+
:param conn: bi-directional pipe
54+
:return: nothing
55+
"""
56+
f = None
57+
try:
58+
# Wait for parent to send path
59+
msg = conn.recv()
60+
if msg is True:
61+
# Do nothing - we have been told to exit without a path or action
62+
pass
63+
else:
64+
path, action = msg
65+
# Open the file
66+
try:
67+
f = open(path, 'r')
68+
# NOTE: action is for future use and may be unused
69+
if action == 'lock':
70+
lock_action(f)
71+
elif action == 'noread':
72+
make_unreadable_file(path)
73+
except (OSError, IOError):
74+
# IOError is OSError post PEP 3151
75+
traceback.print_exc(None, sys.stderr)
76+
77+
# Indicate the file is opened
78+
conn.send(True)
79+
# Now path is open and we wait for signal to exit
80+
conn.recv()
81+
finally:
82+
if f:
83+
f.close()
84+
conn.close()
85+
86+
87+
class FileOpener(object):
88+
"""
89+
Test class acts as a context manager which can open a file from a
90+
subprocess, and hold it open to assure that this does not interfere with
91+
pip's operations.
92+
93+
If a path is passed to the FileOpener, it immediately sends a message to
94+
the other process to open that path. An action of "lock" or "noread" can
95+
also be sent to the subprocess, resulting in various additional monkey
96+
wrenches now and in the future.
97+
98+
Opening the path and taking the action can be deferred however, so that
99+
the FileOpener may function as a pytest fixture if so desired.
100+
"""
101+
def __init__(self, path=None, action=None):
102+
self.path = None
103+
self.conn, child_conn = multiprocessing.Pipe()
104+
self.child = multiprocessing.Process(
105+
target=external_file_opener,
106+
args=(child_conn,)
107+
)
108+
self.child.daemon = True
109+
self.child.start()
110+
if path:
111+
self.send(path, action)
112+
113+
def send(self, path, action=None):
114+
if self.path is not None:
115+
raise AttributeError('path may only be set once')
116+
self.path = str(path)
117+
self.conn.send((str(path), action))
118+
return self.conn.recv()
119+
120+
def cleanup(self):
121+
# send a message to the child to exit
122+
if self.child:
123+
self.conn.send(True)
124+
self.child.join()
125+
self.child = None
126+
127+
def __enter__(self):
128+
return self
129+
130+
def __exit__(self, exc_type, exc_val, exc_tb):
131+
self.cleanup()
132+
133+
37134
def get_filelist(base):
38135
def join(dirpath, dirnames, filenames):
39136
relative_dirpath = os.path.relpath(dirpath, base)

tests/unit/test_utils_filesystem.py

+62-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import os
22
import shutil
33

4+
import psutil
45
import pytest
56

67
from pip._internal.utils.filesystem import copy2_fixed, is_socket
7-
from tests.lib.filesystem import make_socket_file, make_unreadable_file
8+
from tests.lib.filesystem import (
9+
FileOpener,
10+
make_socket_file,
11+
make_unreadable_file,
12+
)
813
from tests.lib.path import Path
914

1015

16+
@pytest.fixture()
17+
def process():
18+
return psutil.Process()
19+
20+
1121
def make_file(path):
1222
Path(path).touch()
1323

@@ -27,6 +37,7 @@ def make_dir(path):
2737

2838

2939
skip_on_windows = pytest.mark.skipif("sys.platform == 'win32'")
40+
skip_unless_windows = pytest.mark.skipif("sys.platform != 'win32'")
3041

3142

3243
@skip_on_windows
@@ -59,3 +70,53 @@ def test_copy2_fixed_raises_appropriate_errors(create, error_type, tmpdir):
5970
copy2_fixed(src, dest)
6071

6172
assert not dest.exists()
73+
74+
75+
def test_file_opener_no_file(process):
76+
# FileOpener joins the subprocess even if the parent never sends the path
77+
with FileOpener():
78+
pass
79+
assert len(process.children()) == 0
80+
81+
82+
def test_file_opener_not_found(tmpdir, process):
83+
# The FileOpener cleans up the subprocess when the file cannot be opened
84+
path = tmpdir.joinpath('foo.txt')
85+
with FileOpener(path):
86+
pass
87+
assert len(process.children()) == 0
88+
89+
90+
def test_file_opener_normal(tmpdir, process):
91+
# The FileOpener cleans up the subprocess when the file exists
92+
path = tmpdir.joinpath('foo.txt')
93+
with open(path, 'w') as f:
94+
f.write('Hello\n')
95+
with FileOpener(path):
96+
pass
97+
assert len(process.children()) == 0
98+
99+
100+
@skip_unless_windows
101+
def test_file_opener_produces_unlink_error(tmpdir, process):
102+
# FileOpener forces an error on Windows when we attempt to remove a file
103+
# The initial path may be deferred; which must be tested with an error
104+
path = tmpdir.joinpath('foo.txt')
105+
with open(path, 'w') as f:
106+
f.write('Hello\n')
107+
with FileOpener() as opener:
108+
opener.send(path)
109+
with pytest.raises(OSError):
110+
os.unlink(path)
111+
112+
113+
@skip_unless_windows
114+
def test_file_opener_produces_rmtree_error(tmpdir, process):
115+
subdir = tmpdir.joinpath('foo')
116+
os.mkdir(subdir)
117+
path = subdir.joinpath('bar.txt')
118+
with open(path, 'w') as f:
119+
f.write('Hello\n')
120+
with FileOpener(path):
121+
with pytest.raises(OSError):
122+
shutil.rmtree(subdir)

tests/unit/test_utils_temp_dir.py

+34
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import itertools
2+
import logging
23
import os
4+
import shutil
35
import stat
46
import tempfile
7+
import time
58

69
import pytest
710

@@ -12,6 +15,7 @@
1215
TempDirectory,
1316
global_tempdir_manager,
1417
)
18+
from tests.lib.filesystem import FileOpener
1519

1620

1721
# No need to test symlinked directories on Windows
@@ -207,3 +211,33 @@ def test_tempdirectory_asserts_global_tempdir(monkeypatch):
207211
monkeypatch.setattr(temp_dir, "_tempdir_manager", None)
208212
with pytest.raises(AssertionError):
209213
TempDirectory(globally_managed=True)
214+
215+
216+
@pytest.mark.skipif("sys.platform != 'win32'")
217+
def test_temp_dir_warns_if_cannot_clean(caplog):
218+
temp_dir = TempDirectory()
219+
temp_dir_path = temp_dir.path
220+
221+
stime = time.time()
222+
223+
# Capture only at WARNING level and up
224+
with caplog.at_level(logging.WARNING, 'pip._internal.utils.temp_dir'):
225+
# open a file within the temporary directory in a sub-process
226+
with FileOpener() as opener:
227+
subpath = os.path.join(temp_dir_path, 'foo.txt')
228+
with open(subpath, 'w') as f:
229+
f.write('Cannot be deleted')
230+
opener.send(subpath)
231+
# with the file open, attempt to remove the log directory
232+
temp_dir.cleanup()
233+
234+
# assert that a WARNING was logged about virus scanner
235+
assert 'WARNING' in caplog.text
236+
assert 'virus scanner' in caplog.text
237+
238+
# Assure that the cleanup was properly retried
239+
duration = time.time() - stime
240+
assert duration >= 2.0
241+
242+
# Clean-up for failed TempDirectory cleanup
243+
shutil.rmtree(temp_dir_path, ignore_errors=True)

tools/requirements/tests.txt

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ cryptography==2.8
22
freezegun
33
mock
44
pretend
5+
# Below is to count sub-processes and assure child was cleaned up properly
6+
psutil
57
pytest==3.8.2
68
pytest-cov
79
# Prevent installing 7.0 which has install_requires "pytest >= 3.10".

0 commit comments

Comments
 (0)