Commit bedccb23 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.
parent 551e45f9
...@@ -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.
......
...@@ -1113,22 +1113,6 @@ def _rm(*paths): ...@@ -1113,22 +1113,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 +1159,11 @@ def _detect_distutils_scripts(directory): ...@@ -1175,13 +1159,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 +1185,89 @@ def develop(setup, dest, ...@@ -1203,43 +1185,89 @@ 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]
else:
args[2] == '-v'
if log_level < logging.DEBUG:
logger.debug("in: %r\n%s", directory, ' '.join(args))
if SETUPTOOLS_IGNORE_WARNINGS: entry_path = os.path.join(tmp3, entry)
env = dict(os.environ, PYTHONWARNINGS='ignore')
entry_filename, ext = os.path.splitext(entry)
project_name = entry_filename.split('-', 1)[0]
def move(src, dst):
_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()
try:
package_paths = subprocess.check_output([
sys.executable, '-S', '-c',
"import importlib.util, site; "
"site.addsitedir(%(site)r); "
"packages = %(packages)r; "
"specs = [importlib.util.find_spec(p) for p in packages]; "
"paths = sum((s.submodule_search_locations"
" for s in specs), []); "
"print('\\n'.join(paths))" % {
'site': tmp3,
'packages': tuple(p for p in top_level if p),
}
],
env=dict(os.environ, PYTHONPATH=''),
stderr=subprocess.STDOUT,
universal_newlines=True,
).splitlines()
except subprocess.CalledProcessError as e:
logger.error(e.output)
raise
# Resolve import path; warn if there are several and
# arbitrarily take the first one.
import_paths = [os.path.dirname(p) for p in package_paths]
import_path = import_paths[0]
if len(set(import_paths)) > 1:
logger.warning(
"Found multiple package import paths in develop project %s"
"\n(\n%s\n)\n"
"Selected %s"
% (directory, '\n'.join(set(import_paths)), import_path))
# 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 +1834,50 @@ class IncompatibleConstraintError(zc.buildout.UserError): ...@@ -1806,63 +1834,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.
# Prevent pip from installing build dependencies on the fly
# without respecting pinned versions. This only works for
# PEP 517 specifications using pyproject.toml and not for
# dependencies in setup_requires option in legacy setup.py
if not options._allow_picked_versions: if not options._allow_picked_versions:
# Prevent pip from installing build dependencies on the fly
# without respecting pinned versions. This only works for
# PEP 517 specifications using pyproject.toml and not for
# dependencies in setup_requires option in legacy setup.py
args.append('--no-index') args.append('--no-index')
args.append('--no-build-isolation') args.append('--no-build-isolation')
args.append(spec) # Prevent setuptools from downloading and thus installing
# build dependencies specified in setup_requires option of
try: # legacy setup.py by providing a crafted .pydistutils.cfg.
from pip._internal.cli.cmdoptions import no_python_version_warning # This is used in complement to --no-build-isolation.
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
# build dependencies specified in setup_requires option of
# legacy setup.py by providing a crafted .pydistutils.cfg.
# 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,24 +1885,60 @@ def call_pip_wheel(spec, dest, options): ...@@ -1870,24 +1885,60 @@ 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
subprocess.check_call(args, env=env) 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')
entries = os.listdir(dest) args.extend(operand)
try:
assert len(entries) == 1, "Got multiple entries afer pip wheel"
wheel = entries[0]
assert os.path.splitext(wheel)[1] == '.whl', "Expected a .whl"
except AssertionError:
logger.error(
"No .whl after successful pip wheel of %s",
spec)
raise
return make_egg_after_pip_wheel(dest, wheel) 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)
finally: finally:
for f in cleanup: for f in cleanup:
f() 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)
try:
assert len(entries) == 1, "Got multiple entries afer pip wheel"
wheel = entries[0]
assert os.path.splitext(wheel)[1] == '.whl', "Expected a .whl"
except AssertionError:
logger.error("No .whl after successful pip wheel of %s", spec)
raise
return make_egg_after_pip_wheel(dest, wheel)
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)
assert len(os.listdir(dest)) == 2 assert len(os.listdir(dest)) == 2
......
...@@ -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():
......
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