pulp-python 3.21.0__tar.gz → 3.22.1__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 (93) hide show
  1. {pulp_python-3.21.0 → pulp_python-3.22.1}/CHANGES.md +50 -0
  2. {pulp_python-3.21.0 → pulp_python-3.22.1}/MANIFEST.in +1 -0
  3. {pulp_python-3.21.0 → pulp_python-3.22.1}/PKG-INFO +3 -2
  4. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/__init__.py +1 -1
  5. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0017_pythonpackagecontent_size.py +1 -1
  6. pulp_python-3.22.1/pulp_python/app/migrations/0018_packageprovenance.py +59 -0
  7. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/models.py +46 -1
  8. pulp_python-3.22.1/pulp_python/app/provenance.py +71 -0
  9. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/pypi/serializers.py +47 -5
  10. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/pypi/views.py +78 -25
  11. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/serializers.py +141 -1
  12. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/tasks/sync.py +41 -5
  13. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/tasks/upload.py +60 -13
  14. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/urls.py +12 -1
  15. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/utils.py +46 -2
  16. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/viewsets.py +33 -0
  17. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/pytest_plugin.py +30 -0
  18. pulp_python-3.22.1/pulp_python/tests/functional/api/test_attestations.py +242 -0
  19. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_pypi_apis.py +0 -30
  20. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_pypi_simple_json_api.py +1 -0
  21. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_sync.py +12 -0
  22. pulp_python-3.22.1/pulp_python/tests/functional/api/test_upload.py +124 -0
  23. pulp_python-3.22.1/pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation +1 -0
  24. pulp_python-3.22.1/pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation +1 -0
  25. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/constants.py +8 -5
  26. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python.egg-info/PKG-INFO +3 -2
  27. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python.egg-info/SOURCES.txt +5 -0
  28. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python.egg-info/requires.txt +2 -1
  29. {pulp_python-3.21.0 → pulp_python-3.22.1}/pyproject.toml +4 -3
  30. pulp_python-3.21.0/pulp_python/tests/functional/api/test_upload.py +0 -44
  31. {pulp_python-3.21.0 → pulp_python-3.22.1}/COMMITMENT +0 -0
  32. {pulp_python-3.21.0 → pulp_python-3.22.1}/COPYRIGHT +0 -0
  33. {pulp_python-3.21.0 → pulp_python-3.22.1}/LICENSE +0 -0
  34. {pulp_python-3.21.0 → pulp_python-3.22.1}/README.md +0 -0
  35. {pulp_python-3.21.0 → pulp_python-3.22.1}/functest_requirements.txt +0 -0
  36. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/__init__.py +0 -0
  37. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/global_access_conditions.py +0 -0
  38. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/management/__init__.py +0 -0
  39. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/management/commands/__init__.py +0 -0
  40. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/management/commands/repair-python-metadata.py +0 -0
  41. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0001_initial.py +0 -0
  42. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  43. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  44. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  45. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  46. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  47. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  48. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  49. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  50. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  51. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  52. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  53. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  54. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  55. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  56. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  57. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +0 -0
  58. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/migrations/__init__.py +0 -0
  59. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/modelresource.py +0 -0
  60. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/pypi/__init__.py +0 -0
  61. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/replica.py +0 -0
  62. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/settings.py +0 -0
  63. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/tasks/__init__.py +0 -0
  64. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/tasks/publish.py +0 -0
  65. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/tasks/repair.py +0 -0
  66. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/tasks/vulnerability_report.py +0 -0
  67. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  68. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  69. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  70. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/__init__.py +0 -0
  71. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/__init__.py +0 -0
  72. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/__init__.py +0 -0
  73. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  74. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
  75. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_crud_content_unit.py +0 -0
  76. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  77. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  78. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_domains.py +0 -0
  79. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  80. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_export_import.py +0 -0
  81. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_full_mirror.py +0 -0
  82. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  83. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_repair.py +0 -0
  84. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/api/test_vulnerability_report.py +0 -0
  85. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/functional/utils.py +0 -0
  86. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/unit/__init__.py +0 -0
  87. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python/tests/unit/test_models.py +0 -0
  88. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python.egg-info/dependency_links.txt +0 -0
  89. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python.egg-info/entry_points.txt +0 -0
  90. {pulp_python-3.21.0 → pulp_python-3.22.1}/pulp_python.egg-info/top_level.txt +0 -0
  91. {pulp_python-3.21.0 → pulp_python-3.22.1}/setup.cfg +0 -0
  92. {pulp_python-3.21.0 → pulp_python-3.22.1}/test_requirements.txt +0 -0
  93. {pulp_python-3.21.0 → pulp_python-3.22.1}/unittest_requirements.txt +0 -0
