Skip to content

Commit 376106b

Browse files
committed
Add JSON-based Simple API
1 parent 12e605a commit 376106b

File tree

7 files changed

+293
-25
lines changed

7 files changed

+293
-25
lines changed
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": f,
367+
"url": urljoin(self.base_content_url, f"{path}/{f}"),
368+
"sha256": s,
369+
"metadata_sha256": ms,
370+
"requires_python": rp,
371+
}
372+
for f, s, _, ms, rp 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/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),

pulp_python/app/utils.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,34 @@
1616
"""TODO This serial constant is temporary until Python repositories implements serials"""
1717
PYPI_SERIAL_CONSTANT = 1000000000
1818

19+
SIMPLE_API_VERSION = "1.0"
20+
1921
simple_index_template = """<!DOCTYPE html>
2022
<html>
2123
<head>
2224
<title>Simple Index</title>
23-
<meta name="api-version" value="2" />
25+
<meta name="pypi:repository-version" content="{{ SIMPLE_API_VERSION }}">
2426
</head>
2527
<body>
2628
{% for name, canonical_name in projects %}
27-
<a href="{{ canonical_name }}/">{{ name }}</a><br/>
29+
<a href="{{ canonical_name }}/">{{ name }}</a><br/>
2830
{% endfor %}
2931
</body>
3032
</html>
3133
"""
3234

3335
simple_detail_template = """<!DOCTYPE html>
3436
<html>
35-
<head>
36-
<title>Links for {{ project_name }}</title>
37-
<meta name="api-version" value="2" />
38-
</head>
39-
<body>
37+
<head>
38+
<title>Links for {{ project_name }}</title>
39+
<meta name="pypi:repository-version" content="{{ SIMPLE_API_VERSION }}">
40+
</head>
41+
<body>
4042
<h1>Links for {{ project_name }}</h1>
41-
{% for name, path, sha256 in project_packages %}
42-
<a href="{{ path }}#sha256={{ sha256 }}" rel="internal">{{ name }}</a><br/>
43+
{% for pkg in project_packages %}
44+
<a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal">{{ pkg.filename }}</a><br/>
4345
{% endfor %}
44-
</body>
46+
</body>
4547
</html>
4648
"""
4749

@@ -128,6 +130,7 @@ def parse_project_metadata(project):
128130
# Release metadata
129131
"packagetype": project.get("packagetype") or "",
130132
"python_version": project.get("python_version") or "",
133+
"metadata_sha256": "", # TODO
131134
}
132135

133136

@@ -158,6 +161,9 @@ def parse_metadata(project, version, distribution):
158161
package["requires_python"] = distribution.get("requires_python") or package.get(
159162
"requires_python"
160163
) # noqa: E501
164+
package["metadata_sha256"] = distribution.get("data-dist-info-metadata", {}).get(
165+
"sha256"
166+
) or package.get("metadata_sha256")
161167

162168
return package
163169

@@ -403,17 +409,65 @@ def find_artifact():
403409
def write_simple_index(project_names, streamed=False):
404410
"""Writes the simple index."""
405411
simple = Template(simple_index_template)
406-
context = {"projects": ((x, canonicalize_name(x)) for x in project_names)}
412+
context = {
413+
"SIMPLE_API_VERSION": SIMPLE_API_VERSION,
414+
"projects": ((x, canonicalize_name(x)) for x in project_names),
415+
}
407416
return simple.stream(**context) if streamed else simple.render(**context)
408417

409418

410419
def write_simple_detail(project_name, project_packages, streamed=False):
411420
"""Writes the simple detail page of a package."""
412421
detail = Template(simple_detail_template)
413-
context = {"project_name": project_name, "project_packages": project_packages}
422+
context = {
423+
"SIMPLE_API_VERSION": SIMPLE_API_VERSION,
424+
"project_name": project_name,
425+
"project_packages": project_packages,
426+
}
414427
return detail.stream(**context) if streamed else detail.render(**context)
415428

