Commit 64f61417 authored by Xavier Thompson's avatar Xavier Thompson

[feat] Use pip install --editable in easy_install.develop

Instead of running python setup.py develop directly. This will allow
using zc.buildout.easy_install.develop on recent projects that have
only a pyproject.toml. It also fixes develop leaving build artifacts
in the source directory that caused later runs to do nothing, e.g.
preventing develop-eggs to be rebuilt when a build dependency passed
in setup-eggs option of zc.recipe.egg:develop changed.

A verbosity parameter to tune verbosity of pip is added, with adjusted
values for the case of buildout:develop and of zc.recipe.egg:develop,
so as to remain close to the previous behavior with regards to logs.

Technical details:

For packages using PEP-660-style editable installs, supported by more
recent versions of pip, pip will not delegate to `setup.py develop` -
enabling editable installs for pure pyproject.toml projects - and will
instead generate a .dist-info metadata folder but not a .egg-link.

Since buildout currently requires a .egg-link, as it does not support
PEP 660's mechanism that relies on having a sites-packages directory,
we need to create this .egg-link after the fact. The tricky part is
finding out where the .egg-link should point. For this we make use
of importlib to extract info from the PEP-660-style install.

Support namespace packages where `spec.submodule_search_locations` is
a `_NamespacePath` object instead of a simple `list` and also support
cases where the layout of the source project does not follow the same
structure as the package tree - meaning some custom magic might be
involved in making editable imports work as intended.

