Skip to content

Commit c08fcb2

Browse files
jobselkodralley
authored andcommitted
Add JSON-based Simple API
closes #625
1 parent 12e605a commit c08fcb2

File tree

9 files changed

+350
-29
lines changed

9 files changed

+350
-29
lines changed

CHANGES/625.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added JSON-based Simple API (PEP 691).
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.25 on 2025-11-04 07:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("python", "0015_alter_pythonpackagecontent_options"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="pythonpackagecontent",
15+
name="metadata_sha256",
16+
field=models.CharField(max_length=64, null=True),
17+
),
18+
]

pulp_python/app/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ class PythonPackageContent(Content):
192192
packagetype = models.TextField(choices=PACKAGE_TYPES)
193193
python_version = models.TextField()
194194
sha256 = models.CharField(db_index=True, max_length=64)
195+
metadata_sha256 = models.CharField(max_length=64, null=True)
196+
# yanked and yanked_reason are not implemented because they are mutable
195197

196198
# From pulpcore
197199
PROTECTED_FROM_RECLAIM = False

pulp_python/app/pypi/views.py

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
from aiohttp.client_exceptions import ClientError
55
from rest_framework.viewsets import ViewSet
6+
from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
67
from rest_framework.response import Response
8+
from rest_framework.exceptions import NotAcceptable
79
from django.core.exceptions import ObjectDoesNotExist
810
from django.shortcuts import redirect
911
from datetime import datetime, timezone, timedelta
@@ -43,7 +45,9 @@
4345
)
4446
from pulp_python.app.utils import (
4547
write_simple_index,
48+
write_simple_index_json,
4649
write_simple_detail,
50+
write_simple_detail_json,
4751
python_content_to_json,
4852
PYPI_LAST_SERIAL,
4953
PYPI_SERIAL_CONSTANT,
@@ -57,6 +61,17 @@
5761
ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
5862
BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
5963

64+
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
65+
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
66+
67+
68+
class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
69+
media_type = PYPI_SIMPLE_V1_HTML
70+
71+
72+
class PyPISimpleJSONRenderer(JSONRenderer):
73+
media_type = PYPI_SIMPLE_V1_JSON
74+
6075

6176
class PyPIMixin:
6277
"""Mixin to get index specific info."""
@@ -235,24 +250,58 @@ class SimpleView(PackageUploadMixin, ViewSet):
235250
],
236251
}
237252

253+
def perform_content_negotiation(self, request, force=False):
254+
"""
255+
Uses standard content negotiation, defaulting to HTML if no acceptable renderer is found.
256+
"""
257+
try:
258+
return super().perform_content_negotiation(request, force)
259+
except NotAcceptable:
260+
return TemplateHTMLRenderer(), TemplateHTMLRenderer.media_type # text/html
261+
262+
def get_renderers(self):
263+
"""
264+
Uses custom renderers for PyPI Simple API endpoints, defaulting to standard ones.
265+
"""
266+
if self.action in ["list", "retrieve"]:
267+
# Ordered by priority if multiple content types are present
268+
return [TemplateHTMLRenderer(), PyPISimpleHTMLRenderer(), PyPISimpleJSONRenderer()]
269+
else:
270+
return [JSONRenderer(), BrowsableAPIRenderer()]
271+
238272
@extend_schema(summary="Get index simple page")
239273
def list(self, request, path):
240274
"""Gets the simple api html page for the index."""
241275
repo_version, content = self.get_rvc()
242276
if self.should_redirect(repo_version=repo_version):
243277
return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
244278
names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
245-
return StreamingHttpResponse(write_simple_index(names, streamed=True))
279+
media_type = request.accepted_renderer.media_type
280+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
281+
282+
if media_type == PYPI_SIMPLE_V1_JSON:
283+
index_data = write_simple_index_json(names)
284+
return Response(index_data, headers=headers)
285+
else:
286+
index_data = write_simple_index(names, streamed=True)
287+
kwargs = {"content_type": media_type, "headers": headers}
288+
return StreamingHttpResponse(index_data, **kwargs)
246289

247-
def pull_through_package_simple(self, package, path, remote):
290+
def pull_through_package_simple(self, package, path, remote, media_type):
248291
"""Gets the package's simple page from remote."""
249292

250293
def parse_package(release_package):
251294
parsed = urlparse(release_package.url)
252295
stripped_url = urlunsplit(chain(parsed[:3], ("", "")))
253296
redirect_path = f"{path}/{release_package.filename}?redirect={stripped_url}"
254297
d_url = urljoin(self.base_content_url, redirect_path)
255-
return release_package.filename, d_url, release_package.digests.get("sha256", "")
298+
return {
299+
"filename": release_package.filename,
300+
"url": d_url,
301+
"sha256": release_package.digests.get("sha256", ""),
302+
"requires_python": release_package.requires_python,
303+
"metadata_sha256": (release_package.metadata_digests or {}).get("sha256"),
304+
}
256305

