Skip to content

Commit 5f68a09

Browse files
committed
Add JSON-based Simple API
1 parent 2b34643 commit 5f68a09

File tree

7 files changed

+275
-10
lines changed

7 files changed

+275
-10
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.24 on 2025-09-19 11:10
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="sha256_metadata",
16+
field=models.CharField(default="", max_length=64),
17+
preserve_default=False,
18+
),
19+
migrations.AddField(
20+
model_name="pythonpackagecontent",
21+
name="yanked",
22+
field=models.BooleanField(default=False),
23+
),
24+
migrations.AddField(
25+
model_name="pythonpackagecontent",
26+
name="yanked_reason",
27+
field=models.TextField(default=""),
28+
preserve_default=False,
29+
),
30+
]

pulp_python/app/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ 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+
sha256_metadata = models.CharField(max_length=64)
196+
yanked = models.BooleanField(default=False)
197+
yanked_reason = models.TextField()
195198

196199
# From pulpcore
197200
PROTECTED_FROM_RECLAIM = False

pulp_python/app/pypi/views.py

Lines changed: 82 additions & 6 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,14 +250,42 @@ 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+
281+
if media_type == PYPI_SIMPLE_V1_JSON:
282+
index_data = write_simple_index_json(names)
283+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
284+
return Response(index_data, headers=headers)
285+
else:
286+
index_data = write_simple_index(names, streamed=True)
287+
kwargs = {"content_type": media_type}
288+
return StreamingHttpResponse(index_data, **kwargs)
246289

247290
def pull_through_package_simple(self, package, path, remote):
248291
"""Gets the package's simple page from remote."""
@@ -252,7 +295,12 @@ def parse_package(release_package):
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+
# todo: more fields?
303+
}
256304

257305
rfilter = get_remote_package_filter(remote)
258306
if not rfilter.filter_project(package):
@@ -269,7 +317,7 @@ def parse_package(release_package):
269317
except TimeoutException:
270318
return HttpResponse(f"{remote.url} timed out while fetching {package}.", status=504)
271319

272-
if d.headers["content-type"] == "application/vnd.pypi.simple.v1+json":
320+
if d.headers["content-type"] == PYPI_SIMPLE_V1_JSON:
273321
page = ProjectPage.from_json_data(json.load(open(d.path, "rb")), base_url=url)
274322
else:
275323
page = ProjectPage.from_html(package, open(d.path, "rb").read(), base_url=url)
@@ -290,7 +338,15 @@ def retrieve(self, request, path, package):
290338
return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
291339
packages = (
292340
content.filter(name__normalize=normalized)
293-
.values_list("filename", "sha256", "name")
341+
.values_list(
342+
"filename",
343+
"sha256",
344+
"name",
345+
"sha256_metadata",
346+
"requires_python",
347+
"yanked",
348+
"yanked_reason",
349+
)
294350
.iterator()
295351
)
296352
try:
@@ -300,8 +356,28 @@ def retrieve(self, request, path, package):
300356
else:
301357
packages = chain([present], packages)
302358
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))
359+
releases = (
360+
{
361+
"filename": f,
362+
"url": urljoin(self.base_content_url, f"{path}/{f}"),
363+
"sha256": s,
364+
"sha256_metadata": sm,
365+
"requires_python": rp,
366+
"yanked": y,
367+
"yanked_reason": yr,
368+
}
369+
for f, s, _, sm, rp, y, yr in packages
370+
)
371+
media_type = request.accepted_renderer.media_type
372+
373+
if media_type == PYPI_SIMPLE_V1_JSON:
374+
detail_data = write_simple_detail_json(name, releases)
375+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
376+
return Response(detail_data, headers=headers)
377+
else:
378+
detail_data = write_simple_detail(name, releases, streamed=True)
379+
kwargs = {"content_type": media_type}
380+
return StreamingHttpResponse(detail_data, kwargs)
305381

306382
@extend_schema(
307383
request=PackageUploadSerializer,

pulp_python/app/tasks/publish.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def write_project_page(name, simple_dir, package_releases, publication):
116116
metadata_relative_path = f"{project_dir}index.html"
117117

118118
with open(metadata_relative_path, "w") as simple_metadata:
119+
# todo?
119120
simple_metadata.write(write_simple_detail(name, package_releases))
120121

121122
project_metadata = models.PublishedMetadata.create_from_file(

pulp_python/app/utils.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
from pulpcore.plugin.models import Remote
1313

1414

15+
# todo: why upper case?
1516
PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
1617
"""TODO This serial constant is temporary until Python repositories implements serials"""
1718
PYPI_SERIAL_CONSTANT = 1000000000
1819

20+
PYPI_API_VERSION = "1.0"
21+
1922
simple_index_template = """<!DOCTYPE html>
2023
<html>
2124
<head>
@@ -38,8 +41,8 @@
3841
</head>
3942
<body>
4043
<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/>
44+
{% for pkg in project_packages %}
45+
<a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal">{{ pkg.filename }}</a><br/>
4346
{% endfor %}
4447
</body>
4548
</html>
@@ -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["yanked"] = distribution.get("yanked") or False
165+
package["yanked_reason"] = distribution.get("yanked_reason") or ""
166+
package["sha256_metadata"] = distribution.get("data-dist-info-metadata", {}).get("sha256") or ""
161167

162168
return package
163169

@@ -203,6 +209,10 @@ def artifact_to_python_content_data(filename, artifact, domain=None):
203209
data["filename"] = filename
204210
data["pulp_domain"] = domain or artifact.pulp_domain
205211
data["_pulp_domain"] = data["pulp_domain"]
212+
# todo: how to get these / should they be here?
213+
# data["yanked"] = False
214+
# data["yanked_reason"] = ""
215+
# data["sha256_metadata"] = ""
206216
return data
207217

208218

@@ -414,6 +424,53 @@ def write_simple_detail(project_name, project_packages, streamed=False):
414424
return detail.stream(**context) if streamed else detail.render(**context)
415425

416426

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

pulp_python/tests/functional/api/test_full_mirror.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)
6666

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

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

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

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

0 commit comments

Comments
 (0)