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:
/cc @sbidoul 
For an alternative solution (not suggesting we _should_ do it instead), we can make `hashes` a list of `algo=hashval` strings.
It's a mapping -- let's store it as one. I expect that users will compose one anyway, regardless of what format we spew it out in. (`[s.split("=") for s in li]` vs `di.items()`, `di.get(algo)` vs {alternative too difficult to code-golf})
Yeah, I've had that in mind too.

I think that should be done as an evolution of PEP 610.

I'm under the impression that PyPI only returns one hash type, though ?
The JSON API returns multiple hashes.
> The JSON API returns multiple hashes.

It is foreseen in the PEP 691 API yes, but in practice pypi.org returns sha256 only.

Anyway I agree this should be done, I'm not sure about the urgency. 

Let's do this before we stabilise the format.
I've removed the needs standard thing, because it's already technically possible, through existing standards, to have multiple hashes from multiple algorithms.
> I've removed the needs standard thing, because it's already technically possible, through existing standards, to have multiple hashes from multiple algorithms.

Yes but no :) The standard used for the `download_info` field is PEP 610 and it is important to keep it so because
- in some case, they *are* direct URLs, 
- and sometimes it comes from `origin.json` in the wheel cache for which we use the direct URL format too,
- and because the closer we are to documented interoperability standards, the easier it will be to consume the format.

So let's do the standard update work first, please. It should be relatively easy: add the `hashes` field you propose and write up the SHOULD/MUST language to address backward compatibility concerns for consumers and producers.


I'm very confused. The pip installation report format is explicitly noted to be a pip-only non-interoperability standard format. 

Why is a PEP/standard process involved in changing it? 
I can understand that if we wanted to extend the download info key in the standard, to allow storing the metadata in that format there -- but we're not blocked on that. Like, literally, we can add a `dict([s.split("=")])` in the pip-specific format, and close this issue. And, come around to dealing with multiple hashes later.

Sure, that's not the best way to do things, but my point is that we don't have to wait on a standardisation process and this is firmly a detail we can change today.
I agree. I think we should future-proof the pip-only format *now*, before people start relying on it, and then we're prepared should PEP 610 get extended later. And if PEP 610 never gets extended, we've lost nothing and we've gained a small amount of user friendliness (we do the `.split("=")` so you don't have to 🙂)
Of course we *could* do without changing PEP 610. 

Yet let me add these arguments:
- the installation report is not an interoperability standard but it composes interoperability standards to ease consumption (as well as production)
- download_info is explicitly documented as being PEP 610 compliant
- diverging from it will make the implementation and documentation much more complex
- it will make it harder to consume for our users
- the fact that PEP 610 supports a single hash only is clearly an oversight

I'll try to prepare an amendment PEP 610 and I'll see how well it fits.

> I'll try to prepare an amendment PEP 610 and I'll see how well it fits.

A very gentle nudge on this. :)

Hi @pradyunsg, this is still on my radar, but I won't have time to tackle this before November/December.
That's perfectly reasonable. We should keep the warning about the format until we address this though.
I think this change will be backward compatible, though, as I plan to propose new a `hashes` field to the PEP 610 data structure, while keeping `hash` for backward compatibility.
@pradyunsg As promised, [here is the draft PEP](https://discuss.python.org/t/amendment-to-the-pep-610-direct-url-json-data-structure/22299) to amend the `direct_url.json` data structure.
Thank you for doing this @sbidoul! ^.^

Resolving patch:
diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py
--- 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
--- 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
--- 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: