Repository: pypi/warehouse

Issue:
Classifiers: Python version sort order
<!--
    NOTE: This issue should be for problems with PyPI itself, including:
    * pypi.org
    * test.pypi.org
    * files.pythonhosted.org

    This issue should NOT be for a project installed from PyPI. If you are
    having an issue with a specific package, you should reach out to the
    maintainers of that project directly instead.

    Furthermore, this issue should NOT be for any non-PyPI properties (like
    python.org, docs.python.org, etc.)
-->

**Describe the bug**
<!-- A clear and concise description the bug -->

The classifiers "Programming Language :: Python :: 3.X" aren't sorted in the right order on https://pypi.org as well as on https://test.pypi.org

I'm defining the classifiers like this in the `setup.py` file.

```
classifiers=[
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.6",
    "Programming Language :: Python :: 3.7",
    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10"
    ]
```

In the navigation bar on pypy.org it will then appear like this: 
![image](https://user-images.githubusercontent.com/70264417/99465712-42797f00-293b-11eb-8f1a-dced842f433f.png)

With Python 3.10 at the top instead of at the bottom (after Python 3.9).


To give the visitors of pypi.org a better and faster overview over a project, it would be great if the Python classifiers were sorted by the Python versions.


**Expected behavior**
<!-- A clear and concise description of what you expected to happen -->

Classifiers sorted by Python versions.

Python :: 3
Python :: 3.6
Python :: 3.7
Python :: 3.8
Python :: 3.9
Python :: 3.10
Python :: 3.11
Python :: 3.12
etc.

**To Reproduce**
<!-- Steps to reproduce the bug, or a link to PyPI where the bug is visible -->

It can be seen for example here: https://pypi.org/project/officeextractor/

Bump trove-classifiers from 2021.1.14 to 2021.2.28
Bumps [trove-classifiers](https://github.com/pypa/trove-classifiers) from 2021.1.14 to 2021.2.28.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a href="https://github.com/pypa/trove-classifiers/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://api.dependabot.com/badges/compatibility_score?dependency-name=trove-classifiers&package-manager=pip&previous-version=2021.1.14&new-version=2021.2.28)](https://dependabot.com/compatibility-score/?dependency-name=trove-classifiers&package-manager=pip&previous-version=2021.1.14&new-version=2021.2.28)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
- `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language
- `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language
- `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language
- `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language
- `@dependabot badge me` will comment on this PR with code to add a "Dependabot enabled" badge to your readme

Additionally, you can set the following in your Dependabot [dashboard](https://app.dependabot.com):
- Update frequency (including time of day and day of week)
- Pull request limits (per update run and/or open at any time)
- Automerge options (never/patch/minor, and dev/runtime dependencies)
- Out-of-range updates (receive only lockfile updates, if desired)
- Security updates (receive only security updates, if desired)



</details>


Discussion:
Thanks for filing an issue. PyPI is technically sorting these "correctly", since the original classifier order is not preserved in the project metadata, classifiers are just strings, and the classifiers are being sorted lexicographically. This can be seen in a number of other places as well, like https://pypi.org/classifiers/.

I agree that it does feel unintuitive for humans, though. I'd be open to accepting a change that takes any integer/float value of segment of a classifier into account when sorting. However, this should probably be computed once and pre-built into https://github.com/pypa/trove-classifiers instead of being done on the fly everywhere by PyPI. For instances like this, where we'd have to do it on the fly, `trove-classifiers` should also provide a sorting function as well.

I've filed https://github.com/pypa/trove-classifiers/issues/56 to capture that and marked this issue as blocked until it's implemented.
This is also an issue in the list of filters in the sidebar of the search results page, e.g. https://pypi.org/search/?q=beautifulsoup

![Screenshot from 2021-01-19 23-43-05](https://user-images.githubusercontent.com/5687998/105128436-08e70080-5ab1-11eb-9f12-05cf9390b9f5.png)

Also an issue in https://pypi.org/pypi?%3Aaction=list_classifiers
I assume this has been fixed now by https://github.com/pypa/trove-classifiers/pull/57 👍🏼


Resolving patch:
diff --git a/warehouse/legacy/api/pypi.py b/warehouse/legacy/api/pypi.py
--- a/warehouse/legacy/api/pypi.py
+++ b/warehouse/legacy/api/pypi.py
@@ -13,7 +13,7 @@
 from pyramid.httpexceptions import HTTPGone, HTTPMovedPermanently, HTTPNotFound
 from pyramid.response import Response
 from pyramid.view import forbidden_view_config, view_config
-from trove_classifiers import classifiers
+from trove_classifiers import sorted_classifiers
 
 from warehouse.classifiers.models import Classifier
 
@@ -76,7 +76,7 @@ def forbidden_legacy(exc, request):
 @view_config(route_name="legacy.api.pypi.list_classifiers")
 def list_classifiers(request):
     return Response(
-        text="\n".join(sorted(classifiers)), content_type="text/plain; charset=utf-8"
+        text="\n".join(sorted_classifiers), content_type="text/plain; charset=utf-8"
     )
 
 
diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py
--- a/warehouse/packaging/models.py
+++ b/warehouse/packaging/models.py
@@ -45,6 +45,8 @@
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import validates
 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
+from sqlalchemy.sql import expression
+from trove_classifiers import sorted_classifiers
 
 from warehouse import db
 from warehouse.accounts.models import User
@@ -398,7 +400,10 @@ def __table_args__(cls):  # noqa
         Classifier,
         backref="project_releases",
         secondary=lambda: release_classifiers,
-        order_by=Classifier.classifier,
+        order_by=expression.case(
+            {c: i for i, c in enumerate(sorted_classifiers)},
+            value=Classifier.classifier,
+        ),
         passive_deletes=True,
     )
     classifiers = association_proxy("_classifiers", "classifier")
diff --git a/warehouse/views.py b/warehouse/views.py
--- a/warehouse/views.py
+++ b/warehouse/views.py
@@ -37,8 +37,8 @@
 )
 from sqlalchemy import func
 from sqlalchemy.orm import aliased, joinedload
-from sqlalchemy.sql import exists
-from trove_classifiers import classifiers, deprecated_classifiers
+from sqlalchemy.sql import exists, expression
+from trove_classifiers import deprecated_classifiers, sorted_classifiers
 
 from warehouse.accounts import REDIRECT_FIELD_NAME
 from warehouse.accounts.models import User
@@ -261,7 +261,7 @@ def locale(request):
     route_name="classifiers", renderer="pages/classifiers.html", has_translations=True
 )
 def list_classifiers(request):
-    return {"classifiers": sorted(classifiers)}
+    return {"classifiers": sorted_classifiers}
 
 
 @view_config(
@@ -311,7 +311,12 @@ def search(request):
             ),
             Classifier.classifier.notin_(deprecated_classifiers.keys()),
         )
