Skip to content

Commit 7c1954b

Browse files
committed
PEP-710: implement provenance_url.json file
Signed-off-by: Fridolin Pokorny <fridolin.pokorny@gmail.com>
1 parent 8c7df92 commit 7c1954b

6 files changed

Lines changed: 165 additions & 16 deletions

File tree

news/11865.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implement PEP-710 for storing provenance_url.json file.

src/pip/_internal/models/direct_url.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
T = TypeVar("T")
1818

1919
DIRECT_URL_METADATA_NAME = "direct_url.json"
20+
PROVENANCE_URL_METADATA_NAME = "provenance_url.json"
2021
ENV_VAR_RE = re.compile(r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$")
2122

2223

@@ -205,20 +206,25 @@ def from_dict(cls, d: Dict[str, Any]) -> "DirectUrl":
205206
),
206207
)
207208

208-
def to_dict(self) -> Dict[str, Any]:
209+
def to_dict(self, *, keep_hash_key: bool = True) -> Dict[str, Any]:
209210
res = _filter_none(
210211
url=self.redacted_url,
211212
subdirectory=self.subdirectory,
212213
)
213-
res[self.info.name] = self.info._to_dict()
214+
215+
info_dict = self.info._to_dict()
216+
if not keep_hash_key:
217+
info_dict.pop("hash", None)
218+
219+
res[self.info.name] = info_dict
214220
return res
215221

216222
@classmethod
217223
def from_json(cls, s: str) -> "DirectUrl":
218224
return cls.from_dict(json.loads(s))
219225

220-
def to_json(self) -> str:
221-
return json.dumps(self.to_dict(), sort_keys=True)
226+
def to_json(self, *, keep_hash_key: bool = True) -> str:
227+
return json.dumps(self.to_dict(keep_hash_key=keep_hash_key), sort_keys=True)
222228

223229
def is_local_editable(self) -> bool:
224230
return isinstance(self.info, DirInfo) and self.info.editable

