pulp-python 3.32.1__tar.gz → 3.34.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 (103) hide show
  1. {pulp_python-3.32.1 → pulp_python-3.34.0}/CHANGES.md +27 -0
  2. {pulp_python-3.32.1 → pulp_python-3.34.0}/PKG-INFO +1 -1
  3. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/__init__.py +1 -1
  4. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/management/commands/repair-python-metadata.py +1 -1
  5. pulp_python-3.34.0/pulp_python/app/migrations/0023_packageyank.py +50 -0
  6. pulp_python-3.34.0/pulp_python/app/migrations/0024_pythonrepository_error_on_reject.py +16 -0
  7. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/models.py +111 -27
  8. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/pypi/serializers.py +21 -0
  9. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/pypi/views.py +93 -0
  10. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/serializers.py +31 -0
  11. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/tasks/__init__.py +1 -0
  12. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/tasks/sync.py +10 -0
  13. pulp_python-3.34.0/pulp_python/app/tasks/yank.py +64 -0
  14. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/urls.py +3 -0
  15. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/utils.py +42 -23
  16. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/viewsets.py +68 -13
  17. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_blocklist.py +51 -1
  18. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +77 -2
  19. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_domains.py +1 -1
  20. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_full_mirror.py +1 -1
  21. pulp_python-3.34.0/pulp_python/tests/functional/api/test_version_specifier_filter.py +28 -0
  22. pulp_python-3.34.0/pulp_python/tests/functional/api/test_yank.py +306 -0
  23. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python.egg-info/PKG-INFO +1 -1
  24. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python.egg-info/SOURCES.txt +5 -0
  25. {pulp_python-3.32.1 → pulp_python-3.34.0}/pyproject.toml +2 -2
  26. {pulp_python-3.32.1 → pulp_python-3.34.0}/COMMITMENT +0 -0
  27. {pulp_python-3.32.1 → pulp_python-3.34.0}/COPYRIGHT +0 -0
  28. {pulp_python-3.32.1 → pulp_python-3.34.0}/LICENSE +0 -0
  29. {pulp_python-3.32.1 → pulp_python-3.34.0}/MANIFEST.in +0 -0
  30. {pulp_python-3.32.1 → pulp_python-3.34.0}/README.md +0 -0
  31. {pulp_python-3.32.1 → pulp_python-3.34.0}/functest_requirements.txt +0 -0
  32. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/__init__.py +0 -0
  33. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/cache.py +0 -0
  34. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/exceptions.py +0 -0
  35. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/global_access_conditions.py +0 -0
  36. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/management/__init__.py +0 -0
  37. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/management/commands/__init__.py +0 -0
  38. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0001_initial.py +0 -0
  39. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  40. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  41. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  42. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  43. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  44. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  45. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  46. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  47. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  48. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  49. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  50. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  51. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  52. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  53. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  54. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +0 -0
  55. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0017_pythonpackagecontent_size.py +0 -0
  56. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0018_packageprovenance.py +0 -0
  57. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0019_create_missing_metadata_artifacts.py +0 -0
  58. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0020_pythonpackagecontent_name_normalized.py +0 -0
  59. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0021_pythonrepository_upload_duplicate_filenames.py +0 -0
  60. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/0022_pythonblocklistentry.py +0 -0
  61. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/migrations/__init__.py +0 -0
  62. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/modelresource.py +0 -0
  63. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/provenance.py +0 -0
  64. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/pypi/__init__.py +0 -0
  65. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/replica.py +0 -0
  66. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/settings.py +0 -0
  67. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/tasks/publish.py +0 -0
  68. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/tasks/repair.py +0 -0
  69. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/tasks/upload.py +0 -0
  70. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/tasks/vulnerability_report.py +0 -0
  71. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  72. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  73. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  74. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/pytest_plugin.py +0 -0
  75. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/__init__.py +0 -0
  76. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/__init__.py +0 -0
  77. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/__init__.py +0 -0
  78. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_attestations.py +0 -0
  79. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  80. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
  81. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  82. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  83. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  84. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_export_import.py +0 -0
  85. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_pypi_apis.py +0 -0
  86. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_pypi_simple_api.py +0 -0
  87. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  88. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_repair.py +0 -0
  89. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_simple_cache.py +0 -0
  90. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_sync.py +0 -0
  91. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_upload.py +0 -0
  92. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/api/test_vulnerability_report.py +0 -0
  93. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/constants.py +0 -0
  94. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/functional/utils.py +0 -0
  95. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/unit/__init__.py +0 -0
  96. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python/tests/unit/test_models.py +0 -0
  97. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python.egg-info/dependency_links.txt +0 -0
  98. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python.egg-info/entry_points.txt +0 -0
  99. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python.egg-info/requires.txt +0 -0
  100. {pulp_python-3.32.1 → pulp_python-3.34.0}/pulp_python.egg-info/top_level.txt +0 -0
  101. {pulp_python-3.32.1 → pulp_python-3.34.0}/setup.cfg +0 -0
  102. {pulp_python-3.32.1 → pulp_python-3.34.0}/test_requirements.txt +0 -0
  103. {pulp_python-3.32.1 → pulp_python-3.34.0}/unittest_requirements.txt +0 -0
