From 241ae5dc5b9e2729d92c9a3c0a604f354b619a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 19 Nov 2023 06:00:39 +0100 Subject: [PATCH] link: improve support for PEP 691 (JSON-based Simple API) * add option to pass hashes dict * add option to pass metadata hashes dict * deprecate hash_name and hash in favor of hashes * deprecate metadata_hash_name and metadata_hash in favor of metadata_hashes * use cached_property instead of property --- src/poetry/core/packages/utils/link.py | 104 ++++++++++++++++++------ tests/packages/utils/test_utils_link.py | 78 +++++++++++++----- 2 files changed, 138 insertions(+), 44 deletions(-) diff --git a/src/poetry/core/packages/utils/link.py b/src/poetry/core/packages/utils/link.py index ce6c7a92c..e1f2d3823 100644 --- a/src/poetry/core/packages/utils/link.py +++ b/src/poetry/core/packages/utils/link.py @@ -3,17 +3,27 @@ import posixpath import re import urllib.parse as urlparse +import warnings + +from functools import cached_property +from typing import TYPE_CHECKING from poetry.core.packages.utils.utils import path_to_url from poetry.core.packages.utils.utils import splitext +if TYPE_CHECKING: + from collections.abc import Mapping + + class Link: def __init__( self, url: str, + *, requires_python: str | None = None, - metadata: str | bool | None = None, + hashes: Mapping[str, str] | None = None, + metadata: str | bool | dict[str, str] | None = None, yanked: str | bool = False, ) -> None: """ @@ -25,11 +35,16 @@ def __init__( String containing the `Requires-Python` metadata field, specified in PEP 345. This may be specified by a data-requires-python attribute in the HTML link tag, as described in PEP 503. + hashes: + A dictionary of hash names and associated hashes of the file. + Only relevant for JSON-API (PEP 691). metadata: - String of the syntax `=` representing the hash - of the Core Metadata file. This may be specified by a - data-dist-info-metadata attribute in the HTML link tag, as described - in PEP 658. + One of: + - bool indicating that metadata is available + - string of the syntax `=` representing the hash + of the Core Metadata file according to PEP 658 (HTML). + - dict with hash names and associated hashes of the Core Metadata file + according to PEP 691 (JSON). yanked: False, if the data-yanked attribute is not present. A string, if the data-yanked attribute has a string value. @@ -43,6 +58,7 @@ def __init__( self.url = url self.requires_python = requires_python if requires_python else None + self._hashes = hashes if isinstance(metadata, str): metadata = {"true": True, "": False, "false": False}.get( @@ -96,7 +112,7 @@ def __ge__(self, other: object) -> bool: def __hash__(self) -> int: return hash(self.url) - @property + @cached_property def filename(self) -> str: _, netloc, path, _, _ = urlparse.urlsplit(self.url) name = posixpath.basename(path.rstrip("/")) or netloc @@ -104,33 +120,33 @@ def filename(self) -> str: return name - @property + @cached_property def scheme(self) -> str: return urlparse.urlsplit(self.url)[0] - @property + @cached_property def netloc(self) -> str: return urlparse.urlsplit(self.url)[1] - @property + @cached_property def path(self) -> str: return urlparse.unquote(urlparse.urlsplit(self.url)[2]) def splitext(self) -> tuple[str, str]: return splitext(posixpath.basename(self.path.rstrip("/"))) - @property + @cached_property def ext(self) -> str: return self.splitext()[1] - @property + @cached_property def url_without_fragment(self) -> str: scheme, netloc, path, query, fragment = urlparse.urlsplit(self.url) return urlparse.urlunsplit((scheme, netloc, path, query, None)) _egg_fragment_re = re.compile(r"[#&]egg=([^&]*)") - @property + @cached_property def egg_fragment(self) -> str | None: match = self._egg_fragment_re.search(self.url) if not match: @@ -139,7 +155,7 @@ def egg_fragment(self) -> str | None: _subdirectory_fragment_re = re.compile(r"[#&]subdirectory=([^&]*)") - @property + @cached_property def subdirectory_fragment(self) -> str | None: match = self._subdirectory_fragment_re.search(self.url) if not match: @@ -148,20 +164,36 @@ def subdirectory_fragment(self) -> str | None: _hash_re = re.compile(r"(sha1|sha224|sha384|sha256|sha512|md5)=([a-f0-9]+)") - @property + @cached_property def has_metadata(self) -> bool: if self._metadata is None: return False return bool(self._metadata) and (self.is_wheel or self.is_sdist) - @property + @cached_property def metadata_url(self) -> str | None: if self.has_metadata: return f"{self.url_without_fragment.split('?', 1)[0]}.metadata" return None + @cached_property + def metadata_hashes(self) -> Mapping[str, str]: + if self.has_metadata: + if isinstance(self._metadata, dict): + return self._metadata + if isinstance(self._metadata, str): + match = self._hash_re.search(self._metadata) + if match: + return {match.group(1): match.group(2)} + return {} + @property def metadata_hash(self) -> str | None: + warnings.warn( + "metadata_hash is deprecated. Use metadata_hashes instead.", + DeprecationWarning, + stacklevel=2, + ) if self.has_metadata and isinstance(self._metadata, str): match = self._hash_re.search(self._metadata) if match: @@ -170,14 +202,33 @@ def metadata_hash(self) -> str | None: @property def metadata_hash_name(self) -> str | None: + warnings.warn( + "metadata_hash_name is deprecated. Use metadata_hashes instead.", + DeprecationWarning, + stacklevel=2, + ) if self.has_metadata and isinstance(self._metadata, str): match = self._hash_re.search(self._metadata) if match: return match.group(1) return None + @cached_property + def hashes(self) -> Mapping[str, str]: + if self._hashes: + return self._hashes + match = self._hash_re.search(self.url) + if match: + return {match.group(1): match.group(2)} + return {} + @property def hash(self) -> str | None: + warnings.warn( + "hash is deprecated. Use hashes instead.", + DeprecationWarning, + stacklevel=2, + ) match = self._hash_re.search(self.url) if match: return match.group(2) @@ -185,47 +236,52 @@ def hash(self) -> str | None: @property def hash_name(self) -> str | None: + warnings.warn( + "hash_name is deprecated. Use hashes instead.", + DeprecationWarning, + stacklevel=2, + ) match = self._hash_re.search(self.url) if match: return match.group(1) return None - @property + @cached_property def show_url(self) -> str: return posixpath.basename(self.url.split("#", 1)[0].split("?", 1)[0]) - @property + @cached_property def is_wheel(self) -> bool: return self.ext == ".whl" - @property + @cached_property def is_wininst(self) -> bool: return self.ext == ".exe" - @property + @cached_property def is_egg(self) -> bool: return self.ext == ".egg" - @property + @cached_property def is_sdist(self) -> bool: return self.ext in {".tar.bz2", ".tar.gz", ".zip"} - @property + @cached_property def is_artifact(self) -> bool: """ Determines if this points to an actual artifact (e.g. a tarball) or if it points to an "abstract" thing like a path or a VCS location. """ - if self.scheme in ("ssh", "git", "hg", "bzr", "sftp", "svn"): + if self.scheme in {"ssh", "git", "hg", "bzr", "sftp", "svn"}: return False return True - @property + @cached_property def yanked(self) -> bool: return isinstance(self._yanked, str) or bool(self._yanked) - @property + @cached_property def yanked_reason(self) -> str: if isinstance(self._yanked, str): return self._yanked diff --git a/tests/packages/utils/test_utils_link.py b/tests/packages/utils/test_utils_link.py index b49587545..8abe9c0b9 100644 --- a/tests/packages/utils/test_utils_link.py +++ b/tests/packages/utils/test_utils_link.py @@ -24,27 +24,49 @@ def metadata_checksum() -> str: def make_url( - ext: str, file_checksum: str | None = None, metadata_checksum: str | None = None + ext: str, + *, + file_checksum: str | None = None, + metadata_checksum: str | None = None, + hashes: dict[str, str] | None = None, + metadata: dict[str, str] | str | None = None, ) -> Link: - file_checksum = file_checksum or make_checksum() - return Link( - "https://files.pythonhosted.org/packages/16/52/dead/" - f"demo-1.0.0.{ext}#sha256={file_checksum}", - metadata=f"sha256={metadata_checksum}" if metadata_checksum else None, - ) + url = f"https://files.pythonhosted.org/packages/16/52/dead/demo-1.0.0.{ext}" + if not hashes: + file_checksum = file_checksum or make_checksum() + url += f"#sha256={file_checksum}" + if not metadata: + metadata = f"sha256={metadata_checksum}" if metadata_checksum else None + return Link(url, hashes=hashes, metadata=metadata) def test_package_link_hash(file_checksum: str) -> None: link = make_url(ext="whl", file_checksum=file_checksum) - assert link.hash_name == "sha256" - assert link.hash == file_checksum + assert link.hashes == {"sha256": file_checksum} + with pytest.warns(DeprecationWarning): + assert link.hash_name == "sha256" + with pytest.warns(DeprecationWarning): + assert link.hash == file_checksum assert link.show_url == "demo-1.0.0.whl" # this is legacy PEP 503, no metadata hash is present assert not link.has_metadata assert not link.metadata_url - assert not link.metadata_hash - assert not link.metadata_hash_name + assert not link.metadata_hashes + with pytest.warns(DeprecationWarning): + assert not link.metadata_hash + with pytest.warns(DeprecationWarning): + assert not link.metadata_hash_name + + +def test_package_link_hashes(file_checksum: str) -> None: + link = make_url(ext="whl", hashes={"sha256": file_checksum, "other": "1234"}) + assert link.hashes == {"sha256": file_checksum, "other": "1234"} + with pytest.warns(DeprecationWarning): + assert link.hash_name is None + with pytest.warns(DeprecationWarning): + assert link.hash is None + assert link.show_url == "demo-1.0.0.whl" @pytest.mark.parametrize( @@ -74,13 +96,19 @@ def test_package_link_pep658( if has_metadata: assert link.has_metadata assert link.metadata_url == f"{link.url_without_fragment}.metadata" - assert link.metadata_hash == metadata_checksum - assert link.metadata_hash_name == "sha256" + assert link.metadata_hashes == {"sha256": metadata_checksum} + with pytest.warns(DeprecationWarning): + assert link.metadata_hash == metadata_checksum + with pytest.warns(DeprecationWarning): + assert link.metadata_hash_name == "sha256" else: assert not link.has_metadata assert not link.metadata_url - assert not link.metadata_hash - assert not link.metadata_hash_name + assert not link.metadata_hashes + with pytest.warns(DeprecationWarning): + assert not link.metadata_hash + with pytest.warns(DeprecationWarning): + assert not link.metadata_hash_name def test_package_link_pep658_no_default_metadata() -> None: @@ -88,8 +116,7 @@ def test_package_link_pep658_no_default_metadata() -> None: assert not link.has_metadata assert not link.metadata_url - assert not link.metadata_hash - assert not link.metadata_hash_name + assert not link.metadata_hashes @pytest.mark.parametrize( @@ -100,7 +127,7 @@ def test_package_link_pep658_no_default_metadata() -> None: ("", False), ], ) -def test_package_link_pep653_non_hash_metadata_value( +def test_package_link_pep658_non_hash_metadata_value( file_checksum: str, metadata: str | bool, has_metadata: bool ) -> None: link = Link( @@ -116,8 +143,19 @@ def test_package_link_pep653_non_hash_metadata_value( assert not link.has_metadata assert not link.metadata_url - assert not link.metadata_hash - assert not link.metadata_hash_name + assert not link.metadata_hashes + + +def test_package_link_pep691() -> None: + link = make_url(ext="whl", metadata={"sha256": "abcd", "sha512": "1234"}) + + assert link.has_metadata + assert link.metadata_url == f"{link.url_without_fragment}.metadata" + assert link.metadata_hashes == {"sha256": "abcd", "sha512": "1234"} + with pytest.warns(DeprecationWarning): + assert link.metadata_hash is None + with pytest.warns(DeprecationWarning): + assert link.metadata_hash_name is None def test_package_link_pep592_default_not_yanked() -> None: