Skip to content

Commit d830c96

Browse files
authored
Merge pull request #10771 from sbidoul/install-report-sbi
Installation/resolution report (aka pip install --dry-run --report)
2 parents e975318 + 074c6b5 commit d830c96

File tree

7 files changed

+490
-0
lines changed

7 files changed

+490
-0
lines changed

docs/html/cli/pip_install.rst

+12
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ for an exception regarding pre-release versions). Where more than one source of
7979
the chosen version is available, it is assumed that any source is acceptable
8080
(as otherwise the versions would differ).
8181

82+
Obtaining information about what was installed
83+
----------------------------------------------
84+
85+
The install command has a ``--report`` option that will generate a JSON report of what
86+
pip has installed. In combination with the ``--dry-run`` and ``--ignore-installed`` it
87+
can be used to *resolve* a set of requirements without actually installing them.
88+
89+
The report can be written to a file, or to standard output (using ``--report -`` in
90+
combination with ``--quiet``).
91+
92+
The format of the JSON report is described in :doc:`../reference/installation-report`.
93+
8294
Installation Order
8395
------------------
8496

docs/html/reference/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ interoperability standards that pip utilises/implements.
99
build-system/index
1010
requirement-specifiers
1111
requirements-file-format
12+
installation-report
1213
```
+176
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# Installation Report
2+
3+
The `--report` option of the pip install command produces a detailed JSON report of what
4+
it did install (or what it would have installed, if used with the `--dry-run` option).
5+
6+
## Specification
7+
8+
The report is a JSON object with the following properties:
9+
10+
- `version`: the string `0`, denoting that the installation report is an experimental
11+
feature. This value will change to `1`, when the feature is deemed stable after
12+
gathering user feedback (likely in pip 22.3 or 23.0). Backward incompatible changes
13+
may be introduced in version `1` without notice. After that, it will change only if
14+
and when backward incompatible changes are introduced, such as removing mandatory
15+
fields or changing the semantics or data type of existing fields. The introduction of
16+
backward incompatible changes will follow the usual pip processes such as the
17+
deprecation cycle or feature flags. Tools must check this field to ensure they support
18+
the corresponding version.
19+
20+
- `pip_version`: a string with the version of pip used to produce the report.
21+
22+
- `install`: an array of [InstallationReportItem](InstallationReportItem) representing
23+
the distribution packages (to be) installed.
24+
25+
- `environment`: an object describing the environment where the installation report was
26+
generated. See [PEP 508 environment
27+
markers](https://peps.python.org/pep-0508/#environment-markers) for more information.
28+
Values have a string type.
29+
30+
(InstallationReportItem)=
31+
32+
An `InstallationReportItem` is an object describing a (to be) installed distribution
33+
package with the following properties:
34+
35+
- `metadata`: the metadata of the distribution, converted to a JSON object according to
36+
the [PEP 566
37+
transformation](https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata).
38+
39+
- `is_direct`: `true` if the requirement was provided as, or constrained to, a direct
40+
URL reference. `false` if the requirements was provided as a name and version
41+
specifier.
42+
43+
- `download_info`: Information about the artifact (to be) downloaded for installation,
44+
using the [direct
45+
URL](https://packaging.python.org/en/latest/specifications/direct-url/) data
46+
structure. When `is_direct` is `true`, this field is the same as the `direct_url.json`
47+
metadata, otherwise it represents the URL of the artifact obtained from the index or
48+
`--find-links`.
49+
50+
```{note}
51+
For source archives, `download_info.archive_info.hash` may
52+
be absent when the requirement was installed from the wheel cache
53+
and the cache entry was populated by an older pip version that did not
54+
record the origin URL of the downloaded artifact.
55+
```
56+
57+
- `requested`: `true` if the requirement was explicitly provided by the user, either
58+
directely via a command line argument or indirectly via a requirements file. `false`
59+
if the requirement was installed as a dependency of another requirement.
60+
61+
- `requested_extras`: extras requested by the user. This field is only present when the
62+
`requested` field is true.
63+
64+
## Example
65+
66+
The following command:
67+
68+
```console
69+
pip install \
70+
--ignore-installed --dry-run --quiet \
71+
--report - \
72+
"pydantic>=1.9" git+https://github.com/pypa/packaging@main
73+
```
74+
75+
will produce an output similar to this (metadata abriged for brevity):
76+
77+
```json
78+
{
79+
"version": "0",
80+
"pip_version": "22.2",
81+
"install": [
82+
{
83+
"download_info": {
84+
"url": "https://files.pythonhosted.org/packages/a4/0c/fbaa7319dcb5eecd3484686eb5a5c5702a6445adb566f01aee6de3369bc4/pydantic-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
85+
"archive_info": {
86+
"hash": "sha256=18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310"
87+
}
88+
},
89+
"is_direct": false,
90+
"requested": true,
91+
"metadata": {
92+
"name": "pydantic",
93+
"version": "1.9.1",
94+
"requires_dist": [
95+
"typing-extensions (>=3.7.4.3)",
96+
"dataclasses (>=0.6) ; python_version < \"3.7\"",
97+
"python-dotenv (>=0.10.4) ; extra == 'dotenv'",
98+
"email-validator (>=1.0.3) ; extra == 'email'"
99+
],
100+
"requires_python": ">=3.6.1",
101+
"provides_extra": [
102+
"dotenv",
103+
"email"
104+
]
105+
}
106+
},
107+
{
108+
"download_info": {
109+
"url": "https://github.com/pypa/packaging",
110+
"vcs_info": {
111+
"vcs": "git",
112+
"requested_revision": "main",
113+
"commit_id": "4f42225e91a0be634625c09e84dd29ea82b85e27"
114+
}
115+
},
116+
"is_direct": true,
117+
"requested": true,
118+
"metadata": {
119+
"name": "packaging",
120+
"version": "21.4.dev0",
121+
"requires_dist": [
122+
"pyparsing (!=3.0.5,>=2.0.2)"
123+
],
124+
"requires_python": ">=3.7"
125+
}
126+
},
127+
{
128+
"download_info": {
129+
"url": "https://files.pythonhosted.org/packages/6c/10/a7d0fa5baea8fe7b50f448ab742f26f52b80bfca85ac2be9d35cdd9a3246/pyparsing-3.0.9-py3-none-any.whl",
130+
"archive_info": {
131+
"hash": "sha256=5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
132+
}
133+
},
134+
"is_direct": false,
135+
"requested": false,
136+
"metadata": {
137+
"name": "pyparsing",
138+
"version": "3.0.9",
139+
"requires_dist": [
140+
"railroad-diagrams ; extra == \"diagrams\"",
141+
"jinja2 ; extra == \"diagrams\""
142+
],
143+
"requires_python": ">=3.6.8"
144+
}
145+
},
146+
{
147+
"download_info": {
148+
"url": "https://files.pythonhosted.org/packages/75/e1/932e06004039dd670c9d5e1df0cd606bf46e29a28e65d5bb28e894ea29c9/typing_extensions-4.2.0-py3-none-any.whl",
149+
"archive_info": {
150+
"hash": "sha256=6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"
151+
}
152+
},
153+
"is_direct": false,
154+
"requested": false,
155+
"metadata": {
156+
"name": "typing_extensions",
157+
"version": "4.2.0",
158+
"requires_python": ">=3.7"
159+
}
160+
}
161+
],
162+
"environment": {
163+
"implementation_name": "cpython",
164+
"implementation_version": "3.10.5",
165+
"os_name": "posix",
166+
"platform_machine": "x86_64",
167+
"platform_release": "5.13-generic",
168+
"platform_system": "Linux",
169+
"platform_version": "...",
170+
"python_full_version": "3.10.5",
171+
"platform_python_implementation": "CPython",
172+
"python_version": "3.10",
173+
"sys_platform": "linux"
174+
}
175+
}
176+
```

news/53.feature.rst

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

src/pip/_internal/commands/install.py

+31
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
@@ -7,6 +8,7 @@
78
from typing import Iterable, List, Optional
89

910
from pip._vendor.packaging.utils import canonicalize_name
11+
from pip._vendor.rich import print_json
1012

1113
from pip._internal.cache import WheelCache
1214
from pip._internal.cli import cmdoptions
@@ -21,6 +23,7 @@
2123
from pip._internal.locations import get_scheme
2224
from pip._internal.metadata import get_environment
2325
from pip._internal.models.format_control import FormatControl
26+
from pip._internal.models.installation_report import InstallationReport
2427
from pip._internal.operations.build.build_tracker import get_build_tracker
2528
from pip._internal.operations.check import ConflictDetails, check_install_conflicts
2629
from pip._internal.req import install_given_reqs
@@ -250,6 +253,20 @@ def add_options(self) -> None:
250253
self.parser.insert_option_group(0, index_opts)
251254
self.parser.insert_option_group(0, self.cmd_opts)
252255

256+
self.cmd_opts.add_option(
257+
"--report",
258+
dest="json_report_file",
259+
metavar="file",
260+
default=None,
261+
help=(
262+
"Generate a JSON file describing what pip did to install "
263+
"the provided requirements. "
264+
"Can be used in combination with --dry-run and --ignore-installed "
265+
"to 'resolve' the requirements. "
266+
"When - is used as file name it writes to stdout."
267+
),
268+
)
269+
253270
@with_cleanup
254271
def run(self, options: Values, args: List[str]) -> int:
255272
if options.use_user_site and options.target_dir is not None:
@@ -353,6 +370,20 @@ def run(self, options: Values, args: List[str]) -> int:
353370
reqs, check_supported_wheels=not options.target_dir
354371
)
355372

373+
if options.json_report_file:
374+
logger.warning(
375+
"--report is currently an experimental option. "
376+
"The output format may change in a future release "
377+
"without prior warning."
378+
)
379+
380+
report = InstallationReport(requirement_set.requirements_to_install)
381+
if options.json_report_file == "-":
382+
print_json(data=report.to_dict())
383+
else:
384+
with open(options.json_report_file, "w", encoding="utf-8") as f:
385+
json.dump(report.to_dict(), f, indent=2, ensure_ascii=False)
386+
356387
if options.dry_run:
357388
would_install_items = sorted(
358389
(r.metadata["name"], r.metadata["version"])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from typing import Any, Dict, Sequence
2+
3+
from pip._vendor.packaging.markers import default_environment
4+
5+
from pip import __version__
6+
from pip._internal.req.req_install import InstallRequirement
7+
8+
9+
class InstallationReport:
10+
def __init__(self, install_requirements: Sequence[InstallRequirement]):
11+
self._install_requirements = install_requirements
12+
13+
@classmethod
14+
def _install_req_to_dict(cls, ireq: InstallRequirement) -> Dict[str, Any]:
15+
assert ireq.download_info, f"No download_info for {ireq}"
16+
res = {
17+
# PEP 610 json for the download URL. download_info.archive_info.hash may
18+
# be absent when the requirement was installed from the wheel cache
19+
# and the cache entry was populated by an older pip version that did not
20+
# record origin.json.
21+
"download_info": ireq.download_info.to_dict(),
22+
# is_direct is true if the requirement was a direct URL reference (which
23+
# includes editable requirements), and false if the requirement was
24+
# downloaded from a PEP 503 index or --find-links.
25+
"is_direct": bool(ireq.original_link),
26+
# requested is true if the requirement was specified by the user (aka
27+
# top level requirement), and false if it was installed as a dependency of a
28+
# requirement. https://peps.python.org/pep-0376/#requested
29+
"requested": ireq.user_supplied,
30+
# PEP 566 json encoding for metadata
31+
# https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata
32+
"metadata": ireq.get_dist().metadata_dict,
33+
}
34+
if ireq.user_supplied and ireq.extras:
35+
# For top level requirements, the list of requested extras, if any.
36+
res["requested_extras"] = list(sorted(ireq.extras))
37+
return res
38+
39+
def to_dict(self) -> Dict[str, Any]:
40+
return {
41+
"version": "0",
42+
"pip_version": __version__,
43+
"install": [
44+
self._install_req_to_dict(ireq) for ireq in self._install_requirements
45+
],
46+
# https://peps.python.org/pep-0508/#environment-markers
47+
# TODO: currently, the resolver uses the default environment to evaluate
48+
# environment markers, so that is what we report here. In the future, it
49+
# should also take into account options such as --python-version or
50+
# --platform, perhaps under the form of an environment_override field?
51+
# https://github.com/pypa/pip/issues/11198
52+
"environment": default_environment(),
53+
}

0 commit comments

Comments
 (0)