Repository: pypa/pip

Issue:
Install report download_info.info.hashes missing for direct URL archives without link hash
### Description

When installing an archive from a direct URL where the URL does not contain a hash fragment, the installation report misses the `hashes` key and only has the legacy `hash` key.

Originally reported by @phaer in https://github.com/pypa/pip/issues/11946#issuecomment-1503143597


### Expected behavior

The `hashes` key should be populated.

### pip version

From 23.0

### Python version

any

### OS

any

### How to Reproduce

`pip install --dry-run --quiet --report -  {local archive}`.

### Output

_No response_

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).

Discussion:

Patch:
diff --git a/news/11948.bugfix.rst b/news/11948.bugfix.rst
new file mode 100644
index 000000000..74af91381
--- /dev/null
+++ b/news/11948.bugfix.rst
@@ -0,0 +1,3 @@
+When installing an archive from a direct URL or local file, populate
+``download_info.info.hashes`` in the installation report, in addition to the legacy
+``download_info.info.hash`` key.
diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py
index c3de70a74..e219d7384 100644
--- a/src/pip/_internal/models/direct_url.py
+++ b/src/pip/_internal/models/direct_url.py
@@ -105,22 +105,31 @@ class ArchiveInfo:
         hash: Optional[str] = None,
         hashes: Optional[Dict[str, str]] = None,
     ) -> None:
-        if hash is not None:
+        # set hashes before hash, since the hash setter will further populate hashes
+        self.hashes = hashes
+        self.hash = hash
+
+    @property
+    def hash(self) -> Optional[str]:
+        return self._hash
+
+    @hash.setter
+    def hash(self, value: Optional[str]) -> None:
+        if value is not None:
             # Auto-populate the hashes key to upgrade to the new format automatically.
-            # We don't back-populate the legacy hash key.
+            # We don't back-populate the legacy hash key from hashes.
             try:
-                hash_name, hash_value = hash.split("=", 1)
+                hash_name, hash_value = value.split("=", 1)
             except ValueError:
                 raise DirectUrlValidationError(
-                    f"invalid archive_info.hash format: {hash!r}"
+                    f"invalid archive_info.hash format: {value!r}"
                 )
-            if hashes is None:
-                hashes = {hash_name: hash_value}
-            elif hash_name not in hash:
-                hashes = hashes.copy()
-                hashes[hash_name] = hash_value
-        self.hash = hash
-        self.hashes = hashes
+            if self.hashes is None:
+                self.hashes = {hash_name: hash_value}
+            elif hash_name not in self.hashes:
+                self.hashes = self.hashes.copy()
+                self.hashes[hash_name] = hash_value
+        self._hash = value
 
     @classmethod
     def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["ArchiveInfo"]:
diff --git a/src/pip/_internal/models/installation_report.py b/src/pip/_internal/models/installation_report.py
index b54afb109..fef3757f2 100644
--- a/src/pip/_internal/models/installation_report.py
+++ b/src/pip/_internal/models/installation_report.py
@@ -14,7 +14,7 @@ class InstallationReport:
     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
+            # PEP 610 json for the download URL. download_info.archive_info.hashes 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.
diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py
index 343a01bef..dda92d29b 100644
--- a/src/pip/_internal/operations/prepare.py
+++ b/src/pip/_internal/operations/prepare.py
@@ -571,12 +571,15 @@ class RequirementPreparer:
             # Make sure we have a hash in download_info. If we got it as part of the
             # URL, it will have been verified and we can rely on it. Otherwise we
             # compute it from the downloaded file.
+            # FIXME: https://github.com/pypa/pip/issues/11943
             if (
                 isinstance(req.download_info.info, ArchiveInfo)
-                and not req.download_info.info.hash
+                and not req.download_info.info.hashes
                 and local_file
             ):
                 hash = hash_file(local_file.path)[0].hexdigest()
+                # We populate info.hash for backward compatibility.
+                # This will automatically populate info.hashes.
                 req.download_info.info.hash = f"sha256={hash}"
 
         # For use in later processing,
diff --git a/src/pip/_internal/resolution/legacy/resolver.py b/src/pip/_internal/resolution/legacy/resolver.py
index fb49d4169..3a561e6db 100644
--- a/src/pip/_internal/resolution/legacy/resolver.py
+++ b/src/pip/_internal/resolution/legacy/resolver.py
@@ -436,7 +436,7 @@ class Resolver(BaseResolver):
                 req.download_info = cache_entry.origin
             else:
                 # Legacy cache entry that does not have origin.json.
