Repository: pypi/warehouse

Issue:
Use natural sort order for file listings
**What's the problem this feature will solve?**

Currently on https://pypi.org/project/lxml/4.6.3/#files, the files are listed as:

- lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl
- lxml-4.6.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl
- lxml-4.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl
- lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl

This is because the strings are sorted as 27 < 310 < 35, for strings.

**Describe the solution you'd like**

Use natural sorting order for filenames, similar to what we did for https://github.com/pypa/trove-classifiers/issues/56.

This _may_ also make sense for the simple pages, where it would be a nice-to-have when a human looks at the page.



Discussion:
I added the 'good first issue' label to this issue. I would recommend using https://pypi.org/project/natsort/ for this.
How about writing all the filenames in fixed N digits, for example 027 for 27 if using 3 digits? 
That isn't permitted. The filenames come from files uploaded by the users/package authors, and need to be presented without modification.
Alright, then I'll get to using [natsort](https://pypi.org/project/natsort/) for the file listings. 
I looked at this yesterday, it seems like the ordering comes from the view definition here: 
https://github.com/pypa/warehouse/blob/9029bb58bd2273efcfcf6906b6bda2145f028a44/warehouse/packaging/models.py#L412-L419

It looks like the sqlalchemy method `order_by` takes a clause to order the results by. I'm not sure whether this can just be a python function so we could wrap this with `natsorted()` and call it a day, or if it would have to be a SQL implementation of natsorting similar to this: http://www.rhodiumtoad.org.uk/junk/naturalsort.sql 

I'm having issues adding `natsort` as a dependency, when I go to make the `requirements.txt` I get the following:
```bash
$ make requirements/main.txt                                            
/bin/sh: .state/env/bin/python: No such file or directory
/Users/crow/warehouse/.state/env/bin/pip-compile --allow-unsafe --generate-hashes --output-file=requirements/main.txt requirements/main.in
make: /Users/crow/warehouse/.state/env/bin/pip-compile: No such file or directory
```


@Benjscho Sorry about that, that should be resolved by https://github.com/pypa/warehouse/pull/10278
@di Thanks! I'll try again now
You'd want to order using natsort.natsort_keygen in the sqlalchemy order_by lambda: https://natsort.readthedocs.io/en/master/api.html#natsort.natsort_keygen



Still struggling to get make requirements working, now getting an error relating to `pg_config` not found 
```bash
    Error: pg_config executable not found.
    
    pg_config is required to build psycopg2 from source.  Please add the directory
    containing pg_config to the $PATH or specify the full executable path with the
    option:
    
        python setup.py build_ext --pg-config /path/to/pg_config build ...
    
    or with the pg_config option in 'setup.cfg'.
```

This may be because I have incompatible python versions running but not sure how to disable them. 

Should otherwise be able to test the change here which I believe should work https://github.com/Benjscho/warehouse/commit/38a3b5434fb65ad082889905d68e6e1126cb8faf
re `pg_config` not found: you need to apt-get install either postgresql-common, libpq-dev (making some assumptions about build env).
If you're using mac os, what you may want to do is
```
brew install libpq
```
In the output, it will mentionned that libpq is not automatically put in the path, so you may want to either edit your path for the current shell or link libpq executables to your path with the `link --force` command that will be described in the output.

Resolving patch:
diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py
--- a/warehouse/packaging/views.py
+++ b/warehouse/packaging/views.py
@@ -10,6 +10,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from natsort import natsorted
 from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
 from pyramid.view import view_config
 from sqlalchemy.orm.exc import NoResultFound
@@ -125,7 +126,8 @@ def release_detail(release, request):
         "project": project,
         "release": release,
         "description": description,
-        "files": release.files.all(),
+        # We cannot easily sort naturally in SQL, sort here and pass to template
+        "files": natsorted(release.files.all(), reverse=True, key=lambda f: f.filename),
         "latest_version": project.latest_version,
         "all_versions": project.all_versions,
         "maintainers": maintainers,


Test patch:
diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py
--- a/tests/unit/packaging/test_views.py
+++ b/tests/unit/packaging/test_views.py
@@ -13,6 +13,7 @@
 import pretend
 import pytest
 
+from natsort import natsorted
 from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
 
 from warehouse.packaging import views
@@ -271,6 +272,27 @@ def test_detail_renders(self, monkeypatch, db_request):
             pretend.call("unrendered description", "text/plain")
         ]
 
+    def test_detail_renders_files_natural_sort(self, db_request):
+        """Tests that when a release has multiple versions of Python,
+        the sort order is most recent Python version first."""
+        project = ProjectFactory.create()
+        release = ReleaseFactory.create(project=project, version="3.0")
+        files = [
+            FileFactory.create(
+                release=release,
+                filename="{}-{}-{}.tar.gz".format(
+                    project.name, release.version, py_ver
+                ),
+                python_version="source",
+            )
+            for py_ver in ["cp27", "cp310", "cp39"]  # intentionally out of order
+        ]
+        sorted_files = natsorted(files, reverse=True, key=lambda f: f.filename)
+
+        result = views.release_detail(release, db_request)
+
+        assert result["files"] == sorted_files
+
     def test_license_from_classifier(self, db_request):
         """A license label is added when a license classifier exists."""
         other_classifier = ClassifierFactory.create(