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 }}