Skip to content

Commit ef91173

Browse files
committed
PEP 592 initial
1 parent 2408615 commit ef91173

10 files changed

Lines changed: 476 additions & 3 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
("python", "0022_pythonblocklistentry"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="PackageYank",
18+
fields=[
19+
(
20+
"content_ptr",
21+
models.OneToOneField(
22+
auto_created=True,
23+
on_delete=django.db.models.deletion.CASCADE,
24+
parent_link=True,
25+
primary_key=True,
26+
serialize=False,
27+
to="core.content",
28+
),
29+
),
30+
("name_normalized", models.TextField()),
31+
("version", models.TextField()),
32+
("yanked_reason", models.TextField(default="")),
33+
(
34+
"_pulp_domain",
35+
models.ForeignKey(
36+
default=pulpcore.app.util.get_domain_pk,
37+
on_delete=django.db.models.deletion.PROTECT,
38+
to="core.domain",
39+
),
40+
),
41+
],
42+
options={
43+
"default_related_name": "%(app_label)s_%(model_name)s",
44+
"unique_together": {("name_normalized", "version", "_pulp_domain")},
45+
},
46+
bases=("core.content",),
47+
),
48+
]

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
@@ -46,6 +46,7 @@
4646
PackageUploadSerializer,
4747
PackageUploadTaskSerializer,
4848
SummarySerializer,
49+
YankSerializer,
4950
)
5051
from pulp_python.app.utils import (
5152
PYPI_LAST_SERIAL,
@@ -586,3 +587,70 @@ def retrieve(self, request, path, package, version, filename):
586587
if provenance:
587588
return Response(data=provenance.provenance)
588589
return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")
590+
591+
592+
class YankView(PyPIMixin, ViewSet):
593+
"""View for yank/unyank requests (PEP 592)."""
594+
595+
endpoint_name = "yank"
596+
DEFAULT_ACCESS_POLICY = {
597+
"statements": [
598+
{
599+
"action": ["yank", "unyank"],
600+
"principal": "authenticated",
601+
"effect": "allow",
602+
"condition": "index_has_repo_perm:python.modify_pythonrepository",
603+
},
604+
],
605+
}
606+
607+
@extend_schema(request=YankSerializer, summary="Yank a package version")
608+
def yank(self, request, path):
609+
"""Yank a package version, marking all its files with data-yanked."""
610+
repo = self.distribution.repository
611+
if not repo:
612+
return HttpResponseBadRequest(reason="Index is not pointing to a repository")
613+
614+
serializer = YankSerializer(data=request.data)
615+
serializer.is_valid(raise_exception=True)
616+
617+
normalized = canonicalize_name(serializer.validated_data["name"])
618+
version = serializer.validated_data["version"]
619+
repo_ver = self.get_repository_version(self.distribution)
620+
if not PythonPackageContent.objects.filter(
621+
pk__in=repo_ver.content, name_normalized=normalized, version=version
622+
).exists():
623+
return HttpResponseNotFound(f"{normalized}=={version} not found in repository")
624+
625+
result = dispatch(
626+
tasks.ayank_package,
627+
exclusive_resources=[repo],
628+
kwargs={
629+
"repository_pk": str(repo.pk),
630+
"name": serializer.validated_data["name"],
631+
"version": serializer.validated_data["version"],
632+
"yanked_reason": serializer.validated_data.get("yanked_reason", ""),
633+
},
634+
)
635+
return OperationPostponedResponse(result, request)
636+
637+
@extend_schema(request=YankSerializer, summary="Unyank a package version")
638+
def unyank(self, request, path):
639+
"""Unyank a package version, unmarking all its files with data-yanked."""
640+
repo = self.distribution.repository
641+
if not repo:
642+
return HttpResponseBadRequest(reason="Index is not pointing to a repository")
643+
644+
serializer = YankSerializer(data=request.data)
645+
serializer.is_valid(raise_exception=True)
646+
647+
result = dispatch(
648+
tasks.aunyank_package,
649+
exclusive_resources=[repo],
650+
kwargs={
651+
"repository_pk": str(repo.pk),
652+
"name": serializer.validated_data["name"],
653+
"version": serializer.validated_data["version"],
654+
},
655+
)
656+
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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from packaging.utils import canonicalize_name
2+
3+
from pulpcore.plugin.tasking import aadd_and_remove
4+
5+
from pulpcore.exceptions import ValidationError
6+
from pulp_python.app.models import PackageYank, PythonPackageContent, PythonRepository
7+
8+
9+
async def ayank_package(repository_pk, name, version, yanked_reason=""):
10+
"""
11+
Yank a package version in a repository by adding a PackageYank marker.
12+
Creates a new repository version with the yank marker added.
13+
"""
14+
normalized = canonicalize_name(name)
15+
repository = await PythonRepository.objects.aget(pk=repository_pk)
16+
latest = await repository.alatest_version()
17+
18+
exists = await PythonPackageContent.objects.filter(
19+
pk__in=latest.content, name_normalized=normalized, version=version
20+
).aexists()
21+
if not exists:
22+
raise ValidationError(f"Package {name}=={version} not found in repository")
23+
24+
already_yanked = await PackageYank.objects.filter(
25+
pk__in=latest.content, name_normalized=normalized, version=version
26+
).aexists()
27+
if already_yanked:
28+
return
29+
30+
yank_marker, _ = await PackageYank.objects.aget_or_create(
31+
name_normalized=normalized,
32+
version=version,
33+
_pulp_domain_id=repository.pulp_domain_id,
34+
defaults={"yanked_reason": yanked_reason},
35+
)
36+
37+
await aadd_and_remove(
38+
repository_pk=repository.pk,
39+
add_content_units=[yank_marker.pk],
40+
remove_content_units=[],
41+
)
42+
43+
44+
async def aunyank_package(repository_pk, name, version):
45+
"""
46+
Unyank a package version in a repository by removing its PackageYank marker.
47+
Creates a new repository version with the yank marker removed.
48+
"""
49+
normalized = canonicalize_name(name)
50+
repository = await PythonRepository.objects.aget(pk=repository_pk)
51+
latest = await repository.alatest_version()
52+
53+
yank_marker = await PackageYank.objects.filter(
54+
pk__in=latest.content, name_normalized=normalized, version=version
55+
).afirst()
56+
57+
if yank_marker is None:
58+
return
59+
60+
await aadd_and_remove(
61+
repository_pk=repository.pk,
62+
add_content_units=[],
63+
remove_content_units=[yank_marker.pk],
64+
)

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)