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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.24 on 2025-09-19 11:10

from django.db import migrations, models


class Migration(migrations.Migration):

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

operations = [
migrations.AddField(
model_name="pythonpackagecontent",
name="sha256_metadata",
field=models.CharField(default="", max_length=64),
preserve_default=False,
),
migrations.AddField(
model_name="pythonpackagecontent",
name="yanked",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="pythonpackagecontent",
name="yanked_reason",
field=models.TextField(default=""),
preserve_default=False,
),
]
3 changes: 3 additions & 0 deletions pulp_python/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ class PythonPackageContent(Content):
packagetype = models.TextField(choices=PACKAGE_TYPES)
python_version = models.TextField()
sha256 = models.CharField(db_index=True, max_length=64)
sha256_metadata = models.CharField(max_length=64)
yanked = models.BooleanField(default=False)
yanked_reason = models.TextField()

# From pulpcore
PROTECTED_FROM_RECLAIM = False
Expand Down
88 changes: 82 additions & 6 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,14 +250,42 @@ 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()]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does super().get_renders() return these values?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes


@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

if media_type == PYPI_SIMPLE_V1_JSON:
index_data = write_simple_index_json(names)
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
return Response(index_data, headers=headers)
else:
index_data = write_simple_index(names, streamed=True)
kwargs = {"content_type": media_type}
return StreamingHttpResponse(index_data, **kwargs)

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

rfilter = get_remote_package_filter(remote)
if not rfilter.filter_project(package):
Expand All @@ -269,7 +317,7 @@ 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)
Expand All @@ -290,7 +338,15 @@ def retrieve(self, request, path, package):
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",
"sha256_metadata",
"requires_python",
"yanked",
"yanked_reason",
)
.iterator()
)
try:
Expand All @@ -300,8 +356,28 @@ 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": f,
"url": urljoin(self.base_content_url, f"{path}/{f}"),
"sha256": s,
"sha256_metadata": sm,
"requires_python": rp,
"yanked": y,
"yanked_reason": yr,
}
for f, s, _, sm, rp, y, yr in packages
)
media_type = request.accepted_renderer.media_type

if media_type == PYPI_SIMPLE_V1_JSON:
detail_data = write_simple_detail_json(name, releases)
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
return Response(detail_data, headers=headers)
else:
detail_data = write_simple_detail(name, releases, streamed=True)
kwargs = {"content_type": media_type}
return StreamingHttpResponse(detail_data, kwargs)

@extend_schema(
request=PackageUploadSerializer,
Expand Down
1 change: 1 addition & 0 deletions pulp_python/app/tasks/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def write_project_page(name, simple_dir, package_releases, publication):
metadata_relative_path = f"{project_dir}index.html"

with open(metadata_relative_path, "w") as simple_metadata:
# todo?
simple_metadata.write(write_simple_detail(name, package_releases))

project_metadata = models.PublishedMetadata.create_from_file(
Expand Down
63 changes: 61 additions & 2 deletions pulp_python/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
from pulpcore.plugin.models import Remote


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

PYPI_API_VERSION = "1.0"

simple_index_template = """<!DOCTYPE html>
<html>
<head>
Expand All @@ -38,8 +41,8 @@
</head>
<body>
<h1>Links for {{ project_name }}</h1>
{% for name, path, sha256 in project_packages %}
<a href="{{ path }}#sha256={{ sha256 }}" rel="internal">{{ name }}</a><br/>
{% for pkg in project_packages %}
<a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal">{{ pkg.filename }}</a><br/>
{% endfor %}
</body>
</html>
Expand Down Expand Up @@ -158,6 +161,9 @@ def parse_metadata(project, version, distribution):
package["requires_python"] = distribution.get("requires_python") or package.get(
"requires_python"
) # noqa: E501
package["yanked"] = distribution.get("yanked") or False
package["yanked_reason"] = distribution.get("yanked_reason") or ""
package["sha256_metadata"] = distribution.get("data-dist-info-metadata", {}).get("sha256") or ""

return package

Expand Down Expand Up @@ -203,6 +209,10 @@ def artifact_to_python_content_data(filename, artifact, domain=None):
data["filename"] = filename
data["pulp_domain"] = domain or artifact.pulp_domain
data["_pulp_domain"] = data["pulp_domain"]
# todo: how to get these / should they be here?
# data["yanked"] = False
# data["yanked_reason"] = ""
# data["sha256_metadata"] = ""
return data


Expand Down Expand Up @@ -325,6 +335,7 @@ def python_content_to_info(content):
"platform": content.platform or "",
"requires_dist": json_to_dict(content.requires_dist) or None,
"classifiers": json_to_dict(content.classifiers) or None,
# todo yanked
"yanked": False, # These are no longer used on PyPI, but are still present
"yanked_reason": None,
# New core metadata (Version 2.1, 2.2, 2.4)
Expand Down Expand Up @@ -395,6 +406,7 @@ def find_artifact():
"upload_time": str(content.pulp_created),
"upload_time_iso_8601": str(content.pulp_created.isoformat()),
"url": url,
# todo yanked
"yanked": False,
"yanked_reason": None,
}
Expand All @@ -414,6 +426,53 @@ def write_simple_detail(project_name, project_packages, streamed=False):
return detail.stream(**context) if streamed else detail.render(**context)


def write_simple_index_json(project_names):
"""Writes the simple index in JSON format."""
return {
"meta": {"api-version": PYPI_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
"projects": [
{"name": name, "_last-serial": PYPI_SERIAL_CONSTANT} for name in project_names
],
}


def write_simple_detail_json(project_name, project_packages):
"""Writes the simple detail page in JSON format."""
return {
"meta": {"api-version": PYPI_API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT},
"name": canonicalize_name(project_name),
"files": [
{
# v1.0, PEP 691
"filename": package["filename"],
"url": package["url"],
"hashes": {"sha256": package["sha256"]},
"requires_python": package["requires_python"] or None,
# data-dist-info-metadata is deprecated alias for core-metadata
"data-dist-info-metadata": (
{"sha256": package["sha256_metadata"]} if package["sha256_metadata"] else False
),
"yanked": (
package["yanked_reason"]
if package["yanked"] and package["yanked_reason"]
else package["yanked"]
),
# gpg-sig (not in warehouse)
# todo (from new PEPs):
# size (v1.1, PEP 700)
# upload-time (v1.1, PEP 700)
# core-metadata (PEP 7.14)
# provenance (v1.3, PEP 740)
}
for package in project_packages
],
# todo (from new PEPs):
# versions (v1.1, PEP 700)
# alternate-locations (v1.2, PEP 708)
# project-status (v1.4, PEP 792 - pypi and docs differ)
}


class PackageIncludeFilter:
"""A special class to help filter Package's based on a remote's include/exclude"""

Expand Down
4 changes: 2 additions & 2 deletions pulp_python/tests/functional/api/test_full_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def test_pull_through_filter(python_remote_factory, python_distribution_factory)

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

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

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

r = requests.get(f"{distro.base_url}simple/pulpcore/")
assert r.status_code == 502
Expand Down
Loading
Loading