257306
rfilter = get_remote_package_filter(remote)
258307
if not rfilter.filter_project(package):
@@ -269,28 +318,40 @@ def parse_package(release_package):
269318
except TimeoutException:
270319
return HttpResponse(f"{remote.url} timed out while fetching {package}.", status=504)
271320

272-
if d.headers["content-type"] == "application/vnd.pypi.simple.v1+json":
321+
if d.headers["content-type"] == PYPI_SIMPLE_V1_JSON:
273322
page = ProjectPage.from_json_data(json.load(open(d.path, "rb")), base_url=url)
274323
else:
275324
page = ProjectPage.from_html(package, open(d.path, "rb").read(), base_url=url)
276325
packages = [
277326
parse_package(p) for p in page.packages if rfilter.filter_release(package, p.version)
278327
]
279-
return HttpResponse(write_simple_detail(package, packages))
328+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
329+
330+
if media_type == PYPI_SIMPLE_V1_JSON:
331+
detail_data = write_simple_detail_json(package, packages)
332+
return Response(detail_data, headers=headers)
333+
else:
334+
detail_data = write_simple_detail(package, packages)
335+
kwargs = {"content_type": media_type, "headers": headers}
336+
return HttpResponse(detail_data, **kwargs)
280337

281338
@extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
282339
def retrieve(self, request, path, package):
283-
"""Retrieves the simple api html page for a package."""
340+
"""Retrieves the simple api html/json page for a package."""
341+
media_type = request.accepted_renderer.media_type
342+
284343
repo_ver, content = self.get_rvc()
285344
# Should I redirect if the normalized name is different?
286345
normalized = canonicalize_name(package)
287346
if self.distribution.remote:
288-
return self.pull_through_package_simple(normalized, path, self.distribution.remote)
347+
return self.pull_through_package_simple(
348+
normalized, path, self.distribution.remote, media_type
349+
)
289350
if self.should_redirect(repo_version=repo_ver):
290351
return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
291352
packages = (
292353
content.filter(name__normalize=normalized)
293-
.values_list("filename", "sha256", "name")
354+
.values_list("filename", "sha256", "name", "metadata_sha256", "requires_python")
294355
.iterator()
295356
)
296357
try:
@@ -300,8 +361,26 @@ def retrieve(self, request, path, package):
300361
else:
301362
packages = chain([present], packages)
302363
name = present[2]
303-
releases = ((f, urljoin(self.base_content_url, f"{path}/{f}"), d) for f, d, _ in packages)
304-
return StreamingHttpResponse(write_simple_detail(name, releases, streamed=True))
364+
releases = (
365+
{
366+
"filename": filename,
367+
"url": urljoin(self.base_content_url, f"{path}/{filename}"),
368+
"sha256": sha256,
369+
"metadata_sha256": metadata_sha256,
370+
"requires_python": requires_python,
371+
}
372+
for filename, sha256, _, metadata_sha256, requires_python in packages
373+
)
374+
media_type = request.accepted_renderer.media_type
375+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
376+
377+
if media_type == PYPI_SIMPLE_V1_JSON:
378+
detail_data = write_simple_detail_json(name, releases)
379+
return Response(detail_data, headers=headers)
380+
else:
381+
detail_data = write_simple_detail(name, releases, streamed=True)
382+
kwargs = {"content_type": media_type, "headers": headers}
383+
return StreamingHttpResponse(detail_data, **kwargs)
305384

306385
@extend_schema(
307386
request=PackageUploadSerializer,

pulp_python/app/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,11 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
281281
default="",
282282
help_text=_("The SHA256 digest of this package."),
283283
)
284+
metadata_sha256 = serializers.CharField(
285+
required=False,
286+
allow_null=True,
287+
help_text=_("The SHA256 digest of the package's METADATA file."),
288+
)
284289

285290
def deferred_validate(self, data):
286291
"""
@@ -364,6 +369,7 @@ class Meta:
364369
"packagetype",
365370
"python_version",
366371
"sha256",
372+
"metadata_sha256",
367373
)
368374
model = python_models.PythonPackageContent
369375

pulp_python/app/tasks/publish.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def write_simple_api(publication):
101101
relative_path = release["filename"]
102102
path = f"../../{relative_path}"
103103
checksum = release["sha256"]
104-
package_releases.append((relative_path, path, checksum))
104+
package_releases.append({"filename": relative_path, "url": path, "sha256": checksum})
105105
# Write the final project's page
106106
write_project_page(
107107
name=canonicalize_name(current_name),

0 commit comments

Comments
 (0)