diff --git a/docs/html/cli/index.md b/docs/html/cli/index.md index a3497c308c2..4b56dbeb4cb 100644 --- a/docs/html/cli/index.md +++ b/docs/html/cli/index.md @@ -16,6 +16,7 @@ pip pip_install pip_uninstall +pip_inspect pip_list pip_show pip_freeze diff --git a/docs/html/cli/pip_inspect.rst b/docs/html/cli/pip_inspect.rst new file mode 100644 index 00000000000..2aa8bd3b846 --- /dev/null +++ b/docs/html/cli/pip_inspect.rst @@ -0,0 +1,33 @@ +.. _`pip inspect`: + +=========== +pip inspect +=========== + +.. versionadded:: 22.2 + + +Usage +===== + +.. tab:: Unix/macOS + + .. pip-command-usage:: inspect "python -m pip" + +.. tab:: Windows + + .. pip-command-usage:: inspect "py -m pip" + + +Description +=========== + +.. pip-command-description:: inspect + +The format of the JSON output is described in :doc:`../reference/inspect-report`. + + +Options +======= + +.. pip-command-options:: inspect diff --git a/docs/html/reference/index.md b/docs/html/reference/index.md index 749e88096aa..63ce1ca4dbf 100644 --- a/docs/html/reference/index.md +++ b/docs/html/reference/index.md @@ -10,4 +10,5 @@ build-system/index requirement-specifiers requirements-file-format installation-report +inspect-report ``` diff --git a/docs/html/reference/inspect-report.md b/docs/html/reference/inspect-report.md new file mode 100644 index 00000000000..4d367da9de8 --- /dev/null +++ b/docs/html/reference/inspect-report.md @@ -0,0 +1,217 @@ +# `pip inspect` JSON output specification + +```{versionadded} 22.2 +``` + +The `pip inspect` command produces a detailed JSON report of the Python +environment, including installed distributions. + +## Specification + +The report is a JSON object with the following properties: + +- `version`: the string `0`, denoting that the inspect command 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. + +- `installed`: an array of [InspectReportItem](InspectReportItem) representing the + distribution packages that are 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. + +(InspectReportItem)= + +An `InspectReportItem` is an object describing an 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). + +- `metadata_location`: the location of the metadata of the installed distribution. Most + of the time this is the `.dist-info` directory. For legacy installs it is the + `.egg-info` directory. + + ```{warning} + This field may not necessary point to a directory, for instance, in the case of older + `.egg` installs. + ``` + +- `direct_url`: Information about the direct URL that was used for installation, if any, + using the [direct + URL](https://packaging.python.org/en/latest/specifications/direct-url/) data + structure. In most case, this field corresponds to the `direct_url.json` metadata, + except for legacy editable installs, where it is emulated. + +- `requested`: `true` if the `REQUESTED` metadata is present, `false` otherwise. This + field is only present for modern `.dist-info` installations. + + ```{note} + The `REQUESTED` metadata may not be generated by all installers. + It is generated by pip since version 20.2. + ``` + +- `installer`: the content of the `INSTALLER` metadata, if present and not empty. + +## Example + +Running the ``pip inspect`` command, in an environment where `pip` is installed in +editable mode and `packaging` is installed as well, will produce an output similar to +this (metadata abriged for brevity): + +```json +{ + "version": "0", + "pip_version": "22.2.dev0", + "installed": [ + { + "metadata": { + "metadata_version": "2.1", + "name": "pyparsing", + "version": "3.0.9", + "summary": "pyparsing module - Classes and methods to define and execute parsing grammars", + "description_content_type": "text/x-rst", + "author_email": "Paul McGuire ", + "classifier": [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Typing :: Typed" + ], + "requires_dist": [ + "railroad-diagrams ; extra == \"diagrams\"", + "jinja2 ; extra == \"diagrams\"" + ], + "requires_python": ">=3.6.8", + "project_url": [ + "Homepage, https://github.com/pyparsing/pyparsing/" + ], + "provides_extra": [ + "diagrams" + ], + "description": "..." + }, + "metadata_location": "/home/me/.virtualenvs/demoenv/lib/python3.8/site-packages/pyparsing-3.0.9.dist-info", + "installer": "pip", + "requested": false + }, + { + "metadata": { + "metadata_version": "2.1", + "name": "packaging", + "version": "21.3", + "platform": [ + "UNKNOWN" + ], + "summary": "Core utilities for Python packages", + "description_content_type": "text/x-rst", + "home_page": "https://github.com/pypa/packaging", + "author": "Donald Stufft and individual contributors", + "author_email": "donald@stufft.io", + "license": "BSD-2-Clause or Apache-2.0", + "classifier": [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy" + ], + "requires_dist": [ + "pyparsing (!=3.0.5,>=2.0.2)" + ], + "requires_python": ">=3.6", + "description": "..." + }, + "metadata_location": "/home/me/.virtualenvs/demoenv/lib/python3.8/site-packages/packaging-21.3.dist-info", + "installer": "pip", + "requested": true + }, + { + "metadata": { + "metadata_version": "2.1", + "name": "pip", + "version": "22.2.dev0", + "summary": "The PyPA recommended tool for installing Python packages.", + "home_page": "https://pip.pypa.io/", + "author": "The pip developers", + "author_email": "distutils-sig@python.org", + "license": "MIT", + "classifier": [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Topic :: Software Development :: Build Tools", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy" + ], + "requires_python": ">=3.7", + "project_url": [ + "Documentation, https://pip.pypa.io", + "Source, https://github.com/pypa/pip", + "Changelog, https://pip.pypa.io/en/stable/news/" + ], + "description": "..." + }, + "metadata_location": "/home/me/pip/src/pip.egg-info", + "direct_url": { + "url": "file:///home/me/pip/src", + "dir_info": { + "editable": true + } + } + } + ], + "environment": { + "implementation_name": "cpython", + "implementation_version": "3.8.10", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_release": "5.13-generic", + "platform_system": "Linux", + "platform_version": "...", + "python_full_version": "3.8.10", + "platform_python_implementation": "CPython", + "python_version": "3.8", + "sys_platform": "linux" + } +} +``` diff --git a/news/11245.feature.rst b/news/11245.feature.rst new file mode 100644 index 00000000000..ba80a8976da --- /dev/null +++ b/news/11245.feature.rst @@ -0,0 +1,2 @@ +Add ``pip inspect`` command to obtain the list of installed distributions and other +information about the Python environment, in JSON format. diff --git a/src/pip/_internal/commands/__init__.py b/src/pip/_internal/commands/__init__.py index c72f24f30e2..858a4101416 100644 --- a/src/pip/_internal/commands/__init__.py +++ b/src/pip/_internal/commands/__init__.py @@ -38,6 +38,11 @@ "FreezeCommand", "Output installed packages in requirements format.", ), + "inspect": CommandInfo( + "pip._internal.commands.inspect", + "InspectCommand", + "Inspect the python environment.", + ), "list": CommandInfo( "pip._internal.commands.list", "ListCommand", diff --git a/src/pip/_internal/commands/inspect.py b/src/pip/_internal/commands/inspect.py new file mode 100644 index 00000000000..a4e3599306e --- /dev/null +++ b/src/pip/_internal/commands/inspect.py @@ -0,0 +1,97 @@ +import logging +from optparse import Values +from typing import Any, Dict, List + +from pip._vendor.packaging.markers import default_environment +from pip._vendor.rich import print_json + +from pip import __version__ +from pip._internal.cli import cmdoptions +from pip._internal.cli.req_command import Command +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.metadata import BaseDistribution, get_environment +from pip._internal.utils.compat import stdlib_pkgs +from pip._internal.utils.urls import path_to_url + +logger = logging.getLogger(__name__) + + +class InspectCommand(Command): + """ + Inspect the content of a Python environment and produce a report in JSON format. + """ + + ignore_require_venv = True + usage = """ + %prog [options]""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + "--local", + action="store_true", + default=False, + help=( + "If in a virtualenv that has global access, do not list " + "globally-installed packages." + ), + ) + self.cmd_opts.add_option( + "--user", + dest="user", + action="store_true", + default=False, + help="Only output packages installed in user-site.", + ) + self.cmd_opts.add_option(cmdoptions.list_path()) + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options: Values, args: List[str]) -> int: + logger.warning( + "pip inspect is currently an experimental command. " + "The output format may change in a future release without prior warning." + ) + + cmdoptions.check_list_path_option(options) + dists = get_environment(options.path).iter_installed_distributions( + local_only=options.local, + user_only=options.user, + skip=set(stdlib_pkgs), + ) + output = { + "version": "0", + "pip_version": __version__, + "installed": [self._dist_to_dict(dist) for dist in dists], + "environment": default_environment(), + # TODO tags? scheme? + } + print_json(data=output) + return SUCCESS + + def _dist_to_dict(self, dist: BaseDistribution) -> Dict[str, Any]: + res: Dict[str, Any] = { + "metadata": dist.metadata_dict, + "metadata_location": dist.info_location, + } + # direct_url. Note that we don't have download_info (as in the installation + # report) since it is not recorded in installed metadata. + direct_url = dist.direct_url + if direct_url is not None: + res["direct_url"] = direct_url.to_dict() + else: + # Emulate direct_url for legacy editable installs. + editable_project_location = dist.editable_project_location + if editable_project_location is not None: + res["direct_url"] = { + "url": path_to_url(editable_project_location), + "dir_info": { + "editable": True, + }, + } + # installer + installer = dist.installer + if dist.installer: + res["installer"] = installer + # requested + if dist.installed_with_dist_info: + res["requested"] = dist.requested + return res diff --git a/src/pip/_internal/metadata/base.py b/src/pip/_internal/metadata/base.py index 10a6c489783..151fd6d009e 100644 --- a/src/pip/_internal/metadata/base.py +++ b/src/pip/_internal/metadata/base.py @@ -311,6 +311,10 @@ def installer(self) -> str: return cleaned_line return "" + @property + def requested(self) -> bool: + return self.is_file("REQUESTED") + @property def editable(self) -> bool: return bool(self.editable_project_location) diff --git a/tests/functional/test_inspect.py b/tests/functional/test_inspect.py new file mode 100644 index 00000000000..464bdbaa11e --- /dev/null +++ b/tests/functional/test_inspect.py @@ -0,0 +1,45 @@ +import json + +import pytest + +from tests.conftest import ScriptFactory +from tests.lib import PipTestEnvironment, TestData + + +@pytest.fixture(scope="session") +def simple_script( + tmpdir_factory: pytest.TempPathFactory, + script_factory: ScriptFactory, + shared_data: TestData, +) -> PipTestEnvironment: + tmpdir = tmpdir_factory.mktemp("pip_test_package") + script = script_factory(tmpdir.joinpath("workspace")) + script.pip( + "install", + "-f", + shared_data.find_links, + "--no-index", + "simplewheel==1.0", + ) + return script + + +def test_inspect_basic(simple_script: PipTestEnvironment) -> None: + """ + Test default behavior of inspect command. + """ + result = simple_script.pip("inspect", allow_stderr_warning=True) + report = json.loads(result.stdout) + installed = report["installed"] + assert len(installed) == 4 + installed_by_name = {i["metadata"]["name"]: i for i in installed} + assert installed_by_name.keys() == { + "pip", + "setuptools", + "coverage", + "simplewheel", + } + assert installed_by_name["simplewheel"]["metadata"]["version"] == "1.0" + assert installed_by_name["simplewheel"]["requested"] is True + assert installed_by_name["simplewheel"]["installer"] == "pip" + assert "environment" in report