Skip to content

Commit ee9bb4d

Browse files
committed
Expose wheel metadata file
1 parent 1278920 commit ee9bb4d

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
@@ -29,6 +29,7 @@
2929
from pulpcore.plugin.viewsets import OperationPostponedResponse
3030
from pulpcore.plugin.tasking import dispatch
3131
from pulpcore.plugin.util import get_domain, get_url
32+
from pulpcore.plugin.models import ContentArtifact
3233
from pulp_python.app.models import (
3334
PythonDistribution,
3435
PythonPackageContent,
@@ -248,7 +249,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
248249
DEFAULT_ACCESS_POLICY = {
249250
"statements": [
250251
{
251-
"action": ["list", "retrieve"],
252+
"action": ["list", "retrieve", "retrieve_metadata"],
252253
"principal": "*",
253254
"effect": "allow",
254255
},
@@ -392,6 +393,29 @@ def retrieve(self, request, path, package):
392393
kwargs = {"content_type": media_type, "headers": headers}
393394
return HttpResponse(detail_data, **kwargs)
394395

396+
# TODO now: extend schema
397+
def retrieve_metadata(self, request, path, filename):
398+
"""Retrieves content of metadata file for a wheel package."""
399+
domain = get_domain()
400+
_, content = self.get_rvc()
401+
402+
try:
403+
package_content = content.get(filename=filename, _pulp_domain=domain)
404+
metadata_ca = ContentArtifact.objects.filter(
405+
content=package_content, relative_path=f"{filename}.metadata"
406+
).first()
407+
408+
if metadata_ca and metadata_ca.artifact:
409+
headers = {"Content-Type": "text/plain; charset=utf-8"}
410+
with metadata_ca.artifact.file.open("rb") as f:
411+
content = f.read()
412+
return HttpResponse(content, headers=headers)
413+
else:
414+
return HttpResponseNotFound(f"Metadata for {filename} not found")
415+
416+
except ObjectDoesNotExist:
417+
return HttpResponseNotFound(f"Package {filename} not found")
418+
395419
@extend_schema(
396420
request=PackageUploadSerializer,
397421
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
@@ -39,5 +39,10 @@
3939
SimpleView.as_view({"get": "list", "post": "create"}),
4040
name="simple-detail",
4141
),
42+
path(
43+
PYPI_API_URL + "<str:filename>.metadata",
44+
SimpleView.as_view({"get": "retrieve_metadata"}),
45+
name="simple-metadata",
46+
),
4247
path(PYPI_API_URL, PyPIView.as_view({"get": "retrieve"}), name="pypi-detail"),
4348
]

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
@@ -14,10 +15,13 @@
1415
from packaging.requirements import Requirement
1516
from packaging.version import parse, InvalidVersion
1617
from pypi_simple import ACCEPT_JSON_PREFERRED, ProjectPage
17-
from pulpcore.plugin.models import Remote
18+
from pulpcore.plugin.models import Artifact, Remote
1819
from pulpcore.plugin.exceptions import TimeoutException
1920

2021

22+
log = logging.getLogger(__name__)
23+
24+
2125
PYPI_LAST_SERIAL = "X-PYPI-LAST-SERIAL"
2226
"""TODO This serial constant is temporary until Python repositories implements serials"""
2327
PYPI_SERIAL_CONSTANT = 1000000000
@@ -206,25 +210,34 @@ def get_project_metadata_from_file(filename):
206210
return metadata
207211

208212

209-
def compute_metadata_sha256(filename: str) -> str | None:
213+
def extract_wheel_metadata(filename: str) -> bytes | None:
210214
"""
211-
Compute SHA256 hash of the metadata file from a Python package.
215+
Extract the metadata file content from a wheel file.
212216
213-
Returns SHA256 hash or None if metadata cannot be extracted.
217+
Returns the raw metadata content as bytes or None if metadata cannot be extracted.
214218
"""
215219
if not filename.endswith(".whl"):
216220
return None
217221
try:
218222
with zipfile.ZipFile(filename, "r") as f:
219223
for file_path in f.namelist():
220224
if file_path.endswith(".dist-info/METADATA"):
221-
metadata_content = f.read(file_path)
222-
return hashlib.sha256(metadata_content).hexdigest()
223-
except (zipfile.BadZipFile, KeyError, OSError):
224-
pass
225+
return f.read(file_path)
226+
except (zipfile.BadZipFile, KeyError, OSError) as e:
227+
log.warning(f"Failed to extract metadata file from {filename}: {e}")
225228
return None
226229

227230

231+
def compute_metadata_sha256(filename: str) -> str | None:
232+
"""
233+
Compute SHA256 hash of the metadata file from a Python package.
234+
235+
Returns SHA256 hash or None if metadata cannot be extracted.
236+
"""
237+
metadata_content = extract_wheel_metadata(filename)
238+
return hashlib.sha256(metadata_content).hexdigest() if metadata_content else None
239+
240+
228241
def artifact_to_python_content_data(filename, artifact, domain=None):
229242
"""
230243
Takes the artifact/filename and returns the metadata needed to create a PythonPackageContent.
@@ -245,6 +258,27 @@ def artifact_to_python_content_data(filename, artifact, domain=None):
245258
return data
246259

247260

261+
def artifact_to_metadata_artifact(filename: str, artifact: Artifact) -> Artifact | None:
262+
"""
263+
Creates artifact for metadata from the provided wheel artifact.
264+
"""
265+
if not filename.endswith(".whl"):
266+
return None
267+
268+
with tempfile.NamedTemporaryFile("wb", dir=".", suffix=filename) as temp_file:
269+
shutil.copyfileobj(artifact.file, temp_file)
270+
temp_file.flush()
271+
metadata_content = extract_wheel_metadata(temp_file.name)
272+
if not metadata_content:
273+
return None
274+
with tempfile.NamedTemporaryFile(suffix=".metadata") as metadata_temp:
275+
metadata_temp.write(metadata_content)
276+
metadata_temp.flush()
277+
metadata_artifact = Artifact.init_and_validate(metadata_temp.name)
278+
metadata_artifact.save()
279+
return metadata_artifact
280+
281+
248282
def fetch_json_release_metadata(name: str, version: str, remotes: set[Remote]) -> dict:
249283
"""
250284
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
@@ -137,6 +137,38 @@ def test_package_upload_simple(
137137
assert summary.added["python.python"]["count"] == 1
138138

139139

140+
@pytest.mark.parallel
141+
def test_wheel_package_upload_with_metadata(
142+
python_content_summary,
143+
python_empty_repo_distro,
144+
python_package_dist_directory,
145+
monitor_task,
146+
):
147+
"""Tests that the wheel metadata artifact is created during upload."""
148+
repo, distro = python_empty_repo_distro()
149+
url = urljoin(distro.base_url, "simple/")
150+
dist_dir, egg_file, wheel_file = python_package_dist_directory
151+
response = requests.post(
152+
url,
153+
data={"sha256_digest": PYTHON_WHEEL_SHA256},
154+
files={"content": open(wheel_file, "rb")},
155+
auth=("admin", "password"),
156+
)
157+
assert response.status_code == 202
158+
monitor_task(response.json()["task"])
159+
summary = python_content_summary(repository=repo)
160+
assert summary.added["python.python"]["count"] == 1
161+
162+
# Test that metadata is accessible
163+
metadata_url = urljoin(distro.base_url, f"{PYTHON_WHEEL_FILENAME}.metadata")
164+
metadata_response = requests.get(metadata_url)
165+
assert metadata_response.status_code == 200
166+
assert metadata_response.headers["content-type"] == "text/plain; charset=utf-8"
167+
assert len(metadata_response.content) > 0
168+
metadata_text = metadata_response.text
169+
assert "Name: shelf-reader" in metadata_text
170+
171+
140172
@pytest.mark.parallel
141173
def test_twine_upload(
142174
pulpcore_bindings,

0 commit comments

Comments
 (0)