33
44from aiohttp.client_exceptions import ClientError
55from rest_framework.viewsets import ViewSet
6+ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
67from rest_framework.response import Response
8+ from rest_framework.exceptions import NotAcceptable
79from django.core.exceptions import ObjectDoesNotExist
810from django.shortcuts import redirect
911from datetime import datetime, timezone, timedelta
4345)
4446from 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,
5761ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
5862BASE_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
6176class 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,
0 commit comments