Skip to content

Commit d896bfc

Browse files
committed
Add PEP 592 yank support
closes #1270 Assisted By: Claude Opus 4.6
1 parent 0b49215 commit d896bfc

13 files changed

Lines changed: 615 additions & 18 deletions

File tree

CHANGES/1270.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added PEP 592 yank support.
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: 27 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,32 @@ 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+
def __str__(self):
309+
return f"<{self._meta.object_name}: {self.name_normalized} [{self.version}]>"
310+
311+
class Meta:
312+
default_related_name = "%(app_label)s_%(model_name)s"
313+
unique_together = ("name_normalized", "version", "_pulp_domain")
314+
315+
292316
class PythonPublication(Publication, AutoAddObjPermsMixin):
293317
"""
294318
A Publication for PythonContent.
@@ -364,7 +388,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
364388
"""
365389

366390
TYPE = "python"
367-
CONTENT_TYPES = [PythonPackageContent, PackageProvenance]
391+
CONTENT_TYPES = [PythonPackageContent, PackageProvenance, PackageYank]
368392
REMOTE_TYPES = [PythonRemote]
369393
PULL_THROUGH_SUPPORTED = True
370394

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: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from pulp_python.app.cache import PythonApiCache, find_base_path_cached
3838
from pulp_python.app.models import (
3939
PackageProvenance,
40+
PackageYank,
4041
PythonDistribution,
4142
PythonPackageContent,
4243
PythonPublication,
@@ -46,6 +47,7 @@
4647
PackageUploadSerializer,
4748
PackageUploadTaskSerializer,
4849
SummarySerializer,
50+
YankSerializer,
4951
)
5052
from pulp_python.app.utils import (
5153
PYPI_LAST_SERIAL,
@@ -356,6 +358,8 @@ def parse_package(release_package):
356358
"upload_time": release_package.upload_time,
357359
"version": release_package.version,
358360
"provenance": release_package.provenance_url,
361+
"yanked": release_package.is_yanked,
362+
"yanked_reason": release_package.yanked_reason or "",
359363
}
360364

361365
rfilter = get_remote_package_filter(remote)
@@ -408,6 +412,11 @@ def retrieve(self, request, path, package):
408412
"version",
409413
"has_provenance",
410414
)
415+
yank_markers = dict(
416+
PackageYank.objects.filter(
417+
pk__in=repo_ver.content, name_normalized=normalized
418+
).values_list("version", "yanked_reason")
419+
)
411420
local_releases = {
412421
p["filename"]: {
413422
**p,
@@ -418,6 +427,8 @@ def retrieve(self, request, path, package):
418427
if p["has_provenance"]
419428
else None
420429
),
430+
"yanked": p["version"] in yank_markers,
431+
"yanked_reason": yank_markers.get(p["version"], ""),
421432
}
422433
for p in packages
423434
}
@@ -493,12 +504,18 @@ def retrieve(self, request, path, meta):
493504
headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT)}
494505
if settings.DOMAIN_ENABLED:
495506
domain = get_domain()
507+
yank_markers = dict(
508+
PackageYank.objects.filter(
509+
pk__in=repo_ver.content, name_normalized=normalized
510+
).values_list("version", "yanked_reason")
511+
)
496512
json_body = python_content_to_json(
497513
path,
498514
package_content,
499515
version=version,
500516
domain=domain,
501517
repository_version=repo_ver,
518+
yank_markers=yank_markers,
502519
)
503520
if json_body:
504521
return Response(data=json_body, headers=headers)
@@ -586,3 +603,70 @@ def retrieve(self, request, path, package, version, filename):
586603
if provenance:
587604
return Response(data=provenance.provenance)
588605
return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")
606+
607+
608+
class YankView(PyPIMixin, ViewSet):
609+
"""View for yank/unyank requests (PEP 592)."""
610+
611+
endpoint_name = "yank"
612+
DEFAULT_ACCESS_POLICY = {
613+
"statements": [
614+
{
615+
"action": ["yank", "unyank"],
616+
"principal": "authenticated",
617+
"effect": "allow",
618+
"condition": "index_has_repo_perm:python.modify_pythonrepository",
619+
},
620+
],
621+
}
622+
623+
@extend_schema(request=YankSerializer, summary="Yank a package version")
624+
def yank(self, request, path):
625+
"""Yank a package version, marking all its files with data-yanked."""
626+
repo = self.distribution.repository
627+
if not repo:
628+
return HttpResponseBadRequest(reason="Index is not pointing to a repository")
629+
630+
serializer = YankSerializer(data=request.data)
631+
serializer.is_valid(raise_exception=True)
632+
633+
normalized = canonicalize_name(serializer.validated_data["name"])
634+
version = serializer.validated_data["version"]
635+
repo_ver = self.get_repository_version(self.distribution)
636+
if not PythonPackageContent.objects.filter(
637+
pk__in=repo_ver.content, name_normalized=normalized, version=version
638+
).exists():
639+
return HttpResponseNotFound(f"{normalized}=={version} not found in repository")
640+
641+
result = dispatch(
642+
tasks.ayank_package,
643+
exclusive_resources=[repo],
644+
kwargs={
645+
"repository_pk": str(repo.pk),
646+
"name": serializer.validated_data["name"],
647+
"version": serializer.validated_data["version"],
648+
"yanked_reason": serializer.validated_data.get("yanked_reason", ""),
649+
},
650+
)
651+
return OperationPostponedResponse(result, request)
652+
653+
@extend_schema(request=YankSerializer, summary="Unyank a package version")
654+
def unyank(self, request, path):
655+
"""Unyank a package version, unmarking all its files with data-yanked."""
656+
repo = self.distribution.repository
657+
if not repo:
658+
return HttpResponseBadRequest(reason="Index is not pointing to a repository")
659+
660+
serializer = YankSerializer(data=request.data)
661+
serializer.is_valid(raise_exception=True)
662+
663+
result = dispatch(
664+
tasks.aunyank_package,
665+
exclusive_resources=[repo],
666+
kwargs={
667+
"repository_pk": str(repo.pk),
668+
"name": serializer.validated_data["name"],
669+
"version": serializer.validated_data["version"],
670+
},
671+
)
672+
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/sync.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from pulp_python.app.exceptions import UnsupportedProtocolError
2525
from pulp_python.app.models import (
2626
PackageProvenance,
27+
PackageYank,
2728
PythonPackageContent,
2829
PythonRemote,
2930
)
@@ -265,6 +266,15 @@ async def create_content(self, pkg):
265266
)
266267
d_artifacts.append(metadata_artifact)
267268

269+
if upstream_pkg.is_yanked:
270+
yank_marker = PackageYank(
271+
name_normalized=pkg.name,
272+
version=version,
273+
yanked_reason=upstream_pkg.yanked_reason or "",
274+
)
275+
yank_dc = DeclarativeContent(content=yank_marker, d_artifacts=[])
276+
await self.python_stage.put(yank_dc)
277+
268278
dc = DeclarativeContent(content=package, d_artifacts=d_artifacts)
269279
declared_contents[entry["filename"]] = dc
270280
await self.python_stage.put(dc)

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.exceptions import ValidationError
4+
from pulpcore.plugin.tasking import aadd_and_remove
5+
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+
)

0 commit comments

Comments
 (0)