Skip to content

Commit 132bdfe

Browse files
committed
WIP - PEP 592 initial
1 parent 8c12432 commit 132bdfe

10 files changed

Lines changed: 477 additions & 3 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Generated by Django 5.2.13 on 2026-07-17 17:05
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
import pulpcore.app.util
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("core", "0151_upstreampulp_connect_timeout_and_more"),
13+
("python", "0022_pythonblocklistentry"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="PackageYank",
19+
fields=[
20+
(
21+
"content_ptr",
22+
models.OneToOneField(
23+
auto_created=True,
24+
on_delete=django.db.models.deletion.CASCADE,
25+
parent_link=True,
26+
primary_key=True,
27+
serialize=False,
28+
to="core.content",
29+
),
30+
),
31+
("name_normalized", models.TextField()),
32+
("version", models.TextField()),
33+
("yanked_reason", models.TextField(default="")),
34+
(
35+
"_pulp_domain",
36+
models.ForeignKey(
37+
default=pulpcore.app.util.get_domain_pk,
38+
on_delete=django.db.models.deletion.PROTECT,
39+
to="core.domain",
40+
),
41+
),
42+
],
43+
options={
44+
"default_related_name": "%(app_label)s_%(model_name)s",
45+
"unique_together": {("name_normalized", "version", "_pulp_domain")},
46+
},
47+
bases=("core.content",),
48+
),
49+
]

pulp_python/app/models.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,6 @@ class PythonPackageContent(Content):
204204
sha256 = models.CharField(db_index=True, max_length=64)
205205
metadata_sha256 = models.CharField(max_length=64, null=True)
206206
size = models.BigIntegerField(default=0)
207-
# yanked and yanked_reason are not implemented because they are mutable
208-
209207
# From pulpcore
210208
PROTECTED_FROM_RECLAIM = False
211209
TYPE = "python"
@@ -289,6 +287,29 @@ class Meta:
289287
unique_together = ("sha256", "_pulp_domain")
290288

291289

290+
class PackageYank(Content):
291+
"""
292+
A marker content type indicating a package version is yanked in a repository (PEP 592).
293+
294+
Its presence in a repository version means all files for the matching
295+
(name_normalized, version) pair are yanked. Yank/unyank operations
296+
add/remove this marker, creating new repository versions.
297+
"""
298+
299+
TYPE = "python_yank"
300+
repo_key_fields = ("name_normalized", "version")
301+
302+
name_normalized = models.TextField()
303+
version = models.TextField()
304+
yanked_reason = models.TextField(default="")
305+
306+
_pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
307+
308+
class Meta:
309+
default_related_name = "%(app_label)s_%(model_name)s"
310+
unique_together = ("name_normalized", "version", "_pulp_domain")
311+
312+
292313
class PythonPublication(Publication, AutoAddObjPermsMixin):
293314
"""
294315
A Publication for PythonContent.
@@ -364,7 +385,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
364385
"""
365386

366387
TYPE = "python"
367-
CONTENT_TYPES = [PythonPackageContent, PackageProvenance]
388+
CONTENT_TYPES = [PythonPackageContent, PackageProvenance, PackageYank]
368389
REMOTE_TYPES = [PythonRemote]
369390
PULL_THROUGH_SUPPORTED = True
370391

pulp_python/app/pypi/serializers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,27 @@ def validate(self, data):
128128
return data
129129

130130

131+
class YankSerializer(serializers.Serializer):
132+
"""
133+
A Serializer for yank/unyank requests (PEP 592).
134+
"""
135+
136+
name = serializers.CharField(
137+
help_text=_("The name of the package to yank or unyank."),
138+
required=True,
139+
)
140+
version = serializers.CharField(
141+
help_text=_("The version of the package to yank or unyank."),
142+
required=True,
143+
)
144+
yanked_reason = serializers.CharField(
145+
help_text=_("The reason for yanking the package version."),
146+
required=False,
147+
allow_blank=True,
148+
default="",
149+
)
150+
151+
131152
class PackageUploadTaskSerializer(serializers.Serializer):
132153
"""
133154
A Serializer for responding to a package upload task.

pulp_python/app/pypi/views.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
PackageUploadSerializer,
4343
PackageUploadTaskSerializer,
4444
SummarySerializer,
45+
YankSerializer,
4546
)
4647
from pulp_python.app.utils import (
4748
PYPI_LAST_SERIAL,
@@ -565,3 +566,70 @@ def retrieve(self, request, path, package, version, filename):
565566
if provenance:
566567
return Response(data=provenance.provenance)
567568
return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")
569+
570+
571+
class YankView(PyPIMixin, ViewSet):
572+
"""View for yank/unyank requests (PEP 592)."""
573+
574+
endpoint_name = "yank"
575+
DEFAULT_ACCESS_POLICY = {
576+
"statements": [
577+
{
578+
"action": ["yank", "unyank"],
579+
"principal": "authenticated",
580+
"effect": "allow",
581+
"condition": "index_has_repo_perm:python.modify_pythonrepository",
582+
},
583+
],
584+
}
585+
586+
@extend_schema(request=YankSerializer, summary="Yank a package version")
587+
def yank(self, request, path):
588+
"""Yank a package version, marking all its files with data-yanked."""
589+
repo = self.distribution.repository
590+
if not repo:
591+
return HttpResponseBadRequest(reason="Index is not pointing to a repository")
592+
593+
serializer = YankSerializer(data=request.data)
594+
serializer.is_valid(raise_exception=True)
595+
596+
normalized = canonicalize_name(serializer.validated_data["name"])
597+
version = serializer.validated_data["version"]
598+
repo_ver = self.get_repository_version(self.distribution)
599+
if not PythonPackageContent.objects.filter(
600+
pk__in=repo_ver.content, name_normalized=normalized, version=version
601+
).exists():
602+
return HttpResponseNotFound(f"{normalized}=={version} not found in repository")
603+
604+
result = dispatch(
605+
tasks.ayank_package,
606+
exclusive_resources=[repo],
607+
kwargs={
608+
"repository_pk": str(repo.pk),
609+
"name": serializer.validated_data["name"],
610+
"version": serializer.validated_data["version"],
611+
"reason": serializer.validated_data.get("yanked_reason", ""),
612+
},
613+
)
614+
return OperationPostponedResponse(result, request)
615+
616+
@extend_schema(request=YankSerializer, summary="Unyank a package version")
617+
def unyank(self, request, path):
618+
"""Unyank a package version, unmarking all its files with data-yanked."""
619+
repo = self.distribution.repository
620+
if not repo:
621+
return HttpResponseBadRequest(reason="Index is not pointing to a repository")
622+
623+
serializer = YankSerializer(data=request.data)
624+
serializer.is_valid(raise_exception=True)
625+
626+
result = dispatch(
627+
tasks.aunyank_package,
628+
exclusive_resources=[repo],
629+
kwargs={
630+
"repository_pk": str(repo.pk),
631+
"name": serializer.validated_data["name"],
632+
"version": serializer.validated_data["version"],
633+
},
634+
)
635+
return OperationPostponedResponse(result, request)

pulp_python/app/serializers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,25 @@ def to_representation(self, value):
700700
return result
701701

702702

703+
class PackageYankSerializer(core_serializers.NoArtifactContentSerializer):
704+
"""
705+
Read-only serializer for PackageYank content units (PEP 592).
706+
Used by PackageYankViewSet to expose yank markers via the Pulp REST API.
707+
"""
708+
709+
name_normalized = serializers.CharField(read_only=True)
710+
version = serializers.CharField(read_only=True)
711+
yanked_reason = serializers.CharField(read_only=True)
712+
713+
class Meta:
714+
fields = core_serializers.NoArtifactContentSerializer.Meta.fields + (
715+
"name_normalized",
716+
"version",
717+
"yanked_reason",
718+
)
719+
model = python_models.PackageYank
720+
721+
703722
class PythonRemoteSerializer(core_serializers.RemoteSerializer):
704723
"""
705724
A Serializer for PythonRemote.

pulp_python/app/tasks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
from .sync import sync # noqa:F401
88
from .upload import upload, upload_group # noqa:F401
99
from .vulnerability_report import get_repo_version_content # noqa:F401
10+
from .yank import aunyank_package, ayank_package # noqa:F401

