diff --git a/docs/user/api/index-api.md b/docs/user/api/index-api.md index b7bcc3eb4f82..a0530e7e0a1f 100644 --- a/docs/user/api/index-api.md +++ b/docs/user/api/index-api.md @@ -3,10 +3,15 @@ This page documents PyPI's implementation of the [PEP 503] (HTML) and [PEP 691] (JSON) index API. +See the [Simple Repository API] for the official living +specification of this API. + [PEP 503]: https://peps.python.org/pep-0503/ [PEP 691]: https://peps.python.org/pep-0691/ +[Simple Repository API]: https://packaging.python.org/en/latest/specifications/simple-repository-api/ + !!! note The index API is sometimes called the "legacy API," @@ -53,7 +58,7 @@ Accept: application/vnd.pypi.simple.v1+html - + Simple index @@ -84,7 +89,7 @@ Accept: application/vnd.pypi.simple.v1+json { "meta": { "_last-serial": 24888689, - "api-version": "1.1" + "api-version": "1.4" }, "projects": [ { @@ -152,7 +157,8 @@ Accept: application/vnd.pypi.simple.v1+html - + + Links for beautifulsoup4 @@ -277,9 +283,12 @@ Accept: application/vnd.pypi.simple.v1+json ], "meta": { "_last-serial": 22406780, - "api-version": "1.1" + "api-version": "1.4" }, "name": "beautifulsoup4", + "project-status": { + "status": "active" + } "versions": [ "4.0.1", "4.0.2", diff --git a/tests/unit/api/test_simple.py b/tests/unit/api/test_simple.py index fce83f440904..431408a61dd8 100644 --- a/tests/unit/api/test_simple.py +++ b/tests/unit/api/test_simple.py @@ -25,8 +25,7 @@ def _assert_has_cors_headers(headers): assert headers["Access-Control-Allow-Origin"] == "*" assert headers["Access-Control-Allow-Headers"] == ( - "Content-Type, If-Match, If-Modified-Since, If-None-Match, " - "If-Unmodified-Since" + "Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since" ) assert headers["Access-Control-Allow-Methods"] == "GET" assert headers["Access-Control-Max-Age"] == "86400" @@ -208,6 +207,7 @@ def test_no_files_no_serial(self, db_request, content_type, renderer_override): context = { "meta": {"_last-serial": 0, "api-version": API_VERSION}, "name": project.normalized_name, + "project-status": {"status": "active"}, "files": [], "versions": [], "alternate-locations": [], @@ -240,6 +240,7 @@ def test_no_files_with_serial(self, db_request, content_type, renderer_override) context = { "meta": {"_last-serial": je.id, "api-version": API_VERSION}, "name": project.normalized_name, + "project-status": {"status": "active"}, "files": [], "versions": [], "alternate-locations": sorted(al.url for al in als), @@ -278,6 +279,7 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override) context = { "meta": {"_last-serial": 0, "api-version": API_VERSION}, "name": project.normalized_name, + "project-status": {"status": "active"}, "versions": release_versions, "files": [ { @@ -330,6 +332,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid context = { "meta": {"_last-serial": je.id, "api-version": API_VERSION}, "name": project.normalized_name, + "project-status": {"status": "active"}, "versions": release_versions, "files": [ { @@ -419,6 +422,7 @@ def test_with_files_with_version_multi_digit( context = { "meta": {"_last-serial": je.id, "api-version": API_VERSION}, "name": project.normalized_name, + "project-status": {"status": "active"}, "versions": release_versions, "files": [ { @@ -473,6 +477,66 @@ def test_with_files_quarantined_omitted_from_index( context = { "meta": {"_last-serial": 0, "api-version": API_VERSION}, "name": project.normalized_name, + "project-status": {"status": "quarantined"}, + "files": [], + "versions": [], + "alternate-locations": [], + } + context = _update_context(context, content_type, renderer_override) + + assert simple.simple_detail(project, db_request) == context + + if renderer_override is not None: + assert db_request.override_renderer == renderer_override + + @pytest.mark.parametrize( + "archive_marker", + [ + "archived", + "archived-noindex", + ], + ) + @pytest.mark.parametrize( + ("content_type", "renderer_override"), + CONTENT_TYPE_PARAMS, + ) + def test_with_archived_project( + self, db_request, archive_marker, content_type, renderer_override + ): + db_request.accept = content_type + project = ProjectFactory.create(lifecycle_status=archive_marker) + _ = ReleaseFactory.create_batch(3, project=project) + + context = { + "meta": {"_last-serial": 0, "api-version": API_VERSION}, + "name": project.normalized_name, + "project-status": {"status": "archived"}, + "files": [], + "versions": [], + "alternate-locations": [], + } + context = _update_context(context, content_type, renderer_override) + + assert simple.simple_detail(project, db_request) == context + + if renderer_override is not None: + assert db_request.override_renderer == renderer_override + + @pytest.mark.parametrize( + ("content_type", "renderer_override"), + CONTENT_TYPE_PARAMS, + ) + def test_with_quarantine_exit_project( + self, db_request, content_type, renderer_override + ): + db_request.accept = content_type + project = ProjectFactory.create(lifecycle_status="quarantine-exit") + _ = ReleaseFactory.create_batch(3, project=project) + + context = { + "meta": {"_last-serial": 0, "api-version": API_VERSION}, + "name": project.normalized_name, + "project-status": {"status": "active"}, "files": [], "versions": [], "alternate-locations": [], @@ -564,6 +628,7 @@ def route_url(route, **kw): context = { "meta": {"_last-serial": je.id, "api-version": API_VERSION}, "name": project.normalized_name, + "project-status": {"status": "active"}, "versions": ["1.0.0"], "files": [ { diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 8c8b2665dd91..b3d34e73b792 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -158,6 +158,15 @@ def __contains__(self, project): return True +class ProjectStatusMarker(enum.StrEnum): + """PEP 792 status markers.""" + + Active = "active" + Archived = "archived" + Quarantined = "quarantined" + Deprecated = "deprecated" + + class LifecycleStatus(enum.StrEnum): QuarantineEnter = "quarantine-enter" QuarantineExit = "quarantine-exit" @@ -494,6 +503,25 @@ def yanked_releases(self): .all() ) + @property + def project_status(self) -> ProjectStatusMarker: + """ + Return the PEP 792 project status marker that's equivalent + to this project's lifecycle status. + """ + + if self.lifecycle_status == LifecycleStatus.QuarantineEnter: + return ProjectStatusMarker.Quarantined + elif self.lifecycle_status in ( + LifecycleStatus.Archived, + LifecycleStatus.ArchivedNoindex, + ): + return ProjectStatusMarker.Archived + + # PyPI doesn't yet have a deprecated lifecycle status + # and "quarantine-exit" means a return to active. + return ProjectStatusMarker.Active + class DependencyKind(enum.IntEnum): requires = 1 diff --git a/warehouse/packaging/utils.py b/warehouse/packaging/utils.py index b5972a5fc83a..aed135dd4ed7 100644 --- a/warehouse/packaging/utils.py +++ b/warehouse/packaging/utils.py @@ -13,7 +13,7 @@ from warehouse.packaging.interfaces import ISimpleStorage from warehouse.packaging.models import File, LifecycleStatus, Project, Release -API_VERSION = "1.3" +API_VERSION = "1.4" def _simple_index(request, serial): @@ -71,6 +71,11 @@ def _simple_detail(project, request): return { "meta": {"api-version": API_VERSION, "_last-serial": project.last_serial}, "name": project.normalized_name, + "project-status": ( + { + "status": project.project_status.value, + } + ), "versions": versions, "alternate-locations": alternate_repositories, "files": [ @@ -163,5 +168,6 @@ def render_simple_detail(project, request, store=False): def _valid_simple_detail_context(context: dict) -> dict: + context["project_status"] = context.pop("project-status", None) context["alternate_locations"] = context.pop("alternate-locations", []) return context diff --git a/warehouse/templates/api/simple/detail.html b/warehouse/templates/api/simple/detail.html index 9fd0182d45fa..0febe7c4e26e 100644 --- a/warehouse/templates/api/simple/detail.html +++ b/warehouse/templates/api/simple/detail.html @@ -4,6 +4,7 @@ {% for alt_repo in alternate_locations %}{% endfor %} + {% if project_status %}{% endif %} Links for {{ name }}