Skip to content

Commit f458573

Browse files
authored
Merge pull request #7882 from sbidoul/build-in-place-7555-sbi
Build local directories in place
2 parents bdff935 + 9e02a1a commit f458573

File tree

10 files changed

+53
-367
lines changed

10 files changed

+53
-367
lines changed

docs/html/reference/pip_install.rst

+7-2
Original file line numberDiff line numberDiff line change
@@ -728,8 +728,13 @@ You can install local projects by specifying the project path to pip::
728728

729729
$ pip install path/to/SomeProject
730730

731-
During regular installation, pip will copy the entire project directory to a temporary location and install from there.
732-
The exception is that pip will exclude .tox and .nox directories present in the top level of the project from being copied.
731+
Until version 20.0, pip copied the entire project directory to a temporary
732+
location and installed from there. This approach was the cause of several
733+
performance and correctness issues. As of version 20.1 pip installs from the
734+
local project directory. Depending on the build backend used by the project,
735+
this may generate secondary build artifacts in the project directory, such as
736+
the ``.egg-info`` and ``build`` directories in the case of the setuptools
737+
backend.
733738

734739

735740
.. _`editable-installs`:

news/7555.removal

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Building of local directories is now done in place. Previously pip did copy the
2+
local directory tree to a temporary location before building. That approach had
3+
a number of drawbacks, among which performance issues, as well as various
4+
issues arising when the python project directory depends on its parent
5+
directory (such as the presence of a VCS directory). The user visible effect of
6+
this change is that secondary build artifacts, if any, may therefore be created
7+
in the local directory, whereas before they were created in a temporary copy of
8+
the directory and then deleted. This notably includes the ``build`` and
9+
``.egg-info`` directories in the case of the setuptools build backend.

src/pip/_internal/operations/prepare.py

