Skip to content

feat: implement PEP 792 project status markers #18422

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions docs/user/api/index-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,"
Expand Down Expand Up @@ -53,7 +58,7 @@ Accept: application/vnd.pypi.simple.v1+html

<html>
<head>
<meta name="pypi:repository-version" content="1.1">
<meta name="pypi:repository-version" content="1.4">
<title>Simple index</title>
</head>
<body>
Expand Down Expand Up @@ -84,7 +89,7 @@ Accept: application/vnd.pypi.simple.v1+json
{
"meta": {
"_last-serial": 24888689,
"api-version": "1.1"
"api-version": "1.4"
},
"projects": [
{
Expand Down Expand Up @@ -152,7 +157,8 @@ Accept: application/vnd.pypi.simple.v1+html
<!DOCTYPE html>
<html>
<head>
<meta name="pypi:repository-version" content="1.1">
<meta name="pypi:repository-version" content="1.4">
<meta name="pypi:project-status" content="active">
<title>Links for beautifulsoup4</title>
</head>
<body>
Expand Down Expand Up @@ -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",
Expand Down
69 changes: 67 additions & 2 deletions tests/unit/api/test_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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": [],
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [],
Expand Down Expand Up @@ -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": [
{
Expand Down
28 changes: 28 additions & 0 deletions warehouse/packaging/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion warehouse/packaging/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions warehouse/templates/api/simple/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<head>
<meta name="pypi:repository-version" content="{{ meta['api-version'] }}">
{% for alt_repo in alternate_locations %}<meta name="pypi:alternate-locations" content="{{ alt_repo }}">{% endfor %}
{% if project_status %}<meta name="pypi:project-status" content="{{ project_status.status }}">{% endif %}
<title>Links for {{ name }}</title>
</head>
<body>
Expand Down