Skip to content

Commit 6d7bd1c

Browse files
committed
Expose wheel metadata file
1 parent 818d6ad commit 6d7bd1c

File tree

6 files changed

+111
-10
lines changed

6 files changed

+111
-10
lines changed

pulp_python/app/pypi/views.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from pulpcore.plugin.tasking import dispatch
3434
from pulpcore.plugin.util import get_domain, get_url
3535
from pulpcore.plugin.exceptions import TimeoutException
36+
from pulpcore.plugin.models import ContentArtifact
3637
from pulp_python.app.models import (
3738
PythonDistribution,
3839
PythonPackageContent,
@@ -241,7 +242,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
241242
DEFAULT_ACCESS_POLICY = {
242243
"statements": [
243244
{
244-
"action": ["list", "retrieve"],
245+
"action": ["list", "retrieve", "retrieve_metadata"],
245246
"principal": "*",
246247
"effect": "allow",
247248
},
@@ -378,6 +379,29 @@ def retrieve(self, request, path, package):
378379
kwargs = {"content_type": media_type, "headers": headers}
379380
return HttpResponse(detail_data, **kwargs)
380381

382+
# TODO now: extend schema
383+
def retrieve_metadata(self, request, path, filename):
384+
"""Retrieves content of metadata file for a wheel package."""
385+
domain = get_domain()
386+
_, content = self.get_rvc()
387+
388+
try:
389+
package_content = content.get(filename=filename, _pulp_domain=domain)
390+
metadata_ca = ContentArtifact.objects.filter(
391+
content=package_content, relative_path=f"{filename}.metadata"
392+
).first()
393+
394+
if metadata_ca and metadata_ca.artifact:
395+
headers = {"Content-Type": "text/plain; charset=utf-8"}
396+
with metadata_ca.artifact.file.open("rb") as f:
397+
content = f.read()
398+
return HttpResponse(content, headers=headers)
399+
else:
400+
return HttpResponseNotFound(f"Metadata for {filename} not found")
401+
402+
except ObjectDoesNotExist:
403+
return HttpResponseNotFound(f"Package {filename} not found")
404+
381405
@extend_schema(
382406
request=PackageUploadSerializer,
383407
responses={200: PackageUploadTaskSerializer},

pulp_python/app/tasks/upload.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pulpcore.plugin.util import get_domain
88

99
from pulp_python.app.models import PythonPackageContent, PythonRepository
10-
from pulp_python.app.utils import artifact_to_python_content_data
10+
from pulp_python.app.utils import artifact_to_metadata_artifact, artifact_to_python_content_data
1111

1212

1313
def upload(artifact_sha256, filename, repository_pk=None):
@@ -77,11 +77,16 @@ def create_content(artifact_sha256, filename, domain):
7777
"""
7878
artifact = Artifact.objects.get(sha256=artifact_sha256, pulp_domain=domain)
7979
data = artifact_to_python_content_data(filename, artifact, domain)
80+
metadata_artifact = artifact_to_metadata_artifact(filename, artifact)
8081

8182
@transaction.atomic()
8283
def create():
8384
content = PythonPackageContent.objects.create(**data)
8485
ContentArtifact.objects.create(artifact=artifact, content=content, relative_path=filename)
86+
if metadata_artifact:
87+
ContentArtifact.objects.create(
88+
artifact=metadata_artifact, content=content, relative_path=f"{filename}.metadata"
89+
)
8590
return content
8691

8792
new_content = create()

pulp_python/app/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,10 @@
2828
SimpleView.as_view({"get": "list", "post": "create"}),
2929
name="simple-detail",
3030
),
31+
path(
32+
PYPI_API_URL + "<str:filename>.metadata",
33+
SimpleView.as_view({"get": "retrieve_metadata"}),
34+
name="simple-metadata",
35+
),
3136
path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"),
3237
]

pulp_python/app/utils.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import hashlib
2+
import logging
23
import pkginfo
34
import re
45
import shutil
@@ -12,7 +13,10 @@
1213
from packaging.utils import canonicalize_name
1314
from packaging.requirements import Requirement
1415
from packaging.version import parse, InvalidVersion
15-
from pulpcore.plugin.models import Remote
16+
from pulpcore.plugin.models import Artifact, Remote
17+
18+
19+
log = logging.getLogger(__name__)
1620

1721

1822
PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
@@ -199,25 +203,34 @@ def get_project_metadata_from_file(filename):
199203
return metadata
200204

201205

202-
def compute_metadata_sha256(filename: str) -> str | None:
206+
def extract_wheel_metadata(filename: str) -> bytes | None:
203207
"""
204-
Compute SHA256 hash of the metadata file from a Python package.
208+
Extract the metadata file content from a wheel file.
205209
206-
Returns SHA256 hash or None if metadata cannot be extracted.
210+
Returns the raw metadata content as bytes or None if metadata cannot be extracted.
207211
"""
208212
if not filename.endswith(".whl"):
209213
return None
210214
try:
211215
with zipfile.ZipFile(filename, "r") as f:
212216
for file_path in f.namelist():
213217
if file_path.endswith(".dist-info/METADATA"):
214-
metadata_content = f.read(file_path)
215-
return hashlib.sha256(metadata_content).hexdigest()
216-
except (zipfile.BadZipFile, KeyError, OSError):
217-
pass
218+
return f.read(file_path)
219+
except (zipfile.BadZipFile, KeyError, OSError) as e:
220+
log.warning(f"Failed to extract metadata file from {filename}: {e}")
218221
return None
219222

220223

224+
def compute_metadata_sha256(filename: str) -> str | None:
225+
"""
226+
Compute SHA256 hash of the metadata file from a Python package.
227+
228+
Returns SHA256 hash or None if metadata cannot be extracted.
229+
"""
230+
metadata_content = extract_wheel_metadata(filename)
231+
return hashlib.sha256(metadata_content).hexdigest() if metadata_content else None
232+
233+
221234
def artifact_to_python_content_data(filename, artifact, domain=None):
222235
"""
223236
Takes the artifact/filename and returns the metadata needed to create a PythonPackageContent.
@@ -238,6 +251,27 @@ def artifact_to_python_content_data(filename, artifact, domain=None):
238251
return data
239252

240253

254+
def artifact_to_metadata_artifact(filename: str, artifact: Artifact) -> Artifact | None:
255+
"""
256+
Creates artifact for metadata from the provided wheel artifact.
257+
"""
258+
if not filename.endswith(".whl"):
259+
return None
260+
261+
with tempfile.NamedTemporaryFile("wb", dir=".", suffix=filename) as temp_file:
262+
shutil.copyfileobj(artifact.file, temp_file)
263+
temp_file.flush()
264+
metadata_content = extract_wheel_metadata(temp_file.name)
265+
if not metadata_content:
266+
return None
267+
with tempfile.NamedTemporaryFile(suffix=".metadata") as metadata_temp:
268+
metadata_temp.write(metadata_content)
269+
metadata_temp.flush()
270+
metadata_artifact = Artifact.init_and_validate(metadata_temp.name)
271+
metadata_artifact.save()
272+
return metadata_artifact
273+
274+
241275
def fetch_json_release_metadata(name: str, version: str, remotes: set[Remote]) -> dict:
242276
"""
243277
Fetches metadata for a specific release from PyPI's JSON API. A release can contain

pulp_python/app/viewsets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ class Meta:
348348
}
349349

350350

351+
# TODO now: create metadata artifact for sync upload
351352
class PythonPackageSingleArtifactContentUploadViewSet(
352353
core_viewsets.SingleArtifactContentUploadViewSet
353354
):

pulp_python/tests/functional/api/test_pypi_apis.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,38 @@ def test_package_upload_simple(
167167
assert summary.added["python.python"]["count"] == 1
168168

169169

170+
@pytest.mark.parallel
171+
def test_wheel_package_upload_with_metadata(
172+
python_content_summary,
173+
python_empty_repo_distro,
174+
python_package_dist_directory,
175+
monitor_task,
176+
):
177+
"""Tests that the wheel metadata artifact is created during upload."""
178+
repo, distro = python_empty_repo_distro()
179+
url = urljoin(distro.base_url, "simple/")
180+
dist_dir, egg_file, wheel_file = python_package_dist_directory
181+
response = requests.post(
182+
url,
183+
data={"sha256_digest": PYTHON_WHEEL_SHA256},
184+
files={"content": open(wheel_file, "rb")},
185+
auth=("admin", "password"),
186+
)
187+
assert response.status_code == 202
188+
monitor_task(response.json()["task"])
189+
summary = python_content_summary(repository=repo)
190+
assert summary.added["python.python"]["count"] == 1
191+
192+
# Test that metadata is accessible
193+
metadata_url = urljoin(distro.base_url, f"{PYTHON_WHEEL_FILENAME}.metadata")
194+
metadata_response = requests.get(metadata_url)
195+
assert metadata_response.status_code == 200
196+
assert metadata_response.headers["content-type"] == "text/plain; charset=utf-8"
197+
assert len(metadata_response.content) > 0
198+
metadata_text = metadata_response.text
199+
assert "Name: shelf-reader" in metadata_text
200+
201+
170202
@pytest.mark.parallel
171203
def test_twine_upload(
172204
pulpcore_bindings,

0 commit comments

Comments
 (0)