Commit 6eef349c by Sébastien Eustace Committed by Randy Döring

Use the wheel builder to build editable wheels

parent ab087af1
...@@ -94,18 +94,22 @@ class Chef: ...@@ -94,18 +94,22 @@ class Chef:
Path(config.get("cache-dir")).expanduser().joinpath("artifacts") Path(config.get("cache-dir")).expanduser().joinpath("artifacts")
) )
def prepare(self, archive: Path, output_dir: Path | None = None) -> Path: def prepare(
self, archive: Path, output_dir: Path | None = None, *, editable: bool = False
) -> Path:
if not self._should_prepare(archive): if not self._should_prepare(archive):
return archive return archive
if archive.is_dir(): if archive.is_dir():
tmp_dir = tempfile.mkdtemp(prefix="poetry-chef-") tmp_dir = tempfile.mkdtemp(prefix="poetry-chef-")
return self._prepare(archive, Path(tmp_dir)) return self._prepare(archive, Path(tmp_dir), editable=editable)
return self._prepare_sdist(archive, destination=output_dir) return self._prepare_sdist(archive, destination=output_dir)
def _prepare(self, directory: Path, destination: Path) -> Path: def _prepare(
self, directory: Path, destination: Path, *, editable: bool = False
) -> Path:
with ephemeral_environment(self._env.python) as venv: with ephemeral_environment(self._env.python) as venv:
env = IsolatedEnv(venv, self._config) env = IsolatedEnv(venv, self._config)
builder = ProjectBuilder( builder = ProjectBuilder(
...@@ -124,7 +128,7 @@ class Chef: ...@@ -124,7 +128,7 @@ class Chef:
try: try:
return Path( return Path(
builder.build( builder.build(
"wheel", "wheel" if not editable else "editable",
destination.as_posix(), destination.as_posix(),
) )
) )
......
...@@ -477,17 +477,18 @@ class Executor: ...@@ -477,17 +477,18 @@ class Executor:
def _install(self, operation: Install | Update) -> int: def _install(self, operation: Install | Update) -> int:
package = operation.package package = operation.package
if package.source_type == "directory": if package.source_type == "directory" and not self._use_wheel_installer:
if not self._use_wheel_installer: return self._install_directory_without_wheel_installer(operation)
return self._install_directory_without_wheel_installer(operation)
return self._install_directory(operation)
cleanup_archive: bool = False
if package.source_type == "git": if package.source_type == "git":
return self._install_git(operation) archive = self._prepare_git_archive(operation)
cleanup_archive = True
if package.source_type == "file": elif package.source_type == "file":
archive = self._prepare_archive(operation) archive = self._prepare_archive(operation)
elif package.source_type == "directory":
archive = self._prepare_directory_archive(operation)
cleanup_archive = True
elif package.source_type == "url": elif package.source_type == "url":
assert package.source_url is not None assert package.source_url is not None
archive = self._download_link(operation, Link(package.source_url)) archive = self._download_link(operation, Link(package.source_url))
...@@ -504,7 +505,18 @@ class Executor: ...@@ -504,7 +505,18 @@ class Executor:
if not self._use_wheel_installer: if not self._use_wheel_installer:
return self.pip_install(archive, upgrade=operation.job_type == "update") return self.pip_install(archive, upgrade=operation.job_type == "update")
self._wheel_installer.install(archive) try:
if operation.job_type == "update":
# Uninstall first
# TODO: Make an uninstaller and find a way to rollback in case
# the new package can't be installed
assert isinstance(operation, Update)
self._remove(operation.initial_package)
self._wheel_installer.install(archive)
finally:
if cleanup_archive:
archive.unlink()
return 0 return 0
...@@ -541,9 +553,9 @@ class Executor: ...@@ -541,9 +553,9 @@ class Executor:
if not Path(package.source_url).is_absolute() and package.root_dir: if not Path(package.source_url).is_absolute() and package.root_dir:
archive = package.root_dir / archive archive = package.root_dir / archive
return self._chef.prepare(archive) return self._chef.prepare(archive, editable=package.develop)
def _install_directory(self, operation: Install | Update) -> int: def _prepare_directory_archive(self, operation: Install | Update) -> Path:
package = operation.package package = operation.package
operation_message = self.get_operation_message(operation) operation_message = self.get_operation_message(operation)
...@@ -562,27 +574,35 @@ class Executor: ...@@ -562,27 +574,35 @@ class Executor:
if package.source_subdirectory: if package.source_subdirectory:
req /= package.source_subdirectory req /= package.source_subdirectory
if package.develop: return self._prepare_archive(operation)
# Editable installations are currently not supported
# for PEP-517 build systems so we defer to pip.
# TODO: Remove this workaround once either PEP-660 or PEP-662 is accepted
return self.pip_install(req, editable=True)
archive = self._prepare_archive(operation) def _prepare_git_archive(self, operation: Install | Update) -> Path:
from poetry.vcs.git import Git
try: package = operation.package
if operation.job_type == "update": operation_message = self.get_operation_message(operation)
# Uninstall first
# TODO: Make an uninstaller and find a way to rollback in case
# the new package can't be installed
assert isinstance(operation, Update)
self._remove(operation.initial_package)
self._wheel_installer.install(archive) message = (
finally: f" <fg=blue;options=bold>•</> {operation_message}: <info>Cloning...</info>"
archive.unlink() )
self._write(operation, message)
return 0 assert package.source_url is not None
source = Git.clone(
url=package.source_url,
source_root=self._env.path / "src",
revision=package.source_resolved_reference or package.source_reference,
)
# Now we just need to install from the source directory
original_url = package.source_url
package._source_url = str(source.path)
archive = self._prepare_directory_archive(operation)
package._source_url = original_url
return archive
def _install_directory_without_wheel_installer( def _install_directory_without_wheel_installer(
self, operation: Install | Update self, operation: Install | Update
...@@ -650,34 +670,6 @@ class Executor: ...@@ -650,34 +670,6 @@ class Executor:
return self.pip_install(req, upgrade=True, editable=package.develop) return self.pip_install(req, upgrade=True, editable=package.develop)
def _install_git(self, operation: Install | Update) -> int:
from poetry.vcs.git import Git
package = operation.package
operation_message = self.get_operation_message(operation)
message = (
f" <fg=blue;options=bold>•</> {operation_message}: <info>Cloning...</info>"
)
self._write(operation, message)
assert package.source_url is not None
source = Git.clone(
url=package.source_url,
source_root=self._env.path / "src",
revision=package.source_resolved_reference or package.source_reference,
)
# Now we just need to install from the source directory
original_url = package.source_url
package._source_url = str(source.path)
status_code = self._install_directory(operation)
package._source_url = original_url
return status_code
def _download(self, operation: Install | Update) -> Path: def _download(self, operation: Install | Update) -> Path:
link = self._chooser.choose_for(operation.package) link = self._chooser.choose_for(operation.package)
......
...@@ -2,6 +2,7 @@ from __future__ import annotations ...@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zipfile import ZipFile
import pytest import pytest
...@@ -165,3 +166,16 @@ def test_prepare_directory_with_extensions( ...@@ -165,3 +166,16 @@ def test_prepare_directory_with_extensions(
wheel = chef.prepare(archive) wheel = chef.prepare(archive)
assert wheel.name == f"extended-0.1-{env.supported_tags[0]}.whl" assert wheel.name == f"extended-0.1-{env.supported_tags[0]}.whl"
def test_prepare_directory_editable(config: Config, config_cache_dir: Path):
chef = Chef(config, EnvManager.get_system_env())
archive = Path(__file__).parent.parent.joinpath("fixtures/simple_project").resolve()
wheel = chef.prepare(archive, editable=True)
assert wheel.name == "simple_project-1.2.3-py2.py3-none-any.whl"
with ZipFile(wheel) as z:
assert "simple_project.pth" in z.namelist()
...@@ -4,16 +4,19 @@ import csv ...@@ -4,16 +4,19 @@ import csv
import json import json
import re import re
import shutil import shutil
import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Callable
from urllib.parse import urlparse from urllib.parse import urlparse
import pytest import pytest
from cleo.formatters.style import Style from cleo.formatters.style import Style
from cleo.io.buffered_io import BufferedIO from cleo.io.buffered_io import BufferedIO
from cleo.io.outputs.output import Verbosity
from poetry.core.packages.package import Package from poetry.core.packages.package import Package
from poetry.core.packages.utils.link import Link from poetry.core.packages.utils.link import Link
...@@ -41,26 +44,40 @@ if TYPE_CHECKING: ...@@ -41,26 +44,40 @@ if TYPE_CHECKING:
class Chef(BaseChef): class Chef(BaseChef):
_directory_wheel = None _directory_wheels: list[Path] | None = None
_sdist_wheel = None _sdist_wheels: list[Path] | None = None
def set_directory_wheel(self, wheel: Path) -> None: def set_directory_wheel(self, wheels: Path | list[Path]) -> None:
self._directory_wheel = wheel if not isinstance(wheels, list):
wheels = [wheels]
def set_sdist_wheel(self, wheel: Path) -> None: self._directory_wheels = wheels
self._sdist_wheel = wheel
def set_sdist_wheel(self, wheels: Path | list[Path]) -> None:
if not isinstance(wheels, list):
wheels = [wheels]
self._sdist_wheels = wheels
def _prepare_sdist(self, archive: Path, destination: Path | None = None) -> Path: def _prepare_sdist(self, archive: Path, destination: Path | None = None) -> Path:
if self._sdist_wheel is not None: if self._sdist_wheels is not None:
return self._sdist_wheel wheel = self._sdist_wheels.pop(0)
self._sdist_wheels.append(wheel)
return wheel
return super()._prepare_sdist(archive) return super()._prepare_sdist(archive)
def _prepare(self, directory: Path, destination: Path) -> Path: def _prepare(
if self._directory_wheel is not None: self, directory: Path, destination: Path, *, editable: bool = False
return self._directory_wheel ) -> Path:
if self._directory_wheels is not None:
wheel = self._directory_wheels.pop(0)
self._directory_wheels.append(wheel)
return super()._prepare(directory, destination) return wheel
return super()._prepare(directory, destination, editable=editable)
@pytest.fixture @pytest.fixture
...@@ -132,17 +149,38 @@ def mock_file_downloads(http: type[httpretty.httpretty]) -> None: ...@@ -132,17 +149,38 @@ def mock_file_downloads(http: type[httpretty.httpretty]) -> None:
@pytest.fixture() @pytest.fixture()
def wheel(tmp_dir: Path) -> Path: def copy_wheel(tmp_dir: Path) -> Callable[[], Path]:
shutil.copyfile( def _copy_wheel() -> Path:
Path(__file__) tmp_name = tempfile.mktemp()
.parent.parent.joinpath( Path(tmp_dir).joinpath(tmp_name).mkdir()
"fixtures/distributions/demo-0.1.2-py2.py3-none-any.whl"
shutil.copyfile(
Path(__file__)
.parent.parent.joinpath(
"fixtures/distributions/demo-0.1.2-py2.py3-none-any.whl"
)
.as_posix(),
Path(tmp_dir)
.joinpath(tmp_name)
.joinpath("demo-0.1.2-py2.py3-none-any.whl")
.as_posix(),
)
return (
Path(tmp_dir).joinpath(tmp_name).joinpath("demo-0.1.2-py2.py3-none-any.whl")
) )
.as_posix(),
Path(tmp_dir).joinpath("demo-0.1.2-py2.py3-none-any.whl").as_posix(),
)
return Path(tmp_dir).joinpath("demo-0.1.2-py2.py3-none-any.whl") return _copy_wheel
@pytest.fixture()
def wheel(copy_wheel: Callable[[], Path]) -> Path:
archive = copy_wheel()
yield archive
if archive.exists():
archive.unlink()
def test_execute_executes_a_batch_of_operations( def test_execute_executes_a_batch_of_operations(
...@@ -153,15 +191,18 @@ def test_execute_executes_a_batch_of_operations( ...@@ -153,15 +191,18 @@ def test_execute_executes_a_batch_of_operations(
tmp_dir: str, tmp_dir: str,
mock_file_downloads: None, mock_file_downloads: None,
env: MockEnv, env: MockEnv,
wheel: Path, copy_wheel: Callable[[], Path],
): ):
wheel_install = mocker.patch.object(WheelInstaller, "install") wheel_install = mocker.patch.object(WheelInstaller, "install")
config.merge({"cache-dir": tmp_dir}) config.merge({"cache-dir": tmp_dir})
prepare_spy = mocker.spy(Chef, "_prepare")
chef = Chef(config, env) chef = Chef(config, env)
chef.set_directory_wheel(wheel) chef.set_directory_wheel([copy_wheel(), copy_wheel()])
chef.set_sdist_wheel(wheel) chef.set_sdist_wheel(copy_wheel())
io.set_verbosity(Verbosity.VERY_VERBOSE)
executor = Executor(env, pool, config, io) executor = Executor(env, pool, config, io)
executor._chef = chef executor._chef = chef
...@@ -223,11 +264,17 @@ Package operations: 4 installs, 1 update, 1 removal ...@@ -223,11 +264,17 @@ Package operations: 4 installs, 1 update, 1 removal
expected = set(expected.splitlines()) expected = set(expected.splitlines())
output = set(io.fetch_output().splitlines()) output = set(io.fetch_output().splitlines())
assert output == expected assert output == expected
assert wheel_install.call_count == 4 assert wheel_install.call_count == 5
# One pip uninstall and one pip editable install # Two pip uninstalls: one for the remove operation one for the update operation
assert len(env.executed) == 2 assert len(env.executed) == 2
assert return_code == 0 assert return_code == 0
assert prepare_spy.call_count == 2
assert prepare_spy.call_args_list == [
mocker.call(chef, mocker.ANY, mocker.ANY, editable=False),
mocker.call(chef, mocker.ANY, mocker.ANY, editable=True),
]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"operations, has_warning", "operations, has_warning",
......
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