src/pip/_internal/operations/install/wheel.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@
4848
FilesystemWheel,
4949
get_wheel_distribution,
5050
)
51-
from pip._internal.models.direct_url import DIRECT_URL_METADATA_NAME, DirectUrl
51+
from pip._internal.models.direct_url import (
52+
DIRECT_URL_METADATA_NAME,
53+
PROVENANCE_URL_METADATA_NAME,
54+
ArchiveInfo,
55+
DirectUrl,
56+
)
5257
from pip._internal.models.scheme import SCHEME_KEYS, Scheme
5358
from pip._internal.utils.filesystem import adjacent_tmp_file, replace
5459
from pip._internal.utils.misc import StreamWrapper, ensure_dir, hash_file, partition
@@ -424,9 +429,10 @@ def _install_wheel( # noqa: C901, PLR0915 function is too long
424429
wheel_zip: ZipFile,
425430
wheel_path: str,
426431
scheme: Scheme,
432+
download_info: DirectUrl,
433+
is_direct: bool,
427434
pycompile: bool = True,
428435
warn_script_location: bool = True,
429-
direct_url: Optional[DirectUrl] = None,
430436
requested: bool = False,
431437
) -> None:
432438
"""Install a wheel.
@@ -673,12 +679,25 @@ def _generate_file(path: str, **kwargs: Any) -> Generator[BinaryIO, None, None]:
673679
installer_file.write(b"pip\n")
674680
generated.append(installer_path)
675681

676-
# Record the PEP 610 direct URL reference
677-
if direct_url is not None:
682+
if is_direct:
683+
# Record the PEP 610 direct URL reference
678684
direct_url_path = os.path.join(dest_info_dir, DIRECT_URL_METADATA_NAME)
679685
with _generate_file(direct_url_path) as direct_url_file:
680-
direct_url_file.write(direct_url.to_json().encode("utf-8"))
686+
direct_url_file.write(download_info.to_json().encode("utf-8"))
681687
generated.append(direct_url_path)
688+
else:
689+
# Record the PEP 710 provenance URL reference only if we have hashes for
690+
# the given wheel. They can be missing when wheels are built using an old pip.
691+
assert isinstance(download_info.info, ArchiveInfo)
692+
if download_info.info.hashes:
693+
provenance_url_path = os.path.join(
694+
dest_info_dir, PROVENANCE_URL_METADATA_NAME
695+
)
696+
with _generate_file(provenance_url_path) as provenance_url_file:
697+
provenance_url_file.write(
698+
download_info.to_json(keep_hash_key=False).encode("utf-8")
699+
)
700+
generated.append(provenance_url_path)
682701

683702
# Record the REQUESTED file
684703
if requested:
@@ -721,10 +740,11 @@ def install_wheel(
721740
name: str,
722741
wheel_path: str,
723742
scheme: Scheme,
743+
download_info: DirectUrl,
744+
is_direct: bool,
724745
req_description: str,
725746
pycompile: bool = True,
726747
warn_script_location: bool = True,
727-
direct_url: Optional[DirectUrl] = None,
728748
requested: bool = False,
729749
) -> None:
730750
with ZipFile(wheel_path, allowZip64=True) as z:
@@ -734,8 +754,9 @@ def install_wheel(
734754
wheel_zip=z,
735755
wheel_path=wheel_path,
736756
scheme=scheme,
757+
download_info=download_info,
758+
is_direct=is_direct,
737759
pycompile=pycompile,
738760
warn_script_location=warn_script_location,
739-
direct_url=direct_url,
740761
requested=requested,
741762
)

src/pip/_internal/req/req_install.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -861,17 +861,19 @@ def install(
861861
self.install_succeeded = True
862862
return
863863

864+
assert self.download_info
864865
assert self.is_wheel
865866
assert self.local_file_path
866867

867868
install_wheel(
868869
self.req.name,
869870
self.local_file_path,
870871
scheme=scheme,
872+
download_info=self.download_info,
873+
is_direct=self.is_direct,
871874
req_description=str(self.req),
872875
pycompile=pycompile,
873876
warn_script_location=warn_script_location,
874-
direct_url=self.download_info if self.is_direct else None,
875877
requested=self.user_supplied,
876878
)
877879
self.install_succeeded = True

tests/unit/test_direct_url.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ def test_to_json() -> None:
2828
)
2929

3030

31+
def test_to_json_no_keep_hash_key() -> None:
32+
direct_url = DirectUrl(
33+
url="https://pypi.org/simple/sample/sample-1.2.0-py3-none-any.whl",
34+
info=ArchiveInfo(
35+
hash="sha256=257ded4ea1fafa475f099e544b2d7560f674d42"
36+
"917e096d462e8a46a64f51245",
37+
hashes={
38+
"sha256": "257ded4ea1fafa475f099e544b2d7560f674d"
39+
"42917e096d462e8a46a64f51245",
40+
},
41+
),
42+
)
43+
direct_url.validate()
44+
assert direct_url.to_json(keep_hash_key=False) == (
45+
'{"archive_info": {"hashes": {'
46+
'"sha256": "257ded4ea1fafa475f099e544b2d7560f674d42917e096d462e8a46a64f51245"}'
47+
'}, "url": "https://pypi.org/simple/sample/sample-1.2.0-py3-none-any.whl"}'
48+
)
49+
50+
3151
def test_archive_info() -> None:
3252
direct_url_dict = {
3353
"url": "file:///home/user/archive.tgz",

tests/unit/test_wheel.py

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pip._internal.locations import get_scheme
1919
from pip._internal.models.direct_url import (
2020
DIRECT_URL_METADATA_NAME,
21+
PROVENANCE_URL_METADATA_NAME,
2122
ArchiveInfo,
2223
DirectUrl,
2324
)
@@ -330,6 +331,17 @@ def main():
330331
"gui_scripts": ["sample2 = sample:main"],
331332
},
332333
).save_to_dir(tmpdir)
334+
self.download_info = DirectUrl(
335+
url="https://localhost:8080/sample/sample-1.2.0-py3-none-any.whl",
336+
info=ArchiveInfo(
337+
hash="sha256=257ded4ea1fafa475f099e544b2d7560f674d42917e096d462e"
338+
"8a46a64f51245",
339+
hashes={
340+
"sha256": "257ded4ea1fafa475f099e544b2d7560f674d42917e096d46"
341+
"2e8a46a64f51245",
342+
},
343+
),
344+
)
333345
self.req = Requirement("sample")
334346
self.src = os.path.join(tmpdir, "src")
335347
self.dest = os.path.join(tmpdir, "dest")
@@ -370,6 +382,8 @@ def test_std_install(self, data: TestData, tmpdir: Path) -> None:
370382
self.name,
371383
self.wheelpath,
372384
scheme=self.scheme,
385+
download_info=self.download_info,
386+
is_direct=False,
373387
req_description=str(self.req),
374388
)
375389
self.assert_installed(0o644)
@@ -388,6 +402,8 @@ def test_std_install_with_custom_umask(
388402
self.name,
389403
self.wheelpath,
390404
scheme=self.scheme,
405+
download_info=self.download_info,
406+
is_direct=False,
391407
req_description=str(self.req),
392408
)
393409
self.assert_installed(expected_permission)
@@ -400,6 +416,8 @@ def test_std_install_requested(self, data: TestData, tmpdir: Path) -> None:
400416
self.name,
401417
self.wheelpath,
402418
scheme=self.scheme,
419+
download_info=self.download_info,
420+
is_direct=False,
403421
req_description=str(self.req),
404422
requested=True,
405423
)
@@ -415,27 +433,100 @@ def test_std_install_with_direct_url(self, data: TestData, tmpdir: Path) -> None
415433
because wheelpath is typically the result of a local build.
416434
"""
417435
self.prep(data, tmpdir)
418-
direct_url = DirectUrl(
436+
download_info = DirectUrl(
419437
url="file:///home/user/archive.tgz",
420438
info=ArchiveInfo(),
421439
)
422440
wheel.install_wheel(
423441
self.name,
424442
self.wheelpath,
425443
scheme=self.scheme,
444+
download_info=download_info,
445+
is_direct=True,
426446
req_description=str(self.req),
427-
direct_url=direct_url,
428447
)
429448
direct_url_path = os.path.join(self.dest_dist_info, DIRECT_URL_METADATA_NAME)
430449
self.assert_permission(direct_url_path, 0o644)
431450
with open(direct_url_path, "rb") as f1:
432-
expected_direct_url_json = direct_url.to_json()
451+
expected_direct_url_json = download_info.to_json()
433452
direct_url_json = f1.read().decode("utf-8")
434453
assert direct_url_json == expected_direct_url_json
435-
# check that the direc_url file is part of RECORDS
454+
# check that the direct_url file is part of RECORDS
436455
with open(os.path.join(self.dest_dist_info, "RECORD")) as f2:
437456
assert DIRECT_URL_METADATA_NAME in f2.read()
438457

458+
def test_std_install_with_provenance_url(
459+
self, data: TestData, tmpdir: Path
460+
) -> None:
461+
"""Test that install_wheel creates provenance_url.json metadata."""
462+
self.prep(data, tmpdir)
463+
download_info = DirectUrl(
464+
url="https://pypi.org/simple/sample/sample-1.2.0-py3-none-any.whl",
465+
info=ArchiveInfo(
466+
hash="sha256=257ded4ea1fafa475f099e544b2d7560f674d42"
467+
"917e096d462e8a46a64f51245",
468+
hashes={
469+
"sha256": "257ded4ea1fafa475f099e544b2d7560f674d"
470+
"42917e096d462e8a46a64f51245",
471+
},
472+
),
473+
)
474+
wheel.install_wheel(
475+
self.name,
476+
self.wheelpath,
477+
scheme=self.scheme,
478+
download_info=download_info,
479+
is_direct=False,
480+
req_description=str(self.req),
481+
)
482+
provenance_url_path = os.path.join(
483+
self.dest_dist_info, PROVENANCE_URL_METADATA_NAME
484+
)
485+
self.assert_permission(provenance_url_path, 0o644)
486+
with open(provenance_url_path, "rb") as f1:
487+
expected_provenance_url_json = download_info.to_json(keep_hash_key=False)
488+
provenance_url_json = f1.read().decode("utf-8")
489+
assert provenance_url_json == expected_provenance_url_json
490+
# check that the provenance_url.json file is part of RECORDS
491+
with open(os.path.join(self.dest_dist_info, "RECORD")) as f2:
492+
assert PROVENANCE_URL_METADATA_NAME in f2.read()
493+
494+
@pytest.mark.parametrize(
495+
"hashes",
496+
[
497+
pytest.param(None, id="None"),
498+
pytest.param({}, id="empty"),
499+
],
500+
)
501+
def test_std_install_with_provenance_url_no_hashes(
502+
self, data: TestData, tmpdir: Path, hashes: Optional[Dict[str, str]]
503+
) -> None:
504+
"""Test that install_wheel does not create provenance_url.json
505+
when hashes are missing.
506+
"""
507+
self.prep(data, tmpdir)
508+
download_info = DirectUrl(
509+
url="https://pypi.org/simple/sample/sample-1.2.0-py3-none-any.whl",
510+
info=ArchiveInfo(
511+
hash=None,
512+
hashes=hashes,
513+
),
514+
)
515+
wheel.install_wheel(
516+
self.name,
517+
self.wheelpath,
518+
scheme=self.scheme,
519+
download_info=download_info,
520+
is_direct=False,
521+
req_description=str(self.req),
522+
)
523+
provenance_url_path = os.path.join(
524+
self.dest_dist_info, PROVENANCE_URL_METADATA_NAME
525+
)
526+
assert not os.path.exists(provenance_url_path)
527+
with open(os.path.join(self.dest_dist_info, "RECORD")) as f2:
528+
assert PROVENANCE_URL_METADATA_NAME not in f2.read()
529+
439530
def test_install_prefix(self, data: TestData, tmpdir: Path) -> None:
440531
prefix = os.path.join(os.path.sep, "some", "path")
441532
self.prep(data, tmpdir)
@@ -451,6 +542,8 @@ def test_install_prefix(self, data: TestData, tmpdir: Path) -> None:
451542
self.name,
452543
self.wheelpath,
453544
scheme=scheme,
545+
download_info=self.download_info,
546+
is_direct=False,
454547
req_description=str(self.req),
455548
)
456549

@@ -468,6 +561,8 @@ def test_dist_info_contains_empty_dir(self, data: TestData, tmpdir: Path) -> Non
468561
self.name,
469562
self.wheelpath,
470563
scheme=self.scheme,
564+
download_info=self.download_info,
565+
is_direct=False,
471566
req_description=str(self.req),
472567
)
473568
self.assert_installed(0o644)
@@ -486,6 +581,8 @@ def test_wheel_install_rejects_bad_paths(
486581
"simple",
487582
str(wheel_path),
488583
scheme=self.scheme,
584+
download_info=self.download_info,
585+
is_direct=False,
489586
req_description="simple",
490587
)
491588

@@ -508,6 +605,8 @@ def test_invalid_entrypoints_fail(
508605
"simple",
509606
str(wheel_path),
510607
scheme=self.scheme,
608+
download_info=self.download_info,
609+
is_direct=False,
511610
req_description="simple",
512611
)
513612

0 commit comments

Comments
 (0)