Repository: pypi/warehouse

Issue:
Default search message
We need to add a message for when a search doesn't match any packages.



Discussion:
This message should use a `<div class="callout-block"></div>`

Hi @nlhkabu, is this close to what you had in mind?

Original:
<img width="852" alt="screen shot 2016-02-04 at 9 59 00 am" src="https://cloud.githubusercontent.com/assets/294415/12818720/f9a245f0-cb25-11e5-9354-8f9290b7cb62.png">
New:
<img width="849" alt="screen shot 2016-02-04 at 9 57 08 am" src="https://cloud.githubusercontent.com/assets/294415/12818669/ce177ce8-cb25-11e5-9c41-9e76129cb489.png">

Should the match count element stay, go away or be replaced by this message?

That's perfect, thanks @di!

I don't think the duplication is a problem - it just reinforces the message.  

What would be **awesome** is if we could display some other search terms that are close to the input...

For example, if I search "Pyranid", I should see:

> There were no packages matching _Pyranid_ did you mean **Pyramid**

ping @dstufft and @HonzaKral - is this possible with elastic?

Looks like it can, [Phrase Suggester](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters-phrase.html).

Yes, as @dstufft mentioned - Phrase or Term suggesters is what you are looking for. The difference is that `terms` suggester is only looking at individual words in isolation whereas `phrase` is considering their use together. The simple demonstration is that terms suggester cannot see anything wrong with `"johnny walker"` (since both words are correct and exist in the index) whereas phrase suggester will suggest `"johnnie walker"`.

I would also suggest using completion suggester for auto complete, might help a lot. Let me know if you'd like to discuss the options in more detail, I an hop on irc. Thanks

Fantastic!  @dstufft I'm guessing this is not on the list for "become PyPI" - so maybe we should open another issue?

Hi @nlhkabu, how does this look?

<img width="834" alt="screen shot 2016-02-04 at 5 22 51 pm" src="https://cloud.githubusercontent.com/assets/294415/12831749/f54e703a-cb63-11e5-9f95-de3a06f5f7e9.png">

The link leads to `/search/?q=pybankid`

(For whatever reason the `pyramid` package is not actually in the development DB, so `pybankid` is elasticsearch's best guess)


Resolving patch:
diff --git a/warehouse/utils/paginate.py b/warehouse/utils/paginate.py
--- a/warehouse/utils/paginate.py
+++ b/warehouse/utils/paginate.py
@@ -18,12 +18,18 @@ class _ElasticsearchWrapper:
     def __init__(self, query):
         self.query = query
         self.results = None
+        self.best_guess = None
 
     def __getitem__(self, range):
         if self.results is not None:
             raise RuntimeError("Cannot reslice after having already sliced.")
         self.results = self.query[range].execute()
 
+        if hasattr(self.results, "suggest"):
+            suggestion = self.results.suggest.name_suggestion[0]
+            if suggestion.options:
+                self.best_guess = suggestion.options[0]
+
         return list(self.results)
 
     def __len__(self):
diff --git a/warehouse/views.py b/warehouse/views.py
--- a/warehouse/views.py
+++ b/warehouse/views.py
@@ -163,6 +163,10 @@ def search(request):
                 "maintainer_email", "home_page", "license", "summary",
                 "description", "keywords", "platform", "download_url",
             ],
+        ).suggest(
+            name="name_suggestion",
+            text=request.params["q"],
+            term={"field": "name"}
         )
     else:
         query = request.es.query()


Test patch:
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
@@ -100,7 +100,10 @@ def test_with_a_query(self, monkeypatch, page):
         params = {"q": "foo bar"}
         if page is not None:
             params["page"] = page
-        query = pretend.stub()
+        suggest = pretend.stub()
+        query = pretend.stub(
+            suggest=pretend.call_recorder(lambda *a, **kw: suggest),
+        )
         request = pretend.stub(
             es=pretend.stub(
                 query=pretend.call_recorder(lambda *a, **kw: query),
@@ -118,7 +121,7 @@ def test_with_a_query(self, monkeypatch, page):
 
         assert search(request) == {"page": page_obj, "term": params.get("q")}
         assert page_cls.calls == [
-            pretend.call(query, url_maker=url_maker, page=page or 1),
+            pretend.call(suggest, url_maker=url_maker, page=page or 1),
         ]
         assert url_maker_factory.calls == [pretend.call(request)]
         assert request.es.query.calls == [
@@ -132,6 +135,13 @@ def test_with_a_query(self, monkeypatch, page):
                 ],
             ),
         ]
+        assert query.suggest.calls == [
+            pretend.call(
+                name="name_suggestion",
+                term={"field": "name"},
+                text="foo bar",
+            ),
+        ]
 
     @pytest.mark.parametrize("page", [None, 1, 5])
     def test_without_a_query(self, monkeypatch, page):
diff --git a/tests/unit/utils/test_paginate.py b/tests/unit/utils/test_paginate.py
--- a/tests/unit/utils/test_paginate.py
+++ b/tests/unit/utils/test_paginate.py
@@ -18,6 +18,18 @@
 from warehouse.utils import paginate
 
 
+class FakeSuggestion:
+
+    def __init__(self, options):
+        self.options = options
+
+
+class FakeSuggest:
+
+    def __init__(self, name_suggestion):
+        self.name_suggestion = name_suggestion
+
+
 class FakeResult:
 
     def __init__(self, data, total):
@@ -33,6 +45,18 @@ def __iter__(self):
             yield i
 
 
+class FakeSuggestResult(FakeResult):
+
+    def __init__(self, data, total, options):
+        super().__init__(data, total)
+        self.options = options
+
+    @property
+    def suggest(self):
+        suggestion = FakeSuggestion(options=self.options)
+        return FakeSuggest(name_suggestion=[suggestion])
+
+
 class FakeQuery:
 
     def __init__(self, fake):
@@ -51,6 +75,18 @@ def execute(self):
         return FakeResult(self.fake[self.range], len(self.fake))
 
 
+class FakeSuggestQuery(FakeQuery):
+
+    def __init__(self, fake, options):
+        super().__init__(fake)
+        self.options = options
+
+    def execute(self):
+        data = self.fake[self.range]
+        total = len(self.fake)
+        return FakeSuggestResult(data, total, self.options)
+
+
 class TestElasticsearchWrapper:
 
     def test_slices_and_length(self):
@@ -71,6 +107,21 @@ def test_len_before_slice_fails(self):
         with pytest.raises(RuntimeError):
             len(wrapper)
 
+    def test_best_guess_suggestion(self):
+        fake_option = pretend.stub()
+        query = FakeSuggestQuery([1, 2, 3, 4, 5, 6], options=[fake_option])
+        wrapper = paginate._ElasticsearchWrapper(query)
+        wrapper[1:3]
+
+        assert wrapper.best_guess == fake_option
+
+    def test_best_guess_suggestion_no_options(self):
+        query = FakeSuggestQuery([1, 2, 3, 4, 5, 6], options=[])
+        wrapper = paginate._ElasticsearchWrapper(query)
+        wrapper[1:3]
+
+        assert wrapper.best_guess is None
+
 
 def test_elasticsearch_page_has_wrapper(monkeypatch):
     page_obj = pretend.stub()