@@ -8,6 +8,29 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.22.1 (2025-12-10) {: #3.22.1 }
12
+
13
+ #### Bugfixes {: #3.22.1-bugfix }
14
+
15
+ - Fixed edge-case migration error in 0017_pythonpackagecontent_size.
16
+ [#1042](https://github.com/pulp/pulp_python/issues/1042)
17
+
18
+ ---
19
+
20
+ ## 3.22.0 (2025-12-09) {: #3.22.0 }
21
+
22
+ #### Features {: #3.22.0-feature }
23
+
24
+ - Added attestations field to package upload that will create a PEP 740 Provenance object for that content.
25
+ [#984](https://github.com/pulp/pulp_python/issues/984)
26
+ - Added the ability to upload PEP 740 Provenance files to repositories.
27
+
28
+ #### Bugfixes {: #3.22.0-bugfix }
29
+
30
+ - Added some sanity validation checks to twine upload endpoint.
31
+
32
+ ---
33
+
11
34
  ## 3.21.0 (2025-11-18) {: #3.21.0 }
12
35
 
13
36
  #### Features {: #3.21.0-feature }
@@ -26,6 +49,15 @@
26
49
 
27
50
  ---
28
51
 
52
+ ## 3.20.1 (2025-11-18) {: #3.20.1 }
53
+
54
+ #### Bugfixes {: #3.20.1-bugfix }
55
+
56
+ - Fixed pull-through caching not checking the repository if package was not present on remote.
57
+ [#1004](https://github.com/pulp/pulp_python/issues/1004)
58
+
59
+ ---
60
+
29
61
  ## 3.20.0 (2025-11-07) {: #3.20.0 }
30
62
 
31
63
  #### Features {: #3.20.0-feature }
@@ -214,6 +246,12 @@ No significant changes.
214
246
 
215
247
  ---
216
248
 
249
+ ## 3.12.8 (2025-11-18) {: #3.12.8 }
250
+
251
+ No significant changes.
252
+
253
+ ---
254
+
217
255
  ## 3.12.7 (2025-07-23) {: #3.12.7 }
218
256
 
219
257
  No significant changes.
@@ -300,6 +338,12 @@ No significant changes.
300
338
 
301
339
  ---
302
340
 
341
+ ## 3.11.7 (2025-11-18) {: #3.11.7 }
342
+
343
+ No significant changes.
344
+
345
+ ---
346
+
303
347
  ## 3.11.6 (2025-07-23) {: #3.11.6 }
304
348
 
305
349
  No significant changes.
@@ -364,6 +408,12 @@ No significant changes.
364
408
 
365
409
  ---
366
410
 
411
+ ## 3.10.2 (2025-11-18) {: #3.10.2 }
412
+
413
+ No significant changes
414
+
415
+ ---
416
+
367
417
  ## 3.10.1 (2025-07-23) {: #3.10.1 }
368
418
 
369
419
  ### Bugfixes
@@ -9,4 +9,5 @@ include functest_requirements.txt
9
9
  include test_requirements.txt
10
10
  include unittest_requirements.txt
11
11
  include pulp_python/app/webserver_snippets/*
12
+ include pulp_python/tests/functional/assets/*
12
13
  exclude releasing.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.21.0
3
+ Version: 3.22.1
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
@@ -23,7 +23,8 @@ License-File: LICENSE
23
23
  Requires-Dist: pulpcore<3.100,>=3.85.3
24
24
  Requires-Dist: pkginfo<1.13.0,>=1.12.0
25
25
  Requires-Dist: bandersnatch<6.7,>=6.6.0
26
- Requires-Dist: pypi-simple<2.0,>=1.5.0
26
+ Requires-Dist: pypi-simple<2.0,>=1.8.0
27
+ Requires-Dist: pypi-attestations==0.0.28
27
28
  Dynamic: license-file
28
29
 
29
30
  # pulp_python
@@ -10,7 +10,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
10
10
 
11
11
  name = "pulp_python.app"
12
12
  label = "python"
13
- version = "3.21.0"
13
+ version = "3.22.1"
14
14
  python_package_name = "pulp-python"
15
15
  domain_compatible = True
16
16
 
@@ -14,7 +14,7 @@ def add_size_to_current_models(apps, schema_editor):
14
14
  artifact = content_artifact.artifact
15
15
  else:
16
16
  artifact = RemoteArtifact.objects.filter(content_artifact=content_artifact).first()
17
- python_package.size = artifact.size or 0
17
+ python_package.size = artifact and artifact.size or 0
18
18
  package_bulk.append(python_package)
19
19
  if len(package_bulk) == 100000:
20
20
  with transaction.atomic():
@@ -0,0 +1,59 @@
1
+ # Generated by Django 4.2.26 on 2025-12-01 19:49
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+ import pulpcore.app.util
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ("python", "0017_pythonpackagecontent_size"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="PackageProvenance",
17
+ fields=[
18
+ (
19
+ "content_ptr",
20
+ models.OneToOneField(
21
+ auto_created=True,
22
+ on_delete=django.db.models.deletion.CASCADE,
23
+ parent_link=True,
24
+ primary_key=True,
25
+ serialize=False,
26
+ to="core.content",
27
+ ),
28
+ ),
29
+ ("provenance", models.JSONField()),
30
+ ("sha256", models.CharField(max_length=64)),
31
+ (
32
+ "_pulp_domain",
33
+ models.ForeignKey(
34
+ default=pulpcore.app.util.get_domain_pk,
35
+ on_delete=django.db.models.deletion.PROTECT,
36
+ to="core.domain",
37
+ ),
38
+ ),
39
+ (
40
+ "package",
41
+ models.ForeignKey(
42
+ on_delete=django.db.models.deletion.CASCADE,
43
+ related_name="provenances",
44
+ to="python.pythonpackagecontent",
45
+ ),
46
+ ),
47
+ ],
48
+ options={
49
+ "default_related_name": "%(app_label)s_%(model_name)s",
50
+ "unique_together": {("sha256", "_pulp_domain")},
51
+ },
52
+ bases=("core.content",),
53
+ ),
54
+ migrations.AddField(
55
+ model_name="pythonremote",
56
+ name="provenance",
57
+ field=models.BooleanField(default=False),
58
+ ),
59
+ ]
@@ -1,3 +1,5 @@
1
+ import hashlib
2
+ import json
1
3
  from logging import getLogger
2
4
 
3
5
  from aiohttp.web import json_response
@@ -5,6 +7,10 @@ from django.contrib.postgres.fields import ArrayField
5
7
  from django.core.exceptions import ObjectDoesNotExist
6
8
  from django.db import models
7
9
  from django.conf import settings
10
+ from django_lifecycle import (
11
+ BEFORE_SAVE,
12
+ hook,
13
+ )
8
14
  from pulpcore.plugin.models import (
9
15
  AutoAddObjPermsMixin,
10
16
  Content,
@@ -16,6 +22,7 @@ from pulpcore.plugin.models import (
16
22
  from pulpcore.plugin.responses import ArtifactResponse
17
23
 
18
24
  from pathlib import PurePath
25
+ from .provenance import Provenance
19
26
  from .utils import (
20
27
  artifact_to_python_content_data,
21
28
  canonicalize_name,
@@ -235,6 +242,43 @@ class PythonPackageContent(Content):
235
242
  ]
236
243
 
237
244
 
245
+ class PackageProvenance(Content):
246
+ """
247
+ PEP 740 provenance objects.
248
+ """
249
+
250
+ TYPE = "provenance"
251
+ repo_key_fields = ("package_id",)
252
+
253
+ package = models.ForeignKey(
254
+ PythonPackageContent, on_delete=models.CASCADE, related_name="provenances"
255
+ )
256
+ provenance = models.JSONField(null=False)
257
+ sha256 = models.CharField(max_length=64, null=False)
258
+
259
+ _pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT)
260
+
261
+ @staticmethod
262
+ def calculate_sha256(provenance):
263
+ """Calculates the sha256 from the provenance."""
264
+ provenance_json = json.dumps(provenance, sort_keys=True).encode("utf-8")
265
+ hasher = hashlib.sha256(provenance_json)
266
+ return hasher.hexdigest()
267
+
268
+ @hook(BEFORE_SAVE)
269
+ def set_sha256_hook(self):
270
+ """Ensure that sha256 is set before saving."""
271
+ self.sha256 = self.calculate_sha256(self.provenance)
272
+
273
+ @property
274
+ def as_model(self):
275
+ return Provenance.model_validate(self.provenance)
276
+
277
+ class Meta:
278
+ default_related_name = "%(app_label)s_%(model_name)s"
279
+ unique_together = ("sha256", "_pulp_domain")
280
+
281
+
238
282
  class PythonPublication(Publication, AutoAddObjPermsMixin):
239
283
  """
240
284
  A Publication for PythonContent.
@@ -270,6 +314,7 @@ class PythonRemote(Remote, AutoAddObjPermsMixin):
270
314
  exclude_platforms = ArrayField(
271
315
  models.CharField(max_length=10, blank=True), choices=PLATFORMS, default=list
272
316
  )
317
+ provenance = models.BooleanField(default=False)
273
318
 
274
319
  def get_remote_artifact_url(self, relative_path=None, request=None):
275
320
  """Get url for remote_artifact"""
@@ -295,7 +340,7 @@ class PythonRepository(Repository, AutoAddObjPermsMixin):
295
340
  """
296
341
 
297
342
  TYPE = "python"
298
- CONTENT_TYPES = [PythonPackageContent]
343
+ CONTENT_TYPES = [PythonPackageContent, PackageProvenance]
299
344
  REMOTE_TYPES = [PythonRemote]
300
345
  PULL_THROUGH_SUPPORTED = True
301
346
 
@@ -0,0 +1,71 @@
1
+ from typing import Annotated, Literal, Union, get_args
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+ from pydantic.alias_generators import to_snake
5
+ from pypi_attestations import (
6
+ Attestation,
7
+ Distribution,
8
+ Publisher,
9
+ )
10
+
11
+
12
+ class _PermissivePolicy:
13
+ """A permissive verification policy that always succeeds."""
14
+
15
+ def verify(self, cert):
16
+ """Succeed regardless of the publisher's identity."""
17
+ pass
18
+
19
+
20
+ class AnyPublisher(BaseModel):
21
+ """A fallback publisher for any kind not matching other publisher types."""
22
+
23
+ model_config = ConfigDict(alias_generator=to_snake, extra="allow")
24
+
25
+ kind: str
26
+
27
+ def _as_policy(self):
28
+ """Return a permissive policy that always succeed."""
29
+ return _PermissivePolicy()
30
+
31
+
32
+ # Get the underlying Union type of the original Publisher
33
+ # Publisher is Annotated[Union[...], Field(discriminator="kind")]
34
+ _OriginalPublisherTypes = get_args(Publisher.__origin__)
35
+ # Add AnyPublisher to the list of original publisher types
36
+ _ExtendedPublisherTypes = (*_OriginalPublisherTypes, AnyPublisher)
37
+ _ExtendedPublisherUnion = Union[_ExtendedPublisherTypes]
38
+ # Create a new type that fallbacks to AnyPublisher
39
+ ExtendedPublisher = Annotated[_ExtendedPublisherUnion, Field(union_mode="left_to_right")]
40
+
41
+
42
+ class AttestationBundle(BaseModel):
43
+ """
44
+ AttestationBundle object as defined in PEP740.
45
+
46
+ PyPI only accepts attestations from TrustedPublishers (GitHub, GitLab, Google), but we will
47
+ accept from any user.
48
+ """
49
+
50
+ publisher: ExtendedPublisher
51
+ attestations: list[Attestation]
52
+
53
+
54
+ class Provenance(BaseModel):
55
+ """Provenance object as defined in PEP740."""
56
+
57
+ version: Literal[1] = 1
58
+ attestation_bundles: list[AttestationBundle]
59
+
60
+
61
+ def verify_provenance(filename, sha256, provenance, offline=False):
62
+ """Verify the provenance object is valid for the package."""
63
+ dist = Distribution(name=filename, digest=sha256)
64
+ for bundle in provenance.attestation_bundles:
65
+ publisher = bundle.publisher
66
+ policy = publisher._as_policy()
67
+ for attestation in bundle.attestations:
68
+ sig_bundle = attestation.to_bundle()
69
+ checkpoint = sig_bundle.log_entry._inner.inclusion_proof.checkpoint
70
+ staging = "sigstage.dev" in checkpoint.envelope
71
+ attestation.verify(policy, dist, staging=staging, offline=offline)
@@ -2,7 +2,9 @@ import logging
2
2
  from gettext import gettext as _
3
3
 
4
4
  from rest_framework import serializers
5
- from pulp_python.app.utils import DIST_EXTENSIONS
5
+ from pydantic import TypeAdapter, ValidationError
6
+ from pulp_python.app.provenance import Attestation
7
+ from pulp_python.app.utils import DIST_EXTENSIONS, SUPPORTED_METADATA_VERSIONS
6
8
  from pulpcore.plugin.models import Artifact
7
9
  from pulpcore.plugin.util import get_domain
8
10
  from django.db.utils import IntegrityError
@@ -54,6 +56,27 @@ class PackageUploadSerializer(serializers.Serializer):
54
56
  min_length=64,
55
57
  max_length=64,
56
58
  )
59
+ protocol_version = serializers.ChoiceField(
60
+ help_text=_("Protocol version to use for the upload. Only version 1 is supported."),
61
+ required=False,
62
+ choices=(1,),
63
+ default=1,
64
+ )
65
+ filetype = serializers.ChoiceField(
66
+ help_text=_("Type of artifact to upload."),
67
+ required=False,
68
+ choices=("bdist_wheel", "sdist"),
69
+ )
70
+ metadata_version = serializers.ChoiceField(
71
+ help_text=_("Metadata version of the uploaded package."),
72
+ required=False,
73
+ choices=SUPPORTED_METADATA_VERSIONS,
74
+ )
75
+ attestations = serializers.JSONField(
76
+ required=False,
77
+ help_text=_("A JSON list containing attestations for the package."),
78
+ write_only=True,
79
+ )
57
80
 
58
81
  def validate(self, data):
59
82
  """Validates the request."""
@@ -63,14 +86,33 @@ class PackageUploadSerializer(serializers.Serializer):
63
86
  file = data.get("content")
64
87
  for ext, packagetype in DIST_EXTENSIONS.items():
65
88
  if file.name.endswith(ext):
89
+ if (filetype := data.get("filetype")) and filetype != packagetype:
90
+ raise serializers.ValidationError(
91
+ {
92
+ "filetype": _(
93
+ "filetype {} does not match found filetype {} for file {}"
94
+ ).format(filetype, packagetype, file.name)
95
+ }
96
+ )
66
97
  break
67
98
  else:
68
99
  raise serializers.ValidationError(
69
- _(
70
- "Extension on {} is not a valid python extension "
71
- "(.whl, .exe, .egg, .tar.gz, .tar.bz2, .zip)"
72
- ).format(file.name)
100
+ {
101
+ "content": _(
102
+ "Extension on {} is not a valid python extension "
103
+ "(.whl, .exe, .egg, .tar.gz, .tar.bz2, .zip)"
104
+ ).format(file.name)
105
+ }
73
106
  )
107
+
108
+ if attestations := data.get("attestations"):
109
+ try:
110
+ attestations = TypeAdapter(list[Attestation]).validate_python(attestations)
111
+ except ValidationError as e:
112
+ raise serializers.ValidationError(
113
+ {"attestations": _("The uploaded attestations are not valid: {}".format(e))}
114
+ )
115
+
74
116
  sha256 = data.get("sha256_digest")
75
117
  digests = {"sha256": sha256} if sha256 else None
76
118
  artifact = Artifact.init_and_validate(file, expected_digests=digests)
@@ -1,7 +1,5 @@
1
- import json
2
1
  import logging
3
2
 
4
- from aiohttp.client_exceptions import ClientError
5
3
  from rest_framework.viewsets import ViewSet
6
4
  from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer, TemplateHTMLRenderer
7
5
  from rest_framework.response import Response
@@ -27,16 +25,15 @@ from itertools import chain
27
25
  from packaging.utils import canonicalize_name
28
26
  from urllib.parse import urljoin, urlparse, urlunsplit
29
27
  from pathlib import PurePath
30
- from pypi_simple import ACCEPT_JSON_PREFERRED, ProjectPage
31
28
 
32
29
  from pulpcore.plugin.viewsets import OperationPostponedResponse
33
30
  from pulpcore.plugin.tasking import dispatch
34
31
  from pulpcore.plugin.util import get_domain, get_url
35
- from pulpcore.plugin.exceptions import TimeoutException
36
32
  from pulp_python.app.models import (
37
33
  PythonDistribution,
38
34
  PythonPackageContent,
39
35
  PythonPublication,
36
+ PackageProvenance,
40
37
  )
41
38
  from pulp_python.app.pypi.serializers import (
42
39
  SummarySerializer,
@@ -53,6 +50,7 @@ from pulp_python.app.utils import (
53
50
  PYPI_LAST_SERIAL,
54
51
  PYPI_SERIAL_CONSTANT,
55
52
  get_remote_package_filter,
53
+ get_remote_simple_page,
56
54
  )
57
55
 
58
56
  from pulp_python.app import tasks
@@ -61,6 +59,7 @@ log = logging.getLogger(__name__)
61
59
 
62
60
  ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
63
61
  BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
62
+ BASE_API_URL = urljoin(settings.PYPI_API_HOSTNAME, "pypi/")
64
63
 
65
64
  PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
66
65
  PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
@@ -120,6 +119,11 @@ class PyPIMixin:
120
119
  """Returns queryset of the content in this repository version."""
121
120
  return PythonPackageContent.objects.filter(pk__in=repository_version.content)
122
121
 
122
+ @staticmethod
123
+ def get_provenances(repository_version):
124
+ """Returns queryset of the provenance for this repository version."""
125
+ return PackageProvenance.objects.filter(pk__in=repository_version.content)
126
+
123
127
  def should_redirect(self, repo_version=None):
124
128
  """Checks if there is a publication the content app can serve."""
125
129
  if self.distribution.publication:
@@ -139,10 +143,13 @@ class PyPIMixin:
139
143
  def initial(self, request, *args, **kwargs):
140
144
  """Perform common initialization tasks for PyPI endpoints."""
141
145
  super().initial(request, *args, **kwargs)
146
+ domain_name = get_domain().name
142
147
  if settings.DOMAIN_ENABLED:
143
- self.base_content_url = urljoin(BASE_CONTENT_URL, f"{get_domain().name}/")
148
+ self.base_content_url = urljoin(BASE_CONTENT_URL, f"{domain_name}/")
149
+ self.base_api_url = urljoin(BASE_API_URL, f"{domain_name}/")
144
150
  else:
145
151
  self.base_content_url = BASE_CONTENT_URL
152
+ self.base_api_url = BASE_API_URL
146
153
 
147
154
  @classmethod
148
155
  def urlpattern(cls):
@@ -174,12 +181,15 @@ class PackageUploadMixin(PyPIMixin):
174
181
  serializer = PackageUploadSerializer(data=request.data)
175
182
  serializer.is_valid(raise_exception=True)
176
183
  artifact, filename = serializer.validated_data["content"]
184
+ attestations = serializer.validated_data.get("attestations", None)
177
185
  repo_content = self.get_content(self.get_repository_version(self.distribution))
178
186
  if repo_content.filter(filename=filename).exists():
179
187
  return HttpResponseBadRequest(reason=f"Package {filename} already exists in index")
180
188
 
181
189
  if settings.PYTHON_GROUP_UPLOADS:
182
- return self.upload_package_group(repo, artifact, filename, request.session)
190
+ return self.upload_package_group(
191
+ repo, artifact, filename, attestations, request.session
192
+ )
183
193
 
184
194
  result = dispatch(
185
195
  tasks.upload,
@@ -187,17 +197,20 @@ class PackageUploadMixin(PyPIMixin):
187
197
  kwargs={
188
198
  "artifact_sha256": artifact.sha256,
189
199
  "filename": filename,
200
+ "attestations": attestations,
190
201
  "repository_pk": str(repo.pk),
191
202
  },
192
203
  )
193
204
  return OperationPostponedResponse(result, request)
194
205
 
195
- def upload_package_group(self, repo, artifact, filename, session):
206
+ def upload_package_group(self, repo, artifact, filename, attestations, session):
196
207
  """Steps 4 & 5, spawns tasks to add packages to index."""
197
208
  start_time = datetime.now(tz=timezone.utc) + timedelta(seconds=5)
198
209
  task = "updated"
199
210
  if not session.get("start"):
200
- task = self.create_group_upload_task(session, repo, artifact, filename, start_time)
211
+ task = self.create_group_upload_task(
212
+ session, repo, artifact, filename, attestations, start_time
213
+ )
201
214
  else:
202
215
  sq = Session.objects.select_for_update(nowait=True).filter(pk=session.session_key)
203
216
  try:
@@ -205,7 +218,7 @@ class PackageUploadMixin(PyPIMixin):
205
218
  sq.first()
206
219
  current_start = datetime.fromisoformat(session["start"])
207
220
  if current_start >= datetime.now(tz=timezone.utc):
208
- session["artifacts"].append((str(artifact.sha256), filename))
221
+ session["artifacts"].append((str(artifact.sha256), filename, attestations))
209
222
  session["start"] = str(start_time)
210
223
  session.modified = False
211
224
  session.save()
@@ -213,14 +226,18 @@ class PackageUploadMixin(PyPIMixin):
213
226
  raise DatabaseError
214
227
  except DatabaseError:
215
228
  session.cycle_key()
216
- task = self.create_group_upload_task(session, repo, artifact, filename, start_time)
229
+ task = self.create_group_upload_task(
230
+ session, repo, artifact, filename, attestations, start_time
231
+ )
217
232
  data = {"session": session.session_key, "task": task, "task_start_time": start_time}
218
233
  return Response(data=data)
219
234
 
220
- def create_group_upload_task(self, cur_session, repository, artifact, filename, start_time):
235
+ def create_group_upload_task(
236
+ self, cur_session, repository, artifact, filename, attestations, start_time
237
+ ):
221
238
  """Creates the actual task that adds the packages to the index."""
222
239
  cur_session["start"] = str(start_time)
223
- cur_session["artifacts"] = [(str(artifact.sha256), filename)]
240
+ cur_session["artifacts"] = [(str(artifact.sha256), filename, attestations)]
224
241
  cur_session.modified = False
225
242
  cur_session.save()
226
243
  task = dispatch(
@@ -273,6 +290,13 @@ class SimpleView(PackageUploadMixin, ViewSet):
273
290
  else:
274
291
  return [JSONRenderer(), BrowsableAPIRenderer()]
275
292
 
293
+ def get_provenance_url(self, package, version, filename):
294
+ """Gets the provenance url for a package."""
295
+ base_path = self.distribution.base_path
296
+ return urljoin(
297
+ self.base_api_url, f"{base_path}/integrity/{package}/{version}/{filename}/provenance/"
298
+ )
299
+
276
300
  @extend_schema(summary="Get index simple page")
277
301
  def list(self, request, path):
278
302
  """Gets the simple api html page for the index."""
@@ -308,26 +332,17 @@ class SimpleView(PackageUploadMixin, ViewSet):
308
332
  "size": release_package.size,
309
333
  "upload_time": release_package.upload_time,
310
334
  "version": release_package.version,
335
+ "provenance": release_package.provenance_url,
311
336
  }
312
337
 
313
338
  rfilter = get_remote_package_filter(remote)
314
339
  if not rfilter.filter_project(package):
315
340
  return {}
316
341
 
317
- url = remote.get_remote_artifact_url(f"simple/{package}/")
318
- remote.headers = remote.headers or []
319
- remote.headers.append({"Accept": ACCEPT_JSON_PREFERRED})
320
- downloader = remote.get_downloader(url=url, max_retries=1)
321
- try:
322
- d = downloader.fetch()
323
- except (ClientError, TimeoutException):
342
+ page = get_remote_simple_page(package, remote)
343
+ if not page:
324
344
  log.info(f"Failed to fetch {package} simple page from {remote.url}")
325
345
  return {}
326
-
327
- if d.headers["content-type"] == PYPI_SIMPLE_V1_JSON:
328
- page = ProjectPage.from_json_data(json.load(open(d.path, "rb")), base_url=url)
329
- else:
330
- page = ProjectPage.from_html(package, open(d.path, "rb").read(), base_url=url)
331
346
  return {
332
347
  p.filename: parse_package(p)
333
348
  for p in page.packages
@@ -348,7 +363,8 @@ class SimpleView(PackageUploadMixin, ViewSet):
348
363
  elif self.should_redirect(repo_version=repo_ver):
349
364
  return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
350
365
  if content:
351
- packages = content.filter(name__normalize=normalized).values(
366
+ local_packages = content.filter(name__normalize=normalized)
367
+ packages = local_packages.values(
352
368
  "filename",
353
369
  "sha256",
354
370
  "metadata_sha256",
@@ -357,11 +373,19 @@ class SimpleView(PackageUploadMixin, ViewSet):
357
373
  "pulp_created",
358
374
  "version",
359
375
  )
376
+ provenances = PackageProvenance.objects.filter(package__in=local_packages).values_list(
377
+ "package__filename", flat=True
378
+ )
360
379
  local_releases = {
361
380
  p["filename"]: {
362
381
  **p,
363
382
  "url": urljoin(self.base_content_url, f"{path}/{p['filename']}"),
364
383
  "upload_time": p["pulp_created"],
384
+ "provenance": (
385
+ self.get_provenance_url(normalized, p["version"], p["filename"])
386
+ if p["filename"] in provenances
387
+ else None
388
+ ),
365
389
  }
366
390
  for p in packages
367
391
  }
@@ -497,3 +521,32 @@ class UploadView(PackageUploadMixin, ViewSet):
497
521
  This is the endpoint that tools like Twine and Poetry use for their upload commands.
498
522
  """
499
523
  return self.upload(request, path)
524
+
525
+
526
+ class ProvenanceView(PyPIMixin, ViewSet):
527
+ """View for the PyPI provenance endpoint."""
528
+
529
+ endpoint_name = "integrity"
530
+ DEFAULT_ACCESS_POLICY = {
531
+ "statements": [
532
+ {
533
+ "action": ["retrieve"],
534
+ "principal": "*",
535
+ "effect": "allow",
536
+ },
537
+ ],
538
+ }
539
+
540
+ @extend_schema(summary="Get package provenance")
541
+ def retrieve(self, request, path, package, version, filename):
542
+ """Gets the provenance for a package."""
543
+ repo_ver, content = self.get_rvc()
544
+ if content:
545
+ package_content = content.filter(
546
+ name__normalize=package, version=version, filename=filename
547
+ ).first()
548
+ if package_content:
549
+ provenance = self.get_provenances(repo_ver).filter(package=package_content).first()
550
+ if provenance:
551
+ return Response(data=provenance.provenance)
552
+ return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")