Skip to content

Commit b71031f

Browse files
authored
feat: implement PEP 792 project status markers (#18422)
1 parent 0d2d625 commit b71031f

File tree

5 files changed

+116
-7
lines changed

5 files changed

+116
-7
lines changed

docs/user/api/index-api.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33
This page documents PyPI's implementation of the
44
[PEP 503] (HTML) and [PEP 691] (JSON) index API.
55

6+
See the [Simple Repository API] for the official living
7+
specification of this API.
8+
69
[PEP 503]: https://peps.python.org/pep-0503/
710

811
[PEP 691]: https://peps.python.org/pep-0691/
912

13+
[Simple Repository API]: https://packaging.python.org/en/latest/specifications/simple-repository-api/
14+
1015
!!! note
1116

1217
The index API is sometimes called the "legacy API,"
@@ -53,7 +58,7 @@ Accept: application/vnd.pypi.simple.v1+html
5358

5459
<html>
5560
<head>
56-
<meta name="pypi:repository-version" content="1.1">
61+
<meta name="pypi:repository-version" content="1.4">
5762
<title>Simple index</title>
5863
</head>
5964
<body>
@@ -84,7 +89,7 @@ Accept: application/vnd.pypi.simple.v1+json
8489
{
8590
"meta": {
8691
"_last-serial": 24888689,
87-
"api-version": "1.1"
92+
"api-version": "1.4"
8893
},
8994
"projects": [
9095
{
@@ -152,7 +157,8 @@ Accept: application/vnd.pypi.simple.v1+html
152157
<!DOCTYPE html>
153158
<html>
154159
<head>
155-
<meta name="pypi:repository-version" content="1.1">
160+
<meta name="pypi:repository-version" content="1.4">
161+
<meta name="pypi:project-status" content="active">
156162
<title>Links for beautifulsoup4</title>
157163
</head>
158164
<body>
@@ -277,9 +283,12 @@ Accept: application/vnd.pypi.simple.v1+json
277283
],
278284
"meta": {
279285
"_last-serial": 22406780,
280-
"api-version": "1.1"
286+
"api-version": "1.4"
281287
},
282288
"name": "beautifulsoup4",
289+
"project-status": {
290+
"status": "active"
291+
}
283292
"versions": [
284293
"4.0.1",
285294
"4.0.2",

tests/unit/api/test_simple.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@
2525
def _assert_has_cors_headers(headers):
2626
assert headers["Access-Control-Allow-Origin"] == "*"
2727
assert headers["Access-Control-Allow-Headers"] == (
28-
"Content-Type, If-Match, If-Modified-Since, If-None-Match, "
29-
"If-Unmodified-Since"
28+
"Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since"
3029
)
3130
assert headers["Access-Control-Allow-Methods"] == "GET"
3231
assert headers["Access-Control-Max-Age"] == "86400"
@@ -208,6 +207,7 @@ def test_no_files_no_serial(self, db_request, content_type, renderer_override):
208207
context = {
209208
"meta": {"_last-serial": 0, "api-version": API_VERSION},
210209
"name": project.normalized_name,
210+
"project-status": {"status": "active"},
211211
"files": [],
212212
"versions": [],
213213
"alternate-locations": [],
@@ -240,6 +240,7 @@ def test_no_files_with_serial(self, db_request, content_type, renderer_override)
240240
context = {
241241
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
242242
"name": project.normalized_name,
243+
"project-status": {"status": "active"},
243244
"files": [],
244245
"versions": [],
245246
"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)
278279
context = {
279280
"meta": {"_last-serial": 0, "api-version": API_VERSION},
280281
"name": project.normalized_name,
282+
"project-status": {"status": "active"},
281283
"versions": release_versions,
282284
"files": [
283285
{
@@ -330,6 +332,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid
330332
context = {
331333
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
332334
"name": project.normalized_name,
335+
"project-status": {"status": "active"},
333336
"versions": release_versions,
334337
"files": [
335338
{
@@ -419,6 +422,7 @@ def test_with_files_with_version_multi_digit(
419422
context = {
420423
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
421424
"name": project.normalized_name,
425+
"project-status": {"status": "active"},
422426
"versions": release_versions,
423427
"files": [
424428
{
@@ -473,6 +477,66 @@ def test_with_files_quarantined_omitted_from_index(
473477
context = {
474478
"meta": {"_last-serial": 0, "api-version": API_VERSION},
475479
"name": project.normalized_name,
480+
"project-status": {"status": "quarantined"},
481+
"files": [],
482+
"versions": [],
483+
"alternate-locations": [],
484+
}
485+
context = _update_context(context, content_type, renderer_override)
486+
487+
assert simple.simple_detail(project, db_request) == context
488+
489+
if renderer_override is not None:
490+
assert db_request.override_renderer == renderer_override
491+
492+
@pytest.mark.parametrize(
493+
"archive_marker",
494+
[
495+
"archived",
496+
"archived-noindex",
497+
],
498+
)
499+
@pytest.mark.parametrize(
500+
("content_type", "renderer_override"),
501+
CONTENT_TYPE_PARAMS,
502+
)
503+
def test_with_archived_project(
504+
self, db_request, archive_marker, content_type, renderer_override
505+
):
506+
db_request.accept = content_type
507+
project = ProjectFactory.create(lifecycle_status=archive_marker)
508+
_ = ReleaseFactory.create_batch(3, project=project)
509+
510+
context = {
511+
"meta": {"_last-serial": 0, "api-version": API_VERSION},
512+
"name": project.normalized_name,
513+
"project-status": {"status": "archived"},
514+
"files": [],
515+
"versions": [],
516+
"alternate-locations": [],
517+
}
518+
context = _update_context(context, content_type, renderer_override)
519+
520+
assert simple.simple_detail(project, db_request) == context
521+
522+
if renderer_override is not None:
523+
assert db_request.override_renderer == renderer_override
524+
525+
@pytest.mark.parametrize(
526+
("content_type", "renderer_override"),
527+
CONTENT_TYPE_PARAMS,
528+
)
529+
def test_with_quarantine_exit_project(
530+
self, db_request, content_type, renderer_override
531+
):
532+
db_request.accept = content_type
533+
project = ProjectFactory.create(lifecycle_status="quarantine-exit")
534+
_ = ReleaseFactory.create_batch(3, project=project)
535+
536+
context = {
537+
"meta": {"_last-serial": 0, "api-version": API_VERSION},
538+
"name": project.normalized_name,
539+
"project-status": {"status": "active"},
476540
"files": [],
477541
"versions": [],
478542
"alternate-locations": [],
@@ -564,6 +628,7 @@ def route_url(route, **kw):
564628
context = {
565629
"meta": {"_last-serial": je.id, "api-version": API_VERSION},
566630
"name": project.normalized_name,
631+
"project-status": {"status": "active"},
567632
"versions": ["1.0.0"],
568633
"files": [
569634
{

warehouse/packaging/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,15 @@ def __contains__(self, project):
158158
return True
159159

160160

161+
class ProjectStatusMarker(enum.StrEnum):
162+
"""PEP 792 status markers."""
163+
164+
Active = "active"
165+
Archived = "archived"
166+
Quarantined = "quarantined"
167+
Deprecated = "deprecated"
168+
169+
161170
class LifecycleStatus(enum.StrEnum):
162171
QuarantineEnter = "quarantine-enter"
163172
QuarantineExit = "quarantine-exit"
@@ -494,6 +503,25 @@ def yanked_releases(self):
494503
.all()
495504
)
496505

506+
@property
507+
def project_status(self) -> ProjectStatusMarker:
508+
"""
509+
Return the PEP 792 project status marker that's equivalent
510+
to this project's lifecycle status.
511+
"""
512+
513+
if self.lifecycle_status == LifecycleStatus.QuarantineEnter:
514+
return ProjectStatusMarker.Quarantined
515+
elif self.lifecycle_status in (
516+
LifecycleStatus.Archived,
517+
LifecycleStatus.ArchivedNoindex,
518+
):
519+
return ProjectStatusMarker.Archived
520+
521+
# PyPI doesn't yet have a deprecated lifecycle status
522+
# and "quarantine-exit" means a return to active.
523+
return ProjectStatusMarker.Active
524+
497525

498526
class DependencyKind(enum.IntEnum):
499527
requires = 1

warehouse/packaging/utils.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from warehouse.packaging.interfaces import ISimpleStorage
1414
from warehouse.packaging.models import File, LifecycleStatus, Project, Release
1515

16-
API_VERSION = "1.3"
16+
API_VERSION = "1.4"
1717

1818

1919
def _simple_index(request, serial):
@@ -71,6 +71,11 @@ def _simple_detail(project, request):
7171
return {
7272
"meta": {"api-version": API_VERSION, "_last-serial": project.last_serial},
7373
"name": project.normalized_name,
74+
"project-status": (
75+
{
76+
"status": project.project_status.value,
77+
}
78+
),
7479
"versions": versions,
7580
"alternate-locations": alternate_repositories,
7681
"files": [
@@ -163,5 +168,6 @@ def render_simple_detail(project, request, store=False):
163168

164169

165170
def _valid_simple_detail_context(context: dict) -> dict:
171+
context["project_status"] = context.pop("project-status", None)
166172
context["alternate_locations"] = context.pop("alternate-locations", [])
167173
return context

warehouse/templates/api/simple/detail.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<head>
55
<meta name="pypi:repository-version" content="{{ meta['api-version'] }}">
66
{% for alt_repo in alternate_locations %}<meta name="pypi:alternate-locations" content="{{ alt_repo }}">{% endfor %}
7+
{% if project_status %}<meta name="pypi:project-status" content="{{ project_status.status }}">{% endif %}
78
<title>Links for {{ name }}</title>
89
</head>
910
<body>

0 commit comments

Comments
 (0)