Repository: pypi/warehouse

Issue:
Redirect legacy classifier filter URLs
Some projects' documentation ([example](https://github.com/sphinx-doc/sphinx/pull/4891)) points to legacy PyPI URLs for classifier-based searches like

https://pypi.python.org/pypi/?:action=browse&c=599

Could we have those redirect?


Discussion:
@dstufft @di thoughts here?

I'm neutral here. If there is a trivial way of supporting a URL rewrite we should consider it.

As far as I know, archival services do not generally respect query parameters so while we would be breaking links... I'm not sure how damaging this is to the web overall.
It wouldn't be hard to support I don't think. You're just adding another legacy API in ``warehouse/legacy/api/pypi.py`` and looking up the classifier by ID, and redirecting to the search page for that classifier.
I think this is totally doable, see what we do for `list_classifiers` here: https://github.com/pypa/warehouse/blob/d8269247e43123feeeb7632d89f4698b58f1cb8a/warehouse/legacy/api/pypi.py#L80-L92
Note that regular search links (without classifiers) should probably be supported as well, e.g:

https://legacy.pypi.org/pypi?%3Aaction=search&term=foobar&submit=search
should redirect to:
https://pypi.org/search/?q=foobar

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
@@ -10,7 +10,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from pyramid.httpexceptions import HTTPGone
+from pyramid.httpexceptions import HTTPGone, HTTPMovedPermanently, HTTPNotFound
 from pyramid.response import Response
 from pyramid.view import forbidden_view_config, view_config
 
@@ -90,3 +90,31 @@ def list_classifiers(request):
         text='\n'.join(c[0] for c in classifiers),
         content_type='text/plain; charset=utf-8'
     )
+
+
+@view_config(route_name='legacy.api.pypi.search')
+def search(request):
+    return HTTPMovedPermanently(
+        request.route_path(
+            'search', _query={'q': request.params.get('term')}
+        )
+    )
+
+
+@view_config(route_name='legacy.api.pypi.browse')
+def browse(request):
+    classifier_id = request.params.get('c')
+
+    if not classifier_id:
+        raise HTTPNotFound
+
+    classifier = request.db.query(Classifier).get(classifier_id)
+
+    if not classifier:
+        raise HTTPNotFound
+
+    return HTTPMovedPermanently(
+        request.route_path(
+            'search', _query={'c': classifier.classifier}
+        )
+    )
diff --git a/warehouse/routes.py b/warehouse/routes.py
--- a/warehouse/routes.py
+++ b/warehouse/routes.py
@@ -279,6 +279,16 @@ def includeme(config):
         "list_classifiers",
         domain=warehouse,
     )
+    config.add_pypi_action_route(
+        'legacy.api.pypi.search',
+        'search',
+        domain=warehouse,
+    )
+    config.add_pypi_action_route(
+        'legacy.api.pypi.browse',
+        'browse',
+        domain=warehouse,
+    )
 
     # Legacy XMLRPC
     config.add_xmlrpc_endpoint(


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
@@ -13,7 +13,9 @@
 import pretend
 import pytest
 
-from pyramid.httpexceptions import HTTPBadRequest
+from pyramid.httpexceptions import (
+    HTTPBadRequest, HTTPMovedPermanently, HTTPNotFound,
+)
 
 from warehouse.legacy.api import pypi
 
@@ -80,3 +82,52 @@ def test_list_classifiers(db_request):
 
     assert resp.status_code == 200
     assert resp.text == "fiz :: buz\nfoo :: bar\nfoo :: baz"
+
+
+def test_search():
+    term = pretend.stub()
+    request = pretend.stub(
+        params={'term': term},
+        route_path=pretend.call_recorder(lambda *a, **kw: '/the/path'),
+    )
+
+    result = pypi.search(request)
+
+    assert isinstance(result, HTTPMovedPermanently)
+    assert result.headers['Location'] == '/the/path'
+    assert result.status_code == 301
+    assert request.route_path.calls == [
+        pretend.call('search', _query={'q': term}),
+    ]
+
+
+class TestBrowse:
+
+    def test_browse(self, db_request):
+        classifier = ClassifierFactory.create(classifier="foo :: bar")
+
+        db_request.params = {'c': str(classifier.id)}
+        db_request.route_path = pretend.call_recorder(
+            lambda *a, **kw: '/the/path'
+        )
+
+        result = pypi.browse(db_request)
+
+        assert isinstance(result, HTTPMovedPermanently)
+        assert result.headers['Location'] == '/the/path'
+        assert result.status_code == 301
+        assert db_request.route_path.calls == [
+            pretend.call('search', _query={'c': classifier.classifier}),
+        ]
+
+    def test_browse_no_id(self):
+        request = pretend.stub(params={})
+
+        with pytest.raises(HTTPNotFound):
+            pypi.browse(request)
+
+    def test_browse_bad_id(self, db_request):
+        db_request.params = {'c': '99999'}
+
+        with pytest.raises(HTTPNotFound):
+            pypi.browse(db_request)
diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py
--- a/tests/unit/test_routes.py
+++ b/tests/unit/test_routes.py
@@ -322,6 +322,16 @@ def add_policy(name, filename):
             "list_classifiers",
             domain=warehouse,
         ),
+        pretend.call(
+            'legacy.api.pypi.search',
+            'search',
+            domain=warehouse,
+        ),
+        pretend.call(
+            'legacy.api.pypi.browse',
+            'browse',
+            domain=warehouse,
+        ),
     ]
 
     assert config.add_pypi_action_redirect.calls == [