Skip to content

Commit 7dc37c4

Browse files
committed
Add --report option to pip install
1 parent 91f0b09 commit 7dc37c4

File tree

4 files changed

+237
-0
lines changed

4 files changed

+237
-0
lines changed

news/53.feature.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add ``--report`` to the install command to generate a json report of what was installed.
2+
In combination with ``--dry-run`` and ``--ignore-installed`` it can be used to resolve
3+
the requirements.

src/pip/_internal/commands/install.py

+20
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import errno
2+
import json
23
import operator
34
import os
45
import shutil
@@ -21,6 +22,7 @@
2122
from pip._internal.locations import get_scheme
2223
from pip._internal.metadata import get_environment
2324
from pip._internal.models.format_control import FormatControl
25+
from pip._internal.models.installation_report import InstallationReport
2426
from pip._internal.operations.build.build_tracker import get_build_tracker
2527
from pip._internal.operations.check import ConflictDetails, check_install_conflicts
2628
from pip._internal.req import install_given_reqs
@@ -250,6 +252,19 @@ def add_options(self) -> None:
250252
self.parser.insert_option_group(0, index_opts)
251253
self.parser.insert_option_group(0, self.cmd_opts)
252254

255+
self.cmd_opts.add_option(
256+
"--report",
257+
dest="json_report_file",
258+
metavar="file",
259+
default=None,
260+
help=(
261+
"Generate a JSON file describing what pip did to install "
262+
"the provided requirements. "
263+
"Can be used in combination with --dry-run and --ignore-installed "
264+
"to 'resolve' the requirements."
265+
),
266+
)
267+
253268
@with_cleanup
254269
def run(self, options: Values, args: List[str]) -> int:
255270
if options.use_user_site and options.target_dir is not None:
@@ -353,6 +368,11 @@ def run(self, options: Values, args: List[str]) -> int:
353368
reqs, check_supported_wheels=not options.target_dir
354369
)
355370