+22-79
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,9 @@
2424
PreviousBuildDirError,
2525
VcsHashUnsupported,
2626
)
27-
from pip._internal.utils.filesystem import copy2_fixed
2827
from pip._internal.utils.hashes import MissingHashes
2928
from pip._internal.utils.logging import indent_log
30-
from pip._internal.utils.misc import (
31-
display_path,
32-
hide_url,
33-
path_to_display,
34-
rmtree,
35-
)
29+
from pip._internal.utils.misc import display_path, hide_url
3630
from pip._internal.utils.temp_dir import TempDirectory
3731
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
3832
from pip._internal.utils.unpacking import unpack_file
@@ -133,59 +127,6 @@ def get_http_url(
133127
return File(from_path, content_type)
134128

135129

136-
def _copy2_ignoring_special_files(src, dest):
137-
# type: (str, str) -> None
138-
"""Copying special files is not supported, but as a convenience to users
139-
we skip errors copying them. This supports tools that may create e.g.
140-
socket files in the project source directory.
141-
"""
142-
try:
143-
copy2_fixed(src, dest)
144-
except shutil.SpecialFileError as e:
145-
# SpecialFileError may be raised due to either the source or
146-
# destination. If the destination was the cause then we would actually
147-
# care, but since the destination directory is deleted prior to
148-
# copy we ignore all of them assuming it is caused by the source.
149-
logger.warning(
150-
"Ignoring special file error '%s' encountered copying %s to %s.",
151-
str(e),
152-
path_to_display(src),
153-
path_to_display(dest),
154-
)
155-
156-
157-
def _copy_source_tree(source, target):
158-
# type: (str, str) -> None
159-
target_abspath = os.path.abspath(target)
160-
target_basename = os.path.basename(target_abspath)
161-
target_dirname = os.path.dirname(target_abspath)
162-
163-
def ignore(d, names):
164-
# type: (str, List[str]) -> List[str]
165-
skipped = [] # type: List[str]
166-
if d == source:
167-
# Pulling in those directories can potentially be very slow,
168-
# exclude the following directories if they appear in the top
169-
# level dir (and only it).
170-
# See discussion at https://github.com/pypa/pip/pull/6770
171-
skipped += ['.tox', '.nox']
172-
if os.path.abspath(d) == target_dirname:
173-
# Prevent an infinite recursion if the target is in source.
174-
# This can happen when TMPDIR is set to ${PWD}/...
175-
# and we copy PWD to TMPDIR.
176-
skipped += [target_basename]
177-
return skipped
178-
179-
kwargs = dict(ignore=ignore, symlinks=True) # type: CopytreeKwargs
180-
181-
if not PY2:
182-
# Python 2 does not support copy_function, so we only ignore
183-
# errors on special file copy in Python 3.
184-
kwargs['copy_function'] = _copy2_ignoring_special_files
185-
186-
shutil.copytree(source, target, **kwargs)
187-
188-
189130
def get_file_url(
190131
link, # type: Link
191132
download_dir=None, # type: Optional[str]
@@ -239,11 +180,9 @@ def unpack_url(
239180
unpack_vcs_link(link, location)
240181
return None
241182

242-
# If it's a url to a local directory
183+
# If it's a url to a local directory, we build in-place.
184+
# There is nothing to be done here.
243185
if link.is_existing_dir():
244-
if os.path.isdir(location):
245-
rmtree(location)
246-
_copy_source_tree(link.file_path, location)
247186
return None
248187

249188
# file urls
@@ -415,21 +354,25 @@ def prepare_linked_requirement(
415354
with indent_log():
416355
# Since source_dir is only set for editable requirements.
417356
assert req.source_dir is None
418-
req.ensure_has_source_dir(self.build_dir, autodelete_unpacked)
419-
# If a checkout exists, it's unwise to keep going. version
420-
# inconsistencies are logged later, but do not fail the
421-
# installation.
422-
# FIXME: this won't upgrade when there's an existing
423-
# package unpacked in `req.source_dir`
424-
if os.path.exists(os.path.join(req.source_dir, 'setup.py')):
425-
raise PreviousBuildDirError(
426-
"pip can't proceed with requirements '{}' due to a"
427-
" pre-existing build directory ({}). This is "
428-
"likely due to a previous installation that failed"
429-
". pip is being responsible and not assuming it "
430-
"can delete this. Please delete it and try again."
431-
.format(req, req.source_dir)
432-
)
357+
if link.is_existing_dir():
358+
# Build local directories in place.
359+
req.source_dir = link.file_path
360+
else:
361+
req.ensure_has_source_dir(self.build_dir, autodelete_unpacked)
362+
# If a checkout exists, it's unwise to keep going. version
363+
# inconsistencies are logged later, but do not fail the
364+
# installation.
365+
# FIXME: this won't upgrade when there's an existing
366+
# package unpacked in `req.source_dir`
367+
if os.path.exists(os.path.join(req.source_dir, 'setup.py')):
368+
raise PreviousBuildDirError(
369+
"pip can't proceed with requirements '{}' due to a"
370+
" pre-existing build directory ({}). This is "
371+
"likely due to a previous installation that failed"
372+
". pip is being responsible and not assuming it "
373+
"can delete this. Please delete it and try again."
374+
.format(req, req.source_dir)
375+
)
433376

434377
# Now that we have the real link, we can tell what kind of
435378
# requirements we have and raise some more informative errors

src/pip/_internal/utils/filesystem.py

-32
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
import os
44
import os.path
55
import random
6-
import shutil
7-
import stat
86
import sys
97
from contextlib import contextmanager
108
from tempfile import NamedTemporaryFile
@@ -56,36 +54,6 @@ def check_path_owner(path):
5654
return False # assume we don't own the path
5755

5856

59-
def copy2_fixed(src, dest):
60-
# type: (str, str) -> None
61-
"""Wrap shutil.copy2() but map errors copying socket files to
62-
SpecialFileError as expected.
63-
64-
See also https://bugs.python.org/issue37700.
65-
"""
66-
try:
67-
shutil.copy2(src, dest)
68-
except (OSError, IOError):
69-
for f in [src, dest]:
70-
try:
71-
is_socket_file = is_socket(f)
72-
except OSError:
73-
# An error has already occurred. Another error here is not
74-
# a problem and we can ignore it.
75-
pass
76-
else:
77-
if is_socket_file:
78-
raise shutil.SpecialFileError(
79-
"`{f}` is a socket".format(**locals()))
80-
81-
raise
82-
83-
84-
def is_socket(path):
85-
# type: (str) -> bool
86-
return stat.S_ISSOCK(os.lstat(path).st_mode)
87-
88-
8957
@contextmanager
9058
def adjacent_tmp_file(path, **kwargs):
9159
# type: (str, **Any) -> Iterator[NamedTemporaryFileResult]

tests/functional/test_cli.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ def test_entrypoints_work(entrypoint, script):
2727
)
2828
""".format(entrypoint)))
2929

30-
script.pip("install", "-vvv", str(fake_pkg))
30+
# expect_temp=True, because pip install calls setup.py which
31+
# in turn creates fake_pkg.egg-info.
32+
script.pip("install", "-vvv", str(fake_pkg), expect_temp=True)
3133
result = script.pip("-V")
3234
result2 = script.run("fake_pip", "-V", allow_stderr_warning=True)
3335
assert result.stdout == result2.stdout

tests/functional/test_install.py

-26
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import glob
33
import os
44
import re
5-
import shutil
65
import ssl
76
import sys
87
import textwrap
@@ -29,7 +28,6 @@
2928
skip_if_python2,
3029
windows_workaround_7667,
3130
)
32-
from tests.lib.filesystem import make_socket_file
3331
from tests.lib.local_repos import local_checkout
3432
from tests.lib.path import Path
3533
from tests.lib.server import (
@@ -576,30 +574,6 @@ def test_install_from_local_directory_with_symlinks_to_directories(
576574
assert egg_info_folder in result.files_created, str(result)
577575

578576

579-
@pytest.mark.skipif("sys.platform == 'win32' or sys.version_info < (3,)")
580-
def test_install_from_local_directory_with_socket_file(script, data, tmpdir):
581-
"""
582-
Test installing from a local directory containing a socket file.
583-
"""
584-
egg_info_file = (
585-
script.site_packages /
586-
"FSPkg-0.1.dev0-py{pyversion}.egg-info".format(**globals())
587-
)
588-
package_folder = script.site_packages / "fspkg"
589-
to_copy = data.packages.joinpath("FSPkg")
590-
to_install = tmpdir.joinpath("src")
591-
592-
shutil.copytree(to_copy, to_install)
593-
# Socket file, should be ignored.
594-
socket_file_path = os.path.join(to_install, "example")
595-
make_socket_file(socket_file_path)
596-
597-
result = script.pip("install", "--verbose", to_install)
598-
assert package_folder in result.files_created, str(result.stdout)
599-
assert egg_info_file in result.files_created, str(result)
600-
assert str(socket_file_path) in result.stderr
601-
602-
603577
def test_install_from_local_directory_with_no_setup_py(script, data):
604578
"""
605579
Test installing from a local directory with no 'setup.py'.

tests/functional/test_uninstall.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,15 @@ def test_uninstall_console_scripts(script):
271271
sorted(result.files_created.keys())
272272
)
273273
result2 = script.pip('uninstall', 'discover', '-y')
274-
assert_all_changes(result, result2, [script.venv / 'build', 'cache'])
274+
assert_all_changes(
275+
result,
276+
result2,
277+
[
278+
script.venv / 'build',
279+
'cache',
280+
script.scratch / 'discover' / 'discover.egg-info',
281+
]
282+
)
275283

276284

277285
def test_uninstall_console_scripts_uppercase_name(script):

tests/lib/filesystem.py

-48
This file was deleted.

0 commit comments

Comments
 (0)