Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGES/1270.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added yank support (PEP 592).
56 changes: 56 additions & 0 deletions docs/user/guides/package_policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,62 @@ pulp python repository blocklist list --repository "foo"

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

## Package Yanking

[PEP 592](https://peps.python.org/pep-0592/) allows marking package versions as "yanked".
Package installers like `pip` will skip yanked versions when resolving dependencies.
However, if a user requests an exact version (e.g. `pip install twine==5.1.0`),
the yanked package will still be installed, with a warning.

Yank status is per-repository: yanking a package in one repository does not affect other repositories
that contain the same package.

### Yank a package version

To yank a package version, send a POST request to the distribution's `/yank/` endpoint:

```bash
http POST http://localhost:5001/pypi/default/<distribution-base-path>/yank/ \
name=shelf-reader version=0.1 yanked_reason="critical security bug" \
-a admin:password
```

The `yanked_reason` field is optional. If omitted, the package is marked as yanked with no reason.

Yanking creates a new repository version with the yank marker added.
Yanking a version that is already yanked with the same reason is a no-op (no new repository version is created).
Re-yanking with a different reason will update the reason and create a new repository version.

### Unyank a package version

```bash
http POST http://localhost:5001/pypi/default/<distribution-base-path>/unyank/ \
name=shelf-reader version=0.1 \
-a admin:password
```

Unyanking creates a new repository version with the yank marker removed.
Unyanking a version that is not yanked is a no-op.

### Syncing yanked packages

When syncing from a remote that has yanked packages (e.g. PyPI), the yank status is preserved automatically.
Pulp creates a yank marker for each yanked version and includes it in the repository version.

### Viewing yank status

Yank status is visible in the Simple API and the PyPI Metadata API.

Yank markers can also be listed via the REST API:

```bash
# List all yank markers
http GET http://localhost:5001/pulp/default/api/v3/content/python/yanks/ -a admin:password

# List yank markers for a specific repository version
http GET http://localhost:5001/pulp/default/api/v3/content/python/yanks/?repository_version=<repo-version-href> -a admin:password
```

## Package Substitution

By default, Python repositories allow package substitution: uploading, syncing, or adding a package
Expand Down
50 changes: 50 additions & 0 deletions pulp_python/app/migrations/0023_packageyank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Generated by Django 5.2.16 on 2026-07-30 10:44

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", "yanked_reason", "_pulp_domain")
},
},
bases=("core.content",),
),
]
30 changes: 27 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,32 @@ 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)

def __str__(self):
return f"<{self._meta.object_name}: {self.name_normalized} [{self.version}]>"

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


class PythonPublication(Publication, AutoAddObjPermsMixin):
"""
A Publication for PythonContent.
Expand Down Expand Up @@ -364,7 +388,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 @@ -136,3 +136,24 @@ class PackageUploadTaskSerializer(serializers.Serializer):
session = serializers.CharField(allow_null=True)
task = serializers.CharField()
task_start_time = serializers.DateTimeField(allow_null=True)


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="",
)
84 changes: 84 additions & 0 deletions pulp_python/app/pypi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from pulp_python.app.cache import PythonApiCache, find_base_path_cached
from pulp_python.app.models import (
PackageProvenance,
PackageYank,
PythonDistribution,
PythonPackageContent,
PythonPublication,
Expand All @@ -46,6 +47,7 @@
PackageUploadSerializer,
PackageUploadTaskSerializer,
SummarySerializer,
YankSerializer,
)
from pulp_python.app.utils import (
PYPI_LAST_SERIAL,
Expand Down Expand Up @@ -356,6 +358,8 @@ def parse_package(release_package):
"upload_time": release_package.upload_time,
"version": release_package.version,
"provenance": release_package.provenance_url,
"yanked": release_package.is_yanked,
"yanked_reason": release_package.yanked_reason or "",
}

rfilter = get_remote_package_filter(remote)
Expand Down Expand Up @@ -408,6 +412,11 @@ def retrieve(self, request, path, package):
"version",
"has_provenance",
)
yank_markers = dict(
PackageYank.objects.filter(
pk__in=repo_ver.content, name_normalized=normalized
).values_list("version", "yanked_reason")
)
local_releases = {
p["filename"]: {
**p,
Expand All @@ -418,6 +427,8 @@ def retrieve(self, request, path, package):
if p["has_provenance"]
else None
),
"yanked": p["version"] in yank_markers,
"yanked_reason": yank_markers.get(p["version"], ""),
}
for p in packages
}
Expand Down Expand Up @@ -493,12 +504,18 @@ def retrieve(self, request, path, meta):
headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT)}
if settings.DOMAIN_ENABLED:
domain = get_domain()
yank_markers = dict(
PackageYank.objects.filter(
pk__in=repo_ver.content, name_normalized=normalized
).values_list("version", "yanked_reason")
)
json_body = python_content_to_json(
path,
package_content,
version=version,
domain=domain,
repository_version=repo_ver,
yank_markers=yank_markers,
)
if json_body:
return Response(data=json_body, headers=headers)
Expand Down Expand Up @@ -586,3 +603,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"],
"yanked_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 @@ -683,6 +683,25 @@ class Meta:
model = python_models.PackageProvenance


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 MultipleChoiceArrayField(serializers.MultipleChoiceField):
"""
A wrapper to make sure this DRF serializer works properly with ArrayFields.
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
10 changes: 10 additions & 0 deletions pulp_python/app/tasks/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from pulp_python.app.exceptions import UnsupportedProtocolError
from pulp_python.app.models import (
PackageProvenance,
PackageYank,
PythonPackageContent,
PythonRemote,
)
Expand Down Expand Up @@ -265,6 +266,15 @@ async def create_content(self, pkg):
)
d_artifacts.append(metadata_artifact)

if upstream_pkg.is_yanked:
yank_marker = PackageYank(
name_normalized=pkg.name,
version=version,
yanked_reason=upstream_pkg.yanked_reason or "",
)
yank_dc = DeclarativeContent(content=yank_marker, d_artifacts=[])
await self.python_stage.put(yank_dc)

dc = DeclarativeContent(content=package, d_artifacts=d_artifacts)
declared_contents[entry["filename"]] = dc
await self.python_stage.put(dc)
Expand Down
Loading
Loading