Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/625.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added JSON-based Simple API (PEP 691).
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.25 on 2025-11-04 07:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("python", "0015_alter_pythonpackagecontent_options"),
]

operations = [
migrations.AddField(
model_name="pythonpackagecontent",
name="metadata_sha256",
field=models.CharField(max_length=64, null=True),
),
]
2 changes: 2 additions & 0 deletions pulp_python/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ class PythonPackageContent(Content):
packagetype = models.TextField(choices=PACKAGE_TYPES)
python_version = models.TextField()
sha256 = models.CharField(db_index=True, max_length=64)
metadata_sha256 = models.CharField(max_length=64, null=True)
# yanked and yanked_reason are not implemented because they are mutable

# From pulpcore
PROTECTED_FROM_RECLAIM = False
Expand Down
99 changes: 89 additions & 10 deletions pulp_python/app/pypi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from aiohttp.client_exceptions import ClientError
from rest_framework.viewsets import ViewSet
from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
from rest_framework.response import Response
from rest_framework.exceptions import NotAcceptable
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import redirect
from datetime import datetime, timezone, timedelta
Expand Down Expand Up @@ -43,7 +45,9 @@
)
from pulp_python.app.utils import (
write_simple_index,
write_simple_index_json,
write_simple_detail,
write_simple_detail_json,
python_content_to_json,
PYPI_LAST_SERIAL,
PYPI_SERIAL_CONSTANT,
Expand All @@ -57,6 +61,17 @@
ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)

PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"


class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
media_type = PYPI_SIMPLE_V1_HTML


class PyPISimpleJSONRenderer(JSONRenderer):
media_type = PYPI_SIMPLE_V1_JSON


class PyPIMixin:
"""Mixin to get index specific info."""
Expand Down Expand Up @@ -235,24 +250,58 @@ class SimpleView(PackageUploadMixin, ViewSet):
],
}

def perform_content_negotiation(self, request, force=False):
"""
Uses standard content negotiation, defaulting to HTML if no acceptable renderer is found.
"""
try:
return super().perform_content_negotiation(request, force)
except NotAcceptable:
return TemplateHTMLRenderer(), TemplateHTMLRenderer.media_type # text/html

def get_renderers(self):
"""
Uses custom renderers for PyPI Simple API endpoints, defaulting to standard ones.
"""
if self.action in ["list", "retrieve"]:
# Ordered by priority if multiple content types are present
return [TemplateHTMLRenderer(), PyPISimpleHTMLRenderer(), PyPISimpleJSONRenderer()]
else:
return [JSONRenderer(), BrowsableAPIRenderer()]

@extend_schema(summary="Get index simple page")
def list(self, request, path):
"""Gets the simple api html page for the index."""
repo_version, content = self.get_rvc()
if self.should_redirect(repo_version=repo_version):
return redirect(urljoin(self.base_content_url, f"{path}/simple/"))
names = content.order_by("name").values_list("name", flat=True).distinct().iterator()
return StreamingHttpResponse(write_simple_index(names, streamed=True))
media_type = request.accepted_renderer.media_type
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}

if media_type == PYPI_SIMPLE_V1_JSON:
index_data = write_simple_index_json(names)
return Response(index_data, headers=headers)
else:
index_data = write_simple_index(names, streamed=True)
kwargs = {"content_type": media_type, "headers": headers}
return StreamingHttpResponse(index_data, **kwargs)

def pull_through_package_simple(self, package, path, remote):
def pull_through_package_simple(self, package, path, remote, media_type):
"""Gets the package's simple page from remote."""

def parse_package(release_package):
parsed = urlparse(release_package.url)
stripped_url = urlunsplit(chain(parsed[:3], ("", "")))
redirect_path = f"{path}/{release_package.filename}?redirect={stripped_url}"
d_url = urljoin(self.base_content_url, redirect_path)
return release_package.filename, d_url, release_package.digests.get("sha256", "")
return {
"filename": release_package.filename,
"url": d_url,
"sha256": release_package.digests.get("sha256", ""),
"requires_python": release_package.requires_python,
"metadata_sha256": (release_package.metadata_digests or {}).get("sha256"),
}

rfilter = get_remote_package_filter(remote)
if not rfilter.filter_project(package):
Expand All @@ -269,28 +318,40 @@ def parse_package(release_package):
except TimeoutException:
return HttpResponse(f"{remote.url} timed out while fetching {package}.", status=504)

if d.headers["content-type"] == "application/vnd.pypi.simple.v1+json":
if d.headers["content-type"] == PYPI_SIMPLE_V1_JSON:
page = ProjectPage.from_json_data(json.load(open(d.path, "rb")), base_url=url)
else:
page = ProjectPage.from_html(package, open(d.path, "rb").read(), base_url=url)
packages = [
parse_package(p) for p in page.packages if rfilter.filter_release(package, p.version)
]
return HttpResponse(write_simple_detail(package, packages))
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}

if media_type == PYPI_SIMPLE_V1_JSON:
detail_data = write_simple_detail_json(package, packages)
return Response(detail_data, headers=headers)
else:
detail_data = write_simple_detail(package, packages)
kwargs = {"content_type": media_type, "headers": headers}
return HttpResponse(detail_data, **kwargs)

@extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
def retrieve(self, request, path, package):
"""Retrieves the simple api html page for a package."""
"""Retrieves the simple api html/json page for a package."""
media_type = request.accepted_renderer.media_type

repo_ver, content = self.get_rvc()
# Should I redirect if the normalized name is different?
normalized = canonicalize_name(package)
if self.distribution.remote:
return self.pull_through_package_simple(normalized, path, self.distribution.remote)
return self.pull_through_package_simple(
normalized, path, self.distribution.remote, media_type
)
if self.should_redirect(repo_version=repo_ver):
return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
packages = (
content.filter(name__normalize=normalized)
.values_list("filename", "sha256", "name")
.values_list("filename", "sha256", "name", "metadata_sha256", "requires_python")
.iterator()
)
try:
Expand All @@ -300,8 +361,26 @@ def retrieve(self, request, path, package):
else:
packages = chain([present], packages)
name = present[2]
releases = ((f, urljoin(self.base_content_url, f"{path}/{f}"), d) for f, d, _ in packages)
return StreamingHttpResponse(write_simple_detail(name, releases, streamed=True))
releases = (
{
"filename": filename,
"url": urljoin(self.base_content_url, f"{path}/{filename}"),
"sha256": sha256,
"metadata_sha256": metadata_sha256,
"requires_python": requires_python,
}
for filename, sha256, _, metadata_sha256, requires_python in packages
)
media_type = request.accepted_renderer.media_type
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}

if media_type == PYPI_SIMPLE_V1_JSON:
detail_data = write_simple_detail_json(name, releases)
return Response(detail_data, headers=headers)
else:
detail_data = write_simple_detail(name, releases, streamed=True)
kwargs = {"content_type": media_type, "headers": headers}
return StreamingHttpResponse(detail_data, **kwargs)

@extend_schema(
request=PackageUploadSerializer,
Expand Down
6 changes: 6 additions & 0 deletions pulp_python/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
default="",
help_text=_("The SHA256 digest of this package."),
)
metadata_sha256 = serializers.CharField(
required=False,
allow_null=True,
help_text=_("The SHA256 digest of the package's METADATA file."),
)

def deferred_validate(self, data):
"""
Expand Down Expand Up @@ -364,6 +369,7 @@ class Meta:
"packagetype",
"python_version",
"sha256",
"metadata_sha256",
)
model = python_models.PythonPackageContent

Expand Down
2 changes: 1 addition & 1 deletion pulp_python/app/tasks/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def write_simple_api(publication):
relative_path = release["filename"]
path = f"../../{relative_path}"
checksum = release["sha256"]
package_releases.append((relative_path, path, checksum))
package_releases.append({"filename": relative_path, "url": path, "sha256": checksum})
# Write the final project's page
write_project_page(
name=canonicalize_name(current_name),
Expand Down
Loading