Skip to content

Commit 03e61c7

Browse files
committed
Add JSON-based Simple API
1 parent 2b34643 commit 03e61c7

File tree

3 files changed

+178
-3
lines changed

3 files changed

+178
-3
lines changed

pulp_python/app/pypi/views.py

Lines changed: 63 additions & 3 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,18 @@
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_TEXT_HTML = "text/html"
65+
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
66+
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
67+
68+
69+
class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
70+
media_type = PYPI_SIMPLE_V1_HTML
71+
72+
73+
class PyPISimpleJSONRenderer(JSONRenderer):
74+
media_type = PYPI_SIMPLE_V1_JSON
75+
6076

6177
class PyPIMixin:
6278
"""Mixin to get index specific info."""
@@ -235,14 +251,49 @@ class SimpleView(PackageUploadMixin, ViewSet):
235251
],
236252
}
237253

254+
def perform_content_negotiation(self, request, force=False):
255+
"""
256+
Uses standard content negotiation, defaulting to HTML if no acceptable renderer is found.
257+
"""
258+
try:
259+
return super().perform_content_negotiation(request, force)
260+
except NotAcceptable:
261+
return TemplateHTMLRenderer(), "text/html"
262+
263+
def get_renderers(self):
264+
"""
265+
Uses custom renderers for PyPI Simple API endpoints, defaulting to standard ones.
266+
"""
267+
if self.action in ["list", "retrieve"]:
268+
# Ordered by priority if multiple content types are present
269+
return [
270+
TemplateHTMLRenderer(),
271+
PyPISimpleHTMLRenderer(),
272+
PyPISimpleJSONRenderer(),
273+
]
274+
else:
275+
return [JSONRenderer(), BrowsableAPIRenderer()]
276+
277+
238278
@extend_schema(summary="Get index simple page")
239279
def list(self, request, path):
240280
"""Gets the simple api html page for the index."""
241281
repo_version, content = self.get_rvc()
242282
if self.should_redirect(repo_version=repo_version):
243283
return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
244-
names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
245-
return StreamingHttpResponse(write_simple_index(names, streamed=True))
284+
285+
names = content.order_by("name").values_list("name", flat=True).distinct()
286+
media_type = request.accepted_renderer.media_type
287+
288+
if media_type == PYPI_SIMPLE_V1_JSON:
289+
index_data = write_simple_index_json(list(names))
290+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
291+
return Response(index_data, headers=headers)
292+
else:
293+
index_data = write_simple_index(names.iterator(), streamed=True)
294+
kwargs = {"content_type": media_type}
295+
response = StreamingHttpResponse(index_data, **kwargs)
296+
return response
246297

247298
def pull_through_package_simple(self, package, path, remote):
248299
"""Gets the package's simple page from remote."""
@@ -301,7 +352,16 @@ def retrieve(self, request, path, package):
301352
packages = chain([present], packages)
302353
name = present[2]
303354
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))
355+
media_type = request.accepted_renderer.media_type
356+
357+
if media_type == PYPI_SIMPLE_V1_JSON:
358+
detail_data = write_simple_detail_json(name, list(releases))
359+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
360+
return Response(detail_data, headers=headers)
361+
else:
362+
detail_data = write_simple_detail(name, releases, streamed=True)
363+
kwargs = {"content_type": media_type}
364+
return StreamingHttpResponse(detail_data, kwargs)
305365

