Commit 26fbfd67 by quantum-byte Committed by GitHub

installer: fix handling of sdist hashes (#7594)

* fix hash mismatch during installation with `no-binary` option for packages that provide suitable wheels
* do not skip hash check for sdists
* write hash for sdists in direct_url.json
parent f27308fa
...@@ -101,7 +101,6 @@ class Chef: ...@@ -101,7 +101,6 @@ class Chef:
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), editable=editable) 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)
...@@ -198,13 +197,19 @@ class Chef: ...@@ -198,13 +197,19 @@ class Chef:
def _is_wheel(cls, archive: Path) -> bool: def _is_wheel(cls, archive: Path) -> bool:
return archive.suffix == ".whl" return archive.suffix == ".whl"
def get_cached_archive_for_link(self, link: Link) -> Path | None: def get_cached_archive_for_link(self, link: Link, *, strict: bool) -> Path | None:
archives = self.get_cached_archives_for_link(link) archives = self.get_cached_archives_for_link(link)
if not archives: if not archives:
return None return None
candidates: list[tuple[float | None, Path]] = [] candidates: list[tuple[float | None, Path]] = []
for archive in archives: for archive in archives:
if strict:
# in strict mode return the original cached archive instead of the
# prioritized archive type.
if link.filename == archive.name:
return archive
continue
if archive.suffix != ".whl": if archive.suffix != ".whl":
candidates.append((float("inf"), archive)) candidates.append((float("inf"), archive))
continue continue
......
...@@ -693,11 +693,12 @@ class Executor: ...@@ -693,11 +693,12 @@ class Executor:
package = operation.package package = operation.package
output_dir = self._chef.get_cache_directory_for_link(link) output_dir = self._chef.get_cache_directory_for_link(link)
archive = self._chef.get_cached_archive_for_link(link) # Try to get cached original package for the link provided
if archive is None: original_archive = self._chef.get_cached_archive_for_link(link, strict=True)
# No cached distributions was found, so we download and prepare it if original_archive is None:
# No cached original distributions was found, so we download and prepare it
try: try:
archive = self._download_archive(operation, link) original_archive = self._download_archive(operation, link)
except BaseException: except BaseException:
cache_directory = self._chef.get_cache_directory_for_link(link) cache_directory = self._chef.get_cache_directory_for_link(link)
cached_file = cache_directory.joinpath(link.filename) cached_file = cache_directory.joinpath(link.filename)
...@@ -708,6 +709,13 @@ class Executor: ...@@ -708,6 +709,13 @@ class Executor:
raise raise
# Get potential higher prioritized cached archive, otherwise it will fall back
# to the original archive.
archive = self._chef.get_cached_archive_for_link(link, strict=False)
# 'archive' can at this point never be None. Since we previously downloaded
# an archive, we now should have something cached that we can use here
assert archive is not None
if archive.suffix != ".whl": if archive.suffix != ".whl":
message = ( message = (
f" <fg=blue;options=bold>•</> {self.get_operation_message(operation)}:" f" <fg=blue;options=bold>•</> {self.get_operation_message(operation)}:"
...@@ -717,7 +725,8 @@ class Executor: ...@@ -717,7 +725,8 @@ class Executor:
archive = self._chef.prepare(archive, output_dir=output_dir) archive = self._chef.prepare(archive, output_dir=output_dir)
self._populate_hashes_dict(archive, package) # Use the original archive to provide the correct hash.
self._populate_hashes_dict(original_archive, package)
return archive return archive
...@@ -729,7 +738,7 @@ class Executor: ...@@ -729,7 +738,7 @@ class Executor:
@staticmethod @staticmethod
def _validate_archive_hash(archive: Path, package: Package) -> str: def _validate_archive_hash(archive: Path, package: Package) -> str:
archive_hash: str = "sha256:" + get_file_hash(archive) archive_hash: str = "sha256:" + get_file_hash(archive)
known_hashes = {f["hash"] for f in package.files} known_hashes = {f["hash"] for f in package.files if f["file"] == archive.name}
if archive_hash not in known_hashes: if archive_hash not in known_hashes:
raise RuntimeError( raise RuntimeError(
......
from __future__ import annotations from __future__ import annotations
import os
import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zipfile import ZipFile from zipfile import ZipFile
...@@ -38,20 +41,80 @@ def setup(mocker: MockerFixture, pool: RepositoryPool) -> None: ...@@ -38,20 +41,80 @@ def setup(mocker: MockerFixture, pool: RepositoryPool) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("link", "cached"), ("link", "strict", "available_packages"),
[
(
"https://files.python-poetry.org/demo-0.1.0.tar.gz",
True,
[
Path("/cache/demo-0.1.0-py2.py3-none-any"),
Path("/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl"),
Path("/cache/demo-0.1.0-cp37-cp37-macosx_10_15_x86_64.whl"),
],
),
(
"https://example.com/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
False,
[],
),
],
)
def test_get_not_found_cached_archive_for_link(
config: Config,
mocker: MockerFixture,
link: str,
strict: bool,
available_packages: list[Path],
):
chef = Chef(
config,
MockEnv(
version_info=(3, 8, 3),
marker_env={"interpreter_name": "cpython", "interpreter_version": "3.8.3"},
supported_tags=[
Tag("cp38", "cp38", "macosx_10_15_x86_64"),
Tag("py3", "none", "any"),
],
),
Factory.create_pool(config),
)
mocker.patch.object(
chef, "get_cached_archives_for_link", return_value=available_packages
)
archive = chef.get_cached_archive_for_link(Link(link), strict=strict)
assert archive is None
@pytest.mark.parametrize(
("link", "cached", "strict"),
[ [
( (
"https://files.python-poetry.org/demo-0.1.0.tar.gz", "https://files.python-poetry.org/demo-0.1.0.tar.gz",
"/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl", "/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
False,
), ),
( (
"https://example.com/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl", "https://example.com/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
"/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl", "/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
False,
),
(
"https://files.python-poetry.org/demo-0.1.0.tar.gz",
"/cache/demo-0.1.0.tar.gz",
True,
),
(
"https://example.com/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
"/cache/demo-0.1.0-cp38-cp38-macosx_10_15_x86_64.whl",
True,
), ),
], ],
) )
def test_get_cached_archive_for_link( def test_get_found_cached_archive_for_link(
config: Config, mocker: MockerFixture, link: str, cached: str config: Config, mocker: MockerFixture, link: str, cached: str, strict: bool
): ):
chef = Chef( chef = Chef(
config, config,
...@@ -77,7 +140,7 @@ def test_get_cached_archive_for_link( ...@@ -77,7 +140,7 @@ def test_get_cached_archive_for_link(
], ],
) )
archive = chef.get_cached_archive_for_link(Link(link)) archive = chef.get_cached_archive_for_link(Link(link), strict=strict)
assert Path(cached) == archive assert Path(cached) == archive
...@@ -153,6 +216,10 @@ def test_prepare_directory(config: Config, config_cache_dir: Path): ...@@ -153,6 +216,10 @@ def test_prepare_directory(config: Config, config_cache_dir: Path):
assert wheel.name == "simple_project-1.2.3-py2.py3-none-any.whl" assert wheel.name == "simple_project-1.2.3-py2.py3-none-any.whl"
assert wheel.parent.parent == Path(tempfile.gettempdir())
# cleanup generated tmp dir artifact
os.unlink(wheel)
def test_prepare_directory_with_extensions( def test_prepare_directory_with_extensions(
config: Config, config_cache_dir: Path config: Config, config_cache_dir: Path
...@@ -168,8 +235,12 @@ def test_prepare_directory_with_extensions( ...@@ -168,8 +235,12 @@ def test_prepare_directory_with_extensions(
wheel = chef.prepare(archive) wheel = chef.prepare(archive)
assert wheel.parent.parent == Path(tempfile.gettempdir())
assert wheel.name == f"extended-0.1-{env.supported_tags[0]}.whl" assert wheel.name == f"extended-0.1-{env.supported_tags[0]}.whl"
# cleanup generated tmp dir artifact
os.unlink(wheel)
def test_prepare_directory_editable(config: Config, config_cache_dir: Path): def test_prepare_directory_editable(config: Config, config_cache_dir: Path):
chef = Chef(config, EnvManager.get_system_env(), Factory.create_pool(config)) chef = Chef(config, EnvManager.get_system_env(), Factory.create_pool(config))
...@@ -178,7 +249,11 @@ def test_prepare_directory_editable(config: Config, config_cache_dir: Path): ...@@ -178,7 +249,11 @@ def test_prepare_directory_editable(config: Config, config_cache_dir: Path):
wheel = chef.prepare(archive, editable=True) wheel = chef.prepare(archive, editable=True)
assert wheel.parent.parent == Path(tempfile.gettempdir())
assert wheel.name == "simple_project-1.2.3-py2.py3-none-any.whl" assert wheel.name == "simple_project-1.2.3-py2.py3-none-any.whl"
with ZipFile(wheel) as z: with ZipFile(wheel) as z:
assert "simple_project.pth" in z.namelist() assert "simple_project.pth" in z.namelist()
# cleanup generated tmp dir artifact
os.unlink(wheel)
...@@ -139,9 +139,14 @@ def mock_file_downloads(http: type[httpretty.httpretty]) -> None: ...@@ -139,9 +139,14 @@ def mock_file_downloads(http: type[httpretty.httpretty]) -> None:
) )
if not fixture.exists(): if not fixture.exists():
fixture = Path(__file__).parent.parent.joinpath( if name == "demo-0.1.0.tar.gz":
"fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl" fixture = Path(__file__).parent.parent.joinpath(
) "fixtures/distributions/demo-0.1.0.tar.gz"
)
else:
fixture = Path(__file__).parent.parent.joinpath(
"fixtures/distributions/demo-0.1.0-py2.py3-none-any.whl"
)
return [200, headers, fixture.read_bytes()] return [200, headers, fixture.read_bytes()]
...@@ -533,7 +538,7 @@ def test_executor_should_delete_incomplete_downloads( ...@@ -533,7 +538,7 @@ def test_executor_should_delete_incomplete_downloads(
) )
mocker.patch( mocker.patch(
"poetry.installation.chef.Chef.get_cached_archive_for_link", "poetry.installation.chef.Chef.get_cached_archive_for_link",
side_effect=lambda link: None, side_effect=lambda link, strict: None,
) )
mocker.patch( mocker.patch(
"poetry.installation.chef.Chef.get_cache_directory_for_link", "poetry.installation.chef.Chef.get_cache_directory_for_link",
...@@ -601,6 +606,12 @@ def test_executor_should_not_write_pep610_url_references_for_cached_package( ...@@ -601,6 +606,12 @@ def test_executor_should_not_write_pep610_url_references_for_cached_package(
io: BufferedIO, io: BufferedIO,
): ):
link_cached = fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl" link_cached = fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl"
package.files = [
{
"file": "demo-0.1.0-py2.py3-none-any.whl",
"hash": "sha256:70e704135718fffbcbf61ed1fc45933cfd86951a744b681000eaaa75da31f17a", # noqa: E501
}
]
mocker.patch( mocker.patch(
"poetry.installation.executor.Executor._download", return_value=link_cached "poetry.installation.executor.Executor._download", return_value=link_cached
...@@ -611,7 +622,7 @@ def test_executor_should_not_write_pep610_url_references_for_cached_package( ...@@ -611,7 +622,7 @@ def test_executor_should_not_write_pep610_url_references_for_cached_package(
verify_installed_distribution(tmp_venv, package) verify_installed_distribution(tmp_venv, package)
def test_executor_should_write_pep610_url_references_for_files( def test_executor_should_write_pep610_url_references_for_wheel_files(
tmp_venv: VirtualEnv, pool: RepositoryPool, config: Config, io: BufferedIO tmp_venv: VirtualEnv, pool: RepositoryPool, config: Config, io: BufferedIO
): ):
url = ( url = (
...@@ -645,6 +656,38 @@ def test_executor_should_write_pep610_url_references_for_files( ...@@ -645,6 +656,38 @@ def test_executor_should_write_pep610_url_references_for_files(
verify_installed_distribution(tmp_venv, package, expected_url_reference) verify_installed_distribution(tmp_venv, package, expected_url_reference)
def test_executor_should_write_pep610_url_references_for_non_wheel_files(
tmp_venv: VirtualEnv, pool: RepositoryPool, config: Config, io: BufferedIO
):
url = (
Path(__file__)
.parent.parent.joinpath("fixtures/distributions/demo-0.1.0.tar.gz")
.resolve()
)
package = Package("demo", "0.1.0", source_type="file", source_url=url.as_posix())
# Set package.files so the executor will attempt to hash the package
package.files = [
{
"file": "demo-0.1.0.tar.gz",
"hash": "sha256:9fa123ad707a5c6c944743bf3e11a0e80d86cb518d3cf25320866ca3ef43e2ad", # noqa: E501
}
]
executor = Executor(tmp_venv, pool, config, io)
executor.execute([Install(package)])
expected_url_reference = {
"archive_info": {
"hashes": {
"sha256": (
"9fa123ad707a5c6c944743bf3e11a0e80d86cb518d3cf25320866ca3ef43e2ad"
)
},
},
"url": url.as_uri(),
}
verify_installed_distribution(tmp_venv, package, expected_url_reference)
def test_executor_should_write_pep610_url_references_for_directories( def test_executor_should_write_pep610_url_references_for_directories(
tmp_venv: VirtualEnv, tmp_venv: VirtualEnv,
pool: RepositoryPool, pool: RepositoryPool,
...@@ -703,13 +746,25 @@ def test_executor_should_write_pep610_url_references_for_editable_directories( ...@@ -703,13 +746,25 @@ def test_executor_should_write_pep610_url_references_for_editable_directories(
) )
def test_executor_should_write_pep610_url_references_for_urls( @pytest.mark.parametrize("is_artifact_cached", [False, True])
def test_executor_should_write_pep610_url_references_for_wheel_urls(
tmp_venv: VirtualEnv, tmp_venv: VirtualEnv,
pool: RepositoryPool, pool: RepositoryPool,
config: Config, config: Config,
io: BufferedIO, io: BufferedIO,
mock_file_downloads: None, mock_file_downloads: None,
mocker: MockerFixture,
fixture_dir: FixtureDirGetter,
is_artifact_cached: bool,
): ):
if is_artifact_cached:
link_cached = fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl"
mocker.patch(
"poetry.installation.chef.Chef.get_cached_archive_for_link",
return_value=link_cached,
)
download_spy = mocker.spy(Executor, "_download_archive")
package = Package( package = Package(
"demo", "demo",
"0.1.0", "0.1.0",
...@@ -725,7 +780,8 @@ def test_executor_should_write_pep610_url_references_for_urls( ...@@ -725,7 +780,8 @@ def test_executor_should_write_pep610_url_references_for_urls(
] ]
executor = Executor(tmp_venv, pool, config, io) executor = Executor(tmp_venv, pool, config, io)
executor.execute([Install(package)]) operation = Install(package)
executor.execute([operation])
expected_url_reference = { expected_url_reference = {
"archive_info": { "archive_info": {
"hashes": { "hashes": {
...@@ -737,6 +793,104 @@ def test_executor_should_write_pep610_url_references_for_urls( ...@@ -737,6 +793,104 @@ def test_executor_should_write_pep610_url_references_for_urls(
"url": package.source_url, "url": package.source_url,
} }
verify_installed_distribution(tmp_venv, package, expected_url_reference) verify_installed_distribution(tmp_venv, package, expected_url_reference)
if is_artifact_cached:
download_spy.assert_not_called()
else:
download_spy.assert_called_once_with(
mocker.ANY, operation, Link(package.source_url)
)
@pytest.mark.parametrize(
(
"is_sdist_cached",
"is_wheel_cached",
"expect_artifact_building",
"expect_artifact_download",
),
[
(True, False, True, False),
(True, True, False, False),
(False, False, True, True),
(False, True, False, True),
],
)
def test_executor_should_write_pep610_url_references_for_non_wheel_urls(
tmp_venv: VirtualEnv,
pool: RepositoryPool,
config: Config,
io: BufferedIO,
mock_file_downloads: None,
mocker: MockerFixture,
fixture_dir: FixtureDirGetter,
is_sdist_cached: bool,
is_wheel_cached: bool,
expect_artifact_building: bool,
expect_artifact_download: bool,
):
built_wheel = fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl"
mock_prepare = mocker.patch(
"poetry.installation.chef.Chef._prepare",
return_value=built_wheel,
)
download_spy = mocker.spy(Executor, "_download_archive")
if is_sdist_cached | is_wheel_cached:
cached_sdist = fixture_dir("distributions") / "demo-0.1.0.tar.gz"
cached_wheel = fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl"
def mock_get_cached_archive_for_link_func(_: Link, strict: bool):
if is_wheel_cached and not strict:
return cached_wheel
if is_sdist_cached:
return cached_sdist
return None
mocker.patch(
"poetry.installation.chef.Chef.get_cached_archive_for_link",
side_effect=mock_get_cached_archive_for_link_func,
)
package = Package(
"demo",
"0.1.0",
source_type="url",
source_url="https://files.pythonhosted.org/demo-0.1.0.tar.gz",
)
# Set package.files so the executor will attempt to hash the package
package.files = [
{
"file": "demo-0.1.0.tar.gz",
"hash": "sha256:9fa123ad707a5c6c944743bf3e11a0e80d86cb518d3cf25320866ca3ef43e2ad", # noqa: E501
}
]
executor = Executor(tmp_venv, pool, config, io)
operation = Install(package)
executor.execute([operation])
expected_url_reference = {
"archive_info": {
"hashes": {
"sha256": (
"9fa123ad707a5c6c944743bf3e11a0e80d86cb518d3cf25320866ca3ef43e2ad"
)
},
},
"url": package.source_url,
}
verify_installed_distribution(tmp_venv, package, expected_url_reference)
if expect_artifact_building:
mock_prepare.assert_called_once()
else:
mock_prepare.assert_not_called()
if expect_artifact_download:
download_spy.assert_called_once_with(
mocker.ANY, operation, Link(package.source_url)
)
else:
download_spy.assert_not_called()
def test_executor_should_write_pep610_url_references_for_git( def test_executor_should_write_pep610_url_references_for_git(
...@@ -846,38 +1000,6 @@ def test_executor_should_write_pep610_url_references_for_git_with_subdirectories ...@@ -846,38 +1000,6 @@ def test_executor_should_write_pep610_url_references_for_git_with_subdirectories
) )
def test_executor_should_use_cached_link_and_hash(
tmp_venv: VirtualEnv,
pool: RepositoryPool,
config: Config,
io: BufferedIO,
mocker: MockerFixture,
fixture_dir: FixtureDirGetter,
):
link_cached = fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl"
mocker.patch(
"poetry.installation.chef.Chef.get_cached_archive_for_link",
return_value=link_cached,
)
package = Package("demo", "0.1.0")
# Set package.files so the executor will attempt to hash the package
package.files = [
{
"file": "demo-0.1.0-py2.py3-none-any.whl",
"hash": "sha256:70e704135718fffbcbf61ed1fc45933cfd86951a744b681000eaaa75da31f17a", # noqa: E501
}
]
executor = Executor(tmp_venv, pool, config, io)
archive = executor._download_link(
Install(package),
Link("https://example.com/demo-0.1.0-py2.py3-none-any.whl"),
)
assert archive == link_cached
@pytest.mark.parametrize( @pytest.mark.parametrize(
("max_workers", "cpu_count", "side_effect", "expected_workers"), ("max_workers", "cpu_count", "side_effect", "expected_workers"),
[ [
......
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