Repository: peterbe/hashin

Issue:
Order of hashes from `get_package_hashes`
As in #105 , I wonder if we could expect the output of `get_package_hashes` to have hashes in lexicographical order.

GitHub's dependabot is relying on that and is creating commits in my repo that are conflicting with other tools. Of course dependabot could fix it, but I believe it would be cleaner to have the same order out of `get_package_hashes` than the one done by `run_packages`.

Discussion:
MarkusH: This change raises an error for me on Python 3.9 and 3.10:

```bash
$ python -m hashin -r requirements.txt django
```
```python
Traceback (most recent call last):
  File "/usr/lib/python3.9/runpy.py", line 197, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/lib/python3.9/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/home/markus/Coding/hashin/hashin.py", line 855, in <module>
    sys.exit(main())
  File "/home/markus/Coding/hashin/hashin.py", line 837, in main
    return run(
  File "/home/markus/Coding/hashin/hashin.py", line 135, in run
    return run_packages(specs, requirements_file, *args, **kwargs)
  File "/home/markus/Coding/hashin/hashin.py", line 249, in run_packages
    for i, release in enumerate(data["hashes"], key=lambda r: r["hash"]):
TypeError: 'key' is an invalid keyword argument for enumerate()
```
```bash
$ hashin -r requirements.txt django
```
```python
Traceback (most recent call last):
  File "/home/markus/Coding/hashin/venv39/bin/hashin", line 33, in <module>
    sys.exit(load_entry_point('hashin', 'console_scripts', 'hashin')())
  File "/home/markus/Coding/hashin/hashin.py", line 837, in main
    return run(
  File "/home/markus/Coding/hashin/hashin.py", line 135, in run
    return run_packages(specs, requirements_file, *args, **kwargs)
  File "/home/markus/Coding/hashin/hashin.py", line 249, in run_packages
    for i, release in enumerate(data["hashes"], key=lambda r: r["hash"]):
TypeError: 'key' is an invalid keyword argument for enumerate()
```
pcorpet: Oops, I was sloppy, sorry about that.
MarkusH: Works for me. Thank you!
peterbe: @pcorpet 
Would you mind editing the matrix in `.github/workflows/python.yml` and the Python version lists in `tox.ini` as part of this PR?
peterbe: In fact, perhaps it would be best to move the other linting tasks that, currently, 3.8 does into 3.10 in the tox file. 
pcorpet: @peterbe it was actually failing on all versions before my fixes. But I'm all for clean commits and PRs so I'll send you another PR.
pcorpet: I also realize I forgot to update the version history...
pcorpet: I've rebased so that CI runs on python 3.9 and 3.10: ready to merge.

Patch:
diff --git a/README.rst b/README.rst
index 7715b8f..011cbde 100644
--- a/README.rst
+++ b/README.rst
@@ -272,6 +272,10 @@ Version History
 
   * Add python 3.9 and 3.10 to the test matrix.
 
+  * Preserve lexigraphical order of hashes for the output of the
+    ``get_releases_hashes`` function.
+    See https://github.com/peterbe/hashin/issues/126
+
 0.16.0
   * Preserve indented comments when updating requirements files.
     See https://github.com/peterbe/hashin/issues/124
diff --git a/hashin.py b/hashin.py
index 27ecc62..b2937d8 100755
--- a/hashin.py
+++ b/hashin.py
@@ -246,7 +246,7 @@ def run_packages(
         maybe_restriction = "" if not restriction else "; {0}".format(restriction)
         new_lines = "{0}=={1}{2} \\\n".format(req, data["version"], maybe_restriction)
         padding = " " * 4
-        for i, release in enumerate(sorted(data["hashes"], key=lambda r: r["hash"])):
+        for i, release in enumerate(data["hashes"]):
             new_lines += "{0}--hash={1}:{2}".format(padding, algorithm, release["hash"])
             if i != len(data["hashes"]) - 1:
                 new_lines += " \\"
@@ -707,8 +707,9 @@ def get_package_hashes(
         else:
             raise PackageError("No releases could be found for {0}".format(version))
 
-    hashes = list(
-        get_releases_hashes(releases=releases, algorithm=algorithm, verbose=verbose)
+    hashes = sorted(
+        get_releases_hashes(releases=releases, algorithm=algorithm, verbose=verbose),
+        key=lambda r: r["hash"],
     )
     return {"package": package, "version": version, "hashes": hashes}
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0ba7f74..725a159 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2370,10 +2370,10 @@ def test_get_package_hashes_unknown_algorithm(murlopen, capsys):
         "version": "0.10",
         "hashes": [
             {
-                "hash": "45d1c5d2237a3b4f78b4198709fb2ecf1f781c8234ce3d94356f2100a36739433952c6c13b2843952f608949e6baa9f95055a314487cd8fb3f9d76522d8edb50"
+                "hash": "0d63bf4c115154781846ecf573049324f06b021a1d4b92da4fae2bf491da2b83a13096b14d73e73cefad36855f4fa936bac4b2357dabf05a2b1e7329ff1e5455"
             },
             {
-                "hash": "0d63bf4c115154781846ecf573049324f06b021a1d4b92da4fae2bf491da2b83a13096b14d73e73cefad36855f4fa936bac4b2357dabf05a2b1e7329ff1e5455"
+                "hash": "45d1c5d2237a3b4f78b4198709fb2ecf1f781c8234ce3d94356f2100a36739433952c6c13b2843952f608949e6baa9f95055a314487cd8fb3f9d76522d8edb50"
             },
             {
                 "hash": "c32e6d9fb09dc36ab9222c4606a1f43a2dcc183a8c64bdd9199421ef779072c174fa044b155babb12860cf000e36bc4d358694fa22420c997b1dd75b623d4daa"
@@ -2442,6 +2442,48 @@ def test_get_package_hashes_without_version(murlopen, capsys):
         hashin.get_package_hashes(package="uggamugga")
 
 
+def test_get_package_hashes_consistant_order(murlopen):
+    def mocked_get(url, **options):
+        if url == "https://pypi.org/pypi/hashin/json":
+            return _Response(
+                {
+                    "info": {"version": "0.10", "name": "hashin"},
+                    "releases": {
+                        "0.10": [
+                            {
+                                "url": "https://pypi.org/packages/3.3/p/hashin/hashin-0.10-py3-none-any.whl",
+                                "digests": {"sha256": "bbbbb"},
+                            },
+                            {
+                                "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz",
+                                "digests": {"sha256": "ccccc"},
+                            },
+                            {
+                                "url": "https://pypi.org/packages/2.7/p/hashin/hashin-0.10-py2-none-any.whl",
+                                "digests": {"sha256": "aaaaa"},
+                            },
+                        ]
+                    },
+                }
+            )
+
+        raise NotImplementedError(url)
+
+    murlopen.side_effect = mocked_get
+
+    result = hashin.get_package_hashes(
+        package="hashin", version="0.10", algorithm="sha256"
+    )
+
+    expected = {
+        "package": "hashin",
+        "version": "0.10",
+        "hashes": [{"hash": "aaaaa"}, {"hash": "bbbbb"}, {"hash": "ccccc"}],
+    }
+
+    assert result == expected
+
+
 def test_with_extras_syntax(murlopen, tmpfile):
     """When you want to add the hashes of a package by using the
     "extras notation". E.g `requests[security]`.