Skip to content

Commit 1ee19a5

Browse files
authored
link: improve support for PEP 691 (JSON-based Simple API) (#664)
* 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
1 parent 0a6eac4 commit 1ee19a5

File tree

2 files changed

+138
-44
lines changed

2 files changed

+138
-44
lines changed

src/poetry/core/packages/utils/link.py

+80-24
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,27 @@
33
import posixpath
44
import re
55
import urllib.parse as urlparse
6+
import warnings
7+
8+
from functools import cached_property
9+
from typing import TYPE_CHECKING
610

711
from poetry.core.packages.utils.utils import path_to_url
812
from poetry.core.packages.utils.utils import splitext
913

1014

15+
if TYPE_CHECKING:
16+
from collections.abc import Mapping
17+
18+
1119
class Link:
1220
def __init__(
1321
self,
1422
url: str,
23+
*,
1524
requires_python: str | None = None,
16-
metadata: str | bool | None = None,
25+
hashes: Mapping[str, str] | None = None,
26+
metadata: str | bool | dict[str, str] | None = None,
1727
yanked: str | bool = False,
1828
) -> None:
1929
"""
@@ -25,11 +35,16 @@ def __init__(
2535
String containing the `Requires-Python` metadata field, specified
2636
in PEP 345. This may be specified by a data-requires-python
2737
attribute in the HTML link tag, as described in PEP 503.
38+
hashes:
39+
A dictionary of hash names and associated hashes of the file.
40+
Only relevant for JSON-API (PEP 691).
2841
metadata:
29-
String of the syntax `<hashname>=<hashvalue>` representing the hash
30-
of the Core Metadata file. This may be specified by a
31-
data-dist-info-metadata attribute in the HTML link tag, as described
32-
in PEP 658.
42+
One of:
43+
- bool indicating that metadata is available
44+
- string of the syntax `<hashname>=<hashvalue>` representing the hash
45+
of the Core Metadata file according to PEP 658 (HTML).
46+
- dict with hash names and associated hashes of the Core Metadata file
47+
according to PEP 691 (JSON).
3348
yanked:
3449
False, if the data-yanked attribute is not present.
3550
A string, if the data-yanked attribute has a string value.
@@ -43,6 +58,7 @@ def __init__(
4358

4459
self.url = url
4560
self.requires_python = requires_python if requires_python else None
61+
self._hashes = hashes
4662

4763
if isinstance(metadata, str):
4864
metadata = {"true": True, "": False, "false": False}.get(
@@ -96,41 +112,41 @@ def __ge__(self, other: object) -> bool:
96112
def __hash__(self) -> int:
97113
return hash(self.url)
98114

99-
@property
115+
@cached_property
100116
def filename(self) -> str:
101117
_, netloc, path, _, _ = urlparse.urlsplit(self.url)
102118
name = posixpath.basename(path.rstrip("/")) or netloc
103119
name = urlparse.unquote(name)
104120

105121
return name
106122

107-
@property
123+
@cached_property
108124
def scheme(self) -> str:
109125
return urlparse.urlsplit(self.url)[0]
110126

111-
@property
127+
@cached_property
112128
def netloc(self) -> str:
113129
return urlparse.urlsplit(self.url)[1]
114130

115-
@property
131+
@cached_property
116132
def path(self) -> str:
117133
return urlparse.unquote(urlparse.urlsplit(self.url)[2])
118134

119135
def splitext(self) -> tuple[str, str]:
120136
return splitext(posixpath.basename(self.path.rstrip("/")))
121137

122-
@property
138+
@cached_property
123139
def ext(self) -> str:
124140
return self.splitext()[1]
125141

126-
@property
142+
@cached_property
127143
def url_without_fragment(self) -> str:
128144
scheme, netloc, path, query, fragment = urlparse.urlsplit(self.url)
129145
return urlparse.urlunsplit((scheme, netloc, path, query, None))
130146

131147
_egg_fragment_re = re.compile(r"[#&]egg=([^&]*)")
132148

133-
@property
149+
@cached_property
134150
def egg_fragment(self) -> str | None:
135151
match = self._egg_fragment_re.search(self.url)
136152
if not match:
@@ -139,7 +155,7 @@ def egg_fragment(self) -> str | None:
139155

140156
_subdirectory_fragment_re = re.compile(r"[#&]subdirectory=([^&]*)")
141157

142-
@property
158+
@cached_property
143159
def subdirectory_fragment(self) -> str | None:
144160
match = self._subdirectory_fragment_re.search(self.url)
145161
if not match:
@@ -148,20 +164,36 @@ def subdirectory_fragment(self) -> str | None:
148164

149165
_hash_re = re.compile(r"(sha1|sha224|sha384|sha256|sha512|md5)=([a-f0-9]+)")
150166

151-
@property
167+
@cached_property
152168
def has_metadata(self) -> bool:
153169
if self._metadata is None:
154170
return False
155171
return bool(self._metadata) and (self.is_wheel or self.is_sdist)
156172

157-
@property
173+
@cached_property
158174
def metadata_url(self) -> str | None:
159175
if self.has_metadata:
160176
return f"{self.url_without_fragment.split('?', 1)[0]}.metadata"
161177
return None
162178

179+
@cached_property
180+
def metadata_hashes(self) -> Mapping[str, str]:
181+
if self.has_metadata:
182+
if isinstance(self._metadata, dict):
183+
return self._metadata
184+
if isinstance(self._metadata, str):
185+
match = self._hash_re.search(self._metadata)
186+
if match:
187+
return {match.group(1): match.group(2)}
188+
return {}
189+
163190
@property
164191
def metadata_hash(self) -> str | None:
192+
warnings.warn(
193+
"metadata_hash is deprecated. Use metadata_hashes instead.",
194+
DeprecationWarning,
195+
stacklevel=2,
196+
)
165197
if self.has_metadata and isinstance(self._metadata, str):
166198
match = self._hash_re.search(self._metadata)
167199
if match:
@@ -170,62 +202,86 @@ def metadata_hash(self) -> str | None:
170202

171203
@property
172204
def metadata_hash_name(self) -> str | None:
205+
warnings.warn(
206+
"metadata_hash_name is deprecated. Use metadata_hashes instead.",
207+
DeprecationWarning,
208+
stacklevel=2,
209+
)
173210
if self.has_metadata and isinstance(self._metadata, str):
174211
match = self._hash_re.search(self._metadata)
175212
if match:
176213
return match.group(1)
177214
return None
178215

216+
@cached_property
217+
def hashes(self) -> Mapping[str, str]:
218+
if self._hashes:
219+
return self._hashes
220+
match = self._hash_re.search(self.url)
221+
if match:
222+
return {match.group(1): match.group(2)}
223+
return {}
224+
179225
@property
180226
def hash(self) -> str | None:
227+
warnings.warn(
228+
"hash is deprecated. Use hashes instead.",
229+
DeprecationWarning,
230+
stacklevel=2,
231+
)
181232
match = self._hash_re.search(self.url)
182233
if match:
183234
return match.group(2)
184235
return None
185236

186237
@property
187238
def hash_name(self) -> str | None:
239+
warnings.warn(
240+
"hash_name is deprecated. Use hashes instead.",
241+
DeprecationWarning,
242+
stacklevel=2,
243+
)
188244
match = self._hash_re.search(self.url)
189245
if match:
190246
return match.group(1)
191247
return None
192248

193-
@property
249+
@cached_property
194250
def show_url(self) -> str:
195251
return posixpath.basename(self.url.split("#", 1)[0].split("?", 1)[0])
196252

197-
@property
253+
@cached_property
198254
def is_wheel(self) -> bool:
199255
return self.ext == ".whl"
200256

201-
@property
257+
@cached_property
202258
def is_wininst(self) -> bool:
203259
return self.ext == ".exe"
204260

205-
@property
261+
@cached_property
206262
def is_egg(self) -> bool:
207263
return self.ext == ".egg"
208264

209-
@property
265+
@cached_property
210266
def is_sdist(self) -> bool:
211267
return self.ext in {".tar.bz2", ".tar.gz", ".zip"}
212268

213-
@property
269+
@cached_property
214270
def is_artifact(self) -> bool:
215271
"""
216272
Determines if this points to an actual artifact (e.g. a tarball) or if
217273
it points to an "abstract" thing like a path or a VCS location.
218274
"""
219-
if self.scheme in ("ssh", "git", "hg", "bzr", "sftp", "svn"):
275+
if self.scheme in {"ssh", "git", "hg", "bzr", "sftp", "svn"}:
220276
return False
221277

222278
return True
223279

224-
@property
280+
@cached_property
225281
def yanked(self) -> bool:
226282
return isinstance(self._yanked, str) or bool(self._yanked)
227283

228-
@property
284+
@cached_property
229285
def yanked_reason(self) -> str:
230286
if isinstance(self._yanked, str):
231287
return self._yanked

tests/packages/utils/test_utils_link.py

+58-20
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,49 @@ def metadata_checksum() -> str:
2424

2525

2626
def make_url(
27-
ext: str, file_checksum: str | None = None, metadata_checksum: str | None = None
27+
ext: str,
28+
*,
29+
file_checksum: str | None = None,
30+
metadata_checksum: str | None = None,
31+
hashes: dict[str, str] | None = None,
32+
metadata: dict[str, str] | str | None = None,
2833
) -> Link:
29-
file_checksum = file_checksum or make_checksum()
30-
return Link(
31-
"https://files.pythonhosted.org/packages/16/52/dead/"
32-
f"demo-1.0.0.{ext}#sha256={file_checksum}",
33-
metadata=f"sha256={metadata_checksum}" if metadata_checksum else None,
34-
)
34+
url = f"https://files.pythonhosted.org/packages/16/52/dead/demo-1.0.0.{ext}"
35+
if not hashes:
36+
file_checksum = file_checksum or make_checksum()
37+
url += f"#sha256={file_checksum}"
38+
if not metadata:
39+
metadata = f"sha256={metadata_checksum}" if metadata_checksum else None
40+
return Link(url, hashes=hashes, metadata=metadata)
3541

3642

3743
def test_package_link_hash(file_checksum: str) -> None:
3844
link = make_url(ext="whl", file_checksum=file_checksum)
39-
assert link.hash_name == "sha256"
40-
assert link.hash == file_checksum
45+
assert link.hashes == {"sha256": file_checksum}
46+
with pytest.warns(DeprecationWarning):
47+
assert link.hash_name == "sha256"
48+
with pytest.warns(DeprecationWarning):
49+
assert link.hash == file_checksum
4150
assert link.show_url == "demo-1.0.0.whl"
4251

4352
# this is legacy PEP 503, no metadata hash is present
4453
assert not link.has_metadata
4554
assert not link.metadata_url
46-
assert not link.metadata_hash
47-
assert not link.metadata_hash_name
55+
assert not link.metadata_hashes
56+
with pytest.warns(DeprecationWarning):
57+
assert not link.metadata_hash
58+
with pytest.warns(DeprecationWarning):
59+
assert not link.metadata_hash_name
60+
61+
62+
def test_package_link_hashes(file_checksum: str) -> None:
63+
link = make_url(ext="whl", hashes={"sha256": file_checksum, "other": "1234"})
64+
assert link.hashes == {"sha256": file_checksum, "other": "1234"}
65+
with pytest.warns(DeprecationWarning):
66+
assert link.hash_name is None
67+
with pytest.warns(DeprecationWarning):
68+
assert link.hash is None
69+
assert link.show_url == "demo-1.0.0.whl"
4870

4971

5072
@pytest.mark.parametrize(
@@ -74,22 +96,27 @@ def test_package_link_pep658(
7496
if has_metadata:
7597
assert link.has_metadata
7698
assert link.metadata_url == f"{link.url_without_fragment}.metadata"
77-
assert link.metadata_hash == metadata_checksum
78-
assert link.metadata_hash_name == "sha256"
99+
assert link.metadata_hashes == {"sha256": metadata_checksum}
100+
with pytest.warns(DeprecationWarning):
101+
assert link.metadata_hash == metadata_checksum
102+
with pytest.warns(DeprecationWarning):
103+
assert link.metadata_hash_name == "sha256"
79104
else:
80105
assert not link.has_metadata
81106
assert not link.metadata_url
82-
assert not link.metadata_hash
83-
assert not link.metadata_hash_name
107+
assert not link.metadata_hashes
108+
with pytest.warns(DeprecationWarning):
109+
assert not link.metadata_hash
110+
with pytest.warns(DeprecationWarning):
111+
assert not link.metadata_hash_name
84112

85113

86114
def test_package_link_pep658_no_default_metadata() -> None:
87115
link = make_url(ext="whl")
88116

89117
assert not link.has_metadata
90118
assert not link.metadata_url
91-
assert not link.metadata_hash
92-
assert not link.metadata_hash_name
119+
assert not link.metadata_hashes
93120

94121

95122
@pytest.mark.parametrize(
@@ -100,7 +127,7 @@ def test_package_link_pep658_no_default_metadata() -> None:
100127
("", False),
101128
],
102129
)
103-
def test_package_link_pep653_non_hash_metadata_value(
130+
def test_package_link_pep658_non_hash_metadata_value(
104131
file_checksum: str, metadata: str | bool, has_metadata: bool
105132
) -> None:
106133
link = Link(
@@ -116,8 +143,19 @@ def test_package_link_pep653_non_hash_metadata_value(
116143
assert not link.has_metadata
117144
assert not link.metadata_url
118145

119-
assert not link.metadata_hash
120-
assert not link.metadata_hash_name
146+
assert not link.metadata_hashes
147+
148+
149+
def test_package_link_pep691() -> None:
150+
link = make_url(ext="whl", metadata={"sha256": "abcd", "sha512": "1234"})
151+
152+
assert link.has_metadata
153+
assert link.metadata_url == f"{link.url_without_fragment}.metadata"
154+
assert link.metadata_hashes == {"sha256": "abcd", "sha512": "1234"}
155+
with pytest.warns(DeprecationWarning):
156+
assert link.metadata_hash is None
157+
with pytest.warns(DeprecationWarning):
158+
assert link.metadata_hash_name is None
121159

122160

123161
def test_package_link_pep592_default_not_yanked() -> None:

0 commit comments

Comments
 (0)