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/



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 👍🏼
Indeed!
We still need to pull in the new version of `trove-classifiers` here and update our usage of it to use the `sorted_classifiers` list.
Is this actually fixed? Looking at https://pypi.org/project/officeextractor/ the ordering is still wrong.
Looks like we need to update this function which does some secondary sorting which may no longer be necessary:

https://github.com/pypa/warehouse/blob/7fc3ce5bd7ecc93ef54c1652787fb5e7757fe6f2/warehouse/filters.py#L126-L144

which is used here:

https://github.com/pypa/warehouse/blob/20c947a4cd4a9667bf0f31722e77b87964ae195c/warehouse/templates/includes/packaging/project-data.html#L128

Re-opening...
Please see PR https://github.com/pypa/warehouse/pull/10006.

Resolving patch:
diff --git a/warehouse/config.py b/warehouse/config.py
--- a/warehouse/config.py
+++ b/warehouse/config.py
@@ -315,6 +315,7 @@ def configure(settings=None):
 
     # We'll want to configure some filters for Jinja2 as well.
     filters = config.get_settings().setdefault("jinja2.filters", {})
+    filters.setdefault("format_classifiers", "warehouse.filters:format_classifiers")
     filters.setdefault("classifier_id", "warehouse.filters:classifier_id")
     filters.setdefault("format_tags", "warehouse.filters:format_tags")
     filters.setdefault("json", "warehouse.filters:tojson")
diff --git a/warehouse/filters.py b/warehouse/filters.py
--- a/warehouse/filters.py
+++ b/warehouse/filters.py
@@ -11,6 +11,7 @@
 # limitations under the License.
 
 import binascii
+import collections
 import enum
 import hmac
 import json
@@ -122,6 +123,20 @@ def format_tags(tags):
     return formatted_tags
 
 
+def format_classifiers(classifiers):
+    structured = collections.OrderedDict()
+
+    # Split up our classifiers into our data structure
+    for classifier in classifiers:
+        key, *value = classifier.split(" :: ", 1)
+        if value:
+            if key not in structured:
+                structured[key] = []
+            structured[key].append(value[0])
+
+    return structured
+
+
 def classifier_id(classifier):
     return classifier.replace(" ", "_").replace("::", ".")
 


Test patch:
diff --git a/tests/functional/test_templates.py b/tests/functional/test_templates.py
--- a/tests/functional/test_templates.py
+++ b/tests/functional/test_templates.py
@@ -21,6 +21,7 @@
     "format_datetime": "warehouse.i18n.filters:format_datetime",
     "format_rfc822_datetime": "warehouse.i18n.filters:format_rfc822_datetime",
     "format_number": "warehouse.i18n.filters:format_number",
+    "format_classifiers": "warehouse.filters:format_classifiers",
     "classifier_id": "warehouse.filters:classifier_id",
     "format_tags": "warehouse.filters:format_tags",
     "json": "warehouse.filters:tojson",
diff --git a/tests/unit/test_filters.py b/tests/unit/test_filters.py
--- a/tests/unit/test_filters.py
+++ b/tests/unit/test_filters.py
@@ -135,6 +135,23 @@ def test_format_tags(inp, expected):
     assert filters.format_tags(inp) == expected
 
 
+@pytest.mark.parametrize(
+    ("inp", "expected"),
+    [
+        (
+            ["Foo :: Bar :: Baz", "Foo :: Bar :: Qux", "Vleep"],
+            [("Foo", ["Bar :: Baz", "Bar :: Qux"])],
+        ),
+        (
+            ["Foo :: Bar :: Baz", "Vleep :: Foo", "Foo :: Bar :: Qux"],
+            [("Foo", ["Bar :: Baz", "Bar :: Qux"]), ("Vleep", ["Foo"])],
+        ),
+    ],
+)
+def test_format_classifiers(inp, expected):
+    assert list(filters.format_classifiers(inp).items()) == expected
+
+
 @pytest.mark.parametrize(
     ("inp", "expected"), [("Foo", "Foo"), ("Foo :: Foo", "Foo_._Foo")]
 )