-                # download_info may miss the archive_info.hash field.
+                # download_info may miss the archive_info.hashes field.
                 req.download_info = direct_url_from_link(
                     req.link, link_is_in_wheel_cache=cache_entry.persistent
                 )
diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py
index fe83a6123..4d6249944 100644
--- a/src/pip/_internal/resolution/resolvelib/candidates.py
+++ b/src/pip/_internal/resolution/resolvelib/candidates.py
@@ -280,7 +280,7 @@ class LinkCandidate(_InstallRequirementBackedCandidate):
                 ireq.download_info = cache_entry.origin
             else:
                 # Legacy cache entry that does not have origin.json.
-                # download_info may miss the archive_info.hash field.
+                # download_info may miss the archive_info.hashes field.
                 ireq.download_info = direct_url_from_link(
                     source_link, link_is_in_wheel_cache=cache_entry.persistent
                 )


Test patch:
diff --git a/tests/functional/test_install_report.py b/tests/functional/test_install_report.py
index 83f5b5c2c..4560fc9e7 100644
--- a/tests/functional/test_install_report.py
+++ b/tests/functional/test_install_report.py
@@ -92,6 +92,39 @@ def test_install_report_index(script: PipTestEnvironment, tmp_path: Path) -> Non
     assert "requires_dist" in paste_report["metadata"]
 
 
+@pytest.mark.network
+def test_install_report_direct_archive(
+    script: PipTestEnvironment, tmp_path: Path, shared_data: TestData
+) -> None:
+    """Test report for direct URL archive."""
+    report_path = tmp_path / "report.json"
+    script.pip(
+        "install",
+        str(shared_data.root / "packages" / "simplewheel-1.0-py2.py3-none-any.whl"),
+        "--dry-run",
+        "--no-index",
+        "--report",
+        str(report_path),
+    )
+    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 True
+    url = simplewheel_report["download_info"]["url"]
+    assert url.startswith("file://")
+    assert url.endswith("/packages/simplewheel-1.0-py2.py3-none-any.whl")
+    assert (
+        simplewheel_report["download_info"]["archive_info"]["hash"]
+        == "sha256=e63aa139caee941ec7f33f057a5b987708c2128238357cf905429846a2008718"
+    )
+    assert simplewheel_report["download_info"]["archive_info"]["hashes"] == {
+        "sha256": "e63aa139caee941ec7f33f057a5b987708c2128238357cf905429846a2008718"
+    }
+
+
 @pytest.mark.network
 def test_install_report_vcs_and_wheel_cache(
     script: PipTestEnvironment, tmp_path: Path
diff --git a/tests/unit/test_direct_url.py b/tests/unit/test_direct_url.py
index 3ca982b50..151e0a30f 100644
--- a/tests/unit/test_direct_url.py
+++ b/tests/unit/test_direct_url.py
@@ -140,3 +140,33 @@ def test_redact_url() -> None:
         == "https://${PIP_TOKEN}@g.c/u/p.git"
     )
     assert _redact_git("ssh://git@g.c/u/p.git") == "ssh://git@g.c/u/p.git"
+
+
+def test_hash_to_hashes() -> None:
+    direct_url = DirectUrl(url="https://e.c/archive.tar.gz", info=ArchiveInfo())
+    assert isinstance(direct_url.info, ArchiveInfo)
+    direct_url.info.hash = "sha256=abcdef"
+    assert direct_url.info.hashes == {"sha256": "abcdef"}
+
+
+def test_hash_to_hashes_constructor() -> None:
+    direct_url = DirectUrl(
+        url="https://e.c/archive.tar.gz", info=ArchiveInfo(hash="sha256=abcdef")
+    )
+    assert isinstance(direct_url.info, ArchiveInfo)
+    assert direct_url.info.hashes == {"sha256": "abcdef"}
+    direct_url = DirectUrl(
+        url="https://e.c/archive.tar.gz",
+        info=ArchiveInfo(hash="sha256=abcdef", hashes={"sha512": "123456"}),
+    )
+    assert isinstance(direct_url.info, ArchiveInfo)
+    assert direct_url.info.hashes == {"sha256": "abcdef", "sha512": "123456"}
+    # In case of conflict between hash and hashes, hashes wins.
+    direct_url = DirectUrl(
+        url="https://e.c/archive.tar.gz",
+        info=ArchiveInfo(
+            hash="sha256=abcdef", hashes={"sha256": "012345", "sha512": "123456"}
+        ),
+    )
+    assert isinstance(direct_url.info, ArchiveInfo)
+    assert direct_url.info.hashes == {"sha256": "012345", "sha512": "123456"}