416429

430+
def write_simple_index_json(project_names):
431+
"""Writes the simple index in JSON format."""
432+
return {
433+
"meta": {"api-version": SIMPLE_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
434+
"projects": [
435+
{"name": name, "_last-serial": PYPI_SERIAL_CONSTANT} for name in project_names
436+
],
437+
}
438+
439+
440+
def write_simple_detail_json(project_name, project_packages):
441+
"""Writes the simple detail page in JSON format."""
442+
return {
443+
"meta": {"api-version": SIMPLE_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
444+
"name": canonicalize_name(project_name),
445+
"files": [
446+
{
447+
# v1.0, PEP 691
448+
"filename": package["filename"],
449+
"url": package["url"],
450+
"hashes": {"sha256": package["sha256"]},
451+
"requires_python": package["requires_python"] or None,
452+
# data-dist-info-metadata is deprecated alias for core-metadata
453+
"data-dist-info-metadata": (
454+
{"sha256": package["metadata_sha256"]} if package["metadata_sha256"] else False
455+
),
456+
# yanked and yanked_reason are not implemented because they are mutable
457+
# TODO in the future:
458+
# size, upload-time (v1.1, PEP 700)
459+
# core-metadata (PEP 7.14)
460+
# provenance (v1.3, PEP 740)
461+
}
462+
for package in project_packages
463+
],
464+
# TODO in the future:
465+
# versions (v1.1, PEP 700)
466+
# alternate-locations (v1.2, PEP 708)
467+
# project-status (v1.4, PEP 792 - pypi and docs differ)
468+
}
469+
470+
417471
class PackageIncludeFilter:
418472
"""A special class to help filter Package's based on a remote's include/exclude"""
419473

pulp_python/tests/functional/api/test_full_mirror.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ def test_pull_through_simple(python_remote_factory, python_distribution_factory,
5858
assert PYTHON_XS_FIXTURE_CHECKSUMS[package.filename] == package.digests["sha256"]
5959

6060

61+
@pytest.mark.parallel
62+
@pytest.mark.parametrize("media_type", ["application/vnd.pypi.simple.v1+json", "text/html"])
63+
def test_pull_through_simple_media_types(
64+
media_type, python_remote_factory, python_distribution_factory
65+
):
66+
"""Tests pull-through with different media types (JSON and HTML)."""
67+
remote = python_remote_factory(url=PYPI_URL, includes=["shelf-reader"])
68+
distro = python_distribution_factory(remote=remote.pulp_href)
69+
70+
url = f"{distro.base_url}simple/shelf-reader/"
71+
headers = {"Accept": media_type}
72+
response = requests.get(url, headers=headers)
73+
74+
assert response.status_code == 200
75+
assert media_type in response.headers["Content-Type"]
76+
assert "X-PyPI-Last-Serial" in response.headers
77+
78+
6179
@pytest.mark.parallel
6280
def test_pull_through_filter(python_remote_factory, python_distribution_factory):
6381
"""Tests that pull-through respects the includes/excludes filter on the remote."""
@@ -66,7 +84,7 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)
6684

6785
r = requests.get(f"{distro.base_url}simple/pulpcore/")
6886
assert r.status_code == 404
69-
assert r.json() == {"detail": "pulpcore does not exist."}
87+
assert r.text == "404 Not Found"
7088

7189
r = requests.get(f"{distro.base_url}simple/shelf-reader/")
7290
assert r.status_code == 200
@@ -86,7 +104,7 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)
86104

87105
r = requests.get(f"{distro.base_url}simple/django/")
88106
assert r.status_code == 404
89-
assert r.json() == {"detail": "django does not exist."}
107+
assert r.text == "404 Not Found"
90108

91109
r = requests.get(f"{distro.base_url}simple/pulpcore/")
92110
assert r.status_code == 502

0 commit comments

Comments
 (0)