diff --git a/docs/api-reference/integration-guide.rst b/docs/api-reference/integration-guide.rst index b81812f93b8f..ef3d4dc7ecdc 100644 --- a/docs/api-reference/integration-guide.rst +++ b/docs/api-reference/integration-guide.rst @@ -36,6 +36,27 @@ Here are some tips. ``https://pypi.org/pypi/{name}`` (with or without a trailing slash) redirects to ``https://pypi.org/project/{name}/``. +* The PyPI page for a specific version of project ``{name}`` can be + reached via ``https://pypi.org/project/{name}/{version}/``. + + * E.g., for Django v2.0, browse to + ``https://pypi.org/project/Django/2.0``. + + * Special redirects for various flavors of the latest available + version of ``{name}`` have been implemented, with version selection + semantics identical to the analogous + :ref:`JSON endpoints `: + + * ``https://pypi.org/project/{name}/latest/``: + Latest non-prerelease version if any exists; + else, latest pre-release version. + + * ``https://pypi.org/project/{name}/latest-stable/``: + Latest non-prerelease version. + + * ``https://pypi.org/project/{name}/latest-unstable/`` + Latest version regardless of pre-release status. + * Shorter URL: ``https://pypi.org/p/{name}/`` will redirect to ``https://pypi.org/project/{name}/``. diff --git a/docs/api-reference/json.rst b/docs/api-reference/json.rst index 01da86b5950a..e481fe68c285 100644 --- a/docs/api-reference/json.rst +++ b/docs/api-reference/json.rst @@ -264,3 +264,36 @@ Release } :statuscode 200: no error + + + .. _api_json_latest: + + There are three special ```` names that can be passed for any + ````, to obtain a `Release`_ JSON response for various flavors + of the latest available release for that project: + + * ``/pypi//latest/json`` + + Supplies the latest non-prerelease version of ````, + if any exists. If none does exist, supplies instead the latest + pre-release version of ````. + + As of May 2021, this behavior matches that of the + `Project`_ endpoint, and *should* return an identical JSON response. + + * ``/pypi//latest-stable/json`` + + Supplies the latest non-prerelease version of ````. + If no non-prerelease versions exist, returns |http404|_. + + * ``/pypi//latest-unstable/json`` + + Supplies the latest version of ````, + regardless of pre-release status. + + In all cases, if a project has no releases, returns |http404|_. + + +.. |http404| replace:: ``404 Not Found`` + +.. _http404: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.5 diff --git a/tests/conftest.py b/tests/conftest.py index 443f22accf9f..82568bce3a7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ import os.path import xmlrpc.client -from collections import defaultdict +from collections import defaultdict, namedtuple from contextlib import contextmanager from unittest import mock @@ -36,6 +36,7 @@ from warehouse.metrics import IMetricsService from .common.db import Session +from .common.db.packaging import ProjectFactory, ReleaseFactory def pytest_collection_modifyitems(items): @@ -337,3 +338,49 @@ def monkeypatch_session(): m = MonkeyPatch() yield m m.undo() + + +# Standardized dummy projects for testing version-search +# behavior under different stable/prerelease circumstances. +# In particular, created to support the 'latest' endpoints of +# https://github.com/pypa/warehouse/pull/8615 + +ProjectData = namedtuple("ProjectData", ["project", "latest_stable", "latest_pre"]) + + +@pytest.fixture +def project_no_pre(): + project = ProjectFactory.create() + + ReleaseFactory.create(project=project, version="1.0") + ReleaseFactory.create(project=project, version="2.0") + latest_stable = ReleaseFactory.create(project=project, version="3.0") + + return ProjectData(project=project, latest_stable=latest_stable, latest_pre=None) + + +@pytest.fixture +def project_with_pre(): + project = ProjectFactory.create() + + ReleaseFactory.create(project=project, version="1.0") + ReleaseFactory.create(project=project, version="2.0") + + latest_stable = ReleaseFactory.create(project=project, version="3.0") + latest_pre = ReleaseFactory.create(project=project, version="4.0.dev0") + + return ProjectData( + project=project, latest_stable=latest_stable, latest_pre=latest_pre + ) + + +@pytest.fixture +def project_only_pre(): + project = ProjectFactory.create() + + ReleaseFactory.create(project=project, version="1.0.dev0") + ReleaseFactory.create(project=project, version="2.0.dev0") + + latest_pre = ReleaseFactory.create(project=project, version="3.0.dev0") + + return ProjectData(project=project, latest_stable=None, latest_pre=latest_pre) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index a1066c1b1af1..fac14c55d185 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -13,6 +13,7 @@ from collections import OrderedDict import pretend +import pytest from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound @@ -66,57 +67,41 @@ def test_missing_release(self, db_request): assert isinstance(resp, HTTPNotFound) _assert_has_cors_headers(resp.headers) - def test_calls_release_detail(self, monkeypatch, db_request): - project = ProjectFactory.create() - - ReleaseFactory.create(project=project, version="1.0") - ReleaseFactory.create(project=project, version="2.0") - - release = ReleaseFactory.create(project=project, version="3.0") - + def test_calls_release_detail(self, monkeypatch, db_request, project_no_pre): response = pretend.stub() json_release = pretend.call_recorder(lambda ctx, request: response) monkeypatch.setattr(json, "json_release", json_release) - resp = json.json_project(project, db_request) + resp = json.json_project(project_no_pre.project, db_request) assert resp is response - assert json_release.calls == [pretend.call(release, db_request)] - - def test_with_prereleases(self, monkeypatch, db_request): - project = ProjectFactory.create() - - ReleaseFactory.create(project=project, version="1.0") - ReleaseFactory.create(project=project, version="2.0") - ReleaseFactory.create(project=project, version="4.0.dev0") - - release = ReleaseFactory.create(project=project, version="3.0") + assert json_release.calls == [ + pretend.call(project_no_pre.latest_stable, db_request) + ] + def test_with_prereleases(self, monkeypatch, db_request, project_with_pre): response = pretend.stub() json_release = pretend.call_recorder(lambda ctx, request: response) monkeypatch.setattr(json, "json_release", json_release) - resp = json.json_project(project, db_request) + resp = json.json_project(project_with_pre.project, db_request) assert resp is response - assert json_release.calls == [pretend.call(release, db_request)] - - def test_only_prereleases(self, monkeypatch, db_request): - project = ProjectFactory.create() - - ReleaseFactory.create(project=project, version="1.0.dev0") - ReleaseFactory.create(project=project, version="2.0.dev0") - - release = ReleaseFactory.create(project=project, version="3.0.dev0") + assert json_release.calls == [ + pretend.call(project_with_pre.latest_stable, db_request) + ] + def test_only_prereleases(self, monkeypatch, db_request, project_only_pre): response = pretend.stub() json_release = pretend.call_recorder(lambda ctx, request: response) monkeypatch.setattr(json, "json_release", json_release) - resp = json.json_project(project, db_request) + resp = json.json_project(project_only_pre.project, db_request) assert resp is response - assert json_release.calls == [pretend.call(release, db_request)] + assert json_release.calls == [ + pretend.call(project_only_pre.latest_pre, db_request) + ] def test_all_releases_yanked(self, monkeypatch, db_request): """ @@ -206,6 +191,141 @@ def test_normalizing_redirects(self, db_request): assert resp.headers["Location"] == "/project/the-redirect" +class TestJSONLatestReleases: + @pytest.fixture + def check_json_release(self, monkeypatch): + response = pretend.stub() + json_release = pretend.call_recorder(lambda ctx, request: response) + monkeypatch.setattr(json, "json_release", json_release) + + def check_function(db_request, project, release, endpoint): + resp = getattr(json, endpoint)(project, db_request) + + assert resp is response + assert json_release.calls == [pretend.call(release, db_request)] + + return check_function + + def test_latest_no_pre(self, db_request, project_no_pre, check_json_release): + """Confirm 'latest' gives latest-stable for project with no pre-releases.""" + check_json_release( + db_request, + project_no_pre.project, + project_no_pre.latest_stable, + "json_latest", + ) + + def test_latest_with_pre(self, db_request, project_with_pre, check_json_release): + """Confirm 'latest' gives latest-stable with both stable and pre-releases.""" + check_json_release( + db_request, + project_with_pre.project, + project_with_pre.latest_stable, + "json_latest", + ) + + def test_latest_only_pre(self, db_request, project_only_pre, check_json_release): + """Confirm 'latest' gives latest-pre for project with only pre-releases.""" + check_json_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "json_latest", + ) + + def test_latest_stable_no_pre(self, db_request, project_no_pre, check_json_release): + """Confirm 'latest-stable' gives latest-stable with no pre-releases.""" + check_json_release( + db_request, + project_no_pre.project, + project_no_pre.latest_stable, + "json_latest_stable", + ) + + def test_latest_stable_with_pre( + self, db_request, project_with_pre, check_json_release + ): + """Confirm 'latest-stable' gives latest-stable with no pre-releases.""" + check_json_release( + db_request, + project_with_pre.project, + project_with_pre.latest_stable, + "json_latest_stable", + ) + + def test_latest_stable_only_pre(self, db_request, project_only_pre): + """Confirm 'latest-stable' fails for project with no pre-releases.""" + db_request.route_path = pretend.call_recorder( + lambda *a, **kw: "/project/the-redirect" + ) + + resp = json.json_latest_stable(project_only_pre.project, db_request) + assert isinstance(resp, HTTPNotFound) + + def test_latest_unstable_no_pre( + self, db_request, project_no_pre, check_json_release + ): + check_json_release( + db_request, + project_no_pre.project, + project_no_pre.latest_stable, + "json_latest_unstable", + ) + + def test_latest_unstable_with_pre( + self, db_request, project_with_pre, check_json_release + ): + check_json_release( + db_request, + project_with_pre.project, + project_with_pre.latest_pre, + "json_latest_unstable", + ) + + def test_latest_unstable_only_pre( + self, db_request, project_only_pre, check_json_release + ): + check_json_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "json_latest_unstable", + ) + + @pytest.mark.parametrize( + "endpoint", + ["json_latest", "json_latest_stable", "json_latest_unstable"], + ) + def test_missing_release(self, db_request, endpoint): + project = ProjectFactory.create() + + resp = getattr(json, endpoint)(project, db_request) + assert isinstance(resp, HTTPNotFound) + + +class TestJSONLatestSlash: + @pytest.mark.parametrize( + ("endpoint", "route"), + [ + ("json_latest_slash", "legacy.api.json.latest"), + ("json_latest_stable_slash", "legacy.api.json.latest_stable"), + ("json_latest_unstable_slash", "legacy.api.json.latest_unstable"), + ], + ) + def test_normalizing_redirects(self, db_request, endpoint, route): + project = ProjectFactory.create() + + db_request.route_path = pretend.call_recorder( + lambda *a, **kw: "/project/the-redirect" + ) + + resp = getattr(json, endpoint)(project, db_request) + + assert isinstance(resp, HTTPMovedPermanently) + assert db_request.route_path.calls == [pretend.call(route, name=project.name)] + assert resp.headers["Location"] == "/project/the-redirect" + + class TestJSONRelease: def test_normalizing_redirects(self, db_request): project = ProjectFactory.create() diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index ef312e8279ed..feab89efb3be 100644 --- a/tests/unit/packaging/test_views.py +++ b/tests/unit/packaging/test_views.py @@ -13,7 +13,11 @@ import pretend import pytest -from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound +from pyramid.httpexceptions import ( + HTTPMovedPermanently, + HTTPNotFound, + HTTPTemporaryRedirect, +) from warehouse.packaging import views from warehouse.utils import readme @@ -333,3 +337,121 @@ def test_edit_project_button_returns_project(self): assert views.edit_project_button(project, pretend.stub()) == { "project": project } + + +class TestProjectLatestRedirects: + @pytest.fixture + def check_latest_release(self, db_request, monkeypatch): + route_path = pretend.call_recorder(lambda *a, **kw: "/project/the-redirect") + monkeypatch.setattr(db_request, "route_path", route_path) + + def check_function(db_request, project, release, endpoint): + resp = getattr(views, endpoint)(project, db_request) + + assert isinstance(resp, HTTPTemporaryRedirect) + assert db_request.route_path.calls == [ + pretend.call( + "packaging.release", name=project.name, version=release.version + ) + ] + assert resp.headers["Location"] == "/project/the-redirect" + + return check_function + + def test_latest_no_pre(self, db_request, project_no_pre, check_latest_release): + check_latest_release( + db_request, + project_no_pre.project, + project_no_pre.latest_stable, + "project_latest", + ) + + def test_latest_with_pre(self, db_request, project_with_pre, check_latest_release): + check_latest_release( + db_request, + project_with_pre.project, + project_with_pre.latest_stable, + "project_latest", + ) + + def test_latest_only_pre(self, db_request, project_only_pre, check_latest_release): + check_latest_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "project_latest", + ) + + def test_latest_no_releases(self, db_request, monkeypatch): + route_path = pretend.call_recorder(lambda *a, **kw: "/project/the-redirect") + monkeypatch.setattr(db_request, "route_path", route_path) + + resp = views.project_latest(ProjectFactory.create(), db_request) + + assert isinstance(resp, HTTPNotFound) + + def test_latest_stable_no_pre( + self, db_request, project_no_pre, check_latest_release + ): + check_latest_release( + db_request, + project_no_pre.project, + project_no_pre.latest_stable, + "project_latest_stable", + ) + + def test_latest_stable_with_pre( + self, db_request, project_with_pre, check_latest_release + ): + check_latest_release( + db_request, + project_with_pre.project, + project_with_pre.latest_stable, + "project_latest_stable", + ) + + def test_latest_stable_only_pre(self, db_request, project_only_pre, monkeypatch): + route_path = pretend.call_recorder(lambda *a, **kw: "/project/the-redirect") + monkeypatch.setattr(db_request, "route_path", route_path) + + resp = views.project_latest_stable(project_only_pre.project, db_request) + + assert isinstance(resp, HTTPNotFound) + + def test_latest_unstable_no_pre( + self, db_request, project_no_pre, check_latest_release + ): + check_latest_release( + db_request, + project_no_pre.project, + project_no_pre.latest_stable, + "project_latest_unstable", + ) + + def test_latest_unstable_with_pre( + self, db_request, project_with_pre, check_latest_release + ): + check_latest_release( + db_request, + project_with_pre.project, + project_with_pre.latest_pre, + "project_latest_unstable", + ) + + def test_latest_unstable_only_pre( + self, db_request, project_only_pre, check_latest_release + ): + check_latest_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "project_latest_unstable", + ) + + def test_latest_unstable_no_releases(self, db_request, monkeypatch): + route_path = pretend.call_recorder(lambda *a, **kw: "/project/the-redirect") + monkeypatch.setattr(db_request, "route_path", route_path) + + resp = views.project_latest_unstable(ProjectFactory.create(), db_request) + + assert isinstance(resp, HTTPNotFound) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 923fdf04aa3c..f1f3d99bb4d9 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -319,6 +319,27 @@ def add_policy(name, filename): traverse="/{name}", domain=warehouse, ), + pretend.call( + "packaging.project_latest", + "/project/{name}/latest/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ), + pretend.call( + "packaging.project_latest_stable", + "/project/{name}/latest-stable/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ), + pretend.call( + "packaging.project_latest_unstable", + "/project/{name}/latest-unstable/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ), pretend.call( "packaging.release", "/project/{name}/{version}/", @@ -368,6 +389,54 @@ def add_policy(name, filename): read_only=True, domain=warehouse, ), + pretend.call( + "legacy.api.json.latest", + "/pypi/{name}/latest/json", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ), + pretend.call( + "legacy.api.json.latest_slash", + "/pypi/{name}/latest/json/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ), + pretend.call( + "legacy.api.json.latest_stable", + "/pypi/{name}/latest-stable/json", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ), + pretend.call( + "legacy.api.json.latest_stable_slash", + "/pypi/{name}/latest-stable/json/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ), + pretend.call( + "legacy.api.json.latest_unstable", + "/pypi/{name}/latest-unstable/json", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ), + pretend.call( + "legacy.api.json.latest_unstable_slash", + "/pypi/{name}/latest-unstable/json/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ), pretend.call( "legacy.api.json.release", "/pypi/{name}/{version}/json", diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 83b3532050e0..bf00848f0d6d 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -217,3 +217,96 @@ def json_release_slash(release, request): ), headers=_CORS_HEADERS, ) + + +@view_config( + route_name="legacy.api.json.latest", + context=Project, + renderer="json", + decorator=_CACHE_DECORATOR, +) +def json_latest(project, request): + release = project.latest_version + + if release is None: + return HTTPNotFound(headers=_CORS_HEADERS) + + return json_release(release, request) + + +@view_config( + route_name="legacy.api.json.latest_slash", + context=Project, + decorator=_CACHE_DECORATOR, +) +def json_latest_slash(project, request): + # Respond with redirect to url without trailing slash + return HTTPMovedPermanently( + request.route_path( + "legacy.api.json.latest", + name=project.name, + ), + headers=_CORS_HEADERS, + ) + + +@view_config( + route_name="legacy.api.json.latest_stable", + context=Project, + renderer="json", + decorator=_CACHE_DECORATOR, +) +def json_latest_stable(project, request): + release = project.latest_stable_version + + if release is None: + return HTTPNotFound(headers=_CORS_HEADERS) + + return json_release(release, request) + + +@view_config( + route_name="legacy.api.json.latest_stable_slash", + context=Project, + decorator=_CACHE_DECORATOR, +) +def json_latest_stable_slash(project, request): + # Respond with redirect to url without trailing slash + return HTTPMovedPermanently( + request.route_path( + "legacy.api.json.latest_stable", + name=project.name, + ), + headers=_CORS_HEADERS, + ) + + +@view_config( + route_name="legacy.api.json.latest_unstable", + context=Project, + renderer="json", + decorator=_CACHE_DECORATOR, +) +def json_latest_unstable(project, request): + release = project.latest_unstable_version + + if release is None: + return HTTPNotFound(headers=_CORS_HEADERS) + + return json_release(release, request) + + +@view_config( + route_name="legacy.api.json.latest_unstable_slash", + context=Project, + decorator=_CACHE_DECORATOR, +) +def json_latest_unstable_slash(project, request): + # Respond with redirect to url without trailing slash + return HTTPMovedPermanently( + request.route_path( + "legacy.api.json.latest_unstable", + name=project.name, + ), + headers=_CORS_HEADERS, + ) diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index e704e06ab20a..adf0f7bbfc7a 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -260,14 +260,47 @@ def all_versions(self): @property def latest_version(self): + # Supply the latest stable version, if any stable releases exist. + # If only pre-releases exist, supply the latest pre-release version. return ( orm.object_session(self) - .query(Release.version, Release.created, Release.is_prerelease) + .query(Release) .filter(Release.project == self, Release.yanked.is_(False)) .order_by(Release.is_prerelease.nullslast(), Release._pypi_ordering.desc()) .first() ) + @property + def latest_stable_version(self): + # Supply the latest stable version. If no stable versions are + # available, return None. + return ( + orm.object_session(self) + .query(Release) + .filter( + Release.project == self, + Release.yanked.is_(False), + Release.is_prerelease.is_(False), + ) + .order_by(Release._pypi_ordering.desc()) + .first() + ) + + @property + def latest_unstable_version(self): + # Supply the latest available version, regardless of pre-release status. + return ( + orm.object_session(self) + .query(Release) + .filter( + Release.project == self, + Release.yanked.is_(False), + Release.is_prerelease is not None, + ) + .order_by(Release._pypi_ordering.desc()) + .first() + ) + class ProjectEvent(db.Model): __tablename__ = "project_events" diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index 043791110c5b..22cbd3a24b32 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -10,7 +10,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound +from pyramid.httpexceptions import ( + HTTPMovedPermanently, + HTTPNotFound, + HTTPTemporaryRedirect, +) from pyramid.view import view_config from sqlalchemy.orm.exc import NoResultFound @@ -53,6 +57,63 @@ def project_detail(project, request): return release_detail(release, request) +@view_config( + route_name="packaging.project_latest", + context=Project, +) +def project_latest(project, request): + release = project.latest_version + + if release: + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=release.version, + ) + ) + else: + return HTTPNotFound() + + +@view_config( + route_name="packaging.project_latest_stable", + context=Project, +) +def project_latest_stable(project, request): + release = project.latest_stable_version + + if release: + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=release.version, + ) + ) + else: + return HTTPNotFound() + + +@view_config( + route_name="packaging.project_latest_unstable", + context=Project, +) +def project_latest_unstable(project, request): + release = project.latest_unstable_version + + if release: + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=release.version, + ) + ) + else: + return HTTPNotFound() + + @view_config( route_name="packaging.release", context=Release, diff --git a/warehouse/routes.py b/warehouse/routes.py index ed2fe31707f3..40e91289cddd 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -309,6 +309,27 @@ def includeme(config): traverse="/{name}", domain=warehouse, ) + config.add_route( + "packaging.project_latest", + "/project/{name}/latest/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ) + config.add_route( + "packaging.project_latest_stable", + "/project/{name}/latest-stable/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ) + config.add_route( + "packaging.project_latest_unstable", + "/project/{name}/latest-unstable/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + domain=warehouse, + ) config.add_route( "packaging.release", "/project/{name}/{version}/", @@ -368,6 +389,60 @@ def includeme(config): domain=warehouse, ) + config.add_route( + "legacy.api.json.latest", + "/pypi/{name}/latest/json", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ) + + config.add_route( + "legacy.api.json.latest_slash", + "/pypi/{name}/latest/json/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ) + + config.add_route( + "legacy.api.json.latest_stable", + "/pypi/{name}/latest-stable/json", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ) + + config.add_route( + "legacy.api.json.latest_stable_slash", + "/pypi/{name}/latest-stable/json/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ) + + config.add_route( + "legacy.api.json.latest_unstable", + "/pypi/{name}/latest-unstable/json", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ) + + config.add_route( + "legacy.api.json.latest_unstable_slash", + "/pypi/{name}/latest-unstable/json/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{name}", + read_only=True, + domain=warehouse, + ) + config.add_route( "legacy.api.json.release", "/pypi/{name}/{version}/json",