306366
@extend_schema(
307367
request=PackageUploadSerializer,

pulp_python/app/utils.py

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

19+
# todo: update to reflect supported version
20+
# https://packaging.python.org/en/latest/specifications/simple-repository-api/#api-version-history
21+
PYPI_API_VERSION = "1.4"
22+
1923
simple_index_template = """<!DOCTYPE html>
2024
<html>
2125
<head>
@@ -414,6 +418,46 @@ def write_simple_detail(project_name, project_packages, streamed=False):
414418
return detail.stream(**context) if streamed else detail.render(**context)
415419

416420

421+
def write_simple_index_json(project_names):
422+
"""Writes the simple index in JSON format."""
423+
projects = [{"_last-serial": PYPI_SERIAL_CONSTANT, "name": name} for name in project_names]
424+
425+
return {
426+
"meta": {"api-version": PYPI_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
427+
"projects": projects,
428+
}
429+
430+
431+
# todo: fields
432+
def write_simple_detail_json(project_name, project_packages):
433+
"""Writes the simple detail page in JSON format."""
434+
files = []
435+
for filename, url, sha256 in project_packages:
436+
files.append(
437+
{
438+
"filename": filename,
439+
"url": url,
440+
"hashes": {"sha256": sha256},
441+
# requires_python # todo
442+
# size / version 1.1
443+
# upload-time / 1.1
444+
# yanked # todo
445+
# data-dist-info-metadata
446+
# core-metadata
447+
# provenance / 1.3
448+
}
449+
)
450+
451+
return {
452+
"meta": {"api-version": PYPI_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
453+
"name": project_name, # should be normalized
454+
# project-status / 1.4
455+
# versions / 1.1
456+
# alternate-locations
457+
"files": files,
458+
}
459+
460+
417461
class PackageIncludeFilter:
418462
"""A special class to help filter Package's based on a remote's include/exclude"""
419463

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from urllib.parse import urljoin
2+
3+
import pytest
4+
import requests
5+
6+
from pulp_python.tests.functional.constants import PYTHON_SM_PROJECT_SPECIFIER
7+
8+
9+
@pytest.mark.parallel
10+
def test_simple_json_api(python_remote_factory, python_repo_with_sync, python_distribution_factory):
11+
remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
12+
repo = python_repo_with_sync(remote)
13+
distro = python_distribution_factory(repository=repo)
14+
15+
index_url = urljoin(distro.base_url, "simple/")
16+
detail_url = f"{index_url}aiohttp"
17+
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
18+
19+
response_index = requests.get(index_url, headers=headers)
20+
data_index = response_index.json()
21+
assert data_index["meta"] == {"api-version": "1.4", "_last-serial": 1000000000}
22+
assert data_index["projects"]
23+
for project in data_index["projects"]:
24+
for i in ["_last-serial", "name"]:
25+
assert project[i]
26+
assert response_index.headers["Content-Type"] == "application/vnd.pypi.simple.v1+json"
27+
assert response_index.headers["X-PyPI-Last-Serial"] == "1000000000"
28+
29+
response_detail = requests.get(detail_url, headers=headers)
30+
data_detail = response_detail.json()
31+
assert data_detail["meta"] == {"api-version": "1.4", "_last-serial": 1000000000}
32+
assert data_detail["name"] == "aiohttp"
33+
assert data_detail["files"]
34+
for file in data_detail["files"]:
35+
for i in ["filename", "url", "hashes"]:
36+
assert file[i]
37+
assert response_detail.headers["Content-Type"] == "application/vnd.pypi.simple.v1+json"
38+
assert response_detail.headers["X-PyPI-Last-Serial"] == "1000000000"
39+
40+
41+
@pytest.mark.parametrize(
42+
"header, result",
43+
[
44+
("text/html", "text/html"),
45+
("application/vnd.pypi.simple.v1+html", "application/vnd.pypi.simple.v1+html"),
46+
("application/vnd.pypi.simple.v1+json", "application/vnd.pypi.simple.v1+json"),
47+
# Follows defined ordering (html, pypi html, pypi json)
48+
(
49+
"application/vnd.pypi.simple.v1+json, application/vnd.pypi.simple.v1+html",
50+
"application/vnd.pypi.simple.v1+html",
51+
),
52+
# Everything else should be html
53+
("", "text/html"),
54+
("application/json", "text/html"),
55+
("sth/else", "text/html"),
56+
],
57+
)
58+
def test_simple_json_api_headers(
59+
python_remote_factory, python_repo_with_sync, python_distribution_factory, header, result
60+
):
61+
remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
62+
repo = python_repo_with_sync(remote)
63+
distro = python_distribution_factory(repository=repo)
64+
65+
index_url = urljoin(distro.base_url, "simple/")
66+
detail_url = f"{index_url}aiohttp"
67+
68+
for url in [index_url, detail_url]:
69+
response = requests.get(url, headers={"Accept": header})
70+
assert response.status_code == 200
71+
assert result in response.headers["Content-Type"]

0 commit comments

Comments
 (0)