Repository: pypa/pip

Issue:
Change the hashes in the installation report to be a mapping
### What's the problem this feature will solve?

Currently, `pip install --report` generates hash information that looks like:

```
        "archive_info": {
          "hash": "sha256=18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310"
        }
```

This means that we can't show hash information with multiple algorithms easily.

### Describe the solution you'd like

Change archive information into being a mapping of hashes, similar to PEP 691:

```
      "hashes": {"sha256": "...", "blake2b": "..."},
```

We can put this key in `archive_info`, and replace the existing `hash` key with it.

### Alternative Solutions

Not really. The other option is doing something like adding & as a separator between hashes, but that sounds like a horrible thing to do given the alternatives we have and that we're allowed to make backwards incompatible changes right now.

### Additional context

N/A

### 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/11312.feature.rst b/news/11312.feature.rst
new file mode 100644
index 000000000..493dde830
--- /dev/null
+++ b/news/11312.feature.rst
@@ -0,0 +1,2 @@
+Change the hashes in the installation report to be a mapping. Emit the
+``archive_info.hashes`` dictionary in ``direct_url.json``.
diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py
index e75feda9c..09b540f91 100644
--- a/src/pip/_internal/models/direct_url.py
+++ b/src/pip/_internal/models/direct_url.py
@@ -103,17 +103,28 @@ class ArchiveInfo:
     def __init__(
         self,
         hash: Optional[str] = None,
+        hashes: Optional[Dict[str, str]] = None,
     ) -> None:
+        if hash is not None:
+            # Auto-populate the hashes key to upgrade to the new format automatically.
+            # We don't back-populate the legacy hash key.
+            hash_name, hash_value = hash.split("=", 1)
+            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
 
     @classmethod
     def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["ArchiveInfo"]:
         if d is None:
             return None
-        return cls(hash=_get(d, str, "hash"))
+        return cls(hash=_get(d, str, "hash"), hashes=_get(d, dict, "hashes"))
 
     def _to_dict(self) -> Dict[str, Any]:
-        return _filter_none(hash=self.hash)
+        return _filter_none(hash=self.hash, hashes=self.hashes)
 
 
 class DirInfo:


Test patch:
diff --git a/tests/unit/test_direct_url.py b/tests/unit/test_direct_url.py
index c81e51292..e1708ae93 100644
--- a/tests/unit/test_direct_url.py
+++ b/tests/unit/test_direct_url.py
@@ -39,6 +39,10 @@ def test_archive_info() -> None:
     assert (
         direct_url.info.hash == direct_url_dict["archive_info"]["hash"]  # type: ignore
     )
+    # test we add the hashes key automatically
+    direct_url_dict["archive_info"]["hashes"] = {  # type: ignore
+        "sha1": "1b8c5bc61a86f377fea47b4276c8c8a5842d2220"
+    }
     assert direct_url.to_dict() == direct_url_dict
 
 
diff --git a/tests/unit/test_direct_url_helpers.py b/tests/unit/test_direct_url_helpers.py
index 3ab253462..692ee299c 100644
--- a/tests/unit/test_direct_url_helpers.py
+++ b/tests/unit/test_direct_url_helpers.py
@@ -146,6 +146,10 @@ def test_from_link_archive() -> None:
     )
     assert isinstance(direct_url.info, ArchiveInfo)
     assert direct_url.info.hash == "sha1=1b8c5bc61a86f377fea47b4276c8c8a5842d2220"
+    # Test the hashes key has been automatically populated.
+    assert direct_url.info.hashes == {
+        "sha1": "1b8c5bc61a86f377fea47b4276c8c8a5842d2220"
+    }
 
 
 def test_from_link_dir(tmpdir: Path) -> None: