Skip to content
Draft
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
48 changes: 48 additions & 0 deletions pulp_python/app/migrations/0023_packageyank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.2.13 on 2026-07-17 17:05

import django.db.models.deletion
from django.db import migrations, models

import pulpcore.app.util


class Migration(migrations.Migration):

dependencies = [
("python", "0022_pythonblocklistentry"),
]

operations = [
migrations.CreateModel(
name="PackageYank",
fields=[
(
"content_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="core.content",
),
),
("name_normalized", models.TextField()),
("version", models.TextField()),
("yanked_reason", models.TextField(default="")),
(
"_pulp_domain",
models.ForeignKey(
default=pulpcore.app.util.get_domain_pk,
on_delete=django.db.models.deletion.PROTECT,
to="core.domain",
),
),
],
options={
"default_related_name": "%(app_label)s_%(model_name)s",
"unique_together": {("name_normalized", "version", "_pulp_domain")},
},
bases=("core.content",),
),
]
27 changes: 24 additions & 3 deletions pulp_python/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,6 @@ class PythonPackageContent(Content):
sha256 = models.CharField(db_index=True, max_length=64)
metadata_sha256 = models.CharField(max_length=64, null=True)
size = models.BigIntegerField(default=0)
# yanked and yanked_reason are not implemented because they are mutable

# From pulpcore
PROTECTED_FROM_RECLAIM = False
TYPE = "python"
Expand Down Expand Up @@ -289,6 +287,29 @@ class Meta:
unique_together = ("sha256", "_pulp_domain")


class PackageYank(Content):
"""
A marker content type indicating a package version is yanked in a repository (PEP 592).

Its presence in a repository version means all files for the matching
(name_normalized, version) pair are yanked. Yank/unyank operations
add/remove this marker, creating new repository versions.
"""

TYPE = "python_yank"
repo_key_fields = ("name_normalized", "version")

name_normalized = models.TextField()
version = models.TextField()
yanked_reason = models.TextField(default="")

_pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
unique_together = ("name_normalized", "version", "_pulp_domain")


class PythonPublication(Publication, AutoAddObjPermsMixin):
"""
A Publication for PythonContent.
Expand Down Expand Up @@ -364,7 +385,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
"""

TYPE = "python"
CONTENT_TYPES = [PythonPackageContent, PackageProvenance]
CONTENT_TYPES = [PythonPackageContent, PackageProvenance, PackageYank]
REMOTE_TYPES = [PythonRemote]
PULL_THROUGH_SUPPORTED = True

Expand Down
21 changes: 21 additions & 0 deletions pulp_python/app/pypi/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ def validate(self, data):
return data


class YankSerializer(serializers.Serializer):
"""
A Serializer for yank/unyank requests (PEP 592).
"""

name = serializers.CharField(
help_text=_("The name of the package to yank or unyank."),
required=True,
)
version = serializers.CharField(
help_text=_("The version of the package to yank or unyank."),
required=True,
)
yanked_reason = serializers.CharField(
help_text=_("The reason for yanking the package version."),
required=False,
allow_blank=True,
default="",
)


class PackageUploadTaskSerializer(serializers.Serializer):
"""
A Serializer for responding to a package upload task.
Expand Down
68 changes: 68 additions & 0 deletions pulp_python/app/pypi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
PackageUploadSerializer,
PackageUploadTaskSerializer,
SummarySerializer,
YankSerializer,
)
from pulp_python.app.utils import (
PYPI_LAST_SERIAL,
Expand Down Expand Up @@ -565,3 +566,70 @@ def retrieve(self, request, path, package, version, filename):
if provenance:
return Response(data=provenance.provenance)
return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")


class YankView(PyPIMixin, ViewSet):
"""View for yank/unyank requests (PEP 592)."""

endpoint_name = "yank"
DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["yank", "unyank"],
"principal": "authenticated",
"effect": "allow",
"condition": "index_has_repo_perm:python.modify_pythonrepository",
},
],
}

@extend_schema(request=YankSerializer, summary="Yank a package version")
def yank(self, request, path):
"""Yank a package version, marking all its files with data-yanked."""
repo = self.distribution.repository
if not repo:
return HttpResponseBadRequest(reason="Index is not pointing to a repository")

serializer = YankSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

normalized = canonicalize_name(serializer.validated_data["name"])
version = serializer.validated_data["version"]
repo_ver = self.get_repository_version(self.distribution)
if not PythonPackageContent.objects.filter(
pk__in=repo_ver.content, name_normalized=normalized, version=version
).exists():
return HttpResponseNotFound(f"{normalized}=={version} not found in repository")

result = dispatch(
tasks.ayank_package,
exclusive_resources=[repo],
kwargs={
"repository_pk": str(repo.pk),
"name": serializer.validated_data["name"],
"version": serializer.validated_data["version"],
"reason": serializer.validated_data.get("yanked_reason", ""),
},
)
return OperationPostponedResponse(result, request)

@extend_schema(request=YankSerializer, summary="Unyank a package version")
def unyank(self, request, path):
"""Unyank a package version, unmarking all its files with data-yanked."""
repo = self.distribution.repository
if not repo:
return HttpResponseBadRequest(reason="Index is not pointing to a repository")

serializer = YankSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

result = dispatch(
tasks.aunyank_package,
exclusive_resources=[repo],
kwargs={
"repository_pk": str(repo.pk),
"name": serializer.validated_data["name"],
"version": serializer.validated_data["version"],
},
)
return OperationPostponedResponse(result, request)
19 changes: 19 additions & 0 deletions pulp_python/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,25 @@ def to_representation(self, value):
return result


class PackageYankSerializer(core_serializers.NoArtifactContentSerializer):
"""
Read-only serializer for PackageYank content units (PEP 592).
Used by PackageYankViewSet to expose yank markers via the Pulp REST API.
"""

name_normalized = serializers.CharField(read_only=True)
version = serializers.CharField(read_only=True)
yanked_reason = serializers.CharField(read_only=True)

class Meta:
fields = core_serializers.NoArtifactContentSerializer.Meta.fields + (
"name_normalized",
"version",
"yanked_reason",
)
model = python_models.PackageYank


class PythonRemoteSerializer(core_serializers.RemoteSerializer):
"""
A Serializer for PythonRemote.
Expand Down
1 change: 1 addition & 0 deletions pulp_python/app/tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
from .sync import sync # noqa:F401
from .upload import upload, upload_group # noqa:F401
from .vulnerability_report import get_repo_version_content # noqa:F401
from .yank import aunyank_package, ayank_package # noqa:F401
63 changes: 63 additions & 0 deletions pulp_python/app/tasks/yank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from packaging.utils import canonicalize_name

