diff --git a/poetry/console/commands/install.py b/poetry/console/commands/install.py index 8a820c3d585..d567dac4783 100644 --- a/poetry/console/commands/install.py +++ b/poetry/console/commands/install.py @@ -39,7 +39,6 @@ class InstallCommand(EnvCommand): _loggers = ["poetry.repositories.pypi_repository"] def handle(self): - from clikit.io import NullIO from poetry.installation.installer import Installer from poetry.masonry.builders import EditableBuilder from poetry.core.masonry.utils.module import ModuleOrPackageNotFound @@ -69,7 +68,7 @@ def handle(self): return 0 try: - builder = EditableBuilder(self.poetry, self._env, NullIO()) + builder = EditableBuilder(self.poetry, self._env, self._io) except ModuleOrPackageNotFound: # This is likely due to the fact that the project is an application # not following the structure expected by Poetry diff --git a/poetry/installation/pip_installer.py b/poetry/installation/pip_installer.py index 44c7ecc37ad..1b6b47f8069 100644 --- a/poetry/installation/pip_installer.py +++ b/poetry/installation/pip_installer.py @@ -175,9 +175,10 @@ def create_temporary_requirement(self, package): return name def install_directory(self, package): - from poetry.core.masonry.builder import SdistBuilder from poetry.factory import Factory + from poetry.io.null_io import NullIO from poetry.utils._compat import decode + from poetry.masonry.builders.editable import EditableBuilder from poetry.utils.toml_file import TomlFile if package.root_dir: @@ -197,18 +198,36 @@ def install_directory(self, package): "tool" in pyproject_content and "poetry" in pyproject_content["tool"] ) # Even if there is a build system specified - # pip as of right now does not support it fully - # TODO: Check for pip version when proper PEP-517 support lands - # has_build_system = ("build-system" in pyproject_content) + # some versions of pip (< 19.0.0) don't understand it + # so we need to check the version of pip to know + # if we can rely on the build system + pip_version = self._env.pip_version + pip_version_with_build_system_support = pip_version.__class__(19, 0, 0) + has_build_system = ( + "build-system" in pyproject_content + and pip_version >= pip_version_with_build_system_support + ) setup = os.path.join(req, "setup.py") has_setup = os.path.exists(setup) - if not has_setup and has_poetry and (package.develop or not has_build_system): - # We actually need to rely on creating a temporary setup.py - # file since pip, as of this comment, does not support - # build-system for editable packages + if has_poetry and package.develop and not package.build_script: + # This is a Poetry package in editable mode + # we can use the EditableBuilder without going through pip + # to install it, unless it has a build script. + builder = EditableBuilder( + Factory().create_poetry(pyproject.parent), self._env, NullIO() + ) + builder.build() + + return + elif has_poetry and (not has_build_system or package.build_script): + from poetry.core.masonry.builders.sdist import SdistBuilder + + # We need to rely on creating a temporary setup.py + # file since the version of pip does not support + # build-systems # We also need it for non-PEP-517 packages - builder = SdistBuilder(Factory().create_poetry(pyproject.parent),) + builder = SdistBuilder(Factory().create_poetry(pyproject.parent)) with open(setup, "w", encoding="utf-8") as f: f.write(decode(builder.build_setup())) diff --git a/poetry/masonry/builders/editable.py b/poetry/masonry/builders/editable.py index e6924c574ff..c568814dbd6 100644 --- a/poetry/masonry/builders/editable.py +++ b/poetry/masonry/builders/editable.py @@ -1,16 +1,32 @@ from __future__ import unicode_literals +import hashlib import os import shutil -from collections import defaultdict +from base64 import urlsafe_b64encode from poetry.core.masonry.builders.builder import Builder from poetry.core.masonry.builders.sdist import SdistBuilder from poetry.core.semver.version import Version +from poetry.utils._compat import WINDOWS +from poetry.utils._compat import Path from poetry.utils._compat import decode +SCRIPT_TEMPLATE = """\ +#!{python} +from {module} import {callable_} + +if __name__ == '__main__': + {callable_}() +""" + +WINDOWS_CMD_TEMPLATE = """\ +@echo off\r\n"{python}" "%~dp0\\{script}" %*\r\n +""" + + class EditableBuilder(Builder): def __init__(self, poetry, env, io): super(EditableBuilder, self).__init__(poetry) @@ -19,7 +35,22 @@ def __init__(self, poetry, env, io): self._io = io def build(self): - return self._setup_build() + self._debug( + " - Building package {} in editable mode".format( + self._package.name + ) + ) + + if self._package.build_script: + self._debug( + " - Falling back on using a setup.py" + ) + return self._setup_build() + + added_files = [] + added_files += self._add_pth() + added_files += self._add_scripts() + self._add_dist_info(added_files) def _setup_build(self): builder = SdistBuilder(self._poetry) @@ -36,14 +67,14 @@ def _setup_build(self): try: if self._env.pip_version < Version(19, 0): - self._env.run_pip("install", "-e", str(self._path)) + self._env.run_pip("install", "-e", str(self._path), "--no-deps") else: # Temporarily rename pyproject.toml shutil.move( str(self._poetry.file), str(self._poetry.file.with_suffix(".tmp")) ) try: - self._env.run_pip("install", "-e", str(self._path)) + self._env.run_pip("install", "-e", str(self._path), "--no-deps") finally: shutil.move( str(self._poetry.file.with_suffix(".tmp")), @@ -53,71 +84,125 @@ def _setup_build(self): if not has_setup: os.remove(str(setup)) - def _build_egg_info(self): - egg_info = self._path / "{}.egg-info".format( - self._package.name.replace("-", "_") + def _add_pth(self): + pth = self._env.site_packages.joinpath(self._module.name).with_suffix(".pth") + self._debug( + " - Adding {} to {} for {}".format( + pth.name, self._env.site_packages, self._poetry.file.parent + ) ) - egg_info.mkdir(exist_ok=True) + with pth.open("w", encoding="utf-8") as f: + f.write(decode(str(self._poetry.file.parent.resolve()))) + + return [pth] + + def _add_scripts(self): + added = [] + entry_points = self.convert_entry_points() + scripts_path = Path(self._env.paths["scripts"]) + + scripts = entry_points.get("console_scripts", []) + for script in scripts: + name, script = script.split(" = ") + module, callable_ = script.split(":") + script_file = scripts_path.joinpath(name) + self._debug( + " - Adding the {} script to {}".format( + name, scripts_path + ) + ) + with script_file.open("w", encoding="utf-8") as f: + f.write( + decode( + SCRIPT_TEMPLATE.format( + python=self._env._bin("python"), + module=module, + callable_=callable_, + ) + ) + ) + + script_file.chmod(0o755) + + added.append(script_file) + + if WINDOWS: + cmd_script = script_file.with_suffix(".cmd") + cmd = WINDOWS_CMD_TEMPLATE.format( + python=self._env._bin("python"), script=name + ) + self._debug( + " - Adding the {} script wrapper to {}".format( + cmd_script.name, scripts_path + ) + ) + + with cmd_script.open("w", encoding="utf-8") as f: + f.write(decode(cmd)) + + added.append(cmd_script) - with egg_info.joinpath("PKG-INFO").open("w", encoding="utf-8") as f: - f.write(decode(self.get_metadata_content())) + return added - with egg_info.joinpath("entry_points.txt").open("w", encoding="utf-8") as f: - entry_points = self.convert_entry_points() + def _add_dist_info(self, added_files): + from poetry.core.masonry.builders.wheel import WheelBuilder - for group_name in sorted(entry_points): - f.write("[{}]\n".format(group_name)) - for ep in sorted(entry_points[group_name]): - f.write(ep.replace(" ", "") + "\n") + added_files = added_files[:] - f.write("\n") + builder = WheelBuilder(self._poetry) + dist_info = self._env.site_packages.joinpath(builder.dist_info) + + self._debug( + " - Adding the {} directory to {}".format( + dist_info.name, self._env.site_packages + ) + ) - with egg_info.joinpath("requires.txt").open("w", encoding="utf-8") as f: - f.write(self._generate_requires()) + if dist_info.exists(): + shutil.rmtree(str(dist_info)) - def _build_egg_link(self): - egg_link = self._env.site_packages / "{}.egg-link".format(self._package.name) - with egg_link.open("w", encoding="utf-8") as f: - f.write(str(self._poetry.file.parent.resolve()) + "\n") - f.write(".") + dist_info.mkdir() - def _add_easy_install_entry(self): - easy_install_pth = self._env.site_packages / "easy-install.pth" - path = str(self._poetry.file.parent.resolve()) - content = "" - if easy_install_pth.exists(): - with easy_install_pth.open(encoding="utf-8") as f: - content = f.read() + with dist_info.joinpath("METADATA").open("w", encoding="utf-8") as f: + builder._write_metadata_file(f) - if path in content: - return + added_files.append(dist_info.joinpath("METADATA")) - content += "{}\n".format(path) + with dist_info.joinpath("INSTALLER").open("w", encoding="utf-8") as f: + f.write("poetry") - with easy_install_pth.open("w", encoding="utf-8") as f: - f.write(content) + added_files.append(dist_info.joinpath("INSTALLER")) - def _generate_requires(self): - extras = defaultdict(list) + if self.convert_entry_points(): + with dist_info.joinpath("entry_points.txt").open( + "w", encoding="utf-8" + ) as f: + builder._write_entry_points(f) - requires = "" - for dep in sorted(self._package.requires, key=lambda d: d.name): - marker = dep.marker - if marker.is_any(): - requires += "{}\n".format(dep.base_pep_508_name) - continue + added_files.append(dist_info.joinpath("entry_points.txt")) - extras[str(marker)].append(dep.base_pep_508_name) + with dist_info.joinpath("RECORD").open("w", encoding="utf-8") as f: + for path in added_files: + hash = self._get_file_hash(path) + size = path.stat().st_size + f.write("{},sha256={},{}\n".format(str(path), hash, size)) - if extras: - requires += "\n" + # RECORD itself is recorded with no hash or size + f.write("{},,\n".format(dist_info.joinpath("RECORD"))) - for marker, deps in sorted(extras.items()): - requires += "[:{}]\n".format(marker) + def _get_file_hash(self, filepath): + hashsum = hashlib.sha256() + with filepath.open("rb") as src: + while True: + buf = src.read(1024 * 8) + if not buf: + break + hashsum.update(buf) - for dep in deps: - requires += dep + "\n" + src.seek(0) - requires += "\n" + return urlsafe_b64encode(hashsum.digest()).decode("ascii").rstrip("=") - return requires + def _debug(self, msg): + if self._io.is_debug(): + self._io.write_line(msg) diff --git a/poetry/utils/env.py b/poetry/utils/env.py index c392eb69822..013483f7167 100644 --- a/poetry/utils/env.py +++ b/poetry/utils/env.py @@ -1156,6 +1156,9 @@ def __init__(self, path=None, base=None, execute=False): self._execute = execute self.executed = [] + def get_pip_command(self): # type: () -> List[str] + return [self._bin("python"), "-m", "pip"] + def _run(self, cmd, **kwargs): self.executed.append(cmd) diff --git a/tests/fixtures/extended_project/README.rst b/tests/fixtures/extended_project/README.rst new file mode 100644 index 00000000000..f7fe15470f9 --- /dev/null +++ b/tests/fixtures/extended_project/README.rst @@ -0,0 +1,2 @@ +My Package +========== diff --git a/tests/fixtures/extended_project/build.py b/tests/fixtures/extended_project/build.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/extended_project/extended_project/__init__.py b/tests/fixtures/extended_project/extended_project/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/extended_project/pyproject.toml b/tests/fixtures/extended_project/pyproject.toml new file mode 100644 index 00000000000..954b12b3497 --- /dev/null +++ b/tests/fixtures/extended_project/pyproject.toml @@ -0,0 +1,30 @@ +[tool.poetry] +name = "extended-project" +version = "1.2.3" +description = "Some description." +authors = [ + "Sébastien Eustace " +] +license = "MIT" + +readme = "README.rst" + +homepage = "https://python-poetry.org" +repository = "https://github.com/python-poetry/poetry" +documentation = "https://python-poetry.org/docs" + +keywords = ["packaging", "dependency", "poetry"] + +classifiers = [ + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules" +] + +build = "build.py" + +# Requirements +[tool.poetry.dependencies] +python = "~2.7 || ^3.4" + +[tool.poetry.scripts] +foo = "foo:bar" diff --git a/tests/fixtures/simple_project/pyproject.toml b/tests/fixtures/simple_project/pyproject.toml index 72d0c0beeda..a20fddd7dac 100644 --- a/tests/fixtures/simple_project/pyproject.toml +++ b/tests/fixtures/simple_project/pyproject.toml @@ -23,3 +23,6 @@ classifiers = [ # Requirements [tool.poetry.dependencies] python = "~2.7 || ^3.4" + +[tool.poetry.scripts] +foo = "foo:bar" diff --git a/tests/masonry/builders/test_editable_builder.py b/tests/masonry/builders/test_editable_builder.py new file mode 100644 index 00000000000..8d8b4356521 --- /dev/null +++ b/tests/masonry/builders/test_editable_builder.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import shutil + +import pytest + +from poetry.factory import Factory +from poetry.io.null_io import NullIO +from poetry.masonry.builders.editable import EditableBuilder +from poetry.utils._compat import Path +from poetry.utils.env import EnvManager +from poetry.utils.env import MockEnv +from poetry.utils.env import VirtualEnv + + +@pytest.fixture() +def simple_poetry(): + poetry = Factory().create_poetry( + Path(__file__).parent.parent.parent / "fixtures" / "simple_project" + ) + + return poetry + + +@pytest.fixture() +def extended_poetry(): + poetry = Factory().create_poetry( + Path(__file__).parent.parent.parent / "fixtures" / "extended_project" + ) + + return poetry + + +@pytest.fixture() +def env_manager(simple_poetry): + return EnvManager(simple_poetry) + + +@pytest.fixture +def tmp_venv(tmp_dir, env_manager): + venv_path = Path(tmp_dir) / "venv" + + env_manager.build_venv(str(venv_path)) + + venv = VirtualEnv(venv_path) + yield venv + + shutil.rmtree(str(venv.path)) + + +def test_builder_installs_proper_files_for_standard_packages(simple_poetry, tmp_venv): + builder = EditableBuilder(simple_poetry, tmp_venv, NullIO()) + + builder.build() + + assert tmp_venv._bin_dir.joinpath("foo").exists() + assert tmp_venv.site_packages.joinpath("simple_project.pth").exists() + assert ( + str(simple_poetry.file.parent.resolve()) + == tmp_venv.site_packages.joinpath("simple_project.pth").read_text() + ) + + dist_info = tmp_venv.site_packages.joinpath("simple_project-1.2.3.dist-info") + assert dist_info.exists() + assert dist_info.joinpath("INSTALLER").exists() + assert dist_info.joinpath("METADATA").exists() + assert dist_info.joinpath("RECORD").exists() + assert dist_info.joinpath("entry_points.txt").exists() + + assert "poetry" == dist_info.joinpath("INSTALLER").read_text() + assert ( + "[console_scripts]\nfoo=foo:bar\n\n" + == dist_info.joinpath("entry_points.txt").read_text() + ) + + metadata = """\ +Metadata-Version: 2.1 +Name: simple-project +Version: 1.2.3 +Summary: Some description. +Home-page: https://python-poetry.org +License: MIT +Keywords: packaging,dependency,poetry +Author: Sébastien Eustace +Author-email: sebastien@eustace.io +Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Topic :: Software Development :: Build Tools +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Project-URL: Documentation, https://python-poetry.org/docs +Project-URL: Repository, https://github.com/python-poetry/poetry +Description-Content-Type: text/x-rst + +My Package +========== + +""" + assert metadata == dist_info.joinpath("METADATA").read_text(encoding="utf-8") + + records = dist_info.joinpath("RECORD").read_text() + assert str(tmp_venv.site_packages.joinpath("simple_project.pth")) in records + assert str(tmp_venv._bin_dir.joinpath("foo")) in records + assert str(dist_info.joinpath("METADATA")) in records + assert str(dist_info.joinpath("INSTALLER")) in records + assert str(dist_info.joinpath("entry_points.txt")) in records + assert str(dist_info.joinpath("RECORD")) in records + + +def test_builder_falls_back_on_setup_and_pip_for_packages_with_build_scripts( + extended_poetry, +): + env = MockEnv(path=Path("/foo")) + builder = EditableBuilder(extended_poetry, env, NullIO()) + + builder.build() + + assert [ + [ + "python", + "-m", + "pip", + "install", + "-e", + str(extended_poetry.file.parent), + "--no-deps", + ] + ] == env.executed