pulp-python 3.22.1__tar.gz → 3.23.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 (93) hide show
  1. {pulp_python-3.22.1 → pulp_python-3.23.0}/CHANGES.md +16 -0
  2. {pulp_python-3.22.1 → pulp_python-3.23.0}/PKG-INFO +2 -2
  3. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/__init__.py +1 -1
  4. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/management/commands/repair-python-metadata.py +7 -1
  5. pulp_python-3.23.0/pulp_python/app/migrations/0019_create_missing_metadata_artifacts.py +201 -0
  6. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/pypi/views.py +0 -2
  7. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/serializers.py +45 -0
  8. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/tasks/repair.py +11 -3
  9. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/tasks/sync.py +24 -2
  10. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/tasks/upload.py +6 -1
  11. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/utils.py +70 -13
  12. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/viewsets.py +14 -0
  13. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +22 -0
  14. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_export_import.py +0 -1
  15. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_pypi_apis.py +32 -22
  16. pulp_python-3.22.1/pulp_python/tests/functional/api/test_pypi_simple_json_api.py → pulp_python-3.23.0/pulp_python/tests/functional/api/test_pypi_simple_api.py +70 -14
  17. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_sync.py +19 -0
  18. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_upload.py +25 -0
  19. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/constants.py +4 -0
  20. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/utils.py +38 -1
  21. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python.egg-info/PKG-INFO +2 -2
  22. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python.egg-info/SOURCES.txt +2 -1
  23. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python.egg-info/requires.txt +1 -1
  24. {pulp_python-3.22.1 → pulp_python-3.23.0}/pyproject.toml +3 -3
  25. {pulp_python-3.22.1 → pulp_python-3.23.0}/COMMITMENT +0 -0
  26. {pulp_python-3.22.1 → pulp_python-3.23.0}/COPYRIGHT +0 -0
  27. {pulp_python-3.22.1 → pulp_python-3.23.0}/LICENSE +0 -0
  28. {pulp_python-3.22.1 → pulp_python-3.23.0}/MANIFEST.in +0 -0
  29. {pulp_python-3.22.1 → pulp_python-3.23.0}/README.md +0 -0
  30. {pulp_python-3.22.1 → pulp_python-3.23.0}/functest_requirements.txt +0 -0
  31. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/__init__.py +0 -0
  32. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/global_access_conditions.py +0 -0
  33. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/management/__init__.py +0 -0
  34. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/management/commands/__init__.py +0 -0
  35. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0001_initial.py +0 -0
  36. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
  37. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
  38. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
  39. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
  40. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
  41. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
  42. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
  43. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
  44. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
  45. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
  46. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
  47. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
  48. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
  49. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
  50. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
  51. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +0 -0
  52. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0017_pythonpackagecontent_size.py +0 -0
  53. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/0018_packageprovenance.py +0 -0
  54. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/migrations/__init__.py +0 -0
  55. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/modelresource.py +0 -0
  56. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/models.py +0 -0
  57. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/provenance.py +0 -0
  58. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/pypi/__init__.py +0 -0
  59. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/pypi/serializers.py +0 -0
  60. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/replica.py +0 -0
  61. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/settings.py +0 -0
  62. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/tasks/__init__.py +0 -0
  63. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/tasks/publish.py +0 -0
  64. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/tasks/vulnerability_report.py +0 -0
  65. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/urls.py +0 -0
  66. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
  67. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
  68. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
  69. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/pytest_plugin.py +0 -0
  70. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/__init__.py +0 -0
  71. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/__init__.py +0 -0
  72. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/__init__.py +0 -0
  73. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_attestations.py +0 -0
  74. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
  75. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
  76. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
  77. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
  78. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_domains.py +0 -0
  79. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
  80. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_full_mirror.py +0 -0
  81. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
  82. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_repair.py +0 -0
  83. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_vulnerability_report.py +0 -0
  84. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation +0 -0
  85. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation +0 -0
  86. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/unit/__init__.py +0 -0
  87. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python/tests/unit/test_models.py +0 -0
  88. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python.egg-info/dependency_links.txt +0 -0
  89. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python.egg-info/entry_points.txt +0 -0
  90. {pulp_python-3.22.1 → pulp_python-3.23.0}/pulp_python.egg-info/top_level.txt +0 -0
  91. {pulp_python-3.22.1 → pulp_python-3.23.0}/setup.cfg +0 -0
  92. {pulp_python-3.22.1 → pulp_python-3.23.0}/test_requirements.txt +0 -0
  93. {pulp_python-3.22.1 → pulp_python-3.23.0}/unittest_requirements.txt +0 -0
