From cefbbcedf042954840d7b5695e125f9790266ba6 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Fri, 25 Sep 2020 10:57:54 -0400 Subject: [PATCH 01/34] Add 'latest' redirect to main project view Need to add trailing-slash redirect, also. --- warehouse/legacy/api/json.py | 17 +++++++++++++++++ warehouse/routes.py | 10 ++++++++++ 2 files changed, 27 insertions(+) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 8ce44a1c30e5..13dccab17cf8 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -213,3 +213,20 @@ def json_release_slash(release, request): ), headers=_CORS_HEADERS, ) + + +@view_config( + route_name="legacy.api.json.latest", + context=Project, + decorator=_CACHE_DECORATOR, +) +def json_latest(project, request): + return HTTPMovedPermanently( + # Redirect to standard project endpoint + request.route_path( + "legacy.api.json.project", + name=project.name, + ), + headers=_CORS_HEADERS, + ) + diff --git a/warehouse/routes.py b/warehouse/routes.py index 5f6618e971ea..3d1de21a12d4 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -354,6 +354,15 @@ 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.release", "/pypi/{name}/{version}/json", @@ -370,6 +379,7 @@ def includeme(config): read_only=True, domain=warehouse, ) + # Legacy Action URLs # TODO: We should probably add Warehouse routes for these that just error From d9956244086d84f56e4513ab738373135ffd1a92 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 28 Sep 2020 09:38:48 -0400 Subject: [PATCH 02/34] Switch to HTTP 307 for the 'latest' redirect Always want the browser to recheck this endpoint, in case the redirect target changes. --- warehouse/legacy/api/json.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 13dccab17cf8..848e9c433b9b 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -12,7 +12,7 @@ from collections import OrderedDict -from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound +from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound, HTTPTemporaryRedirect from pyramid.view import view_config from sqlalchemy.orm import Load from sqlalchemy.orm.exc import NoResultFound @@ -221,12 +221,12 @@ def json_release_slash(release, request): decorator=_CACHE_DECORATOR, ) def json_latest(project, request): - return HTTPMovedPermanently( - # Redirect to standard project endpoint + # Redirect to standard project endpoint this time, + # but do not instruct to *always* redirect this way + return HTTPTemporaryRedirect( request.route_path( "legacy.api.json.project", name=project.name, ), headers=_CORS_HEADERS, ) - From 1efceaeb21062f8f7de65da4ac22aa3029d6d20e Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 28 Sep 2020 10:11:15 -0400 Subject: [PATCH 03/34] Fix routes-check test and add latest_slash Still need to actually test the latest logic. --- tests/unit/test_routes.py | 16 ++++++++++++++++ warehouse/legacy/api/json.py | 15 +++++++++++++++ warehouse/routes.py | 9 +++++++++ 3 files changed, 40 insertions(+) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 136ec4f8c1d2..b546b1fab55b 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -351,6 +351,22 @@ 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.release", "/pypi/{name}/{version}/json", diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 848e9c433b9b..bf04dacb3d4e 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -230,3 +230,18 @@ def json_latest(project, request): ), headers=_CORS_HEADERS, ) + +@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, + ) diff --git a/warehouse/routes.py b/warehouse/routes.py index 3d1de21a12d4..5fdf79214807 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -363,6 +363,15 @@ def includeme(config): 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.release", "/pypi/{name}/{version}/json", From ae850ae5e144bcac1864daf18397c7c380b9b15a Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 28 Sep 2020 16:06:36 -0400 Subject: [PATCH 04/34] Apply reformat --- warehouse/legacy/api/json.py | 7 ++++++- warehouse/routes.py | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index bf04dacb3d4e..42874efbbdc2 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -12,7 +12,11 @@ from collections import OrderedDict -from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound, HTTPTemporaryRedirect +from pyramid.httpexceptions import ( + HTTPMovedPermanently, + HTTPNotFound, + HTTPTemporaryRedirect, +) from pyramid.view import view_config from sqlalchemy.orm import Load from sqlalchemy.orm.exc import NoResultFound @@ -231,6 +235,7 @@ def json_latest(project, request): headers=_CORS_HEADERS, ) + @view_config( route_name="legacy.api.json.latest_slash", context=Project, diff --git a/warehouse/routes.py b/warehouse/routes.py index 5fdf79214807..710d8ca34433 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -388,7 +388,6 @@ def includeme(config): read_only=True, domain=warehouse, ) - # Legacy Action URLs # TODO: We should probably add Warehouse routes for these that just error From 9c7818c4c9a1e2fe08e5c1506805eb3e1cf55608 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Tue, 29 Sep 2020 12:16:28 -0400 Subject: [PATCH 05/34] Refactor project JSON tests to use fixtures --- tests/unit/legacy/api/test_json.py | 89 +++++++++++++++++++----------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 9a478f70bf6f..1c80d24be3ce 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -10,9 +10,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import OrderedDict +from collections import OrderedDict, namedtuple import pretend +import pytest from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound @@ -28,6 +29,46 @@ ReleaseFactory, ) +ProjectData = namedtuple("ProjectData", ["project", "latest_stable", "latest_pre"]) + + +@pytest.fixture(scope="function") +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(scope="function") +def project_with_pre(): + project = ProjectFactory.create() + + ReleaseFactory.create(project=project, version="1.0") + ReleaseFactory.create(project=project, version="2.0") + latest_pre = ReleaseFactory.create(project=project, version="4.0.dev0") + + latest_stable = ReleaseFactory.create(project=project, version="3.0") + + return ProjectData( + project=project, latest_stable=latest_stable, latest_pre=latest_pre + ) + + +@pytest.fixture(scope="function") +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) + def _assert_has_cors_headers(headers): assert headers["Access-Control-Allow-Origin"] == "*" @@ -66,57 +107,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) + ] class TestJSONProjectSlash: From 8a9bde95b155f74d91956dbccf1678abb0cce3a5 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Tue, 29 Sep 2020 18:43:12 -0400 Subject: [PATCH 06/34] Complete API routes and logic Switch latest from a redirect to /pypi/{name}/json, and instead duplicate the version-search logic. This should make `latest` robust against any future changes to the `json_project` logic. Implement latest-stable and latest-unstable logic. --- warehouse/legacy/api/json.py | 110 ++++++++++++++++++++++++++++++++++- warehouse/routes.py | 36 ++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 42874efbbdc2..1077eba0e98a 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -225,12 +225,22 @@ def json_release_slash(release, request): decorator=_CACHE_DECORATOR, ) def json_latest(project, request): - # Redirect to standard project endpoint this time, - # but do not instruct to *always* redirect this way + try: + release = ( + request.db.query(Release) + .filter(Release.project == project, Release.yanked.is_(False)) + .order_by(Release.is_prerelease.nullslast(), Release._pypi_ordering.desc()) + .limit(1) + .one() + ) + except NoResultFound: + return HTTPNotFound(headers=_CORS_HEADERS) + return HTTPTemporaryRedirect( request.route_path( - "legacy.api.json.project", + "legacy.api.json.release", name=project.name, + version=release.version, ), headers=_CORS_HEADERS, ) @@ -250,3 +260,97 @@ def json_latest_slash(project, request): ), headers=_CORS_HEADERS, ) + + +@view_config( + route_name="legacy.api.json.latest_stable", + context=Project, + decorator=_CACHE_DECORATOR, +) +def json_latest_stable(project, request): + try: + release = ( + request.db.query(Release) + .filter( + Release.project == project, + Release.yanked.is_(False), + Release.is_prerelease.is_(False), + ) + .order_by(Release._pypi_ordering.desc()) + .limit(1) + .one() + ) + except NoResultFound: + return HTTPNotFound(headers=_CORS_HEADERS) + + return HTTPTemporaryRedirect( + request.route_path( + "legacy.api.json.release", + name=project.name, + version=release.version, + ), + headers=_CORS_HEADERS, + ) + + +@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, + decorator=_CACHE_DECORATOR, +) +def json_latest_unstable(project, request): + try: + release = ( + request.db.query(Release) + .filter( + Release.project == project, + Release.yanked.is_(False), + Release.is_prerelease != None, + ) + .order_by(Release._pypi_ordering.desc()) + .limit(1) + .one() + ) + except NoResultFound: + return HTTPNotFound(headers=_CORS_HEADERS) + + return HTTPTemporaryRedirect( + request.route_path( + "legacy.api.json.release", + name=project.name, + version=release.version, + ), + headers=_CORS_HEADERS, + ) + + +@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/routes.py b/warehouse/routes.py index 710d8ca34433..64bf271ba433 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -372,6 +372,42 @@ def includeme(config): 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", From 70d72044b4b4b6769fe98ec2654102325781429e Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 09:35:42 -0400 Subject: [PATCH 07/34] Add 'latest' routes to test_routes.py --- tests/unit/test_routes.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index b546b1fab55b..47f561f73a36 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -367,6 +367,38 @@ def add_policy(name, filename): 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", From 187addbbfc50974a3315505204c038de9a86c760 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 09:52:21 -0400 Subject: [PATCH 08/34] Add initial 'latest' redirect test --- tests/unit/legacy/api/test_json.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 1c80d24be3ce..25877d4ae6f0 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -15,7 +15,7 @@ import pretend import pytest -from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound +from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound, HTTPTemporaryRedirect from warehouse.legacy.api import json from warehouse.packaging.models import Dependency, DependencyKind @@ -161,6 +161,24 @@ def test_normalizing_redirects(self, db_request): assert resp.headers["Location"] == "/project/the-redirect" +class TestJSONLatest: + def test_latest_no_pre(self, db_request, project_no_pre): + project = project_no_pre.project + release = project_no_pre.latest_stable + + db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/project/the-redirect") + + resp = json.json_latest(project, db_request) + + assert isinstance(resp, HTTPTemporaryRedirect) + assert db_request.route_path.calls == [ pretend.call("legacy.api.json.release", + name=project.name, version=release.version)] + assert resp.headers["Location"] == "/project/the-redirect" + + +class TestJSONLatestSlash: + pass + class TestJSONRelease: def test_normalizing_redirects(self, db_request): project = ProjectFactory.create() From 728c6d4623a22a22a6ebc3046705645f9c716277 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 10:00:07 -0400 Subject: [PATCH 09/34] Refactor release check to helper method --- tests/unit/legacy/api/test_json.py | 33 ++++++++++++++++++++++-------- warehouse/legacy/api/json.py | 2 +- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 25877d4ae6f0..1464e94ae3d0 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -15,7 +15,11 @@ import pretend import pytest -from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound, HTTPTemporaryRedirect +from pyramid.httpexceptions import ( + HTTPMovedPermanently, + HTTPNotFound, + HTTPTemporaryRedirect, +) from warehouse.legacy.api import json from warehouse.packaging.models import Dependency, DependencyKind @@ -162,23 +166,34 @@ def test_normalizing_redirects(self, db_request): class TestJSONLatest: - def test_latest_no_pre(self, db_request, project_no_pre): - project = project_no_pre.project - release = project_no_pre.latest_stable - - db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/project/the-redirect") + def check_release(self, db_request, project, release, endpoint): + db_request.route_path = pretend.call_recorder( + lambda *a, **kw: "/project/the-redirect" + ) - resp = json.json_latest(project, db_request) + resp = getattr(json, endpoint)(project, db_request) assert isinstance(resp, HTTPTemporaryRedirect) - assert db_request.route_path.calls == [ pretend.call("legacy.api.json.release", - name=project.name, version=release.version)] + assert db_request.route_path.calls == [ + pretend.call( + "legacy.api.json.release", name=project.name, version=release.version + ) + ] assert resp.headers["Location"] == "/project/the-redirect" + def test_latest_no_pre(self, db_request, project_no_pre): + self.check_release( + db_request, + project_no_pre.project, + project_no_pre.latest_stable, + "json_latest", + ) + class TestJSONLatestSlash: pass + class TestJSONRelease: def test_normalizing_redirects(self, db_request): project = ProjectFactory.create() diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 1077eba0e98a..76bbba94f37d 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -321,7 +321,7 @@ def json_latest_unstable(project, request): .filter( Release.project == project, Release.yanked.is_(False), - Release.is_prerelease != None, + Release.is_prerelease is not None, ) .order_by(Release._pypi_ordering.desc()) .limit(1) From b961397e0ba296b17e03822a50d1fceb0b7f782b Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 10:41:55 -0400 Subject: [PATCH 10/34] Complete latest tests, add unstable tests Need to fix unstable query (or test?), is redirecting to the most recent stable version instead of most recent prerelease for project_with_pre --- tests/unit/legacy/api/test_json.py | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 1464e94ae3d0..3c6c9aa226b5 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -189,6 +189,46 @@ def test_latest_no_pre(self, db_request, project_no_pre): "json_latest", ) + def test_latest_with_pre(self, db_request, project_with_pre): + self.check_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): + self.check_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "json_latest", + ) + + def test_latest_unstable_no_pre(self, db_request, project_no_pre): + self.check_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): + self.check_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): + self.check_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "json_latest_unstable", + ) + class TestJSONLatestSlash: pass From ab7f2846133b15161432e3fea7736897547e5dfc Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 11:34:04 -0400 Subject: [PATCH 11/34] Reorder project_with_pre release creation Apparently, the _pypi_ordering is a function of the order in which Releases are added to a Project. Seems potentially brittle. --- tests/unit/legacy/api/test_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 3c6c9aa226b5..c6e24ef85451 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -53,9 +53,9 @@ def project_with_pre(): ReleaseFactory.create(project=project, version="1.0") ReleaseFactory.create(project=project, version="2.0") - latest_pre = ReleaseFactory.create(project=project, version="4.0.dev0") 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 From 8967523254017420cdda49437d6572ea10055eb4 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 13:02:01 -0400 Subject: [PATCH 12/34] Add API tests for latest-stable --- tests/unit/legacy/api/test_json.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index c6e24ef85451..357ef5f95102 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -205,6 +205,31 @@ def test_latest_only_pre(self, db_request, project_only_pre): "json_latest", ) + def test_latest_stable_no_pre(self, db_request, project_no_pre): + self.check_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): + self.check_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): + 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(HTTPNotFound, resp) + def test_latest_unstable_no_pre(self, db_request, project_no_pre): self.check_release( db_request, From 75b19bccd5b944ab1ecf4816499206378356d0ce Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 13:15:05 -0400 Subject: [PATCH 13/34] Add 'project not found' and _slash tests --- tests/unit/legacy/api/test_json.py | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 357ef5f95102..bf234697febd 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -228,7 +228,7 @@ def test_latest_stable_only_pre(self, db_request, project_only_pre): resp = json.json_latest_stable(project_only_pre.project, db_request) - assert isinstance(HTTPNotFound, resp) + assert isinstance(resp, HTTPNotFound) def test_latest_unstable_no_pre(self, db_request, project_no_pre): self.check_release( @@ -254,9 +254,38 @@ def test_latest_unstable_only_pre(self, db_request, project_only_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) + _assert_has_cors_headers(resp.headers) + class TestJSONLatestSlash: - pass + @pytest.mark.parametrize( + ("route", "endpoint"), + [ + ("legacy.api.json.latest", "json_latest_slash"), + ("legacy.api.json.latest_stable", "json_latest_stable_slash"), + ("legacy.api.json.latest_unstable", "json_latest_unstable_slash"), + ], + ) + def test_normalizing_redirects(self, db_request, route, endpoint): + 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: From be00837a976ffcb81a7a707b6ed345d206807ed7 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 30 Sep 2020 15:41:07 -0400 Subject: [PATCH 14/34] Reorder slash test parametrization for readability --- tests/unit/legacy/api/test_json.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index bf234697febd..a6f749c32511 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -267,14 +267,14 @@ def test_missing_release(self, db_request, endpoint): class TestJSONLatestSlash: @pytest.mark.parametrize( - ("route", "endpoint"), + ("endpoint", "route"), [ - ("legacy.api.json.latest", "json_latest_slash"), - ("legacy.api.json.latest_stable", "json_latest_stable_slash"), - ("legacy.api.json.latest_unstable", "json_latest_unstable_slash"), + ("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, route, endpoint): + def test_normalizing_redirects(self, db_request, endpoint, route): project = ProjectFactory.create() db_request.route_path = pretend.call_recorder( From 0ee0178164480621e519fa4649bb2a81d54076d5 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Thu, 1 Oct 2020 15:01:31 -0400 Subject: [PATCH 15/34] Refactor query logic to Project model --- warehouse/legacy/api/json.py | 44 +++++++---------------------------- warehouse/packaging/models.py | 33 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 76bbba94f37d..76edccd7412b 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -225,15 +225,9 @@ def json_release_slash(release, request): decorator=_CACHE_DECORATOR, ) def json_latest(project, request): - try: - release = ( - request.db.query(Release) - .filter(Release.project == project, Release.yanked.is_(False)) - .order_by(Release.is_prerelease.nullslast(), Release._pypi_ordering.desc()) - .limit(1) - .one() - ) - except NoResultFound: + release = project.latest_version + + if release is None: return HTTPNotFound(headers=_CORS_HEADERS) return HTTPTemporaryRedirect( @@ -268,19 +262,9 @@ def json_latest_slash(project, request): decorator=_CACHE_DECORATOR, ) def json_latest_stable(project, request): - try: - release = ( - request.db.query(Release) - .filter( - Release.project == project, - Release.yanked.is_(False), - Release.is_prerelease.is_(False), - ) - .order_by(Release._pypi_ordering.desc()) - .limit(1) - .one() - ) - except NoResultFound: + release = project.latest_stable_version + + if release is None: return HTTPNotFound(headers=_CORS_HEADERS) return HTTPTemporaryRedirect( @@ -315,19 +299,9 @@ def json_latest_stable_slash(project, request): decorator=_CACHE_DECORATOR, ) def json_latest_unstable(project, request): - try: - release = ( - request.db.query(Release) - .filter( - Release.project == project, - Release.yanked.is_(False), - Release.is_prerelease is not None, - ) - .order_by(Release._pypi_ordering.desc()) - .limit(1) - .one() - ) - except NoResultFound: + release = project.latest_unstable_version + + if release is None: return HTTPNotFound(headers=_CORS_HEADERS) return HTTPTemporaryRedirect( diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 19438b370d8e..b9d6da34a3cf 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -258,6 +258,8 @@ 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) @@ -266,6 +268,37 @@ def latest_version(self): .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.version, Release.created, Release.is_prerelease) + .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.version, Release.created, Release.is_prerelease) + .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" From eaa9971313a4b024f297cdd8e03e1a889bc978a6 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Fri, 2 Oct 2020 09:45:36 -0400 Subject: [PATCH 16/34] Add routes/views for web view redirects --- tests/unit/test_routes.py | 21 ++++++++++++++++ warehouse/packaging/views.py | 48 +++++++++++++++++++++++++++++++++++- warehouse/routes.py | 21 ++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 47f561f73a36..03dc81adbf11 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -307,6 +307,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}/", diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index 043791110c5b..1f1d1728f034 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,48 @@ def project_detail(project, request): return release_detail(release, request) +@view_config( + route_name="packaging.project_latest", + context=Project, +) +def project_latest(project, request): + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=project.latest_version.version, + ) + ) + + +@view_config( + route_name="packaging.project_latest_stable", + context=Project, +) +def project_latest_stable(project, request): + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=project.latest_stable_version.version, + ) + ) + + +@view_config( + route_name="packaging.project_latest_unstable", + context=Project, +) +def project_latest_unstable(project, request): + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=project.latest_unstable_version.version, + ) + ) + + @view_config( route_name="packaging.release", context=Release, diff --git a/warehouse/routes.py b/warehouse/routes.py index 64bf271ba433..2ef1df14a474 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -302,6 +302,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}/", From 0f1efaa5a73f4f76e0dc1fc1f0f203952ba0853b Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Fri, 2 Oct 2020 10:36:02 -0400 Subject: [PATCH 17/34] Refactor dummy project fixtures to conftest.py --- tests/conftest.py | 47 +++++++++++++++++++++++++++++- tests/unit/legacy/api/test_json.py | 42 +------------------------- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 443f22accf9f..59c8c3da17df 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,47 @@ def monkeypatch_session(): m = MonkeyPatch() yield m m.undo() + + +# Standardized dummy projects for testing version-search behavior +# under different stable/prerelease circumstances + +ProjectData = namedtuple("ProjectData", ["project", "latest_stable", "latest_pre"]) + + +@pytest.fixture(scope="function") +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(scope="function") +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(scope="function") +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 a6f749c32511..419920afa66a 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -10,7 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import OrderedDict, namedtuple +from collections import OrderedDict import pretend import pytest @@ -33,46 +33,6 @@ ReleaseFactory, ) -ProjectData = namedtuple("ProjectData", ["project", "latest_stable", "latest_pre"]) - - -@pytest.fixture(scope="function") -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(scope="function") -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(scope="function") -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) - def _assert_has_cors_headers(headers): assert headers["Access-Control-Allow-Origin"] == "*" From 51662d47a4b6fdaae0065e45cbb4b996ddd97591 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Fri, 2 Oct 2020 12:23:06 -0400 Subject: [PATCH 18/34] Add tests for release view redirects --- tests/unit/packaging/test_views.py | 96 +++++++++++++++++++++++++++++- warehouse/packaging/views.py | 17 ++++-- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index ef312e8279ed..e7ec0171464b 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,93 @@ def test_edit_project_button_returns_project(self): assert views.edit_project_button(project, pretend.stub()) == { "project": project } + + +class TestProjectLatestRedirects: + def check_release(self, db_request, project, release, endpoint): + db_request.route_path = pretend.call_recorder( + lambda *a, **kw: "/project/the-redirect" + ) + + 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" + + def test_latest_no_pre(self, db_request, project_no_pre): + self.check_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): + self.check_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): + self.check_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "project_latest", + ) + + def test_latest_stable_no_pre(self, db_request, project_no_pre): + self.check_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): + self.check_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): + db_request.route_path = pretend.call_recorder( + lambda *a, **kw: "/project/the-redirect" + ) + + 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): + self.check_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): + self.check_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): + self.check_release( + db_request, + project_only_pre.project, + project_only_pre.latest_pre, + "project_latest_unstable", + ) diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index 1f1d1728f034..ac541d49b5f3 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -76,13 +76,18 @@ def project_latest(project, request): context=Project, ) def project_latest_stable(project, request): - return HTTPTemporaryRedirect( - request.route_path( - "packaging.release", - name=project.name, - version=project.latest_stable_version.version, + release = project.latest_stable_version + + if release: + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=project.latest_stable_version.version, + ) ) - ) + else: + return HTTPNotFound() @view_config( From 5823118a3680349beda624f364d37832ce33cde3 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Sun, 4 Oct 2020 07:24:11 -0400 Subject: [PATCH 19/34] 1) Start docs work [skip ci] 2) Draft latest JSON doc chunk [skip ci] 3) Add docs for 'latest' web UI endpoints Plus a bit of tweaking to the JSON docs language. --- docs/api-reference/integration-guide.rst | 21 ++++++++++++++++ docs/api-reference/json.rst | 32 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) 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 aa843788ab1a..7dda4a1a2fa2 100644 --- a/docs/api-reference/json.rst +++ b/docs/api-reference/json.rst @@ -261,3 +261,35 @@ 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`` + + Redirects to the latest non-prerelease version of ````, + if any exists. If none does exist, redirects instead to the latest + pre-release version of ````. + + As of Oct 2020, this behavior is identical to that of the + `Project`_ endpoint, and should return an identical JSON response. + + * ``/pypi//latest-stable/json`` + + Redirects to the latest non-prerelease version of ````. + If no non-prerelease versions exist, returns |http404|_. + + * ``/pypi//latest-unstable/json`` + + Redirects to a JSON query for the latest version of ````, + regardless of pre-release status. + + + +.. |http404| replace:: ``404 Not Found`` + +.. _http404: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.5 From 1da81c2c8b0ae9e7c96e6c8b11dc0737d724c963 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 21 Apr 2021 09:20:35 -0400 Subject: [PATCH 20/34] Convert 'latest' JSON endpoint to direct result Not that bad! The sqlalchemy magic is pretty darn intuitive! --- warehouse/legacy/api/json.py | 37 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 76edccd7412b..9eecd61e2f01 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -222,23 +222,24 @@ def json_release_slash(release, request): @view_config( route_name="legacy.api.json.latest", context=Project, + renderer="json", decorator=_CACHE_DECORATOR, ) def json_latest(project, request): - release = project.latest_version + version = project.latest_version.version - if release is None: + if version is None: return HTTPNotFound(headers=_CORS_HEADERS) - return HTTPTemporaryRedirect( - request.route_path( - "legacy.api.json.release", - name=project.name, - version=release.version, - ), - headers=_CORS_HEADERS, + release = ( + request.db.query(Release) + .filter(Release.project == project) + .filter(Release.version == version) + .first() ) + return json_release(release, request) + @view_config( route_name="legacy.api.json.latest_slash", @@ -267,14 +268,7 @@ def json_latest_stable(project, request): if release is None: return HTTPNotFound(headers=_CORS_HEADERS) - return HTTPTemporaryRedirect( - request.route_path( - "legacy.api.json.release", - name=project.name, - version=release.version, - ), - headers=_CORS_HEADERS, - ) + return json_release(release, request) @view_config( @@ -304,14 +298,7 @@ def json_latest_unstable(project, request): if release is None: return HTTPNotFound(headers=_CORS_HEADERS) - return HTTPTemporaryRedirect( - request.route_path( - "legacy.api.json.release", - name=project.name, - version=release.version, - ), - headers=_CORS_HEADERS, - ) + return json_release(release, request) @view_config( From 930a66d9ae98381b85f4e58d42b7a3afdf36b38f Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Wed, 21 Apr 2021 10:47:40 -0400 Subject: [PATCH 21/34] Rework stable and unstable to direct returns. Getting errors on local tests; will see how CI does. --- warehouse/legacy/api/json.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 9eecd61e2f01..1d04e1a2c0a8 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -12,11 +12,7 @@ from collections import OrderedDict -from pyramid.httpexceptions import ( - HTTPMovedPermanently, - HTTPNotFound, - HTTPTemporaryRedirect, -) +from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound from pyramid.view import view_config from sqlalchemy.orm import Load from sqlalchemy.orm.exc import NoResultFound @@ -260,14 +256,22 @@ def json_latest_slash(project, request): @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 + version = project.latest_stable_version.version - if release is None: + if version is None: return HTTPNotFound(headers=_CORS_HEADERS) + release = ( + request.db.query(Release) + .filter(Release.project == project) + .filter(Release.version == version) + .first() + ) + return json_release(release, request) @@ -290,14 +294,22 @@ def json_latest_stable_slash(project, request): @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 + version = project.latest_unstable_version.version - if release is None: + if version is None: return HTTPNotFound(headers=_CORS_HEADERS) + release = ( + request.db.query(Release) + .filter(Release.project == project) + .filter(Release.version == version) + .first() + ) + return json_release(release, request) From d81ecc92eec0284bf9bba8a720376f5c5e61b797 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Thu, 22 Apr 2021 12:42:56 -0400 Subject: [PATCH 22/34] Refactor latest version lookup for missing release Depending on how the various version lookups are called, project.latest_version may itself return None. So, breaking up the attribute access chain will be more robust. Could have used .getattr() here, but stringifying the member names in the actual codebase (as opposed to in test code) seemed imprudent. --- warehouse/legacy/api/json.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 1d04e1a2c0a8..1b0ed72e8c2e 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -222,7 +222,8 @@ def json_release_slash(release, request): decorator=_CACHE_DECORATOR, ) def json_latest(project, request): - version = project.latest_version.version + release = project.latest_version + version = release.version if release else None if version is None: return HTTPNotFound(headers=_CORS_HEADERS) @@ -260,7 +261,8 @@ def json_latest_slash(project, request): decorator=_CACHE_DECORATOR, ) def json_latest_stable(project, request): - version = project.latest_stable_version.version + release = project.latest_stable_version + version = release.version if release else None if version is None: return HTTPNotFound(headers=_CORS_HEADERS) @@ -298,7 +300,8 @@ def json_latest_stable_slash(project, request): decorator=_CACHE_DECORATOR, ) def json_latest_unstable(project, request): - version = project.latest_unstable_version.version + release = project.latest_unstable_version + version = release.version if release else None if version is None: return HTTPNotFound(headers=_CORS_HEADERS) From ede804187287131d00587e3c66e1c65a82c25464 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Thu, 22 Apr 2021 12:45:11 -0400 Subject: [PATCH 23/34] Switch 'latest JSON' tests to mocked responses Instead of testing them as redirects, as originally implemented, they are now tested via catching the call-through to the underlying json_release() JSON API function. --- tests/unit/legacy/api/test_json.py | 79 +++++++++++++++++------------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 419920afa66a..b21d4c490d97 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -15,11 +15,7 @@ import pretend import pytest -from pyramid.httpexceptions import ( - HTTPMovedPermanently, - HTTPNotFound, - HTTPTemporaryRedirect, -) +from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound from warehouse.legacy.api import json from warehouse.packaging.models import Dependency, DependencyKind @@ -125,56 +121,63 @@ def test_normalizing_redirects(self, db_request): assert resp.headers["Location"] == "/project/the-redirect" -class TestJSONLatest: - def check_release(self, db_request, project, release, endpoint): - db_request.route_path = pretend.call_recorder( - lambda *a, **kw: "/project/the-redirect" - ) +@pytest.fixture +def check_json_release(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 isinstance(resp, HTTPTemporaryRedirect) - assert db_request.route_path.calls == [ - pretend.call( - "legacy.api.json.release", name=project.name, version=release.version - ) - ] - assert resp.headers["Location"] == "/project/the-redirect" + 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): - self.check_release( +class TestJSONLatestReleases: + 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): - self.check_release( + 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): - self.check_release( + 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): - self.check_release( + 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): - self.check_release( + 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, @@ -182,32 +185,38 @@ def test_latest_stable_with_pre(self, db_request, project_with_pre): ) 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): - self.check_release( + 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): - self.check_release( + 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): - self.check_release( + 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, @@ -220,9 +229,9 @@ def test_latest_unstable_only_pre(self, db_request, project_only_pre): ) def test_missing_release(self, db_request, endpoint): project = ProjectFactory.create() + resp = getattr(json, endpoint)(project, db_request) assert isinstance(resp, HTTPNotFound) - _assert_has_cors_headers(resp.headers) class TestJSONLatestSlash: From c57792c0dc53c8d784b0052872ac8f84a36e6b53 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Thu, 22 Apr 2021 22:44:56 -0400 Subject: [PATCH 24/34] Refactor check functions to class-scope fixtures Fixtures allow monkeypatching; making them class-scope keeps their definitions closer to the tests and prevents (possibly undesirably) re-use elsewhere. If they end up needed elsewhere, they can easily be refactored out to conftest.py. --- tests/conftest.py | 6 ++- tests/unit/legacy/api/test_json.py | 23 ++++----- tests/unit/packaging/test_views.py | 82 +++++++++++++++++------------- 3 files changed, 62 insertions(+), 49 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 59c8c3da17df..e1652f84c3ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -340,8 +340,10 @@ def monkeypatch_session(): m.undo() -# Standardized dummy projects for testing version-search behavior -# under different stable/prerelease circumstances +# 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"]) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index bc3bdeafebe6..fac14c55d185 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -191,22 +191,21 @@ def test_normalizing_redirects(self, db_request): assert resp.headers["Location"] == "/project/the-redirect" -@pytest.fixture -def check_json_release(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) +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) - assert resp is response - assert json_release.calls == [pretend.call(release, db_request)] + def check_function(db_request, project, release, endpoint): + resp = getattr(json, endpoint)(project, db_request) - return check_function + assert resp is response + assert json_release.calls == [pretend.call(release, db_request)] + return check_function -class TestJSONLatestReleases: 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( diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index e7ec0171464b..226105339767 100644 --- a/tests/unit/packaging/test_views.py +++ b/tests/unit/packaging/test_views.py @@ -340,88 +340,100 @@ def test_edit_project_button_returns_project(self): class TestProjectLatestRedirects: - def check_release(self, db_request, project, release, endpoint): - db_request.route_path = pretend.call_recorder( - lambda *a, **kw: "/project/the-redirect" - ) - - 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" - - def test_latest_no_pre(self, db_request, project_no_pre): - self.check_release( + @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): - self.check_release( + 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): - self.check_release( + 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_stable_no_pre(self, db_request, project_no_pre): - self.check_release( + 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): - self.check_release( + 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): - db_request.route_path = pretend.call_recorder( - lambda *a, **kw: "/project/the-redirect" - ) + 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): - self.check_release( + 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): - self.check_release( + 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): - self.check_release( + 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, From ed0baa180d8bd628e80fcd37450c9d4d60dd5b9d Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Fri, 23 Apr 2021 10:06:55 -0400 Subject: [PATCH 25/34] Switch JSON database query to .one() As noted in discussion in #8615, zero or multiple query results in this case are a pathological scenario. --- warehouse/legacy/api/json.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 197785770151..0eb0a21f2f7c 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -236,7 +236,7 @@ def json_latest(project, request): request.db.query(Release) .filter(Release.project == project) .filter(Release.version == version) - .first() + .one() ) return json_release(release, request) @@ -275,7 +275,7 @@ def json_latest_stable(project, request): request.db.query(Release) .filter(Release.project == project) .filter(Release.version == version) - .first() + .one() ) return json_release(release, request) @@ -314,7 +314,7 @@ def json_latest_unstable(project, request): request.db.query(Release) .filter(Release.project == project) .filter(Release.version == version) - .first() + .one() ) return json_release(release, request) From 1cb61d6e89add642dedb2208f16ebae55ffbbcea Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 26 Apr 2021 23:02:32 -0400 Subject: [PATCH 26/34] Switch 'latest' properties to return Releases No failures introduced to test suite on the change. --- warehouse/packaging/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 1ce801896cc9..adf0f7bbfc7a 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -264,7 +264,7 @@ def latest_version(self): # 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() @@ -276,7 +276,7 @@ def latest_stable_version(self): # available, return None. return ( orm.object_session(self) - .query(Release.version, Release.created, Release.is_prerelease) + .query(Release) .filter( Release.project == self, Release.yanked.is_(False), @@ -291,7 +291,7 @@ def latest_unstable_version(self): # Supply the latest available version, regardless of pre-release status. return ( orm.object_session(self) - .query(Release.version, Release.created, Release.is_prerelease) + .query(Release) .filter( Release.project == self, Release.yanked.is_(False), From ee873ddacf4b452c5a86a9f4fc94a0a1983171c5 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 17 May 2021 10:05:28 -0400 Subject: [PATCH 27/34] Remove unnecessary scope="function" args Per https://github.com/pypa/warehouse/pull/8615#discussion_r619662268 --- tests/conftest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e1652f84c3ac..82568bce3a7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -348,7 +348,7 @@ def monkeypatch_session(): ProjectData = namedtuple("ProjectData", ["project", "latest_stable", "latest_pre"]) -@pytest.fixture(scope="function") +@pytest.fixture def project_no_pre(): project = ProjectFactory.create() @@ -359,7 +359,7 @@ def project_no_pre(): return ProjectData(project=project, latest_stable=latest_stable, latest_pre=None) -@pytest.fixture(scope="function") +@pytest.fixture def project_with_pre(): project = ProjectFactory.create() @@ -374,7 +374,7 @@ def project_with_pre(): ) -@pytest.fixture(scope="function") +@pytest.fixture def project_only_pre(): project = ProjectFactory.create() From 0306080753a582d01798b68ef4aeae42eb65c0dc Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 17 May 2021 11:02:13 -0400 Subject: [PATCH 28/34] Revise JSON docs to remove 'redirect' mentions JSON API views now directly return, rather than redirecting. Per https://github.com/pypa/warehouse/pull/8615#pullrequestreview-644034530 --- docs/api-reference/json.rst | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/api-reference/json.rst b/docs/api-reference/json.rst index 2f374ddfbda8..e481fe68c285 100644 --- a/docs/api-reference/json.rst +++ b/docs/api-reference/json.rst @@ -274,23 +274,24 @@ Release * ``/pypi//latest/json`` - Redirects to the latest non-prerelease version of ````, - if any exists. If none does exist, redirects instead to the latest + Supplies the latest non-prerelease version of ````, + if any exists. If none does exist, supplies instead the latest pre-release version of ````. - As of Oct 2020, this behavior is identical to that of the - `Project`_ endpoint, and should return an identical JSON response. + As of May 2021, this behavior matches that of the + `Project`_ endpoint, and *should* return an identical JSON response. * ``/pypi//latest-stable/json`` - Redirects to the latest non-prerelease version of ````. + Supplies the latest non-prerelease version of ````. If no non-prerelease versions exist, returns |http404|_. * ``/pypi//latest-unstable/json`` - Redirects to a JSON query for the latest version of ````, + 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`` From dc8383086a5fae0eace40d706044f149cba5a1e9 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 17 May 2021 13:00:40 -0400 Subject: [PATCH 29/34] Add guard against a nonexisting 'latest' release In the 'latest' views of views.py, pre-fetch the release from the project first, then handle appropriately given the possibility for None. Addresses the following PR review comments: - https://github.com/pypa/warehouse/pull/8615/files/ed0baa180d8bd628e80fcd37450c9d4d60dd5b9d?file-filters%5B%5D=.py#r619663916 - https://github.com/pypa/warehouse/pull/8615/files/ed0baa180d8bd628e80fcd37450c9d4d60dd5b9d?file-filters%5B%5D=.py#r619663799 - https://github.com/pypa/warehouse/pull/8615/files/ed0baa180d8bd628e80fcd37450c9d4d60dd5b9d?file-filters%5B%5D=.py#r619663874 --- warehouse/packaging/views.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index ac541d49b5f3..22cbd3a24b32 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -62,13 +62,18 @@ def project_detail(project, request): context=Project, ) def project_latest(project, request): - return HTTPTemporaryRedirect( - request.route_path( - "packaging.release", - name=project.name, - version=project.latest_version.version, + release = project.latest_version + + if release: + return HTTPTemporaryRedirect( + request.route_path( + "packaging.release", + name=project.name, + version=release.version, + ) ) - ) + else: + return HTTPNotFound() @view_config( @@ -83,7 +88,7 @@ def project_latest_stable(project, request): request.route_path( "packaging.release", name=project.name, - version=project.latest_stable_version.version, + version=release.version, ) ) else: @@ -95,13 +100,18 @@ def project_latest_stable(project, request): context=Project, ) def project_latest_unstable(project, request): - return HTTPTemporaryRedirect( - request.route_path( - "packaging.release", - name=project.name, - version=project.latest_unstable_version.version, + 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( From a8a0cf496aba65354835b50a8d7fda7846082dd5 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 17 May 2021 13:21:09 -0400 Subject: [PATCH 30/34] Add 'latest' views tests for no-release projects For completeness, and to satisfy 100% coverage requirement. --- tests/unit/packaging/test_views.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index 226105339767..feab89efb3be 100644 --- a/tests/unit/packaging/test_views.py +++ b/tests/unit/packaging/test_views.py @@ -382,6 +382,14 @@ def test_latest_only_pre(self, db_request, project_only_pre, check_latest_releas "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 ): @@ -439,3 +447,11 @@ def test_latest_unstable_only_pre( 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) From 9415413f5cfe7a568a0fdbaf5c9a6d132c93a2e2 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 17 May 2021 13:43:38 -0400 Subject: [PATCH 31/34] Streamline release handling in latest JSON calls Addresses https://github.com/pypa/warehouse/pull/8615#discussion_r619663407. The database queries inline here may be duplicative; will check in a subsequent commit. --- warehouse/legacy/api/json.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 0eb0a21f2f7c..6c1bb841be18 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -227,15 +227,14 @@ def json_release_slash(release, request): ) def json_latest(project, request): release = project.latest_version - version = release.version if release else None - if version is None: + if release is None: return HTTPNotFound(headers=_CORS_HEADERS) release = ( request.db.query(Release) .filter(Release.project == project) - .filter(Release.version == version) + .filter(Release.version == release.version) .one() ) @@ -266,15 +265,14 @@ def json_latest_slash(project, request): ) def json_latest_stable(project, request): release = project.latest_stable_version - version = release.version if release else None - if version is None: + if release is None: return HTTPNotFound(headers=_CORS_HEADERS) release = ( request.db.query(Release) .filter(Release.project == project) - .filter(Release.version == version) + .filter(Release.version == release.version) .one() ) @@ -305,15 +303,14 @@ def json_latest_stable_slash(project, request): ) def json_latest_unstable(project, request): release = project.latest_unstable_version - version = release.version if release else None - if version is None: + if release is None: return HTTPNotFound(headers=_CORS_HEADERS) release = ( request.db.query(Release) .filter(Release.project == project) - .filter(Release.version == version) + .filter(Release.version == release.version) .one() ) From 688d842e6d085b03802200b1bd19b2b2e1d25963 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Mon, 17 May 2021 14:06:01 -0400 Subject: [PATCH 32/34] Remove redundant dbquery in 'latest' JSON The 'latest' members off of the Project model now return fully-realized Release instances, making a subsequent database call unnecessary. --- warehouse/legacy/api/json.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 6c1bb841be18..bf00848f0d6d 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -231,13 +231,6 @@ def json_latest(project, request): if release is None: return HTTPNotFound(headers=_CORS_HEADERS) - release = ( - request.db.query(Release) - .filter(Release.project == project) - .filter(Release.version == release.version) - .one() - ) - return json_release(release, request) @@ -269,13 +262,6 @@ def json_latest_stable(project, request): if release is None: return HTTPNotFound(headers=_CORS_HEADERS) - release = ( - request.db.query(Release) - .filter(Release.project == project) - .filter(Release.version == release.version) - .one() - ) - return json_release(release, request) @@ -307,13 +293,6 @@ def json_latest_unstable(project, request): if release is None: return HTTPNotFound(headers=_CORS_HEADERS) - release = ( - request.db.query(Release) - .filter(Release.project == project) - .filter(Release.version == release.version) - .one() - ) - return json_release(release, request) From c6f664a491190723034f05c1a858aa8dfa4bec8f Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Tue, 18 May 2021 13:32:13 -0400 Subject: [PATCH 33/34] Add sidestep for pypa/pip#9644 Should be removed before merging pypa/warehouse#8615 --- requirements/main.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/requirements/main.txt b/requirements/main.txt index e4e19c32d18f..26c078bb3bad 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -266,6 +266,11 @@ google-api-core[grpc]==1.26.3 \ # via # google-cloud-bigquery # google-cloud-core +google-api-core==1.26.3 \ + --hash=sha256:099762d4b4018cd536bcf85136bf337957da438807572db52f21dc61251be089 \ + --hash=sha256:b914345c7ea23861162693a27703bab804a55504f7e6e9abcaff174d80df32ac + # duplicated from requirement immediately above to + # temporarily sidestep https://github.com/pypa/pip/issues/9644 google-auth==1.30.0 \ --hash=sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f \ --hash=sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206 From 715816ca71b07229e5901a44d557f3cda1a53943 Mon Sep 17 00:00:00 2001 From: Brian Skinn Date: Tue, 18 May 2021 15:15:04 -0400 Subject: [PATCH 34/34] Revert "Add sidestep for pypa/pip#9644" This reverts commit c6f664a491190723034f05c1a858aa8dfa4bec8f. --- requirements/main.txt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/requirements/main.txt b/requirements/main.txt index 26c078bb3bad..e4e19c32d18f 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -266,11 +266,6 @@ google-api-core[grpc]==1.26.3 \ # via # google-cloud-bigquery # google-cloud-core -google-api-core==1.26.3 \ - --hash=sha256:099762d4b4018cd536bcf85136bf337957da438807572db52f21dc61251be089 \ - --hash=sha256:b914345c7ea23861162693a27703bab804a55504f7e6e9abcaff174d80df32ac - # duplicated from requirement immediately above to - # temporarily sidestep https://github.com/pypa/pip/issues/9644 google-auth==1.30.0 \ --hash=sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f \ --hash=sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206