Skip to content

Commit 42ed65c

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

14 files changed

Lines changed: 691 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 yank support (PEP 592).

docs/user/guides/package_policies.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,62 @@ pulp python repository blocklist list --repository "foo"
9494

9595
Once an entry is removed, packages matching it can be added to the repository again.
9696

97+
## Package Yanking
98+
99+
[PEP 592](https://peps.python.org/pep-0592/) allows marking package versions as "yanked".
100+
Package installers like `pip` will skip yanked versions when resolving dependencies.
101+
However, if a user requests an exact version (e.g. `pip install twine==5.1.0`),
102+
the yanked package will still be installed, with a warning.
103+
104+
Yank status is per-repository: yanking a package in one repository does not affect other repositories
105+
that contain the same package.
106+
107+
### Yank a package version
108+
109+
To yank a package version, send a POST request to the distribution's `/yank/` endpoint:
110+
111+
```bash
112+
http POST http://localhost:5001/pypi/default/<distribution-base-path>/yank/ \
113+
name=shelf-reader version=0.1 yanked_reason="critical security bug" \
114+
-a admin:password
115+
```
116+
117+
The `yanked_reason` field is optional. If omitted, the package is marked as yanked with no reason.
118+
119+
Yanking creates a new repository version with the yank marker added.
120+
Yanking a version that is already yanked with the same reason is a no-op (no new repository version is created).
121+
Re-yanking with a different reason will update the reason and create a new repository version.
122+
123+
### Unyank a package version
124+
125+
```bash
126+
http POST http://localhost:5001/pypi/default/<distribution-base-path>/unyank/ \
127+
name=shelf-reader version=0.1 \
128+
-a admin:password
129+
```
130+
131+
Unyanking creates a new repository version with the yank marker removed.
132+
Unyanking a version that is not yanked is a no-op.
133+
134+
### Syncing yanked packages
135+
136+
When syncing from a remote that has yanked packages (e.g. PyPI), the yank status is preserved automatically.
137+
Pulp creates a yank marker for each yanked version and includes it in the repository version.
138+
139+
### Viewing yank status
140+
141+
Yank status is visible in the Simple API and the PyPI Metadata API.
142+
143+
Yank markers can also be listed via the REST API:
144+
145+
```bash
146+
# List all yank markers
147+
http GET http://localhost:5001/pulp/default/api/v3/content/python/yanks/ -a admin:password
148+
149+
# List yank markers for a specific repository version
150+
http GET http://localhost:5001/pulp/default/api/v3/content/python/yanks/?repository_version=<repo-version-href> -a admin:password
151+
```
152+
97153
## Package Substitution
98154

99155
By default, Python repositories allow package substitution: uploading, syncing, or adding a package
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Generated by Django 5.2.16 on 2026-07-30 10:44
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": {
45+
("name_normalized", "version", "yanked_reason", "_pulp_domain")
46+
},
47+
},
48+
bases=("core.content",),
49+
),
50+
]

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", "yanked_reason", "_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)

0 commit comments

Comments
 (0)