-        .order_by(Classifier.classifier)
+        .order_by(
+            expression.case(
+                {c: i for i, c in enumerate(sorted_classifiers)},
+                value=Classifier.classifier,
+            )
+        )
     )
 
     for cls in classifiers_q:


Test patch:
diff --git a/tests/unit/legacy/api/test_pypi.py b/tests/unit/legacy/api/test_pypi.py
--- a/tests/unit/legacy/api/test_pypi.py
+++ b/tests/unit/legacy/api/test_pypi.py
@@ -14,7 +14,7 @@
 import pytest
 
 from pyramid.httpexceptions import HTTPBadRequest, HTTPMovedPermanently, HTTPNotFound
-from trove_classifiers import classifiers
+from trove_classifiers import sorted_classifiers
 
 from warehouse.legacy.api import pypi
 
@@ -71,7 +71,7 @@ def test_list_classifiers(db_request):
     resp = pypi.list_classifiers(db_request)
 
     assert resp.status_code == 200
-    assert resp.text == "\n".join(sorted(classifiers))
+    assert resp.text == "\n".join(sorted_classifiers)
 
 
 def test_search():
diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py
--- a/tests/unit/test_views.py
+++ b/tests/unit/test_views.py
@@ -22,7 +22,7 @@
     HTTPSeeOther,
     HTTPServiceUnavailable,
 )
-from trove_classifiers import classifiers
+from trove_classifiers import sorted_classifiers
 from webob.multidict import MultiDict
 
 from warehouse import views
@@ -425,7 +425,7 @@ def raiser(*args, **kwargs):
 
 
 def test_classifiers(db_request):
-    assert list_classifiers(db_request) == {"classifiers": sorted(classifiers)}
+    assert list_classifiers(db_request) == {"classifiers": sorted_classifiers}
 
 
 def test_stats(db_request):