@@ -8,6 +8,33 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.34.0 (2026-08-18) {: #3.34.0 }
12
+
13
+ #### Features {: #3.34.0-feature }
14
+
15
+ - Added an `error_on_reject` boolean field to PythonRepository (default: `True`). When `False`, packages rejected by the blocklist or package substitution policies are skipped instead of failing the entire repository version.
16
+ [#1278](https://github.com/pulp/pulp_python/issues/1278)
17
+
18
+ ---
19
+
20
+ ## 3.33.0 (2026-08-07) {: #3.33.0 }
21
+
22
+ #### Features {: #3.33.0-feature }
23
+
24
+ - Added yank support (PEP 592).
25
+ [#1270](https://github.com/pulp/pulp_python/issues/1270)
26
+ - Added `version_specifier` filter to package content API for PEP 440 version matching.
27
+ [#1277](https://github.com/pulp/pulp_python/issues/1277)
28
+ - Added support for uploading packages with Core Metadata 2.5 via twine.
29
+ [#1321](https://github.com/pulp/pulp_python/issues/1321)
30
+
31
+ #### Bugfixes {: #3.33.0-bugfix }
32
+
33
+ - Fixed Simple API cache incorrectly serving JSON responses for HTML requests when clients used `?format=json`.
34
+ [#1302](https://github.com/pulp/pulp_python/issues/1302)
35
+
36
+ ---
37
+
11
38
  ## 3.32.1 (2026-07-29) {: #3.32.1 }
12
39
 
13
40
  #### 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.34.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.34.0"
16
16
  python_package_name = "pulp-python"
17
17
  domain_compatible = True
18
18
 
@@ -59,7 +59,7 @@ def href_prn_list_handler(value):
59
59
  """Common list parsing for a string of hrefs/prns."""
60
60
  r = re.compile(
61
61
  rf"""
62
- (?:{settings.API_ROOT}(?:[-_a-zA-Z0-9]+/)?api/v3/repositories/python/python/[-a-f0-9]+/)
62
+ (?:{settings.API_ROOT}(?:[-_a-zA-Z0-9]+/)?api/v\d+/repositories/python/python/[-a-f0-9]+/)
63
63
  |(?:prn:python\.pythonrepository:[-a-f0-9]+)
64
64
  """,
65
65
  re.VERBOSE,
@@ -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
+ ]
@@ -0,0 +1,16 @@
1
+ from django.db import migrations, models
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ dependencies = [
7
+ ("python", "0023_packageyank"),
8
+ ]
9
+
10
+ operations = [
11
+ migrations.AddField(
12
+ model_name="pythonrepository",
13
+ name="error_on_reject",
14
+ field=models.BooleanField(default=True),
15
+ ),
16
+ ]
@@ -19,6 +19,7 @@ from pulpcore.plugin.models import (
19
19
  BaseModel,
20
20
  Content,
21
21
  Distribution,
22
+ ProgressReport,
22
23
  Publication,
23
24
  Remote,
24
25
  Repository,
@@ -204,8 +205,6 @@ class PythonPackageContent(Content):
204
205
  sha256 = models.CharField(db_index=True, max_length=64)
205
206
  metadata_sha256 = models.CharField(max_length=64, null=True)
206
207
  size = models.BigIntegerField(default=0)
207
- # yanked and yanked_reason are not implemented because they are mutable
208
-
209
208
  # From pulpcore
210
209
  PROTECTED_FROM_RECLAIM = False
211
210
  TYPE = "python"
@@ -289,6 +288,32 @@ class PackageProvenance(Content):
289
288
  unique_together = ("sha256", "_pulp_domain")
290
289
 
291
290
 
291
+ class PackageYank(Content):
292
+ """
293
+ A marker content type indicating a package version is yanked in a repository (PEP 592).
294
+
295
+ Its presence in a repository version means all files for the matching
296
+ (name_normalized, version) pair are yanked. Yank/unyank operations
297
+ add/remove this marker, creating new repository versions.
298
+ """
299
+
300
+ TYPE = "python_yank"
301
+ repo_key_fields = ("name_normalized", "version")
302
+
303
+ name_normalized = models.TextField()
304
+ version = models.TextField()
305
+ yanked_reason = models.TextField(default="")
306
+
307
+ _pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
308
+
309
+ def __str__(self):
310
+ return f"<{self._meta.object_name}: {self.name_normalized} [{self.version}]>"
311
+
312
+ class Meta:
313
+ default_related_name = "%(app_label)s_%(model_name)s"
314
+ unique_together = ("name_normalized", "version", "yanked_reason", "_pulp_domain")
315
+
316
+
292
317
  class PythonPublication(Publication, AutoAddObjPermsMixin):
293
318
  """
294
319
  A Publication for PythonContent.
@@ -364,12 +389,13 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
364
389
  """
365
390
 
366
391
  TYPE = "python"
367
- CONTENT_TYPES = [PythonPackageContent, PackageProvenance]
392
+ CONTENT_TYPES = [PythonPackageContent, PackageProvenance, PackageYank]
368
393
  REMOTE_TYPES = [PythonRemote]
369
394
  PULL_THROUGH_SUPPORTED = True
370
395
 
371
396
  autopublish = models.BooleanField(default=False)
372
397
  allow_package_substitution = models.BooleanField(default=True)
398
+ error_on_reject = models.BooleanField(default=True)
373
399
 
374
400
  class Meta:
375
401
  default_related_name = "%(app_label)s_%(model_name)s"
@@ -399,10 +425,9 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
399
425
  """
400
426
  Remove duplicate packages that have the same filename.
401
427
 
402
- When allow_package_substitution is False, reject any new version that would implicitly
403
- replace existing content with different checksums (content substitution).
404
-
405
- Also checks newly added content against the repository's blocklist entries.
428
+ Enforces package substitution and blocklist policies on newly added content.
429
+ When error_on_reject is True (default), a ValidationError is raised and the version
430
+ fails. When False, rejected packages are skipped and recorded in a progress report.
406
431
  """
407
432
  if not self.allow_package_substitution:
408
433
  self._check_for_package_substitution(new_version)
@@ -412,52 +437,111 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
412
437
 
413
438
  def _check_for_package_substitution(self, new_version):
414
439
  """
415
- Raise a ValidationError if newly added packages would replace existing packages
416
- that have the same filename but a different sha256 checksum.
440
+ Handle packages that would replace existing packages with the same filename but a
441
+ different sha256 checksum.
442
+
443
+ When error_on_reject is True, raise a ValidationError. When False, remove the
444
+ newly added conflicting packages from the version and record them in a progress report.
417
445
  """
418
446
  qs = PythonPackageContent.objects.filter(pk__in=new_version.content)
419
447
  duplicates = collect_duplicates(qs, ("filename",))
420
- if duplicates:
448
+ if not duplicates:
449
+ return
450
+
451
+ if self.error_on_reject:
421
452
  raise ValidationError(
422
453
  "Found duplicate packages being added with the same filename but different "
423
454
  "checksums. To allow this, set 'allow_package_substitution' to True on the "
424
455
  f"repository. Conflicting packages: {duplicates}"
425
456
  )
426
457
 
458
+ added_content = PythonPackageContent.objects.filter(
459
+ pk__in=new_version.added(base_version=new_version.base_version)
460
+ )
461
+ added_pks = {str(pkg.pk): pkg.filename for pkg in added_content.only("pk", "filename")}
462
+ to_remove_pks = []
463
+ messages = []
464
+ # Skip every newly added package in a conflicting filename group, including when
465
+ # multiple new packages share a filename and none of them remain in the version.
466
+ for dup in duplicates:
467
+ for pk in dup.duplicate_pks:
468
+ if pk in added_pks:
469
+ to_remove_pks.append(pk)
470
+ messages.append(f"{added_pks[pk]} ({pk})")
471
+
472
+ if to_remove_pks:
473
+ new_version.remove_content(PythonPackageContent.objects.filter(pk__in=to_remove_pks))
474
+ self._report_rejected_packages(
475
+ messages,
476
+ message="Skipping packages rejected by package substitution policy",
477
+ code="python.reject.substitution",
478
+ )
479
+
427
480
  def _check_blocklist(self, new_version):
428
481
  """
429
482
  Check newly added content in a repository version against the blocklist.
483
+
484
+ When error_on_reject is True, raise a ValidationError. When False, remove the
485
+ blocklisted packages from the version and record them in a progress report.
430
486
  """
431
487
  added_content = PythonPackageContent.objects.filter(
432
- pk__in=new_version.added().values_list("pk", flat=True)
433
- ).only("filename", "name_normalized", "version")
434
- if added_content.exists():
435
- self.check_blocklist_for_packages(added_content)
488
+ pk__in=new_version.added(base_version=new_version.base_version)
489
+ ).only("pk", "filename", "name_normalized", "version")
490
+ if not added_content.exists():
491
+ return
492
+
493
+ blocked = self.find_blocklisted_packages(added_content)
494
+ if not blocked:
495
+ return
496
+
497
+ if self.error_on_reject:
498
+ raise ValidationError(
499
+ "Blocklisted packages cannot be added to this repository: {}".format(
500
+ ", ".join(pkg.filename for pkg in blocked)
501
+ )
502
+ )
436
503
 
437
- def check_blocklist_for_packages(self, packages):
504
+ new_version.remove_content(
505
+ PythonPackageContent.objects.filter(pk__in=[p.pk for p in blocked])
506
+ )
507
+ self._report_rejected_packages(
508
+ [f"{pkg.filename} ({pkg.pk})" for pkg in blocked],
509
+ message="Skipping packages rejected by blocklist policy",
510
+ code="python.reject.blocklist",
511
+ )
512
+
513
+ def find_blocklisted_packages(self, packages):
438
514
  """
439
- Raise a ValidationError if any of the given packages match a blocklist entry.
515
+ Return the packages from `packages` that match a blocklist entry.
440
516
  """
441
- entries = PythonBlocklistEntry.objects.filter(repository=self)
442
- if not entries.exists():
443
- return
517
+ entries = list(PythonBlocklistEntry.objects.filter(repository=self))
518
+ if not entries:
519
+ return []
444
520
 
445
521
  blocked = []
446
522
  for pkg in packages:
447
523
  for entry in entries:
448
524
  if entry.filename and entry.filename == pkg.filename:
449
- blocked.append(pkg.filename)
525
+ blocked.append(pkg)
450
526
  break
451
527
  if entry.name == pkg.name_normalized:
452
528
  if not entry.version or entry.version == pkg.version:
453
- blocked.append(pkg.filename)
529
+ blocked.append(pkg)
454
530
  break
455
- if blocked:
456
- raise ValidationError(
457
- "Blocklisted packages cannot be added to this repository: {}".format(
458
- ", ".join(blocked)
459
- )
460
- )
531
+ return blocked
532
+
533
+ def _report_rejected_packages(self, details, message, code):
534
+ """
535
+ Record skipped packages in a task progress report.
536
+ """
537
+ log.info("%s (%s package(s))", message, len(details))
538
+ with ProgressReport(
539
+ message=message,
540
+ code=code,
541
+ total=len(details),
542
+ suffix="; ".join(details),
543
+ ) as pb:
544
+ pb.increase_by(len(details))
461
545
 
462
546
 
463
547
  class PythonBlocklistEntry(BaseModel):
@@ -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)
@@ -73,6 +73,17 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
73
73
  default=True,
74
74
  required=False,
75
75
  )
76
+ error_on_reject = serializers.BooleanField(
77
+ help_text=_(
78
+ "Whether to fail the entire repository version when packages are rejected by the "
79
+ "package substitution or blocklist policies. When True (the default), a ValidationError "
80
+ "is raised and no packages from the request are added. When False, rejected packages "
81
+ "are skipped and remaining packages are added; skipped packages are recorded in a "
82
+ "task progress report."
83
+ ),
84
+ default=True,
85
+ required=False,
86
+ )
76
87
 
77
88
  def get_blocklist_entries_href(self, obj):
78
89
  repo_href = reverse("repositories-python/python-detail", kwargs={"pk": obj.pk})
@@ -82,6 +93,7 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
82
93
  fields = core_serializers.RepositorySerializer.Meta.fields + (
83
94
  "autopublish",
84
95
  "allow_package_substitution",
96
+ "error_on_reject",
85
97
  "blocklist_entries_href",
86
98
  )
87
99
  model = python_models.PythonRepository
@@ -683,6 +695,25 @@ class PackageProvenanceSerializer(core_serializers.NoArtifactContentUploadSerial
683
695
  model = python_models.PackageProvenance
684
696
 
685
697
 
698
+ class PackageYankSerializer(core_serializers.NoArtifactContentSerializer):
699
+ """
700
+ Read-only serializer for PackageYank content units (PEP 592).
701
+ Used by PackageYankViewSet to expose yank markers via the Pulp REST API.
702
+ """
703
+
704
+ name_normalized = serializers.CharField(read_only=True)
705
+ version = serializers.CharField(read_only=True)
706
+ yanked_reason = serializers.CharField(read_only=True)
707
+
708
+ class Meta:
709
+ fields = core_serializers.NoArtifactContentSerializer.Meta.fields + (
710
+ "name_normalized",
711
+ "version",
712
+ "yanked_reason",
713
+ )
714
+ model = python_models.PackageYank
715
+
716
+
686
717
  class MultipleChoiceArrayField(serializers.MultipleChoiceField):
687
718
  """
688
719
  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
+ )