@@ -8,6 +8,22 @@
8
8
 
9
9
  [//]: # (towncrier release notes start)
10
10
 
11
+ ## 3.23.0 (2026-01-06) {: #3.23.0 }
12
+
13
+ #### Features {: #3.23.0-feature }
14
+
15
+ - Added exposure of metadata file to Simple API (PEP 658)
16
+ [#1047](https://github.com/pulp/pulp_python/issues/1047)
17
+ - Bump pulpcore upperbound to <3.115.
18
+
19
+ #### Bugfixes {: #3.23.0-bugfix }
20
+
21
+ - Fixed edge-case migration error in 0017_pythonpackagecontent_size.
22
+ [#1042](https://github.com/pulp/pulp_python/issues/1042)
23
+ - Added missing Provenance content `package` and `sha256` filters.
24
+
25
+ ---
26
+
11
27
  ## 3.22.1 (2025-12-10) {: #3.22.1 }
12
28
 
13
29
  #### Bugfixes {: #3.22.1-bugfix }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pulp-python
3
- Version: 3.22.1
3
+ Version: 3.23.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
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: pulpcore<3.100,>=3.85.3
23
+ Requires-Dist: pulpcore<3.115,>=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
26
  Requires-Dist: pypi-simple<2.0,>=1.8.0
@@ -10,7 +10,7 @@ class PulpPythonPluginAppConfig(PulpPluginAppConfig):
10
10
 
11
11
  name = "pulp_python.app"
12
12
  label = "python"
13
- version = "3.22.1"
13
+ version = "3.23.0"
14
14
  python_package_name = "pulp-python"
15
15
  domain_compatible = True
16
16
 
@@ -24,8 +24,14 @@ def repair_metadata(content):
24
24
  set_of_update_fields = set()
25
25
  total_repaired = 0
26
26
  for package in immediate_content.prefetch_related("_artifacts").iterator(chunk_size=1000):
27
+ # Get the main artifact
28
+ main_artifact = (
29
+ package.contentartifact_set.exclude(relative_path__endswith=".metadata")
30
+ .first()
31
+ .artifact
32
+ )
27
33
  new_data = artifact_to_python_content_data(
28
- package.filename, package._artifacts.get(), package.pulp_domain
34
+ package.filename, main_artifact, package.pulp_domain
29
35
  )
30
36
  changed = False
31
37
  for field, value in new_data.items():
@@ -0,0 +1,201 @@
1
+ # Generated manually on 2025-12-15 14:00 for creating missing metadata artifacts
2
+
3
+ from django.db import migrations
4
+
5
+ BATCH_SIZE = 1000
6
+
7
+
8
+ def pulp_hashlib_new(name, *args, **kwargs):
9
+ """
10
+ Copied and updated (to comply with migrations) from pulpcore.
11
+ """
12
+ import hashlib as the_real_hashlib
13
+ from django.conf import settings
14
+
15
+ if name not in settings.ALLOWED_CONTENT_CHECKSUMS:
16
+ return None
17
+
18
+ return the_real_hashlib.new(name, *args, **kwargs)
19
+
20
+
21
+ def init_and_validate(file, artifact_model, expected_digests):
22
+ """
23
+ Copied and updated (to comply with migrations) from pulpcore.
24
+ """
25
+ from django.conf import settings
26
+
27
+ digest_fields = []
28
+ for alg in ("sha512", "sha384", "sha256", "sha224", "sha1", "md5"):
29
+ if alg in settings.ALLOWED_CONTENT_CHECKSUMS:
30
+ digest_fields.append(alg)
31
+
32
+ if isinstance(file, str):
33
+ with open(file, "rb") as f:
34
+ hashers = {
35
+ n: hasher for n in digest_fields if (hasher := pulp_hashlib_new(n)) is not None
36
+ }
37
+ if not hashers:
38
+ return None
39
+
40
+ size = 0
41
+ while True:
42
+ chunk = f.read(1048576) # 1 megabyte
43
+ if not chunk:
44
+ break
45
+ for algorithm in hashers.values():
46
+ algorithm.update(chunk)
47
+ size = size + len(chunk)
48
+ else:
49
+ size = file.size
50
+ hashers = file.hashers
51
+
52
+ mismatched_sha256 = None
53
+ for algorithm, expected_digest in expected_digests.items():
54
+ if algorithm not in hashers:
55
+ return None
56
+ actual_digest = hashers[algorithm].hexdigest()
57
+ if expected_digest != actual_digest:
58
+ # Store the actual value for later fixing if it differs from the package value
59
+ mismatched_sha256 = actual_digest
60
+
61
+ attributes = {"size": size, "file": file}
62
+ for algorithm in digest_fields:
63
+ attributes[algorithm] = hashers[algorithm].hexdigest()
64
+
65
+ return artifact_model(**attributes), mismatched_sha256
66
+
67
+
68
+ def extract_wheel_metadata(filename):
69
+ """
70
+ Extract the metadata file content from a wheel file.
71
+ Return the raw metadata content as bytes or None if metadata cannot be extracted.
72
+ """
73
+ import zipfile
74
+
75
+ try:
76
+ with zipfile.ZipFile(filename, "r") as f:
77
+ for file_path in f.namelist():
78
+ if file_path.endswith(".dist-info/METADATA"):
79
+ return f.read(file_path)
80
+ except (zipfile.BadZipFile, KeyError, OSError):
81
+ pass
82
+ return None
83
+
84
+
85
+ def artifact_to_metadata_artifact(filename, artifact, md_digests, tmp_dir, artifact_model):
86
+ """
87
+ Create artifact for metadata from the provided wheel artifact.
88
+ Return (artifact, mismatched_sha256) on success, None on any failure.
89
+ """
90
+ import shutil
91
+ import tempfile
92
+
93
+ with tempfile.NamedTemporaryFile("wb", dir=tmp_dir, suffix=filename, delete=False) as temp_file:
94
+ temp_wheel_path = temp_file.name
95
+ artifact.file.seek(0)
96
+ shutil.copyfileobj(artifact.file, temp_file)
97
+ temp_file.flush()
98
+
99
+ metadata_content = extract_wheel_metadata(temp_wheel_path)
100
+ if not metadata_content:
101
+ return None
102
+
103
+ with tempfile.NamedTemporaryFile(
104
+ "wb", dir=tmp_dir, suffix=".metadata", delete=False
105
+ ) as temp_md:
106
+ temp_metadata_path = temp_md.name
107
+ temp_md.write(metadata_content)
108
+ temp_md.flush()
109
+
110
+ return init_and_validate(temp_metadata_path, artifact_model, md_digests)
111
+
112
+
113
+ def create_missing_metadata_artifacts(apps, schema_editor):
114
+ """
115
+ Create metadata artifacts for PythonPackageContent instances that have metadata_sha256
116
+ but are missing the corresponding metadata artifact.
117
+ """
118
+ import tempfile
119
+ from django.conf import settings
120
+ from django.db import models
121
+
122
+ PythonPackageContent = apps.get_model("python", "PythonPackageContent")
123
+ ContentArtifact = apps.get_model("core", "ContentArtifact")
124
+ Artifact = apps.get_model("core", "Artifact")
125
+
126
+ packages = (
127
+ PythonPackageContent.objects.filter(
128
+ metadata_sha256__isnull=False,
129
+ filename__endswith=".whl",
130
+ contentartifact__artifact__isnull=False,
131
+ contentartifact__relative_path=models.F("filename"),
132
+ )
133
+ .exclude(metadata_sha256="")
134
+ .prefetch_related("_artifacts")
135
+ .only("filename", "metadata_sha256")
136
+ )
137
+ artifact_batch = []
138
+ contentartifact_batch = []
139
+ packages_batch = []
140
+
141
+ with tempfile.TemporaryDirectory(dir=settings.WORKING_DIRECTORY) as temp_dir:
142
+ for package in packages:
143
+ # Get the main artifact for package
144
+ main_artifact = package._artifacts.get()
145
+
146
+ filename = package.filename
147
+ metadata_digests = {"sha256": package.metadata_sha256}
148
+ result = artifact_to_metadata_artifact(
149
+ filename, main_artifact, metadata_digests, temp_dir, Artifact
150
+ )
151
+ if result is None:
152
+ # Unset metadata_sha256 when extraction or validation fails
153
+ package.metadata_sha256 = None
154
+ packages_batch.append(package)
155
+ continue
156
+ metadata_artifact, mismatched_sha256 = result
157
+ if mismatched_sha256:
158
+ # Fix the package if its metadata_sha256 differs from the actual value
159
+ package.metadata_sha256 = mismatched_sha256
160
+ packages_batch.append(package)
161
+
162
+ contentartifact = ContentArtifact(
163
+ artifact=metadata_artifact,
164
+ content=package,
165
+ relative_path=f"{filename}.metadata",
166
+ )
167
+ artifact_batch.append(metadata_artifact)
168
+ contentartifact_batch.append(contentartifact)
169
+
170
+ if len(artifact_batch) == BATCH_SIZE:
171
+ Artifact.objects.bulk_create(artifact_batch, batch_size=BATCH_SIZE)
172
+ ContentArtifact.objects.bulk_create(contentartifact_batch, batch_size=BATCH_SIZE)
173
+ artifact_batch.clear()
174
+ contentartifact_batch.clear()
175
+ if len(packages_batch) == BATCH_SIZE:
176
+ PythonPackageContent.objects.bulk_update(
177
+ packages_batch, ["metadata_sha256"], batch_size=BATCH_SIZE
178
+ )
179
+ packages_batch.clear()
180
+
181
+ if artifact_batch:
182
+ Artifact.objects.bulk_create(artifact_batch, batch_size=BATCH_SIZE)
183
+ ContentArtifact.objects.bulk_create(contentartifact_batch, batch_size=BATCH_SIZE)
184
+ if packages_batch:
185
+ PythonPackageContent.objects.bulk_update(
186
+ packages_batch, ["metadata_sha256"], batch_size=BATCH_SIZE
187
+ )
188
+
189
+
190
+ class Migration(migrations.Migration):
191
+
192
+ dependencies = [
193
+ ("python", "0018_packageprovenance"),
194
+ ]
195
+
196
+ operations = [
197
+ migrations.RunPython(
198
+ create_missing_metadata_artifacts,
199
+ reverse_code=migrations.RunPython.noop,
200
+ ),
201
+ ]
@@ -352,8 +352,6 @@ class SimpleView(PackageUploadMixin, ViewSet):
352
352
  @extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
353
353
  def retrieve(self, request, path, package):
354
354
  """Retrieves the simple api html/json page for a package."""
355
- media_type = request.accepted_renderer.media_type
356
-
357
355
  repo_ver, content = self.get_rvc()
358
356
  # Should I redirect if the normalized name is different?
359
357
  normalized = canonicalize_name(package)
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os
3
+ import tempfile
3
4
  from gettext import gettext as _
4
5
  from django.conf import settings
5
6
  from django.db.utils import IntegrityError
@@ -22,6 +23,7 @@ from pulp_python.app.provenance import (
22
23
  )
23
24
  from pulp_python.app.utils import (
24
25
  DIST_EXTENSIONS,
26
+ artifact_to_metadata_artifact,
25
27
  artifact_to_python_content_data,
26
28
  get_project_metadata_from_file,
27
29
  parse_project_metadata,
@@ -93,11 +95,31 @@ class PythonDistributionSerializer(core_serializers.DistributionSerializer):
93
95
  model = python_models.PythonDistribution
94
96
 
95
97
 
98
+ class PythonSingleContentArtifactField(core_serializers.SingleContentArtifactField):
99
+ """
100
+ Custom field with overridden get_attribute method. Meant to be used only in
101
+ PythonPackageContentSerializer to handle possible existence of metadata artifact.
102
+ """
103
+
104
+ def get_attribute(self, instance):
105
+ # When content has multiple artifacts (wheel + metadata), return the main one
106
+ if instance._artifacts.count() > 1:
107
+ for ca in instance.contentartifact_set.all():
108
+ if not ca.relative_path.endswith(".metadata"):
109
+ return ca.artifact
110
+
111
+ return super().get_attribute(instance)
112
+
113
+
96
114
  class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploadSerializer):
97
115
  """
98
116
  A Serializer for PythonPackageContent.
99
117
  """
100
118
 
119
+ artifact = PythonSingleContentArtifactField(
120
+ help_text=_("Artifact file representing the physical content"),
121
+ )
122
+
101
123
  # Core metadata
102
124
  # Version 1.0
103
125
  author = serializers.CharField(
@@ -386,8 +408,21 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
386
408
  if attestations := data.pop("attestations", None):
387
409
  data["provenance"] = self.handle_attestations(filename, data["sha256"], attestations)
388
410
 
411
+ # Create metadata artifact for wheel files
412
+ if filename.endswith(".whl"):
413
+ if metadata_artifact := artifact_to_metadata_artifact(filename, artifact):
414
+ data["metadata_artifact"] = metadata_artifact
415
+ data["metadata_sha256"] = metadata_artifact.sha256
416
+
389
417
  return data
390
418
 
419
+ def get_artifacts(self, validated_data):
420
+ artifacts = super().get_artifacts(validated_data)
421
+ if metadata_artifact := validated_data.pop("metadata_artifact", None):
422
+ relative_path = f"{validated_data['filename']}.metadata"
423
+ artifacts[relative_path] = metadata_artifact
424
+ return artifacts
425
+
391
426
  def retrieve(self, validated_data):
392
427
  content = python_models.PythonPackageContent.objects.filter(
393
428
  sha256=validated_data["sha256"], _pulp_domain=get_domain()
@@ -419,6 +454,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
419
454
 
420
455
  class Meta:
421
456
  fields = core_serializers.SingleArtifactContentUploadSerializer.Meta.fields + (
457
+ "artifact",
422
458
  "author",
423
459
  "author_email",
424
460
  "description",
@@ -514,6 +550,15 @@ class PythonPackageContentUploadSerializer(PythonPackageContentSerializer):
514
550
  data["provenance"] = self.handle_attestations(
515
551
  filename, data["sha256"], attestations, offline=True
516
552
  )
553
+ # Create metadata artifact for wheel files
554
+ if filename.endswith(".whl"):
555
+ with tempfile.TemporaryDirectory(dir=settings.WORKING_DIRECTORY) as temp_dir:
556
+ if metadata_artifact := artifact_to_metadata_artifact(
557
+ filename, artifact, tmp_dir=temp_dir
558
+ ):
559
+ data["metadata_artifact"] = metadata_artifact
560
+ data["metadata_sha256"] = metadata_artifact.sha256
561
+
517
562
  return data
518
563
 
519
564
  class Meta(PythonPackageContentSerializer.Meta):
@@ -95,9 +95,13 @@ def repair_metadata(content: QuerySet[PythonPackageContent]) -> tuple[int, set[s
95
95
  progress_report.save()
96
96
  with progress_report:
97
97
  for package in progress_report.iter(immediate_content.iterator(chunk_size=BULK_SIZE)):
98
- new_data = artifact_to_python_content_data(
99
- package.filename, package._artifacts.get(), domain
98
+ # Get the main artifact
99
+ main_artifact = (
100
+ package.contentartifact_set.exclude(relative_path__endswith=".metadata")
101
+ .first()
102
+ .artifact
100
103
  )
104
+ new_data = artifact_to_python_content_data(package.filename, main_artifact, domain)
101
105
  total_repaired += update_package_if_needed(
102
106
  package, new_data, batch, set_of_update_fields
103
107
  )
@@ -113,7 +117,11 @@ def repair_metadata(content: QuerySet[PythonPackageContent]) -> tuple[int, set[s
113
117
  grouped_by_url = defaultdict(list)
114
118
 
115
119
  for package in group_set:
116
- for ra in package.contentartifact_set.get().remoteartifact_set.all():
120
+ for ra in (
121
+ package.contentartifact_set.exclude(relative_path__endswith=".metadata")
122
+ .first()
123
+ .remoteartifact_set.all()
124
+ ):
117
125
  grouped_by_url[ra.remote.url].append((package, ra))
118
126
 
119
127
  # Prioritize the URL that can serve the most packages
@@ -229,11 +229,15 @@ class PulpMirror(Mirror):
229
229
  create a Content Unit to put into the pipeline
230
230
  """
231
231
  declared_contents = {}
232
+ page = await aget_remote_simple_page(pkg.name, self.remote)
233
+ upstream_pkgs = {pkg.filename: pkg for pkg in page.packages}
234
+
232
235
  for version, dists in pkg.releases.items():
233
236
  for package in dists:
234
237
  entry = parse_metadata(pkg.info, version, package)
235
238
  url = entry.pop("url")
236
239
  size = package["size"] or None
240
+ d_artifacts = []
237
241
 
238
242
  artifact = Artifact(sha256=entry["sha256"], size=size)
239
243
  package = PythonPackageContent(**entry)
@@ -245,11 +249,29 @@ class PulpMirror(Mirror):
245
249
  remote=self.remote,
246
250
  deferred_download=self.deferred_download,
247
251
  )
248
- dc = DeclarativeContent(content=package, d_artifacts=[da])
252
+ d_artifacts.append(da)
253
+
254
+ if upstream_pkg := upstream_pkgs.get(entry["filename"]):
255
+ if upstream_pkg.has_metadata:
256
+ url = upstream_pkg.metadata_url
257
+ md_sha256 = upstream_pkg.metadata_digests.get("sha256")
258
+ package.metadata_sha256 = md_sha256
259
+ artifact = Artifact(sha256=md_sha256)
260
+
261
+ metadata_artifact = DeclarativeArtifact(
262
+ artifact=artifact,
263
+ url=url,
264
+ relative_path=f"{entry['filename']}.metadata",
265
+ remote=self.remote,
266
+ deferred_download=self.deferred_download,
267
+ )
268
+ d_artifacts.append(metadata_artifact)
269
+
270
+ dc = DeclarativeContent(content=package, d_artifacts=d_artifacts)
249
271
  declared_contents[entry["filename"]] = dc
250
272
  await self.python_stage.put(dc)
251
273
 
252
- if pkg.releases and (page := await aget_remote_simple_page(pkg.name, self.remote)):
274
+ if pkg.releases and page:
253
275
  if self.remote.provenance:
254
276
  await self.sync_provenance(page, declared_contents)
255
277
 
@@ -15,7 +15,7 @@ from pulp_python.app.provenance import (
15
15
  Provenance,
16
16
  verify_provenance,
17
17
  )
18
- from pulp_python.app.utils import artifact_to_python_content_data
18
+ from pulp_python.app.utils import artifact_to_metadata_artifact, artifact_to_python_content_data
19
19
 
20
20
 
21
21
  def upload(artifact_sha256, filename, attestations=None, repository_pk=None):
@@ -97,6 +97,11 @@ def create_content(artifact_sha256, filename, domain):
97
97
  def create():
98
98
  content = PythonPackageContent.objects.create(**data)
99
99
  ContentArtifact.objects.create(artifact=artifact, content=content, relative_path=filename)
100
+
101
+ if metadata_artifact := artifact_to_metadata_artifact(filename, artifact):
102
+ ContentArtifact.objects.create(
103
+ artifact=metadata_artifact, content=content, relative_path=f"{filename}.metadata"
104
+ )
100
105
  return content
101
106
 
102
107
  new_content = create()
@@ -1,4 +1,5 @@
1
1
  import hashlib
2
+ import logging
2
3
  import pkginfo
3
4
  import re
4
5
  import shutil
@@ -7,15 +8,20 @@ import zipfile
7
8
  import json
8
9
  from aiohttp.client_exceptions import ClientError
9
10
  from collections import defaultdict
11
+ from datetime import timezone
10
12
  from django.conf import settings
11
- from django.utils import timezone
13
+ from django.db.utils import IntegrityError
12
14
  from jinja2 import Template
13
15
  from packaging.utils import canonicalize_name
14
16
  from packaging.requirements import Requirement
15
17
  from packaging.version import parse, InvalidVersion
16
18
  from pypi_simple import ACCEPT_JSON_PREFERRED, ProjectPage
17
- from pulpcore.plugin.models import Remote
19
+ from pulpcore.plugin.models import Artifact, Remote
18
20
  from pulpcore.plugin.exceptions import TimeoutException
21
+ from pulpcore.plugin.util import get_domain
22
+
23
+
24
+ log = logging.getLogger(__name__)
19
25
 
20
26
 
21
27
  PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
@@ -41,6 +47,7 @@ simple_index_template = """<!DOCTYPE html>
41
47
  </html>
42
48
  """
43
49
 
50
+ # TODO in the future: data-requires-python (PEP 503)
44
51
  simple_detail_template = """<!DOCTYPE html>
45
52
  <html>
46
53
  <head>
@@ -49,10 +56,12 @@ simple_detail_template = """<!DOCTYPE html>
49
56
  </head>
50
57
  <body>
51
58
  <h1>Links for {{ project_name }}</h1>
52
- {% for pkg in project_packages %}
53
- <a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal" {% if pkg.provenance -%}
59
+ {%- for pkg in project_packages %}
60
+ <a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal"
61
+ {%- if pkg.metadata_sha256 %} data-dist-info-metadata="sha256={{ pkg.metadata_sha256 }}"
62
+ {%- endif %} {% if pkg.provenance -%}
54
63
  data-provenance="{{ pkg.provenance }}"{% endif %}>{{ pkg.filename }}</a><br/>
55
- {% endfor %}
64
+ {%- endfor %}
56
65
  </body>
57
66
  </html>
58
67
  """
@@ -200,11 +209,11 @@ def get_project_metadata_from_file(filename):
200
209
  return metadata
201
210
 
202
211
 
203
- def compute_metadata_sha256(filename: str) -> str | None:
212
+ def extract_wheel_metadata(filename: str) -> bytes | None:
204
213
  """
205
- Compute SHA256 hash of the metadata file from a Python package.
214
+ Extract the metadata file content from a wheel file.
206
215
 
207
- Returns SHA256 hash or None if metadata cannot be extracted.
216
+ Returns the raw metadata content as bytes or None if metadata cannot be extracted.
208
217
  """
209
218
  if not filename.endswith(".whl"):
210
219
  return None
@@ -212,13 +221,22 @@ def compute_metadata_sha256(filename: str) -> str | None:
212
221
  with zipfile.ZipFile(filename, "r") as f:
213
222
  for file_path in f.namelist():
214
223
  if file_path.endswith(".dist-info/METADATA"):
215
- metadata_content = f.read(file_path)
216
- return hashlib.sha256(metadata_content).hexdigest()
217
- except (zipfile.BadZipFile, KeyError, OSError):
218
- pass
224
+ return f.read(file_path)
225
+ except (zipfile.BadZipFile, KeyError, OSError) as e:
226
+ log.warning(f"Failed to extract metadata file from {filename}: {e}")
219
227
  return None
220
228
 
221
229
 
230
+ def compute_metadata_sha256(filename: str) -> str | None:
231
+ """
232
+ Compute SHA256 hash of the metadata file from a Python package.
233
+
234
+ Returns SHA256 hash or None if metadata cannot be extracted.
235
+ """
236
+ metadata_content = extract_wheel_metadata(filename)
237
+ return hashlib.sha256(metadata_content).hexdigest() if metadata_content else None
238
+
239
+
222
240
  def artifact_to_python_content_data(filename, artifact, domain=None):
223
241
  """
224
242
  Takes the artifact/filename and returns the metadata needed to create a PythonPackageContent.
@@ -227,6 +245,7 @@ def artifact_to_python_content_data(filename, artifact, domain=None):
227
245
  # because pkginfo validates that the filename has a valid extension before
228
246
  # reading it
229
247
  with tempfile.NamedTemporaryFile("wb", dir=".", suffix=filename) as temp_file:
248
+ artifact.file.seek(0)
230
249
  shutil.copyfileobj(artifact.file, temp_file)
231
250
  temp_file.flush()
232
251
  metadata = get_project_metadata_from_file(temp_file.name)
@@ -239,6 +258,42 @@ def artifact_to_python_content_data(filename, artifact, domain=None):
239
258
  return data
240
259
 
241
260
 
261
+ def artifact_to_metadata_artifact(
262
+ filename: str, artifact: Artifact, tmp_dir: str = "."
263
+ ) -> Artifact | None:
264
+ """
265
+ Creates artifact for metadata from the provided wheel artifact.
266
+ """
267
+ if not filename.endswith(".whl"):
268
+ return None
269
+
270
+ with tempfile.NamedTemporaryFile("wb", dir=tmp_dir, suffix=filename, delete=False) as temp_file:
271
+ temp_wheel_path = temp_file.name
272
+ artifact.file.seek(0)
273
+ shutil.copyfileobj(artifact.file, temp_file)
274
+ temp_file.flush()
275
+
276
+ metadata_content = extract_wheel_metadata(temp_wheel_path)
277
+ if not metadata_content:
278
+ return None
279
+
280
+ with tempfile.NamedTemporaryFile(
281
+ "wb", dir=tmp_dir, suffix=".metadata", delete=False
282
+ ) as temp_md:
283
+ temp_metadata_path = temp_md.name
284
+ temp_md.write(metadata_content)
285
+ temp_md.flush()
286
+
287
+ metadata_artifact = Artifact.init_and_validate(temp_metadata_path)
288
+ try:
289
+ metadata_artifact.save()
290
+ except IntegrityError:
291
+ metadata_artifact = Artifact.objects.get(
292
+ sha256=metadata_artifact.sha256, pulp_domain=get_domain()
293
+ )
294
+ return metadata_artifact
295
+
296
+
242
297
  def fetch_json_release_metadata(name: str, version: str, remotes: set[Remote]) -> dict:
243
298
  """
244
299
  Fetches metadata for a specific release from PyPI's JSON API. A release can contain
@@ -402,7 +457,9 @@ def python_content_to_download_info(content, base_path, domain=None):
402
457
  _art = models.RemoteArtifact.objects.filter(content_artifact=content_artifact).first()
403
458
  return _art
404
459
 
405
- content_artifact = content.contentartifact_set.first()
460
+ content_artifact = content.contentartifact_set.exclude(
461
+ relative_path__endswith=".metadata"
462
+ ).first()
406
463
  artifact = find_artifact()
407
464
  origin = settings.CONTENT_ORIGIN or settings.PYPI_API_HOSTNAME or ""
408
465
  origin = origin.strip("/")
@@ -428,6 +428,19 @@ class PythonPackageSingleArtifactContentUploadViewSet(
428
428
  return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
429
429
 
430
430
 
431
+ class PackageProvenanceFilter(core_viewsets.ContentFilter):
432
+ """
433
+ FilterSet for PackageProvenance.
434
+ """
435
+
436
+ class Meta:
437
+ model = python_models.PackageProvenance
438
+ fields = {
439
+ "package": ["exact", "in"],
440
+ "sha256": ["exact", "in"],
441
+ }
442
+
443
+
431
444
  class PackageProvenanceViewSet(core_viewsets.NoArtifactContentUploadViewSet):
432
445
  """
433
446
  PackageProvenance represents a PEP 740 provenance object for a Python package.
@@ -438,6 +451,7 @@ class PackageProvenanceViewSet(core_viewsets.NoArtifactContentUploadViewSet):
438
451
  endpoint_name = "provenance"
439
452
  queryset = python_models.PackageProvenance.objects.all()
440
453
  serializer_class = python_serializers.PackageProvenanceSerializer
454
+ filterset_class = PackageProvenanceFilter
441
455
 
442
456
  DEFAULT_ACCESS_POLICY = {
443
457
  "statements": [