pulp-python 3.32.1__tar.gz → 3.33.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {pulp_python-3.32.1 → pulp_python-3.33.0}/CHANGES.md +18 -0
  2. {pulp_python-3.32.1 → pulp_python-3.33.0}/PKG-INFO +1 -1
  3. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/__init__.py +1 -1
  4. pulp_python-3.33.0/pulp_python/app/migrations/0023_packageyank.py +50 -0
  5. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/models.py +27 -3
  6. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/pypi/serializers.py +21 -0
  7. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/pypi/views.py +93 -0
  8. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/serializers.py +19 -0
  9. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/tasks/__init__.py +1 -0
  10. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/tasks/sync.py +10 -0
  11. pulp_python-3.33.0/pulp_python/app/tasks/yank.py +64 -0
  12. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/urls.py +3 -0
  13. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/utils.py +42 -23
  14. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/viewsets.py +47 -0
  15. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +21 -2
  16. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_full_mirror.py +1 -1
  17. pulp_python-3.33.0/pulp_python/tests/functional/api/test_version_specifier_filter.py +28 -0
  18. pulp_python-3.33.0/pulp_python/tests/functional/api/test_yank.py +306 -0
  19. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python.egg-info/PKG-INFO +1 -1
  20. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python.egg-info/SOURCES.txt +4 -0
  21. {pulp_python-3.32.1 → pulp_python-3.33.0}/pyproject.toml +2 -2
  22. {pulp_python-3.32.1 → pulp_python-3.33.0}/COMMITMENT +0 -0
  23. {pulp_python-3.32.1 → pulp_python-3.33.0}/COPYRIGHT +0 -0
  24. {pulp_python-3.32.1 → pulp_python-3.33.0}/LICENSE +0 -0
  25. {pulp_python-3.32.1 → pulp_python-3.33.0}/MANIFEST.in +0 -0
  26. {pulp_python-3.32.1 → pulp_python-3.33.0}/README.md +0 -0
  27. {pulp_python-3.32.1 → pulp_python-3.33.0}/functest_requirements.txt +0 -0
  28. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/__init__.py +0 -0
  29. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/cache.py +0 -0
  30. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/exceptions.py +0 -0
  31. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/global_access_conditions.py +0 -0
  32. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/management/__init__.py +0 -0
  33. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/management/commands/__init__.py +0 -0
  34. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/management/commands/repair-python-metadata.py +0 -0
  35. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0001_initial.py +0 -0
  36. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  37. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  38. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  39. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  40. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  41. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  42. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  43. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  44. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  45. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  46. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  47. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  48. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  49. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  50. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  51. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +0 -0
  52. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0017_pythonpackagecontent_size.py +0 -0
  53. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0018_packageprovenance.py +0 -0
  54. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0019_create_missing_metadata_artifacts.py +0 -0
  55. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0020_pythonpackagecontent_name_normalized.py +0 -0
  56. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0021_pythonrepository_upload_duplicate_filenames.py +0 -0
  57. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/0022_pythonblocklistentry.py +0 -0
  58. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/migrations/__init__.py +0 -0
  59. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/modelresource.py +0 -0
  60. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/provenance.py +0 -0
  61. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/pypi/__init__.py +0 -0
  62. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/replica.py +0 -0
  63. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/settings.py +0 -0
  64. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/tasks/publish.py +0 -0
  65. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/tasks/repair.py +0 -0
  66. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/tasks/upload.py +0 -0
  67. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/tasks/vulnerability_report.py +0 -0
  68. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  69. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  70. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  71. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/pytest_plugin.py +0 -0
  72. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/__init__.py +0 -0
  73. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/__init__.py +0 -0
  74. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/__init__.py +0 -0
  75. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_attestations.py +0 -0
  76. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  77. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_blocklist.py +0 -0
  78. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
  79. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  80. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  81. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_domains.py +0 -0
  82. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  83. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_export_import.py +0 -0
  84. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_pypi_apis.py +0 -0
  85. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_pypi_simple_api.py +0 -0
  86. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  87. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_repair.py +0 -0
  88. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_simple_cache.py +0 -0
  89. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_sync.py +0 -0
  90. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_upload.py +0 -0
  91. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/api/test_vulnerability_report.py +0 -0
  92. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/constants.py +0 -0
  93. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/functional/utils.py +0 -0
  94. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/unit/__init__.py +0 -0
  95. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python/tests/unit/test_models.py +0 -0
  96. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python.egg-info/dependency_links.txt +0 -0
  97. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python.egg-info/entry_points.txt +0 -0
  98. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python.egg-info/requires.txt +0 -0
  99. {pulp_python-3.32.1 → pulp_python-3.33.0}/pulp_python.egg-info/top_level.txt +0 -0
  100. {pulp_python-3.32.1 → pulp_python-3.33.0}/setup.cfg +0 -0
  101. {pulp_python-3.32.1 → pulp_python-3.33.0}/test_requirements.txt +0 -0
  102. {pulp_python-3.32.1 → pulp_python-3.33.0}/unittest_requirements.txt +0 -0
@@ -8,6 +8,24 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.33.0 (2026-08-07) {: #3.33.0 }
12
+
13
+ #### Features {: #3.33.0-feature }
14
+
15
+ - Added yank support (PEP 592).
16
+ [#1270](https://github.com/pulp/pulp_python/issues/1270)
17
+ - Added `version_specifier` filter to package content API for PEP 440 version matching.
18
+ [#1277](https://github.com/pulp/pulp_python/issues/1277)
19
+ - Added support for uploading packages with Core Metadata 2.5 via twine.
20
+ [#1321](https://github.com/pulp/pulp_python/issues/1321)
21
+
22
+ #### Bugfixes {: #3.33.0-bugfix }
23
+
24
+ - Fixed Simple API cache incorrectly serving JSON responses for HTML requests when clients used `?format=json`.
25
+ [#1302](https://github.com/pulp/pulp_python/issues/1302)
26
+
27
+ ---
28
+
11
29
  ## 3.32.1 (2026-07-29) {: #3.32.1 }
12
30
 
13
31
  #### Bugfixes {: #3.32.1-bugfix }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.32.1
3
+ Version: 3.33.0
4
4
  Summary: pulp-python plugin for the Pulp Project
5
5
  Author-email: Pulp Team <pulp-list@redhat.com>
6
6
  Project-URL: Homepage, https://pulpproject.org
@@ -12,7 +12,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
12
12
 
13
13
  name = "pulp_python.app"
14
14
  label = "python"
15
- version = "3.32.1"
15
+ version = "3.33.0"
16
16
  python_package_name = "pulp-python"
17
17
  domain_compatible = True
18
18
 
@@ -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
+ ]
@@ -204,8 +204,6 @@ class PythonPackageContent(Content):
204
204
  sha256 = models.CharField(db_index=True, max_length=64)
205
205
  metadata_sha256 = models.CharField(max_length=64, null=True)
206
206
  size = models.BigIntegerField(default=0)
207
- # yanked and yanked_reason are not implemented because they are mutable
208
-
209
207
  # From pulpcore
210
208
  PROTECTED_FROM_RECLAIM = False
211
209
  TYPE = "python"
@@ -289,6 +287,32 @@ class PackageProvenance(Content):
289
287
  unique_together = ("sha256", "_pulp_domain")
290
288
 
291
289
 
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
+
292
316
  class PythonPublication(Publication, AutoAddObjPermsMixin):
293
317
  """
294
318
  A Publication for PythonContent.
@@ -364,7 +388,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
364
388
  """
365
389
 
366
390
  TYPE = "python"
367
- CONTENT_TYPES = [PythonPackageContent, PackageProvenance]
391
+ CONTENT_TYPES = [PythonPackageContent, PackageProvenance, PackageYank]
368
392
  REMOTE_TYPES = [PythonRemote]
369
393
  PULL_THROUGH_SUPPORTED = True
370
394
 
@@ -136,3 +136,24 @@ class PackageUploadTaskSerializer(serializers.Serializer):
136
136
  session = serializers.CharField(allow_null=True)
137
137
  task = serializers.CharField()
138
138
  task_start_time = serializers.DateTimeField(allow_null=True)
139
+
140
+
141
+ class YankSerializer(serializers.Serializer):
142
+ """
143
+ A Serializer for yank/unyank requests (PEP 592).
144
+ """
145
+
146
+ name = serializers.CharField(
147
+ help_text=_("The name of the package to yank or unyank."),
148
+ required=True,
149
+ )
150
+ version = serializers.CharField(
151
+ help_text=_("The version of the package to yank or unyank."),
152
+ required=True,
153
+ )
154
+ yanked_reason = serializers.CharField(
155
+ help_text=_("The reason for yanking the package version."),
156
+ required=False,
157
+ allow_blank=True,
158
+ default="",
159
+ )
@@ -29,6 +29,7 @@ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, Templat
29
29
  from rest_framework.response import Response
30
30
  from rest_framework.viewsets import ViewSet
31
31
 
32
+ from pulpcore.plugin.serializers import AsyncOperationResponseSerializer
32
33
  from pulpcore.plugin.tasking import dispatch
33
34
  from pulpcore.plugin.util import get_domain, get_url
34
35
  from pulpcore.plugin.viewsets import OperationPostponedResponse
@@ -37,6 +38,7 @@ from pulp_python.app import tasks
37
38
  from pulp_python.app.cache import PythonApiCache, find_base_path_cached
38
39
  from pulp_python.app.models import (
39
40
  PackageProvenance,
41
+ PackageYank,
40
42
  PythonDistribution,
41
43
  PythonPackageContent,
42
44
  PythonPublication,
@@ -46,6 +48,7 @@ from pulp_python.app.pypi.serializers import (
46
48
  PackageUploadSerializer,
47
49
  PackageUploadTaskSerializer,
48
50
  SummarySerializer,
51
+ YankSerializer,
49
52
  )
50
53
  from pulp_python.app.utils import (
51
54
  PYPI_LAST_SERIAL,
@@ -356,6 +359,8 @@ class SimpleView(PackageUploadMixin, ViewSet):
356
359
  "upload_time": release_package.upload_time,
357
360
  "version": release_package.version,
358
361
  "provenance": release_package.provenance_url,
362
+ "yanked": release_package.is_yanked,
363
+ "yanked_reason": release_package.yanked_reason or "",
359
364
  }
360
365
 
361
366
  rfilter = get_remote_package_filter(remote)
@@ -408,6 +413,11 @@ class SimpleView(PackageUploadMixin, ViewSet):
408
413
  "version",
409
414
  "has_provenance",
410
415
  )
416
+ yank_markers = dict(
417
+ PackageYank.objects.filter(
418
+ pk__in=repo_ver.content, name_normalized=normalized
419
+ ).values_list("version", "yanked_reason")
420
+ )
411
421
  local_releases = {
412
422
  p["filename"]: {
413
423
  **p,
@@ -418,6 +428,8 @@ class SimpleView(PackageUploadMixin, ViewSet):
418
428
  if p["has_provenance"]
419
429
  else None
420
430
  ),
431
+ "yanked": p["version"] in yank_markers,
432
+ "yanked_reason": yank_markers.get(p["version"], ""),
421
433
  }
422
434
  for p in packages
423
435
  }
@@ -493,12 +505,18 @@ class MetadataView(PyPIMixin, ViewSet):
493
505
  headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT)}
494
506
  if settings.DOMAIN_ENABLED:
495
507
  domain = get_domain()
508
+ yank_markers = dict(
509
+ PackageYank.objects.filter(
510
+ pk__in=repo_ver.content, name_normalized=normalized
511
+ ).values_list("version", "yanked_reason")
512
+ )
496
513
  json_body = python_content_to_json(
497
514
  path,
498
515
  package_content,
499
516
  version=version,
500
517
  domain=domain,
501
518
  repository_version=repo_ver,
519
+ yank_markers=yank_markers,
502
520
  )
503
521
  if json_body:
504
522
  return Response(data=json_body, headers=headers)
@@ -586,3 +604,78 @@ class ProvenanceView(PyPIMixin, ViewSet):
586
604
  if provenance:
587
605
  return Response(data=provenance.provenance)
588
606
  return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")
607
+
608
+
609
+ class YankView(PyPIMixin, ViewSet):
610
+ """View for yank/unyank requests (PEP 592)."""
611
+
612
+ endpoint_name = "yank"
613
+ DEFAULT_ACCESS_POLICY = {
614
+ "statements": [
615
+ {
616
+ "action": ["yank", "unyank"],
617
+ "principal": "authenticated",
618
+ "effect": "allow",
619
+ "condition": "index_has_repo_perm:python.modify_pythonrepository",
620
+ },
621
+ ],
622
+ }
623
+
624
+ @extend_schema(
625
+ request=YankSerializer,
626
+ responses={202: AsyncOperationResponseSerializer},
627
+ summary="Yank a package version",
628
+ )
629
+ def yank(self, request, path):
630
+ """Yank a package version, marking all its files with data-yanked."""
631
+ repo = self.distribution.repository
632
+ if not repo:
633
+ return HttpResponseBadRequest(reason="Index is not pointing to a repository")
634
+
635
+ serializer = YankSerializer(data=request.data)
636
+ serializer.is_valid(raise_exception=True)
637
+
638
+ normalized = canonicalize_name(serializer.validated_data["name"])
639
+ version = serializer.validated_data["version"]
640
+ repo_ver = self.get_repository_version(self.distribution)
641
+ if not PythonPackageContent.objects.filter(
642
+ pk__in=repo_ver.content, name_normalized=normalized, version=version
643
+ ).exists():
644
+ return HttpResponseNotFound(f"{normalized}=={version} not found in repository")
645
+
646
+ result = dispatch(
647
+ tasks.ayank_package,
648
+ exclusive_resources=[repo],
649
+ kwargs={
650
+ "repository_pk": str(repo.pk),
651
+ "name": serializer.validated_data["name"],
652
+ "version": serializer.validated_data["version"],
653
+ "yanked_reason": serializer.validated_data.get("yanked_reason", ""),
654
+ },
655
+ )
656
+ return OperationPostponedResponse(result, request)
657
+
658
+ @extend_schema(
659
+ request=YankSerializer,
660
+ responses={202: AsyncOperationResponseSerializer},
661
+ summary="Unyank a package version",
662
+ )
663
+ def unyank(self, request, path):
664
+ """Unyank a package version, unmarking all its files with data-yanked."""
665
+ repo = self.distribution.repository
666
+ if not repo:
667
+ return HttpResponseBadRequest(reason="Index is not pointing to a repository")
668
+
669
+ serializer = YankSerializer(data=request.data)
670
+ serializer.is_valid(raise_exception=True)
671
+
672
+ result = dispatch(
673
+ tasks.aunyank_package,
674
+ exclusive_resources=[repo],
675
+ kwargs={
676
+ "repository_pk": str(repo.pk),
677
+ "name": serializer.validated_data["name"],
678
+ "version": serializer.validated_data["version"],
679
+ },
680
+ )
681
+ return OperationPostponedResponse(result, request)
@@ -683,6 +683,25 @@ class PackageProvenanceSerializer(core_serializers.NoArtifactContentUploadSerial
683
683
  model = python_models.PackageProvenance
684
684
 
685
685
 
686
+ class PackageYankSerializer(core_serializers.NoArtifactContentSerializer):
687
+ """
688
+ Read-only serializer for PackageYank content units (PEP 592).
689
+ Used by PackageYankViewSet to expose yank markers via the Pulp REST API.
690
+ """
691
+
692
+ name_normalized = serializers.CharField(read_only=True)
693
+ version = serializers.CharField(read_only=True)
694
+ yanked_reason = serializers.CharField(read_only=True)
695
+
696
+ class Meta:
697
+ fields = core_serializers.NoArtifactContentSerializer.Meta.fields + (
698
+ "name_normalized",
699
+ "version",
700
+ "yanked_reason",
701
+ )
702
+ model = python_models.PackageYank
703
+
704
+
686
705
  class MultipleChoiceArrayField(serializers.MultipleChoiceField):
687
706
  """
688
707
  A wrapper to make sure this DRF serializer works properly with ArrayFields.
@@ -7,3 +7,4 @@ from .repair import repair # noqa:F401
7
7
  from .sync import sync # noqa:F401
8
8
  from .upload import upload, upload_group # noqa:F401
9
9
  from .vulnerability_report import get_repo_version_content # noqa:F401
10
+ from .yank import aunyank_package, ayank_package # noqa:F401
@@ -24,6 +24,7 @@ from pulpcore.plugin.stages import (
24
24
  from pulp_python.app.exceptions import UnsupportedProtocolError
25
25
  from pulp_python.app.models import (
26
26
  PackageProvenance,
27
+ PackageYank,
27
28
  PythonPackageContent,
28
29
  PythonRemote,
29
30
  )
@@ -265,6 +266,15 @@ class PulpMirror(Mirror):
265
266
  )
266
267
  d_artifacts.append(metadata_artifact)
267
268
 
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
+
268
278
  dc = DeclarativeContent(content=package, d_artifacts=d_artifacts)
269
279
  declared_contents[entry["filename"]] = dc
270
280
  await self.python_stage.put(dc)
@@ -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
+ existing_yank = await PackageYank.objects.filter(
25
+ pk__in=latest.content, name_normalized=normalized, version=version
26
+ ).afirst()
27
+ if existing_yank and existing_yank.yanked_reason == yanked_reason:
28
+ return
29
+
30
+ yank_marker, _ = await PackageYank.objects.aget_or_create(
31
+ name_normalized=normalized,
32
+ version=version,
33
+ yanked_reason=yanked_reason,
34
+ _pulp_domain_id=repository.pulp_domain_id,
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
+ )
@@ -7,6 +7,7 @@ from pulp_python.app.pypi.views import (
7
7
  PyPIView,
8
8
  SimpleView,
9
9
  UploadView,
10
+ YankView,
10
11
  )
11
12
 
12
13
  if settings.DOMAIN_ENABLED:
@@ -40,5 +41,7 @@ urlpatterns = [
40
41
  SimpleView.as_view({"get": "list", "post": "create"}),
41
42
  name="simple-detail",
42
43
  ),
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"),
43
46
  path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"),
44
47
  ]
@@ -30,7 +30,7 @@ log = logging.getLogger(__name__)
30
30
  PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
31
31
  """TODO This serial constant is temporary until Python repositories implements serials"""
32
32
  PYPI_SERIAL_CONSTANT = 1000000000
33
- SUPPORTED_METADATA_VERSIONS = ("1.0", "1.1", "1.2", "2.0", "2.1", "2.2", "2.3", "2.4")
33
+ SUPPORTED_METADATA_VERSIONS = ("1.0", "1.1", "1.2", "2.0", "2.1", "2.2", "2.3", "2.4", "2.5")
34
34
 
35
35
  SIMPLE_API_VERSION = "1.1"
36
36
  PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
@@ -50,7 +50,6 @@ simple_index_template = """<!DOCTYPE html>
50
50
  </html>
51
51
  """
52
52
 
53
- # TODO in the future: data-yanked (not implemented yet because it is mutable)
54
53
  simple_detail_template = """<!DOCTYPE html>
55
54
  <html>
56
55
  <head>
@@ -62,6 +61,7 @@ simple_detail_template = """<!DOCTYPE html>
62
61
  {%- for pkg in project_packages %}
63
62
  <a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal"
64
63
  {%- if pkg.requires_python %} data-requires-python="{{ pkg.requires_python }}" {%- endif %}
64
+ {%- if pkg.yanked %} data-yanked="{{ pkg.yanked_reason }}" {%- endif %}
65
65
  {%- if pkg.metadata_sha256 %} data-dist-info-metadata="sha256={{ pkg.metadata_sha256 }}" data-core-metadata="sha256={{ pkg.metadata_sha256 }}"
66
66
  {%- endif %} {% if pkg.provenance -%}
67
67
  data-provenance="{{ pkg.provenance }}"{% endif %}>{{ pkg.filename }}</a><br/>
@@ -362,7 +362,12 @@ def fetch_json_release_metadata(name: str, version: str, remotes: set[Remote]) -
362
362
 
363
363
 
364
364
  def python_content_to_json(
365
- base_path, content_query, version=None, domain=None, repository_version=None
365
+ base_path,
366
+ content_query,
367
+ version=None,
368
+ domain=None,
369
+ repository_version=None,
370
+ yank_markers=None,
366
371
  ):
367
372
  """
368
373
  Converts a QuerySet of PythonPackageContent into the PyPi JSON format
@@ -375,6 +380,8 @@ def python_content_to_json(
375
380
 
376
381
  Returns None if version is specified but not found within content_query
377
382
  """
383
+ if yank_markers is None:
384
+ yank_markers = {}
378
385
  if repository_version:
379
386
  content_query = content_query.annotate(
380
387
  active_membership=FilteredRelation(
@@ -386,25 +393,32 @@ def python_content_to_json(
386
393
  ),
387
394
  repo_added_time=F("active_membership__pulp_created"),
388
395
  )
389
- full_metadata = {"last_serial": 0} # For now the serial field isn't supported by Pulp
390
- latest_content = latest_content_version(content_query, version)
396
+
397
+ all_content = list(content_query)
398
+ for content in all_content:
399
+ content.yanked = content.version in yank_markers
400
+ content.yanked_reason = yank_markers.get(content.version)
401
+
402
+ latest_content = latest_content_version(all_content, version)
391
403
  if not latest_content:
392
404
  return None
393
- full_metadata.update({"info": python_content_to_info(latest_content[0])})
394
- full_metadata.update({"releases": python_content_to_releases(content_query, base_path, domain)})
395
- full_metadata.update({"urls": python_content_to_urls(latest_content, base_path, domain)})
405
+
406
+ full_metadata = {"last_serial": 0} # For now the serial field isn't supported by Pulp
407
+ full_metadata["info"] = python_content_to_info(latest_content[0])
408
+ full_metadata["releases"] = python_content_to_releases(all_content, base_path, domain)
409
+ full_metadata["urls"] = python_content_to_urls(latest_content, base_path, domain)
396
410
  return full_metadata
397
411
 
398
412
 
399
- def latest_content_version(content_query, version):
413
+ def latest_content_version(all_content, version):
400
414
  """
401
- Walks through the content QuerySet and finds the instances that is the latest version.
415
+ Walks through the content list and finds the instances that are the latest version.
402
416
  If 'version' is specified, the function instead tries to find content instances
403
417
  with that version and will return an empty list if nothing is found
404
418
  """
405
419
  latest_version = version
406
420
  latest_content = []
407
- for content in content_query:
421
+ for content in all_content:
408
422
  if version and parse(version) == parse(content.version):
409
423
  latest_content.append(content)
410
424
  elif not latest_version or parse(content.version) > parse(latest_version):
@@ -462,8 +476,8 @@ def python_content_to_info(content):
462
476
  "platform": content.platform or "",
463
477
  "requires_dist": json_to_dict(content.requires_dist) or None,
464
478
  "classifiers": json_to_dict(content.classifiers) or None,
465
- "yanked": False, # These are no longer used on PyPI, but are still present
466
- "yanked_reason": None,
479
+ "yanked": getattr(content, "yanked", False),
480
+ "yanked_reason": getattr(content, "yanked_reason", None),
467
481
  # New core metadata (Version 2.1, 2.2, 2.4)
468
482
  "provides_extras": json_to_dict(content.provides_extras) or None,
469
483
  "dynamic": json_to_dict(content.dynamic) or None,
@@ -472,13 +486,13 @@ def python_content_to_info(content):
472
486
  }
473
487
 
474
488
 
475
- def python_content_to_releases(content_query, base_path, domain=None):
489
+ def python_content_to_releases(all_content, base_path, domain=None):
476
490
  """
477
- Takes a QuerySet of PythonPackageContent and returns a dictionary of releases
491
+ Takes a list of PythonPackageContent and returns a dictionary of releases
478
492
  with each key being a version and value being a list of content for that version of the package
479
493
  """
480
494
  releases = defaultdict(lambda: [])
481
- for content in content_query:
495
+ for content in all_content:
482
496
  releases[content.version].append(
483
497
  python_content_to_download_info(content, base_path, domain)
484
498
  )
@@ -535,22 +549,22 @@ def python_content_to_download_info(content, base_path, domain=None):
535
549
  (getattr(content, "repo_added_time", None) or content.pulp_created).isoformat()
536
550
  ),
537
551
  "url": url,
538
- "yanked": False,
539
- "yanked_reason": None,
552
+ "yanked": getattr(content, "yanked", False),
553
+ "yanked_reason": getattr(content, "yanked_reason", None),
540
554
  }
541
555
 
542
556
 
543
- def write_simple_index(project_names, streamed=False):
557
+ def write_simple_index(project_names):
544
558
  """Writes the simple index."""
545
559
  simple = Template(simple_index_template)
546
560
  context = {
547
561
  "SIMPLE_API_VERSION": SIMPLE_API_VERSION,
548
562
  "projects": ((x, canonicalize_name(x)) for x in project_names),
549
563
  }
550
- return simple.stream(**context) if streamed else simple.render(**context)
564
+ return simple.render(**context)
551
565
 
552
566
 
553
- def write_simple_detail(project_name, project_packages, streamed=False):
567
+ def write_simple_detail(project_name, project_packages):
554
568
  """Writes the simple detail page of a package."""
555
569
  detail = Template(simple_detail_template, autoescape=True)
556
570
  context = {
@@ -558,7 +572,7 @@ def write_simple_detail(project_name, project_packages, streamed=False):
558
572
  "project_name": project_name,
559
573
  "project_packages": project_packages,
560
574
  }
561
- return detail.stream(**context) if streamed else detail.render(**context)
575
+ return detail.render(**context)
562
576
 
563
577
 
564
578
  def write_simple_index_json(project_names):
@@ -591,7 +605,12 @@ def write_simple_detail_json(project_name, project_packages):
591
605
  "core-metadata": (
592
606
  {"sha256": package["metadata_sha256"]} if package["metadata_sha256"] else False
593
607
  ),
594
- # yanked and yanked_reason are not implemented because they are mutable
608
+ # PEP 592
609
+ "yanked": (
610
+ package.get("yanked_reason")
611
+ if package.get("yanked") and package.get("yanked_reason")
612
+ else package.get("yanked", False)
613
+ ),
595
614
  # (v1.1, PEP 700)
596
615
  "size": package["size"],
597
616
  "upload-time": format_upload_time(package["upload_time"]),