Repository: pypi/warehouse

Issue:
Python 3.1 classifier filtering is broken
When [filtering by the `Programming Language :: Python :: 3.1` classifier on pypi.org][1], the results include packages which don't have that classifier - any package with a classifier matching `Programming Language :: Python :: 3.1*` is included. That is, packages for 3.10, 3.11, 3.12, and so on are included in the results.

[1]: https://pypi.org/search/?q=&o=&c=Programming+Language+%3A%3A+Python+%3A%3A+3.1



Discussion:

Resolving patch:
diff --git a/warehouse/search/queries.py b/warehouse/search/queries.py
--- a/warehouse/search/queries.py
+++ b/warehouse/search/queries.py
@@ -52,36 +52,45 @@ def get_es_query(es, terms, order, classifiers):
     """
     Returns an Elasticsearch query from data from the request.
     """
+    classifier_q = Q(
+        "bool",
+        # Theh results must have all selected classifiers
+        must=[
+            Q(
+                "bool",
+                should=[
+                    # Term search for the exact classifier
+                    Q("term", classifiers=classifier),
+                    # Prefix search for potential children classifiers
+                    Q("prefix", classifiers=classifier + " :: "),
+                ],
+            )
+            for classifier in classifiers
+        ],
+    )
     if not terms:
-        query = es.query()
+        query = es.query(classifier_q) if classifiers else es.query()
     else:
-        bool_query = gather_es_queries(terms)
+        quoted_string, unquoted_string = filter_query(terms)
+        bool_query = Q(
+            "bool",
+            must=[form_query("phrase", i) for i in quoted_string]
+            + [form_query("best_fields", i) for i in unquoted_string]
+            + ([classifier_q] if classifiers else []),
+        )
+
+        # Allow to optionally match on prefix
+        # if ``q`` is longer than one character.
+        if len(terms) > 1:
+            bool_query = bool_query | Q("prefix", normalized_name=terms)
+
         query = es.query(bool_query)
         query = query.suggest("name_suggestion", terms, term={"field": "name"})
 
-    # Require match to all specified classifiers
-    for classifier in classifiers:
-        query = query.query("prefix", classifiers=classifier)
-
     query = query_for_order(query, order)
     return query
 
 
-def gather_es_queries(q):
-    quoted_string, unquoted_string = filter_query(q)
-    must = [form_query("phrase", i) for i in quoted_string] + [
-        form_query("best_fields", i) for i in unquoted_string
-    ]
-
-    bool_query = Q("bool", must=must)
-
-    # Allow to optionally match on prefix
-    # if ``q`` is longer than one character.
-    if len(q) > 1:
-        bool_query = bool_query | Q("prefix", normalized_name=q)
-    return bool_query
-
-
 def filter_query(s):
     """
     Filters given query with the below regex


Test patch:
diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py
--- a/tests/unit/test_search.py
+++ b/tests/unit/test_search.py
@@ -39,7 +39,7 @@ def test_no_terms(self):
 
         query = queries.get_es_query(es, "", "", [])
 
-        assert query == es.query()
+        assert query.to_dict() == {"query": {"match_all": {}}}
 
     @pytest.mark.parametrize(
         "terms,expected_prefix,expected_type",
@@ -54,22 +54,31 @@ def test_quoted_query(self, terms, expected_prefix, expected_type):
 
         query = queries.get_es_query(es, terms, "", [])
 
-        query_dict = query.to_dict()
-        assert len(query_dict["query"]["bool"]["should"]) == 2
-        assert query_dict["query"]["bool"]["should"][1] == {
-            "prefix": {"normalized_name": expected_prefix}
-        }
-        must_params = query_dict["query"]["bool"]["should"][0]["bool"]["must"]
-        assert len(must_params) == 1
-        assert must_params[0]["multi_match"] == {
-            "fields": EXPECTED_SEARCH_FIELDS,
-            "type": expected_type,
-            "query": "foo bar" if terms != '"a"' else "a",
-        }
-        assert query_dict["suggest"] == {
-            "name_suggestion": {"text": terms, "term": {"field": "name"}}
+        assert query.to_dict() == {
+            "query": {
+                "bool": {
+                    "should": [
+                        {
+                            "bool": {
+                                "must": [
+                                    {
+                                        "multi_match": {
+                                            "fields": EXPECTED_SEARCH_FIELDS,
+                                            "query": "foo bar"
+                                            if terms != '"a"'
+                                            else "a",
+                                            "type": expected_type,
+                                        }
+                                    },
+                                ]
+                            }
+                        },
+                        {"prefix": {"normalized_name": expected_prefix}},
+                    ]
+                }
+            },
+            "suggest": {"name_suggestion": {"text": terms, "term": {"field": "name"}}},
         }
-        assert "sort" not in query_dict  # default "relevance" mode does no sorting
 
     def test_single_not_quoted_character(self):
         es = Search()
@@ -77,18 +86,22 @@ def test_single_not_quoted_character(self):
 
         query = queries.get_es_query(es, terms, "", [])
 
-        query_dict = query.to_dict()
-        must_params = query_dict["query"]["bool"]["must"]
-        assert len(must_params) == 1
-        assert must_params[0]["multi_match"] == {
-            "fields": EXPECTED_SEARCH_FIELDS,
-            "type": "best_fields",
-            "query": "a",
-        }
-        assert query_dict["suggest"] == {
-            "name_suggestion": {"text": terms, "term": {"field": "name"}}
+        assert query.to_dict() == {
+            "query": {
+                "bool": {
+                    "must": [
+                        {
+                            "multi_match": {
+                                "fields": EXPECTED_SEARCH_FIELDS,
+                                "query": "a",
+                                "type": "best_fields",
+                            }
+                        },
+                    ]
+                }
+            },
+            "suggest": {"name_suggestion": {"text": "a", "term": {"field": "name"}}},
         }
-        assert "sort" not in query_dict  # default "relevance" mode does no sorting
 
     def test_mixed_quoted_query(self):
         es = Search()
@@ -96,27 +109,38 @@ def test_mixed_quoted_query(self):
 
         query = queries.get_es_query(es, terms, "", [])
 
-        query_dict = query.to_dict()
-        assert len(query_dict["query"]["bool"]["should"]) == 2
-        assert query_dict["query"]["bool"]["should"][1] == {
-            "prefix": {"normalized_name": '"foo bar" baz'}
-        }
-        must_params = query_dict["query"]["bool"]["should"][0]["bool"]["must"]
-        assert len(must_params) == 2
-        assert must_params[0]["multi_match"] == {
-            "fields": EXPECTED_SEARCH_FIELDS,
-            "type": "phrase",
-            "query": "foo bar",
-        }
-        assert must_params[1]["multi_match"] == {
-            "fields": EXPECTED_SEARCH_FIELDS,
-            "type": "best_fields",
-            "query": "baz",
-        }
-        assert query_dict["suggest"] == {
-            "name_suggestion": {"text": terms, "term": {"field": "name"}}
+        assert query.to_dict() == {
+            "query": {
+                "bool": {
+                    "should": [
+                        {
+                            "bool": {
+                                "must": [
+                                    {
+                                        "multi_match": {
+                                            "fields": EXPECTED_SEARCH_FIELDS,
+                                            "query": "foo bar",
+                                            "type": "phrase",
+                                        }
+                                    },
+                                    {
+                                        "multi_match": {
+                                            "fields": EXPECTED_SEARCH_FIELDS,
+                                            "query": "baz",
+                                            "type": "best_fields",
+                                        }
+                                    },
+                                ]
+                            }
+                        },
+                        {"prefix": {"normalized_name": '"foo bar" baz'}},
+                    ]
+                }
+            },
+            "suggest": {
+                "name_suggestion": {"text": '"foo bar" baz', "term": {"field": "name"}}
+            },
         }
-        assert "sort" not in query_dict  # default "relevance" mode does no sorting
 
     @pytest.mark.parametrize(
         "order,field", [("created", "created"), ("-zscore", "zscore")]
@@ -127,79 +151,139 @@ def test_sort_order(self, order, field):
 
         query = queries.get_es_query(es, terms, order, [])
 
-        query_dict = query.to_dict()
-        assert len(query_dict["query"]["bool"]["should"]) == 2
-        assert query_dict["query"]["bool"]["should"][1] == {
-            "prefix": {"normalized_name": "foo bar"}
-        }
-        must_params = query_dict["query"]["bool"]["should"][0]["bool"]["must"]
-        assert len(must_params) == 1
-        assert must_params[0]["multi_match"] == {
-            "fields": EXPECTED_SEARCH_FIELDS,
-            "type": "best_fields",
-            "query": "foo bar",
-        }
-        assert query_dict["suggest"] == {
-            "name_suggestion": {"text": terms, "term": {"field": "name"}}
-        }
-        assert query_dict["sort"] == [
-            {
-                field: {
-                    "order": "desc" if order.startswith("-") else "asc",
-                    "unmapped_type": "long",
+        assert query.to_dict() == {
+            "query": {
+                "bool": {
+                    "should": [
+                        {
+                            "bool": {
+                                "must": [
+                                    {
+                                        "multi_match": {
+                                            "fields": EXPECTED_SEARCH_FIELDS,
+                                            "query": terms,
+                                            "type": "best_fields",
+                                        }
+                                    },
+                                ]
+                            }
+                        },
+                        {"prefix": {"normalized_name": terms}},
+                    ]
                 }
-            }
-        ]
+            },
+            "suggest": {"name_suggestion": {"text": terms, "term": {"field": "name"}}},
+            "sort": [
+                {
+                    field: {
+                        "order": "desc" if order.startswith("-") else "asc",
+                        "unmapped_type": "long",
+                    }
+                }
+            ],
+        }
 
     def test_with_classifiers_with_terms(self):
         es = Search()
         terms = "foo bar"
-        classifiers = [("c", "foo :: bar"), ("c", "fiz :: buz")]
+        classifiers = ["foo :: bar", "fiz :: buz"]
 
         query = queries.get_es_query(es, terms, "", classifiers)
 
-        query_dict = query.to_dict()
-        assert len(query_dict["query"]["bool"]["should"]) == 2
-        assert query_dict["query"]["bool"]["should"][1] == {
-            "prefix": {"normalized_name": "foo bar"}
-        }
-        must_params = query_dict["query"]["bool"]["should"][0]["bool"]["must"]
-        assert len(must_params) == 1
-        assert must_params[0]["multi_match"] == {
-            "fields": EXPECTED_SEARCH_FIELDS,
-            "type": "best_fields",
-            "query": "foo bar",
-        }
-        assert query_dict["suggest"] == {
-            "name_suggestion": {"text": terms, "term": {"field": "name"}}
+        assert query.to_dict() == {
+            "query": {
+                "bool": {
+                    "should": [
+                        {
+                            "bool": {
+                                "must": [
+                                    {
+                                        "multi_match": {
+                                            "fields": EXPECTED_SEARCH_FIELDS,
+                                            "query": terms,
+                                            "type": "best_fields",
+                                        }
+                                    },
+                                    {
+                                        "bool": {
+                                            "must": [
+                                                {
+                                                    "bool": {
+                                                        "should": [
+                                                            {
+                                                                "term": {
+                                                                    "classifiers": classifier  # noqa
+                                                                }
+                                                            },
+                                                            {
+                                                                "prefix": {
+                                                                    "classifiers": classifier  # noqa
+                                                                    + " :: "
+                                                                }
+                                                            },
+                                                        ]
+                                                    }
+                                                }
+                                                for classifier in classifiers
+                                            ]
+                                        }
+                                    },
+                                ]
+                            }
+                        },
+                        {"prefix": {"normalized_name": terms}},
+                    ]
+                }
+            },
+            "suggest": {"name_suggestion": {"text": terms, "term": {"field": "name"}}},
         }
-        assert "sort" not in query_dict
-        assert query_dict["query"]["bool"]["must"] == [
-            {"prefix": {"classifiers": classifier}} for classifier in classifiers
-        ]
-        assert query_dict["query"]["bool"]["minimum_should_match"] == 1
 
     def test_with_classifiers_with_no_terms(self):
         es = Search()
         terms = ""
-        classifiers = [("c", "foo :: bar"), ("c", "fiz :: buz")]
+        classifiers = ["foo :: bar", "fiz :: buz"]
 
         query = queries.get_es_query(es, terms, "", classifiers)
 
-        query_dict = query.to_dict()
-        assert query_dict["query"]["bool"]["must"] == [
-            {"prefix": {"classifiers": classifier}} for classifier in classifiers
-        ]
+        assert query.to_dict() == {
+            "query": {
+                "bool": {
+                    "must": [
+                        {
+                            "bool": {
+                                "should": [
+                                    {"term": {"classifiers": classifier}},
+                                    {"prefix": {"classifiers": classifier + " :: "}},
+                                ]
+                            }
+                        }
+                        for classifier in classifiers
+                    ]
+                }
+            }
+        }
 
     def test_with_classifier_with_no_terms_and_order(self):
         es = Search()
         terms = ""
-        classifiers = [("c", "foo :: bar")]
+        classifiers = ["foo :: bar"]
 
         query = queries.get_es_query(es, terms, "-created", classifiers)
 
-        query_dict = query.to_dict()
-        assert query_dict["query"] == {"prefix": {"classifiers": ("c", "foo :: bar")}}
-        assert query_dict["sort"] == [
-            {"created": {"order": "desc", "unmapped_type": "long"}}
-        ]
+        assert query.to_dict() == {
+            "query": {
+                "bool": {
+                    "must": [
+                        {
+                            "bool": {
+                                "should": [
+                                    {"term": {"classifiers": "foo :: bar"}},
+                                    {"prefix": {"classifiers": "foo :: bar :: "}},
+                                ]
+                            }
+                        }
+                    ]
+                }
+            },
+            "sort": [{"created": {"order": "desc", "unmapped_type": "long"}}],
+        }