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,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 ,
0 commit comments