371+
if options.json_report_file:
372+
report = InstallationReport(requirement_set.requirements_to_install)
373+
with open(options.json_report_file, "w") as f:
374+
json.dump(report.to_dict(), f)
375+
356376
if options.dry_run:
357377
would_install_items = sorted(
358378
(r.metadata["name"], r.metadata["version"])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from typing import Any, Dict, Sequence
2+
3+
from pip._internal.req.req_install import InstallRequirement
4+
5+
6+
class InstallationReport:
7+
def __init__(self, install_requirements: Sequence[InstallRequirement]):
8+
self._install_requirements = install_requirements
9+
10+
@classmethod
11+
def _install_req_to_dict(cls, ireq: InstallRequirement) -> Dict[str, Any]:
12+
assert ireq.download_info, f"No download_info for {ireq}"
13+
res = {
14+
# PEP 610 json for the download URL. download_info.archive_info.hash may
15+
# be absent when the requirement was installed from the wheel cache
16+
# and the cache entry was populated by an older pip version that did not
17+
# record origin.json.
18+
"download_info": ireq.download_info.to_dict(),
19+
# is_direct is true if the requirement was a direct URL reference (which
20+
# includes editable requirements), and false if the requirement was
21+
# downloaded from a PEP 503 index or --find-links.
22+
"is_direct": bool(ireq.original_link),
23+
# requested is true if the requirement was specified by the user (aka
24+
# top level requirement), and false if it was installed as a dependency of a
25+
# requirement. https://peps.python.org/pep-0376/#requested
26+
"requested": ireq.user_supplied,
27+
# PEP 566 json encoding for metadata
28+
# https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata
29+
"metadata": ireq.get_dist().json_metadata,
30+
}
31+
return res
32+
33+
def to_dict(self) -> Dict[str, Any]:
34+
return {
35+
"install": {
36+
ireq.get_dist().metadata["Name"]: self._install_req_to_dict(ireq)
37+
for ireq in self._install_requirements
38+
}
39+
}
+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import json
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from ..lib import PipTestEnvironment, TestData
7+
8+
9+
@pytest.mark.usefixtures("with_wheel")
10+
def test_install_report_basic(
11+
script: PipTestEnvironment, shared_data: TestData, tmp_path: Path
12+
) -> None:
13+
report_path = tmp_path / "report.json"
14+
script.pip(
15+
"install",
16+
"simplewheel",
17+
"--dry-run",
18+
"--no-index",
19+
"--find-links",
20+
str(shared_data.root / "packages/"),
21+
"--report",
22+
str(report_path),
23+
)
24+
report = json.loads(report_path.read_text())
25+
assert "install" in report
26+
assert len(report["install"]) == 1
27+
assert "simplewheel" in report["install"]
28+
simplewheel_report = report["install"]["simplewheel"]
29+
assert simplewheel_report["metadata"]["name"] == "simplewheel"
30+
assert simplewheel_report["requested"] is True
31+
assert simplewheel_report["is_direct"] is False
32+
url = simplewheel_report["download_info"]["url"]
33+
assert url.startswith("file://")
34+
assert url.endswith("/packages/simplewheel-2.0-1-py2.py3-none-any.whl")
35+
assert (
36+
simplewheel_report["download_info"]["archive_info"]["hash"]
37+
== "sha256=191d6520d0570b13580bf7642c97ddfbb46dd04da5dd2cf7bef9f32391dfe716"
38+
)
39+
40+
41+
@pytest.mark.usefixtures("with_wheel")
42+
def test_install_report_dep(
43+
script: PipTestEnvironment, shared_data: TestData, tmp_path: Path
44+
) -> None:
45+
"""Test dependencies are present in the install report with requested=False."""
46+
report_path = tmp_path / "report.json"
47+
script.pip(
48+
"install",
49+
"require_simple",
50+
"--dry-run",
51+
"--no-index",
52+
"--find-links",
53+
str(shared_data.root / "packages/"),
54+
"--report",
55+
str(report_path),
56+
)
57+
report = json.loads(report_path.read_text())
58+
assert len(report["install"]) == 2
59+
assert report["install"]["require-simple"]["requested"] is True
60+
assert report["install"]["simple"]["requested"] is False
61+
62+
63+
@pytest.mark.network
64+
@pytest.mark.usefixtures("with_wheel")
65+
def test_install_report_index(script: PipTestEnvironment, tmp_path: Path) -> None:
66+
"""Test report for wheels obtained from index."""
67+
report_path = tmp_path / "report.json"
68+
script.pip(
69+
"install",
70+
"--dry-run",
71+
"Paste[openid]==1.7.5.1",
72+
"--report",
73+
str(report_path),
74+
)
75+
report = json.loads(report_path.read_text())
76+
assert len(report["install"]) == 2
77+
assert report["install"]["paste"]["requested"] is True
78+
assert report["install"]["python-openid"]["requested"] is False
79+
paste_report = report["install"]["paste"]
80+
assert paste_report["download_info"]["url"].startswith(
81+
"https://files.pythonhosted.org/"
82+
)
83+
assert paste_report["download_info"]["url"].endswith("/Paste-1.7.5.1.tar.gz")
84+
assert (
85+
paste_report["download_info"]["archive_info"]["hash"]
86+
== "sha256=11645842ba8ec986ae8cfbe4c6cacff5c35f0f4527abf4f5581ae8b4ad49c0b6"
87+
)
88+
89+
90+
@pytest.mark.network
91+
@pytest.mark.usefixtures("with_wheel")
92+
def test_install_report_vcs_and_wheel_cache(
93+
script: PipTestEnvironment, tmp_path: Path
94+
) -> None:
95+
"""Test report for wheels obtained from index."""
96+
cache_dir = tmp_path / "cache"
97+
report_path = tmp_path / "report.json"
98+
script.pip(
99+
"install",
100+
"git+https://github.com/pypa/pip-test-package"
101+
"@5547fa909e83df8bd743d3978d6667497983a4b7",
102+
"--cache-dir",
103+
str(cache_dir),
104+
"--report",
105+
str(report_path),
106+
)
107+
report = json.loads(report_path.read_text())
108+
assert len(report["install"]) == 1
109+
pip_test_package_report = report["install"]["pip-test-package"]
110+
assert pip_test_package_report["is_direct"] is True
111+
assert pip_test_package_report["requested"] is True
112+
assert (
113+
pip_test_package_report["download_info"]["url"]
114+
== "https://github.com/pypa/pip-test-package"
115+
)
116+
assert pip_test_package_report["download_info"]["vcs_info"]["vcs"] == "git"
117+
assert (
118+
pip_test_package_report["download_info"]["vcs_info"]["commit_id"]
119+
== "5547fa909e83df8bd743d3978d6667497983a4b7"
120+
)
121+
# Now do it again to make sure the cache is used and that the report still contains
122+
# the original VCS url.
123+
report_path.unlink()
124+
result = script.pip(
125+
"install",
126+
"pip-test-package @ git+https://github.com/pypa/pip-test-package"
127+
"@5547fa909e83df8bd743d3978d6667497983a4b7",
128+
"--ignore-installed",
129+
"--cache-dir",
130+
str(cache_dir),
131+
"--report",
132+
str(report_path),
133+
)
134+
assert "Using cached pip_test_package" in result.stdout
135+
report = json.loads(report_path.read_text())
136+
assert len(report["install"]) == 1
137+
pip_test_package_report = report["install"]["pip-test-package"]
138+
assert pip_test_package_report["is_direct"] is True
139+
assert pip_test_package_report["requested"] is True
140+
assert (
141+
pip_test_package_report["download_info"]["url"]
142+
== "https://github.com/pypa/pip-test-package"
143+
)
144+
assert pip_test_package_report["download_info"]["vcs_info"]["vcs"] == "git"
145+
assert (
146+
pip_test_package_report["download_info"]["vcs_info"]["commit_id"]
147+
== "5547fa909e83df8bd743d3978d6667497983a4b7"
148+
)
149+
150+
151+
@pytest.mark.network
152+
@pytest.mark.usefixtures("with_wheel")
153+
def test_install_report_vcs_editable(
154+
script: PipTestEnvironment, tmp_path: Path
155+
) -> None:
156+
"""Test report for wheels obtained from index."""
157+
report_path = tmp_path / "report.json"
158+
script.pip(
159+
"install",
160+
"--editable",
161+
"git+https://github.com/pypa/pip-test-package"
162+
"@5547fa909e83df8bd743d3978d6667497983a4b7"
163+
"#egg=pip-test-package",
164+
"--report",
165+
str(report_path),
166+
)
167+
report = json.loads(report_path.read_text())
168+
assert len(report["install"]) == 1
169+
pip_test_package_report = report["install"]["pip-test-package"]
170+
assert pip_test_package_report["is_direct"] is True
171+
assert pip_test_package_report["download_info"]["url"].startswith("file://")
172+
assert pip_test_package_report["download_info"]["url"].endswith(
173+
"/src/pip-test-package"
174+
)
175+
assert pip_test_package_report["download_info"]["dir_info"]["editable"] is True

0 commit comments

Comments
 (0)