Skip to content

Commit

Permalink
link: improve support for PEP 691 (JSON-based Simple API)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
radoering committed Nov 19, 2023
1 parent 4e360e8 commit 241ae5d
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 44 deletions.
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

0 comments on commit 241ae5d

Please sign in to comment.