from pulpcore.plugin.tasking import aadd_and_remove

from pulp_python.app.models import PackageYank, PythonPackageContent, PythonRepository


async def ayank_package(repository_pk, name, version, reason=""):
"""
Yank a package version in a repository by adding a PackageYank marker.
Creates a new repository version with the yank marker added.
"""
normalized = canonicalize_name(name)
repository = await PythonRepository.objects.aget(pk=repository_pk)
latest = await repository.alatest_version()

exists = await PythonPackageContent.objects.filter(
pk__in=latest.content, name_normalized=normalized, version=version
).aexists()
if not exists:
raise ValueError(f"Package {name}=={version} not found in repository")

already_yanked = await PackageYank.objects.filter(
pk__in=latest.content, name_normalized=normalized, version=version
).aexists()
if already_yanked:
return

yank_marker, _ = await PackageYank.objects.aget_or_create(
name_normalized=normalized,
version=version,
_pulp_domain_id=repository.pulp_domain_id,
defaults={"yanked_reason": reason},
)

await aadd_and_remove(
repository_pk=repository.pk,
add_content_units=[yank_marker.pk],
remove_content_units=[],
)


async def aunyank_package(repository_pk, name, version):
"""
Unyank a package version in a repository by removing its PackageYank marker.
Creates a new repository version with the yank marker removed.
"""
normalized = canonicalize_name(name)
repository = await PythonRepository.objects.aget(pk=repository_pk)
latest = await repository.alatest_version()

yank_marker = await PackageYank.objects.filter(
pk__in=latest.content, name_normalized=normalized, version=version
).afirst()

if yank_marker is None:
return

await aadd_and_remove(
repository_pk=repository.pk,
add_content_units=[],
remove_content_units=[yank_marker.pk],
)
3 changes: 3 additions & 0 deletions pulp_python/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
PyPIView,
SimpleView,
UploadView,
YankView,
)

if settings.DOMAIN_ENABLED:
Expand Down Expand Up @@ -40,5 +41,7 @@
SimpleView.as_view({"get": "list", "post": "create"}),
name="simple-detail",
),
path(PYPI_API_URL + "yank/", YankView.as_view({"post": "yank"}), name="yank"),
path(PYPI_API_URL + "unyank/", YankView.as_view({"post": "unyank"}), name="unyank"),
path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"),
]
24 changes: 24 additions & 0 deletions pulp_python/app/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,30 @@ class PackageProvenanceViewSet(core_viewsets.NoArtifactContentUploadViewSet):
}


# todo: for .../repo-uuid/versions/ ?
class PackageYankViewSet(core_viewsets.ReadOnlyContentViewSet):
"""
Read-only viewset for PackageYank content units (PEP 592).
PackageYank markers indicate that a package version has been yanked in a repository.
Use the /yank/ and /unyank/ PyPI endpoints to create or remove these markers.
"""

endpoint_name = "yanks"
queryset = python_models.PackageYank.objects.all()
serializer_class = python_serializers.PackageYankSerializer

DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["list", "retrieve"],
"principal": "authenticated",
"effect": "allow",
},
],
"queryset_scoping": {"function": "scope_queryset"},
}


class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin):
"""
<!-- User-facing documentation, rendered as html-->
Expand Down
Loading