pulp_python/app/tasks/yank.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from packaging.utils import canonicalize_name
2+
3+
from pulpcore.plugin.tasking import aadd_and_remove
4+
5+
from pulp_python.app.models import PackageYank, PythonPackageContent, PythonRepository
6+
7+
8+
async def ayank_package(repository_pk, name, version, reason=""):
9+
"""
10+
Yank a package version in a repository by adding a PackageYank marker.
11+
Creates a new repository version with the yank marker added.
12+
"""
13+
normalized = canonicalize_name(name)
14+
repository = await PythonRepository.objects.aget(pk=repository_pk)
15+
latest = await repository.alatest_version()
16+
17+
exists = await PythonPackageContent.objects.filter(
18+
pk__in=latest.content, name_normalized=normalized, version=version
19+
).aexists()
20+
if not exists:
21+
raise ValueError(f"Package {name}=={version} not found in repository")
22+
23+
already_yanked = await PackageYank.objects.filter(
24+
pk__in=latest.content, name_normalized=normalized, version=version
25+
).aexists()
26+
if already_yanked:
27+
return
28+
29+
yank_marker, _ = await PackageYank.objects.aget_or_create(
30+
name_normalized=normalized,
31+
version=version,
32+
_pulp_domain_id=repository.pulp_domain_id,
33+
defaults={"yanked_reason": reason},
34+
)
35+
36+
await aadd_and_remove(
37+
repository_pk=repository.pk,
38+
add_content_units=[yank_marker.pk],
39+
remove_content_units=[],
40+
)
41+
42+
43+
async def aunyank_package(repository_pk, name, version):
44+
"""
45+
Unyank a package version in a repository by removing its PackageYank marker.
46+
Creates a new repository version with the yank marker removed.
47+
"""
48+
normalized = canonicalize_name(name)
49+
repository = await PythonRepository.objects.aget(pk=repository_pk)
50+
latest = await repository.alatest_version()
51+
52+
yank_marker = await PackageYank.objects.filter(
53+
pk__in=latest.content, name_normalized=normalized, version=version
54+
).afirst()
55+
56+
if yank_marker is None:
57+
return
58+
59+
await aadd_and_remove(
60+
repository_pk=repository.pk,
61+
add_content_units=[],
62+
remove_content_units=[yank_marker.pk],
63+
)

pulp_python/app/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
PyPIView,
88
SimpleView,
99
UploadView,
10+
YankView,
1011
)
1112

1213
if settings.DOMAIN_ENABLED:
@@ -40,5 +41,7 @@
4041
SimpleView.as_view({"get": "list", "post": "create"}),
4142
name="simple-detail",
4243
),
44+
path(PYPI_API_URL + "yank/", YankView.as_view({"post": "yank"}), name="yank"),
45+
path(PYPI_API_URL + "unyank/", YankView.as_view({"post": "unyank"}), name="unyank"),
4346
path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"),
4447
]

pulp_python/app/viewsets.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,30 @@ class PackageProvenanceViewSet(core_viewsets.NoArtifactContentUploadViewSet):
615615
}
616616

617617

618+
# todo: for .../repo-uuid/versions/ ?
619+
class PackageYankViewSet(core_viewsets.ReadOnlyContentViewSet):
620+
"""
621+
Read-only viewset for PackageYank content units (PEP 592).
622+
PackageYank markers indicate that a package version has been yanked in a repository.
623+
Use the /yank/ and /unyank/ PyPI endpoints to create or remove these markers.
624+
"""
625+
626+
endpoint_name = "yanks"
627+
queryset = python_models.PackageYank.objects.all()
628+
serializer_class = python_serializers.PackageYankSerializer
629+
630+
DEFAULT_ACCESS_POLICY = {
631+
"statements": [
632+
{
633+
"action": ["list", "retrieve"],
634+
"principal": "authenticated",
635+
"effect": "allow",
636+
},
637+
],
638+
"queryset_scoping": {"function": "scope_queryset"},
639+
}
640+
641+
618642
class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin):
619643
"""
620644
<!-- User-facing documentation, rendered as html-->

0 commit comments

Comments
 (0)