Allow for entries in top_level.txt that are not actually packages,
i.e. where importlib.util.find_spec().submodule_search_locations is
None. A test is added for this case.
parent 7e9b8c93
...@@ -1131,7 +1131,8 @@ class Buildout(DictMixin): ...@@ -1131,7 +1131,8 @@ class Buildout(DictMixin):
for setup in files: for setup in files:
self._logger.info("Develop: %r", setup) self._logger.info("Develop: %r", setup)
__doing__ = 'Processing develop directory %r.', setup __doing__ = 'Processing develop directory %r.', setup
zc.buildout.easy_install.develop(setup, dest) zc.buildout.easy_install.develop(setup, dest,
verbosity=-20)
except: except:
# if we had an error, we need to roll back changes, by # if we had an error, we need to roll back changes, by
# removing any files we created. # removing any files we created.
......
...@@ -23,6 +23,7 @@ import distutils.errors ...@@ -23,6 +23,7 @@ import distutils.errors
import distutils.sysconfig import distutils.sysconfig
import errno import errno
import glob import glob
import json
import logging import logging
import os import os
import pkg_resources import pkg_resources
...@@ -1113,22 +1114,6 @@ def _rm(*paths): ...@@ -1113,22 +1114,6 @@ def _rm(*paths):
os.remove(path) os.remove(path)
def _copyeggs(src, dest, suffix, undo):
result = []
undo.append(lambda : _rm(*result))
for name in os.listdir(src):
if name.endswith(suffix):
new = os.path.join(dest, name)
_rm(new)
os.rename(os.path.join(src, name), new)
result.append(new)
assert len(result) == 1, str(result)
undo.pop()
return result[0]
_develop_distutils_scripts = {} _develop_distutils_scripts = {}
...@@ -1175,13 +1160,11 @@ def _detect_distutils_scripts(directory): ...@@ -1175,13 +1160,11 @@ def _detect_distutils_scripts(directory):
_develop_distutils_scripts[egg_name] = scripts_found _develop_distutils_scripts[egg_name] = scripts_found
def develop(setup, dest, def develop(setup, dest, build_ext=None, executable=None, verbosity=-10):
build_ext=None, if executable is not None: # BBB
executable=sys.executable):
assert executable == sys.executable, (executable, sys.executable) assert executable == sys.executable, (executable, sys.executable)
if os.path.isdir(setup): if os.path.isdir(setup):
directory = setup directory = setup
setup = os.path.join(directory, 'setup.py')
else: else:
directory = os.path.dirname(setup) directory = os.path.dirname(setup)
...@@ -1203,43 +1186,118 @@ def develop(setup, dest, ...@@ -1203,43 +1186,118 @@ def develop(setup, dest,
setuptools.command.setopt.edit_config( setuptools.command.setopt.edit_config(
setup_cfg, dict(build_ext=build_ext)) setup_cfg, dict(build_ext=build_ext))
fd, tsetup = tempfile.mkstemp() tmp3 = tempfile.mkdtemp('build', dir=dest)
undo.append(lambda: os.remove(tsetup)) undo.append(lambda : zc.buildout.rmtree.rmtree(tmp3))
undo.append(lambda: os.close(fd))
extra_path = os.environ.get('PYTHONEXTRAPATH') class options: pass
extra_path_list = [] options._allow_picked_versions = allow_picked_versions()
if extra_path:
extra_path_list = extra_path.split(os.pathsep)
os.write(fd, (runsetup_template % dict( call_pip_editable(directory, tmp3, options, verbosity=verbosity)
setupdir=directory,
setup=setup,
path_list=extra_path_list,
__file__ = setup,
)).encode())
tmp3 = tempfile.mkdtemp('build', dir=dest) _detect_distutils_scripts(tmp3)
undo.append(lambda : zc.buildout.rmtree.rmtree(tmp3))
args = [executable, tsetup, '-q', 'develop', '-mN', '-d', tmp3] egg_link, dist_info = [], []
for entry in os.listdir(tmp3):
if entry.endswith('.egg-link'):
egg_link.append(entry)
if entry.endswith('.dist-info') or entry.endswith('.egg-info'):
dist_info.append(entry)
log_level = logger.getEffectiveLevel() assert len(egg_link) + len(dist_info) == 1, str(egg_link + dist_info)
if log_level <= 0: entry, = (egg_link or dist_info)
if log_level == 0:
del args[2] entry_path = os.path.join(tmp3, entry)
else:
args[2] == '-v' entry_filename, ext = os.path.splitext(entry)
if log_level < logging.DEBUG: project_name = entry_filename.split('-', 1)[0]
logger.debug("in: %r\n%s", directory, ' '.join(args))
if SETUPTOOLS_IGNORE_WARNINGS: def move(src, dst):
env = dict(os.environ, PYTHONWARNINGS='ignore') _rm(dst)
os.rename(src, dst)
return dst
if egg_link:
# case: legacy setup.py
return move(entry_path, os.path.join(dest, entry))
else: else:
env=None # case: PEP 660 pyproject.toml
call_subprocess(args, env=env) # This case can only happen in Python3
_detect_distutils_scripts(tmp3) # (because pip versions that support PEP 660 are Python3-only).
return _copyeggs(tmp3, dest, '.egg-link', undo)
# Resolve the package path(s) and import (parent) path(s).
# This may be a subpath of the project directory,
# e.g. zc.buildout/src.
with open(os.path.join(entry_path, 'top_level.txt')) as f:
top_level = f.read().splitlines()
packages = tuple(p for p in top_level if p)
try:
package_paths = json.loads(subprocess.check_output([
sys.executable, '-S', '-c',
"import importlib.util, json, site; "
"site.addsitedir(%(site)r); "
"packages = %(packages)r; "
"specs = [importlib.util.find_spec(p) for p in packages]; "
"paths = {p: list(s.submodule_search_locations)"
" for p, s in zip(packages, specs) "
" if s.submodule_search_locations is not None}; "
"print(json.dumps(paths))" % {
'site': tmp3,
'packages': packages,
}
],
env=dict(os.environ, PYTHONPATH=''),
stderr=subprocess.STDOUT,
universal_newlines=True,
))
except subprocess.CalledProcessError as e:
logger.error(e.output)
raise
# Resolve import path.
directory = os.path.normpath(directory)
import_paths = []
for package, paths in package_paths.items():
for path in paths:
# Filter out magic hook values from setuptools PEP 660.
if (path.startswith('__editable__')
and path.endswith('.__path_hook__')): continue
path = os.path.normpath(path)
# Do not parent-path out of the source repository.
if path == directory:
import_paths.append(path)
continue
parent, folder = os.path.split(path)
# Only parent-path if the folder has the name of the package.
import_paths.append(parent if folder == package else path)
# Warn if there are several and arbitrarily take the first one.
if import_paths:
import_path = import_paths[0]
unique_import_paths = set(import_paths)
if len(unique_import_paths) > 1:
logger.warning(
"Found multiple package import paths"
" in develop project %s\n(\n %s\n)\n"
"Selected %s" % (
directory,
'\n '.join(unique_import_paths),
import_path
))
# Warn if no import path is found; fallback to source directory.
else:
logger.warning(
"Found no package import path in develop project %s "
"for packages %r\n"
"Falling back to project's root directory"
% (directory, packages),)
import_path = directory
# Move the .dist-info folder to the import path like setuptools.
move(entry_path, os.path.join(import_path, project_name + ext))
# Create a temporary .egg-link.
tmp_egg_link = os.path.join(tmp3, project_name + '.egg-link')
with open(tmp_egg_link, 'w') as f:
f.write(import_path)
# Move the .egg-link to the destination directory.
egg_link_path = os.path.join(dest, project_name + '.egg-link')
return move(tmp_egg_link, egg_link_path)
finally: finally:
undo.reverse() undo.reverse()
...@@ -1806,63 +1864,50 @@ class IncompatibleConstraintError(zc.buildout.UserError): ...@@ -1806,63 +1864,50 @@ class IncompatibleConstraintError(zc.buildout.UserError):
IncompatibleVersionError = IncompatibleConstraintError # Backward compatibility IncompatibleVersionError = IncompatibleConstraintError # Backward compatibility
def call_pip_wheel(spec, dest, options): try:
from pip._internal.cli.cmdoptions import no_python_version_warning
PIP_HAS_PYTHON_VERSION_WARNING_OPTION = True
except ImportError:
PIP_HAS_PYTHON_VERSION_WARNING_OPTION = False
def call_pip_command(command, operand, options, verbosity=-10):
""" """
Call `pip wheel` from a subprocess to install a Call `pip <command...> <operand...>` from a subprocess
distribution specified by `spec` into `dest`. with appropriate options and environment.
Returns all the paths inside `dest` created by the above.
""" """
cleanup = [] cleanup = []
try: try:
args = [sys.executable, '-m', 'pip', 'wheel', '--no-deps', '-w', dest] env = os.environ.copy()
level = logger.getEffectiveLevel() pythonpath = pip_path[:]
if level >= logging.INFO: pythonpath.extend(
args.append('-q') env.get(k) for k in ('PYTHONPATH', 'PYTHONEXTRAPATH'))
else: env['PYTHONPATH'] = os.pathsep.join(p for p in pythonpath if p)
args.append('-v')
args = [sys.executable, '-m', 'pip']
args.extend(command)
args.append('--no-deps')
log_level = logger.getEffectiveLevel()
pip_level = log_level - verbosity
if pip_level >= logging.WARNING:
args.append('-' + 'q' * (1 + (pip_level >= logging.ERROR)))
elif pip_level < logging.INFO:
args.append('-' + 'v' * (1 + (pip_level < logging.DEBUG)))
# Note: more recent pip accepts even -vvv and -qqq.
if not options._allow_picked_versions:
# Prevent pip from installing build dependencies on the fly # Prevent pip from installing build dependencies on the fly
# without respecting pinned versions. This only works for # without respecting pinned versions. This only works for
# PEP 517 specifications using pyproject.toml and not for # PEP 517 specifications using pyproject.toml and not for
# dependencies in setup_requires option in legacy setup.py # dependencies in setup_requires option in legacy setup.py
if not options._allow_picked_versions:
args.append('--no-index') args.append('--no-index')
args.append('--no-build-isolation') args.append('--no-build-isolation')
args.append(spec)
try:
from pip._internal.cli.cmdoptions import no_python_version_warning
HAS_WARNING_OPTION = True
except ImportError:
HAS_WARNING_OPTION = False
if HAS_WARNING_OPTION:
if not hasattr(call_pip_wheel, 'displayed'):
call_pip_wheel.displayed = True
else:
args.append('--no-python-version-warning')
env = os.environ.copy()
python_path = pip_path[:]
env_paths = env.get('PYTHONPATH')
if env_paths:
python_path.append(env_paths)
extra_env_path = env.get('PYTHONEXTRAPATH')
if extra_env_path:
python_path.append(extra_env_path)
env['PYTHONPATH'] = os.pathsep.join(python_path)
if level <= logging.DEBUG:
logger.debug('Running pip install:\n"%s"\npath=%s\n',
'" "'.join(args), pip_path)
sys.stdout.flush() # We want any pending output first
# Prevent setuptools from downloading and thus installing # Prevent setuptools from downloading and thus installing
# build dependencies specified in setup_requires option of # build dependencies specified in setup_requires option of
# legacy setup.py by providing a crafted .pydistutils.cfg. # legacy setup.py by providing a crafted .pydistutils.cfg.
# This is used in complement to --no-build-isolation. # This is used in complement to --no-build-isolation.
if not options._allow_picked_versions:
pip_home = tempfile.mkdtemp('pip-pydistutils-home') pip_home = tempfile.mkdtemp('pip-pydistutils-home')
cleanup.append(lambda: zc.buildout.rmtree.rmtree(pip_home)) cleanup.append(lambda: zc.buildout.rmtree.rmtree(pip_home))
with open(os.path.join(pip_home, '.pydistutils.cfg'), 'w') as f: with open(os.path.join(pip_home, '.pydistutils.cfg'), 'w') as f:
...@@ -1870,7 +1915,47 @@ def call_pip_wheel(spec, dest, options): ...@@ -1870,7 +1915,47 @@ def call_pip_wheel(spec, dest, options):
"index_url = file:///dev/null") "index_url = file:///dev/null")
env['HOME'] = pip_home env['HOME'] = pip_home
if PIP_HAS_PYTHON_VERSION_WARNING_OPTION:
# Let pip display Python warnings only on first run.
if not hasattr(call_pip_command, 'displayed'):
call_pip_command.displayed = True
else:
args.append('--no-python-version-warning')
args.extend(operand)
if log_level < logging.DEBUG:
# Log this only when buildout log level is quite low
logger.debug('Running pip %s', ' '.join(command[0:1] + operand))
if log_level < 0:
# Log this only when buildout log level is even lower
logger.debug(
'%s\nPYTHONPATH=%s\n', ' '.join(args), env['PYTHONPATH'])
sys.stdout.flush() # We want any pending output first
subprocess.check_call(args, env=env) subprocess.check_call(args, env=env)
finally:
for f in cleanup:
f()
def call_pip_editable(path, dest, options, verbosity=-10):
"""
Call `pip install --editable` from a subprocess to install a
the project in `path` into `dest` in editable mode.
"""
call_pip_command(
['install', '-t', dest], ['--editable', path],
options, verbosity)
def call_pip_wheel(spec, dest, options):
"""
Call `pip wheel` from a subprocess to install a
distribution specified by `spec` into `dest`.
Returns all the paths inside `dest` created by the above.
"""
call_pip_command(['wheel', '-w', dest], [spec], options)
entries = os.listdir(dest) entries = os.listdir(dest)
try: try:
...@@ -1878,15 +1963,11 @@ def call_pip_wheel(spec, dest, options): ...@@ -1878,15 +1963,11 @@ def call_pip_wheel(spec, dest, options):
wheel = entries[0] wheel = entries[0]
assert os.path.splitext(wheel)[1] == '.whl', "Expected a .whl" assert os.path.splitext(wheel)[1] == '.whl', "Expected a .whl"
except AssertionError: except AssertionError:
logger.error( logger.error("No .whl after successful pip wheel of %s", spec)
"No .whl after successful pip wheel of %s",
spec)
raise raise
return make_egg_after_pip_wheel(dest, wheel) return make_egg_after_pip_wheel(dest, wheel)
finally:
for f in cleanup:
f()
def make_egg_after_pip_wheel(dest, wheel): def make_egg_after_pip_wheel(dest, wheel):
unpack_wheel(os.path.join(dest, wheel), dest) unpack_wheel(os.path.join(dest, wheel), dest)
......
...@@ -348,7 +348,7 @@ reporting that a version was picked automatically: ...@@ -348,7 +348,7 @@ reporting that a version was picked automatically:
zc.buildout.easy_install DEBUG zc.buildout.easy_install DEBUG
Fetching demoneeded 1.1 from: http://.../demoneeded-1.1.zip Fetching demoneeded 1.1 from: http://.../demoneeded-1.1.zip
zc.buildout.easy_install DEBUG zc.buildout.easy_install DEBUG
Running pip install:... Running pip wheel...
zc.buildout.easy_install INFO zc.buildout.easy_install INFO
Got demoneeded 1.1. Got demoneeded 1.1.
zc.buildout.easy_install DEBUG zc.buildout.easy_install DEBUG
......
...@@ -204,8 +204,7 @@ We should be able to deal with setup scripts that aren't setuptools based. ...@@ -204,8 +204,7 @@ We should be able to deal with setup scripts that aren't setuptools based.
... # doctest: +ELLIPSIS ... # doctest: +ELLIPSIS
Installing... Installing...
Develop: '/sample-buildout/foo' Develop: '/sample-buildout/foo'
... Running pip install --editable /sample-buildout/foo
Installed /sample-buildout/foo
... ...
>>> ls('develop-eggs') >>> ls('develop-eggs')
...@@ -216,10 +215,9 @@ We should be able to deal with setup scripts that aren't setuptools based. ...@@ -216,10 +215,9 @@ We should be able to deal with setup scripts that aren't setuptools based.
... # doctest: +ELLIPSIS ... # doctest: +ELLIPSIS
Installing... Installing...
Develop: '/sample-buildout/foo' Develop: '/sample-buildout/foo'
in: '/sample-buildout/foo' Running pip install --editable /sample-buildout/foo
... -q develop -mN -d /sample-buildout/develop-eggs/... ... -m pip install -t ... --editable /sample-buildout/foo
...
""" """
def buildout_error_handling(): def buildout_error_handling():
...@@ -1157,6 +1155,47 @@ existing setup.cfg: ...@@ -1157,6 +1155,47 @@ existing setup.cfg:
""" """
def develop_pyproject_only_without_package():
"""
Install a pyproject-only project that does not contain any package,
but only a single module.
>>> mkdir('nopkg')
>>> write('nopkg', 'pyproject.toml', '''
... ''')
>>> write('nopkg', 'xnopkg.py', '''
... print('nopkg')
... ''')
>>> write('buildout.cfg', '''
... [buildout]
... develop = nopkg
... parts =
... ''')
>>> print_(system((join('bin', 'buildout'), '-vv')), end='')
Develop: '/sample-buildout/nopkg'
Found no package import path in develop project /sample-buildout/nopkg for packages ('xnopkg',)
Falling back to project's root directory
>>> ls('develop-eggs')
- xnopkg.egg-link
- zc.recipe.egg.egg-link
>>> cat('develop-eggs/xnopkg.egg-link')
/sample-buildout/nopkg
"""
# BBB Python2: skip PEP 660 test
# Whether the test succeeds actually depends on pip and setuptools versions
# supporting PEP 660, but Python2 is the reason they would be stuck at old
# versions, so it will always fail with Python2, and Python3 should use more
# recent versions.
if sys.version_info < (3,):
del develop_pyproject_only_without_package
def uninstall_recipes_used_for_removal(): def uninstall_recipes_used_for_removal():
r""" r"""
Uninstall recipes need to be called when a part is removed too: Uninstall recipes need to be called when a part is removed too:
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment