Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

link: improve support for PEP 691 (JSON-based Simple API) #664

Merged
merged 1 commit into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 80 additions & 24 deletions src/poetry/core/packages/utils/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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 `<hashname>=<hashvalue>` 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 `<hashname>=<hashvalue>` 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.
Expand All @@ -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(
Expand Down Expand Up @@ -96,41 +112,41 @@ 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
name = urlparse.unquote(name)

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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -170,62 +202,86 @@ 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)
return 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
Expand Down
78 changes: 58 additions & 20 deletions tests/packages/utils/test_utils_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -74,22 +96,27 @@ 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:
link = make_url(ext="whl")

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(
Expand All @@ -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(
Expand All @@ -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:
Expand Down