pulp-python 3.22.2__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.
- {pulp_python-3.22.2 → pulp_python-3.23.0}/CHANGES.md +10 -2
- {pulp_python-3.22.2 → pulp_python-3.23.0}/PKG-INFO +2 -2
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/__init__.py +1 -1
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/management/commands/repair-python-metadata.py +7 -1
- pulp_python-3.23.0/pulp_python/app/migrations/0019_create_missing_metadata_artifacts.py +201 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/pypi/views.py +0 -2
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/serializers.py +45 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/tasks/repair.py +11 -3
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/tasks/sync.py +24 -2
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/tasks/upload.py +6 -1
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/utils.py +70 -13
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_crud_content_unit.py +22 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_export_import.py +0 -1
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_pypi_apis.py +32 -22
- pulp_python-3.22.2/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
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_sync.py +19 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_upload.py +25 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/constants.py +4 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/utils.py +38 -1
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python.egg-info/PKG-INFO +2 -2
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python.egg-info/SOURCES.txt +2 -1
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python.egg-info/requires.txt +1 -1
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pyproject.toml +3 -3
- {pulp_python-3.22.2 → pulp_python-3.23.0}/COMMITMENT +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/COPYRIGHT +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/LICENSE +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/MANIFEST.in +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/README.md +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/functest_requirements.txt +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/global_access_conditions.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/management/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/management/commands/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0001_initial.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0001_squashed_0010_update_json_field.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0002_pythonpackagecontent_python_version.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0003_new_sync_filters.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0004_DATA_swap_distribution_model.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0005_pythonpackagecontent_sha256.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0006_pythonrepository_autopublish.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0007_pythonpackagecontent_mv-2-1.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0008_pythonpackagecontent_unique_sha256.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0009_pythondistribution_allow_uploads.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0010_update_json_field.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0011_alter_pythondistribution_distribution_ptr_and_more.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0012_add_domain.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0013_add_rbac_permissions.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0014_pythonpackagecontent_dynamic_and_more.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0015_alter_pythonpackagecontent_options.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0016_pythonpackagecontent_metadata_sha256.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0017_pythonpackagecontent_size.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/0018_packageprovenance.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/migrations/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/modelresource.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/models.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/provenance.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/pypi/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/pypi/serializers.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/replica.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/settings.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/tasks/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/tasks/publish.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/tasks/vulnerability_report.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/urls.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/viewsets.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/webserver_snippets/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/webserver_snippets/apache.conf +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/app/webserver_snippets/nginx.conf +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/pytest_plugin.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_attestations.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_auto_publish.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_consume_content.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_crud_publications.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_crud_remotes.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_domains.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_download_content.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_full_mirror.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_rbac.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_repair.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_vulnerability_report.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/assets/shelf-reader-0.1.tar.gz.publish.attestation +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/assets/shelf_reader-0.1-py2-none-any.whl.publish.attestation +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/unit/__init__.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/unit/test_models.py +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python.egg-info/dependency_links.txt +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python.egg-info/entry_points.txt +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python.egg-info/top_level.txt +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/setup.cfg +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/test_requirements.txt +0 -0
- {pulp_python-3.22.2 → pulp_python-3.23.0}/unittest_requirements.txt +0 -0
|
@@ -8,10 +8,18 @@
|
|
|
8
8
|
|
|
9
9
|
[//]: # (towncrier release notes start)
|
|
10
10
|
|
|
11
|
-
## 3.
|
|
11
|
+
## 3.23.0 (2026-01-06) {: #3.23.0 }
|
|
12
12
|
|
|
13
|
-
####
|
|
13
|
+
#### Features {: #3.23.0-feature }
|
|
14
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)
|
|
15
23
|
- Added missing Provenance content `package` and `sha256` filters.
|
|
16
24
|
|
|
17
25
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pulp-python
|
|
3
|
-
Version: 3.
|
|
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.
|
|
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
|
|
@@ -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,
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
53
|
-
<a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal"
|
|
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
|
-
{
|
|
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
|
|
212
|
+
def extract_wheel_metadata(filename: str) -> bytes | None:
|
|
204
213
|
"""
|
|
205
|
-
|
|
214
|
+
Extract the metadata file content from a wheel file.
|
|
206
215
|
|
|
207
|
-
Returns
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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("/")
|
{pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_crud_content_unit.py
RENAMED
|
@@ -10,7 +10,10 @@ from pulp_python.tests.functional.constants import (
|
|
|
10
10
|
PYTHON_EGG_FILENAME,
|
|
11
11
|
PYTHON_EGG_URL,
|
|
12
12
|
PYTHON_SM_FIXTURE_CHECKSUMS,
|
|
13
|
+
PYTHON_WHEEL_FILENAME,
|
|
14
|
+
PYTHON_WHEEL_URL,
|
|
13
15
|
)
|
|
16
|
+
from pulp_python.tests.functional.utils import ensure_metadata
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
def test_content_crud(
|
|
@@ -179,3 +182,22 @@ def test_upload_metadata_24_spec(python_content_factory):
|
|
|
179
182
|
assert content.license_expression == "MIT"
|
|
180
183
|
assert content.license_file == '["LICENSE"]'
|
|
181
184
|
break
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.parallel
|
|
188
|
+
def test_package_creation_with_metadata(
|
|
189
|
+
pulp_content_url,
|
|
190
|
+
python_content_factory,
|
|
191
|
+
python_distribution_factory,
|
|
192
|
+
python_repo,
|
|
193
|
+
):
|
|
194
|
+
"""
|
|
195
|
+
Test that the creation of a Python wheel package creates a metadata artifact.
|
|
196
|
+
"""
|
|
197
|
+
python_content_factory(
|
|
198
|
+
repository=python_repo, relative_path=PYTHON_WHEEL_FILENAME, url=PYTHON_WHEEL_URL
|
|
199
|
+
)
|
|
200
|
+
distro = python_distribution_factory(repository=python_repo)
|
|
201
|
+
|
|
202
|
+
# Test that metadata is accessible
|
|
203
|
+
ensure_metadata(pulp_content_url, distro.base_path, PYTHON_WHEEL_FILENAME)
|
{pulp_python-3.22.2 → pulp_python-3.23.0}/pulp_python/tests/functional/api/test_export_import.py
RENAMED
|
@@ -16,7 +16,6 @@ from pulp_python.tests.functional.constants import (
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
pytestmark = [
|
|
19
|
-
pytest.mark.skipif(settings.DOMAIN_ENABLED, reason="Domains do not support export."),
|
|
20
19
|
pytest.mark.skipif(
|
|
21
20
|
"/tmp" not in settings.ALLOWED_EXPORT_PATHS,
|
|
22
21
|
reason="Cannot run export-tests unless /tmp is in ALLOWED_EXPORT_PATHS "
|