diff --git a/docs/html/cli/pip_install.rst b/docs/html/cli/pip_install.rst
index 408ef25ff96..384d393fdd7 100644
--- a/docs/html/cli/pip_install.rst
+++ b/docs/html/cli/pip_install.rst
@@ -79,6 +79,18 @@ for an exception regarding pre-release versions). Where more than one source of
the chosen version is available, it is assumed that any source is acceptable
(as otherwise the versions would differ).
+Obtaining information about what was installed
+----------------------------------------------
+
+The install command has a ``--report`` option that will generate a JSON report of what
+pip has installed. In combination with the ``--dry-run`` and ``--ignore-installed`` it
+can be used to *resolve* a set of requirements without actually installing them.
+
+The report can be written to a file, or to standard output (using ``--report -`` in
+combination with ``--quiet``).
+
+The format of the JSON report is described in :doc:`../reference/installation-report`.
+
Installation Order
------------------
diff --git a/docs/html/reference/index.md b/docs/html/reference/index.md
index 855dc79b37a..749e88096aa 100644
--- a/docs/html/reference/index.md
+++ b/docs/html/reference/index.md
@@ -9,4 +9,5 @@ interoperability standards that pip utilises/implements.
build-system/index
requirement-specifiers
requirements-file-format
+installation-report
```
diff --git a/docs/html/reference/installation-report.md b/docs/html/reference/installation-report.md
new file mode 100644
index 00000000000..0be6be75394
--- /dev/null
+++ b/docs/html/reference/installation-report.md
@@ -0,0 +1,176 @@
+# Installation Report
+
+The `--report` option of the pip install command produces a detailed JSON report of what
+it did install (or what it would have installed, if used with the `--dry-run` option).
+
+## Specification
+
+The report is a JSON object with the following properties:
+
+- `version`: the string `0`, denoting that the installation report is an experimental
+ feature. This value will change to `1`, when the feature is deemed stable after
+ gathering user feedback (likely in pip 22.3 or 23.0). Backward incompatible changes
+ may be introduced in version `1` without notice. After that, it will change only if
+ and when backward incompatible changes are introduced, such as removing mandatory
+ fields or changing the semantics or data type of existing fields. The introduction of
+ backward incompatible changes will follow the usual pip processes such as the
+ deprecation cycle or feature flags. Tools must check this field to ensure they support
+ the corresponding version.
+
+- `pip_version`: a string with the version of pip used to produce the report.
+
+- `install`: an array of [InstallationReportItem](InstallationReportItem) representing
+ the distribution packages (to be) installed.
+
+- `environment`: an object describing the environment where the installation report was
+ generated. See [PEP 508 environment
+ markers](https://peps.python.org/pep-0508/#environment-markers) for more information.
+ Values have a string type.
+
+(InstallationReportItem)=
+
+An `InstallationReportItem` is an object describing a (to be) installed distribution
+package with the following properties:
+
+- `metadata`: the metadata of the distribution, converted to a JSON object according to
+ the [PEP 566
+ transformation](https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata).
+
+- `is_direct`: `true` if the requirement was provided as, or constrained to, a direct
+ URL reference. `false` if the requirements was provided as a name and version
+ specifier.
+
+- `download_info`: Information about the artifact (to be) downloaded for installation,
+ using the [direct
+ URL](https://packaging.python.org/en/latest/specifications/direct-url/) data
+ structure. When `is_direct` is `true`, this field is the same as the `direct_url.json`
+ metadata, otherwise it represents the URL of the artifact obtained from the index or
+ `--find-links`.
+
+ ```{note}
+ For source archives, `download_info.archive_info.hash` may
+ be absent when the requirement was installed from the wheel cache
+ and the cache entry was populated by an older pip version that did not
+ record the origin URL of the downloaded artifact.
+ ```
+
+- `requested`: `true` if the requirement was explicitly provided by the user, either
+ directely via a command line argument or indirectly via a requirements file. `false`
+ if the requirement was installed as a dependency of another requirement.
+
+- `requested_extras`: extras requested by the user. This field is only present when the
+ `requested` field is true.
+
+## Example
+
+The following command:
+
+```console
+pip install \
+ --ignore-installed --dry-run --quiet \
+ --report - \
+ "pydantic>=1.9" git+https://github.com/pypa/packaging@main
+```
+
+will produce an output similar to this (metadata abriged for brevity):
+
+```json
+{
+ "version": "0",
+ "pip_version": "22.2",
+ "install": [
+ {
+ "download_info": {
+ "url": "https://files.pythonhosted.org/packages/a4/0c/fbaa7319dcb5eecd3484686eb5a5c5702a6445adb566f01aee6de3369bc4/pydantic-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+ "archive_info": {
+ "hash": "sha256=18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310"
+ }
+ },
+ "is_direct": false,
+ "requested": true,
+ "metadata": {
+ "name": "pydantic",
+ "version": "1.9.1",
+ "requires_dist": [
+ "typing-extensions (>=3.7.4.3)",
+ "dataclasses (>=0.6) ; python_version < \"3.7\"",
+ "python-dotenv (>=0.10.4) ; extra == 'dotenv'",
+ "email-validator (>=1.0.3) ; extra == 'email'"
+ ],
+ "requires_python": ">=3.6.1",
+ "provides_extra": [
+ "dotenv",
+ "email"
+ ]
+ }
+ },
+ {
+ "download_info": {
+ "url": "https://github.com/pypa/packaging",
+ "vcs_info": {
+ "vcs": "git",
+ "requested_revision": "main",
+ "commit_id": "4f42225e91a0be634625c09e84dd29ea82b85e27"
+ }
+ },
+ "is_direct": true,
+ "requested": true,
+ "metadata": {
+ "name": "packaging",
+ "version": "21.4.dev0",
+ "requires_dist": [
+ "pyparsing (!=3.0.5,>=2.0.2)"
+ ],
+ "requires_python": ">=3.7"
+ }
+ },
+ {
+ "download_info": {
+ "url": "https://files.pythonhosted.org/packages/6c/10/a7d0fa5baea8fe7b50f448ab742f26f52b80bfca85ac2be9d35cdd9a3246/pyparsing-3.0.9-py3-none-any.whl",
+ "archive_info": {
+ "hash": "sha256=5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
+ }
+ },
+ "is_direct": false,
+ "requested": false,
+ "metadata": {
+ "name": "pyparsing",
+ "version": "3.0.9",
+ "requires_dist": [
+ "railroad-diagrams ; extra == \"diagrams\"",
+ "jinja2 ; extra == \"diagrams\""
+ ],
+ "requires_python": ">=3.6.8"
+ }
+ },
+ {
+ "download_info": {
+ "url": "https://files.pythonhosted.org/packages/75/e1/932e06004039dd670c9d5e1df0cd606bf46e29a28e65d5bb28e894ea29c9/typing_extensions-4.2.0-py3-none-any.whl",
+ "archive_info": {
+ "hash": "sha256=6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"
+ }
+ },
+ "is_direct": false,
+ "requested": false,
+ "metadata": {
+ "name": "typing_extensions",
+ "version": "4.2.0",
+ "requires_python": ">=3.7"
+ }
+ }
+ ],
+ "environment": {
+ "implementation_name": "cpython",
+ "implementation_version": "3.10.5",
+ "os_name": "posix",
+ "platform_machine": "x86_64",
+ "platform_release": "5.13-generic",
+ "platform_system": "Linux",
+ "platform_version": "...",
+ "python_full_version": "3.10.5",
+ "platform_python_implementation": "CPython",
+ "python_version": "3.10",
+ "sys_platform": "linux"
+ }
+}
+```
diff --git a/news/53.feature.rst b/news/53.feature.rst
new file mode 100644
index 00000000000..a0525602446
--- /dev/null
+++ b/news/53.feature.rst
@@ -0,0 +1,3 @@
+Add an experimental ``--report`` option to the install command to generate a JSON report
+of what was installed. In combination with ``--dry-run`` and ``--ignore-installed`` it
+can be used to resolve the requirements.
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index 9bb99cdf2a5..29907645c81 100644
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -1,4 +1,5 @@
import errno
+import json
import operator
import os
import shutil
@@ -7,6 +8,7 @@
from typing import Iterable, List, Optional
from pip._vendor.packaging.utils import canonicalize_name
+from pip._vendor.rich import print_json
from pip._internal.cache import WheelCache
from pip._internal.cli import cmdoptions
@@ -21,6 +23,7 @@
from pip._internal.locations import get_scheme
from pip._internal.metadata import get_environment
from pip._internal.models.format_control import FormatControl
+from pip._internal.models.installation_report import InstallationReport
from pip._internal.operations.build.build_tracker import get_build_tracker
from pip._internal.operations.check import ConflictDetails, check_install_conflicts
from pip._internal.req import install_given_reqs
@@ -250,6 +253,20 @@ def add_options(self) -> None:
self.parser.insert_option_group(0, index_opts)
self.parser.insert_option_group(0, self.cmd_opts)
+ self.cmd_opts.add_option(
+ "--report",
+ dest="json_report_file",
+ metavar="file",
+ default=None,
+ help=(
+ "Generate a JSON file describing what pip did to install "
+ "the provided requirements. "
+ "Can be used in combination with --dry-run and --ignore-installed "
+ "to 'resolve' the requirements. "
+ "When - is used as file name it writes to stdout."
+ ),
+ )
+
@with_cleanup
def run(self, options: Values, args: List[str]) -> int:
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:
reqs, check_supported_wheels=not options.target_dir
)
+ if options.json_report_file:
+ logger.warning(
+ "--report is currently an experimental option. "
+ "The output format may change in a future release "
+ "without prior warning."
+ )
+
+ report = InstallationReport(requirement_set.requirements_to_install)
+ if options.json_report_file == "-":
+ print_json(data=report.to_dict())
+ else:
+ with open(options.json_report_file, "w", encoding="utf-8") as f:
+ json.dump(report.to_dict(), f, indent=2, ensure_ascii=False)
+
if options.dry_run:
would_install_items = sorted(
(r.metadata["name"], r.metadata["version"])
diff --git a/src/pip/_internal/models/installation_report.py b/src/pip/_internal/models/installation_report.py
new file mode 100644
index 00000000000..965f0952371
--- /dev/null
+++ b/src/pip/_internal/models/installation_report.py
@@ -0,0 +1,53 @@
+from typing import Any, Dict, Sequence
+
+from pip._vendor.packaging.markers import default_environment
+
+from pip import __version__
+from pip._internal.req.req_install import InstallRequirement
+
+
+class InstallationReport:
+ def __init__(self, install_requirements: Sequence[InstallRequirement]):
+ self._install_requirements = install_requirements
+
+ @classmethod
+ def _install_req_to_dict(cls, ireq: InstallRequirement) -> Dict[str, Any]:
+ assert ireq.download_info, f"No download_info for {ireq}"
+ res = {
+ # PEP 610 json for the download URL. download_info.archive_info.hash may
+ # be absent when the requirement was installed from the wheel cache
+ # and the cache entry was populated by an older pip version that did not
+ # record origin.json.
+ "download_info": ireq.download_info.to_dict(),
+ # is_direct is true if the requirement was a direct URL reference (which
+ # includes editable requirements), and false if the requirement was
+ # downloaded from a PEP 503 index or --find-links.
+ "is_direct": bool(ireq.original_link),
+ # requested is true if the requirement was specified by the user (aka
+ # top level requirement), and false if it was installed as a dependency of a
+ # requirement. https://peps.python.org/pep-0376/#requested
+ "requested": ireq.user_supplied,
+ # PEP 566 json encoding for metadata
+ # https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata
+ "metadata": ireq.get_dist().metadata_dict,
+ }
+ if ireq.user_supplied and ireq.extras:
+ # For top level requirements, the list of requested extras, if any.
+ res["requested_extras"] = list(sorted(ireq.extras))
+ return res
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "version": "0",
+ "pip_version": __version__,
+ "install": [
+ self._install_req_to_dict(ireq) for ireq in self._install_requirements
+ ],
+ # https://peps.python.org/pep-0508/#environment-markers
+ # TODO: currently, the resolver uses the default environment to evaluate
+ # environment markers, so that is what we report here. In the future, it
+ # should also take into account options such as --python-version or
+ # --platform, perhaps under the form of an environment_override field?
+ # https://github.com/pypa/pip/issues/11198
+ "environment": default_environment(),
+ }
diff --git a/tests/functional/test_install_report.py b/tests/functional/test_install_report.py
new file mode 100644
index 00000000000..b61fd89c69f
--- /dev/null
+++ b/tests/functional/test_install_report.py
@@ -0,0 +1,214 @@
+import json
+from pathlib import Path
+from typing import Any, Dict
+
+import pytest
+from packaging.utils import canonicalize_name
+
+from ..lib import PipTestEnvironment, TestData
+
+
+def _install_dict(report: Dict[str, Any]) -> Dict[str, Any]:
+ return {canonicalize_name(i["metadata"]["name"]): i for i in report["install"]}
+
+
+@pytest.mark.usefixtures("with_wheel")
+def test_install_report_basic(
+ script: PipTestEnvironment, shared_data: TestData, tmp_path: Path
+) -> None:
+ report_path = tmp_path / "report.json"
+ script.pip(
+ "install",
+ "simplewheel",
+ "--dry-run",
+ "--no-index",
+ "--find-links",
+ str(shared_data.root / "packages/"),
+ "--report",
+ str(report_path),
+ allow_stderr_warning=True,
+ )
+ report = json.loads(report_path.read_text())
+ assert "install" in report
+ assert len(report["install"]) == 1
+ simplewheel_report = _install_dict(report)["simplewheel"]
+ assert simplewheel_report["metadata"]["name"] == "simplewheel"
+ assert simplewheel_report["requested"] is True
+ assert simplewheel_report["is_direct"] is False
+ url = simplewheel_report["download_info"]["url"]
+ assert url.startswith("file://")
+ assert url.endswith("/packages/simplewheel-2.0-1-py2.py3-none-any.whl")
+ assert (
+ simplewheel_report["download_info"]["archive_info"]["hash"]
+ == "sha256=191d6520d0570b13580bf7642c97ddfbb46dd04da5dd2cf7bef9f32391dfe716"
+ )
+
+
+@pytest.mark.usefixtures("with_wheel")
+def test_install_report_dep(
+ script: PipTestEnvironment, shared_data: TestData, tmp_path: Path
+) -> None:
+ """Test dependencies are present in the install report with requested=False."""
+ report_path = tmp_path / "report.json"
+ script.pip(
+ "install",
+ "require_simple",
+ "--dry-run",
+ "--no-index",
+ "--find-links",
+ str(shared_data.root / "packages/"),
+ "--report",
+ str(report_path),
+ allow_stderr_warning=True,
+ )
+ report = json.loads(report_path.read_text())
+ assert len(report["install"]) == 2
+ assert _install_dict(report)["require-simple"]["requested"] is True
+ assert _install_dict(report)["simple"]["requested"] is False
+
+
+@pytest.mark.network
+@pytest.mark.usefixtures("with_wheel")
+def test_install_report_index(script: PipTestEnvironment, tmp_path: Path) -> None:
+ """Test report for sdist obtained from index."""
+ report_path = tmp_path / "report.json"
+ script.pip(
+ "install",
+ "--dry-run",
+ "Paste[openid]==1.7.5.1",
+ "--report",
+ str(report_path),
+ allow_stderr_warning=True,
+ )
+ report = json.loads(report_path.read_text())
+ assert len(report["install"]) == 2
+ install_dict = _install_dict(report)
+ assert install_dict["paste"]["requested"] is True
+ assert install_dict["python-openid"]["requested"] is False
+ paste_report = install_dict["paste"]
+ assert paste_report["download_info"]["url"].startswith(
+ "https://files.pythonhosted.org/"
+ )
+ assert paste_report["download_info"]["url"].endswith("/Paste-1.7.5.1.tar.gz")
+ assert (
+ paste_report["download_info"]["archive_info"]["hash"]
+ == "sha256=11645842ba8ec986ae8cfbe4c6cacff5c35f0f4527abf4f5581ae8b4ad49c0b6"
+ )
+ assert paste_report["requested_extras"] == ["openid"]
+ assert "requires_dist" in paste_report["metadata"]
+
+
+@pytest.mark.network
+@pytest.mark.usefixtures("with_wheel")
+def test_install_report_vcs_and_wheel_cache(
+ script: PipTestEnvironment, tmp_path: Path
+) -> None:
+ """Test report for VCS reference, and interactions with the wheel cache."""
+ cache_dir = tmp_path / "cache"
+ report_path = tmp_path / "report.json"
+ script.pip(
+ "install",
+ "git+https://github.com/pypa/pip-test-package"
+ "@5547fa909e83df8bd743d3978d6667497983a4b7",
+ "--cache-dir",
+ str(cache_dir),
+ "--report",
+ str(report_path),
+ allow_stderr_warning=True,
+ )
+ report = json.loads(report_path.read_text())
+ assert len(report["install"]) == 1
+ pip_test_package_report = report["install"][0]
+ assert pip_test_package_report["is_direct"] is True
+ assert pip_test_package_report["requested"] is True
+ assert (
+ pip_test_package_report["download_info"]["url"]
+ == "https://github.com/pypa/pip-test-package"
+ )
+ assert pip_test_package_report["download_info"]["vcs_info"]["vcs"] == "git"
+ assert (
+ pip_test_package_report["download_info"]["vcs_info"]["commit_id"]
+ == "5547fa909e83df8bd743d3978d6667497983a4b7"
+ )
+ # Now do it again to make sure the cache is used and that the report still contains
+ # the original VCS url.
+ report_path.unlink()
+ result = script.pip(
+ "install",
+ "pip-test-package @ git+https://github.com/pypa/pip-test-package"
+ "@5547fa909e83df8bd743d3978d6667497983a4b7",
+ "--ignore-installed",
+ "--cache-dir",
+ str(cache_dir),
+ "--report",
+ str(report_path),
+ allow_stderr_warning=True,
+ )
+ assert "Using cached pip_test_package" in result.stdout
+ report = json.loads(report_path.read_text())
+ assert len(report["install"]) == 1
+ pip_test_package_report = report["install"][0]
+ assert pip_test_package_report["is_direct"] is True
+ assert pip_test_package_report["requested"] is True
+ assert (
+ pip_test_package_report["download_info"]["url"]
+ == "https://github.com/pypa/pip-test-package"
+ )
+ assert pip_test_package_report["download_info"]["vcs_info"]["vcs"] == "git"
+ assert (
+ pip_test_package_report["download_info"]["vcs_info"]["commit_id"]
+ == "5547fa909e83df8bd743d3978d6667497983a4b7"
+ )
+
+
+@pytest.mark.network
+@pytest.mark.usefixtures("with_wheel")
+def test_install_report_vcs_editable(
+ script: PipTestEnvironment, tmp_path: Path
+) -> None:
+ """Test report remote editable."""
+ report_path = tmp_path / "report.json"
+ script.pip(
+ "install",
+ "--editable",
+ "git+https://github.com/pypa/pip-test-package"
+ "@5547fa909e83df8bd743d3978d6667497983a4b7"
+ "#egg=pip-test-package",
+ "--report",
+ str(report_path),
+ allow_stderr_warning=True,
+ )
+ report = json.loads(report_path.read_text())
+ assert len(report["install"]) == 1
+ pip_test_package_report = report["install"][0]
+ assert pip_test_package_report["is_direct"] is True
+ assert pip_test_package_report["download_info"]["url"].startswith("file://")
+ assert pip_test_package_report["download_info"]["url"].endswith(
+ "/src/pip-test-package"
+ )
+ assert pip_test_package_report["download_info"]["dir_info"]["editable"] is True
+
+
+@pytest.mark.usefixtures("with_wheel")
+def test_install_report_to_stdout(
+ script: PipTestEnvironment, shared_data: TestData
+) -> None:
+ result = script.pip(
+ "install",
+ "simplewheel",
+ "--quiet",
+ "--dry-run",
+ "--no-index",
+ "--find-links",
+ str(shared_data.root / "packages/"),
+ "--report",
+ "-",
+ allow_stderr_warning=True,
+ )
+ assert result.stderr == (
+ "WARNING: --report is currently an experimental option. "
+ "The output format may change in a future release without prior warning.\n"
+ )
+ report = json.loads(result.stdout)
+ assert "install" in report
+ assert len(report["install"]) == 1