Skip to content

Commit 8d0a351

Browse files
committed
Add synchronous upload API
closes #933
1 parent 2a40538 commit 8d0a351

File tree

8 files changed

+196
-22
lines changed

8 files changed

+196
-22
lines changed

CHANGES/933.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a synchronous upload API.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 4.2.23 on 2025-08-19 17:10
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("python", "0014_pythonpackagecontent_dynamic_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name="pythonpackagecontent",
15+
options={
16+
"default_related_name": "%(app_label)s_%(model_name)s",
17+
"permissions": [
18+
("upload_python_packages", "Can upload Python packages using synchronous API.")
19+
],
20+
},
21+
),
22+
]

pulp_python/app/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ def __str__(self):
227227
class Meta:
228228
default_related_name = "%(app_label)s_%(model_name)s"
229229
unique_together = ("sha256", "_pulp_domain")
230+
permissions = [
231+
("upload_python_packages", "Can upload Python packages using synchronous API."),
232+
]
230233

231234

232235
class PythonPublication(Publication, AutoAddObjPermsMixin):

pulp_python/app/serializers.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import logging
2+
import shutil
3+
import tempfile
14
from gettext import gettext as _
25
from django.conf import settings
6+
from django.db.utils import IntegrityError
37
from packaging.requirements import Requirement
48
from rest_framework import serializers
59

@@ -8,7 +12,13 @@
812
from pulpcore.plugin.util import get_domain
913

1014
from pulp_python.app import models as python_models
11-
from pulp_python.app.utils import artifact_to_python_content_data
15+
from pulp_python.app.utils import (
16+
DIST_EXTENSIONS,
17+
artifact_to_python_content_data,
18+
get_project_metadata_from_file,
19+
)
20+
21+
log = logging.getLogger(__name__)
1222

1323

1424
class PythonRepositorySerializer(core_serializers.RepositorySerializer):
@@ -215,7 +225,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
215225
required=False,
216226
allow_blank=True,
217227
help_text=_(
218-
"A string stating the markup syntax (if any) used in the distributions"
228+
"A string stating the markup syntax (if any) used in the distribution's"
219229
" description, so that tools can intelligently render the description."
220230
),
221231
)
@@ -357,6 +367,61 @@ class Meta:
357367
model = python_models.PythonPackageContent
358368

359369

370+
class PythonPackageContentUploadSerializer(PythonPackageContentSerializer):
371+
"""
372+
A serializer for requests to synchronously upload Python packages.
373+
"""
374+
375+
class Meta(PythonPackageContentSerializer.Meta):
376+
# This API does not support uploading to a repository or using a custom relative_path
377+
fields = tuple(
378+
f
379+
for f in PythonPackageContentSerializer.Meta.fields
380+
if f not in ["repository", "relative_path"]
381+
)
382+
model = python_models.PythonPackageContent
383+
# Name used for the OpenAPI request object
384+
ref_name = "PythonPackageContentUpload"
385+
386+
def validate(self, data):
387+
file = data.pop("file")
388+
389+
for ext, packagetype in DIST_EXTENSIONS.items():
390+
if file.name.endswith(ext):
391+
break
392+
else:
393+
raise serializers.ValidationError(
394+
_(
395+
"Extension on {} is not a valid python extension "
396+
"(.whl, .exe, .egg, .tar.gz, .tar.bz2, .zip)"
397+
).format(file.name)
398+
)
399+
400+
artifact = core_models.Artifact.init_and_validate(file)
401+
try:
402+
artifact.save()
403+
except IntegrityError:
404+
artifact = core_models.Artifact.objects.get(
405+
sha256=artifact.sha256, pulp_domain=get_domain()
406+
)
407+
artifact.touch()
408+
log.info(f"Artifact for {file.name} already existed in database")
409+
410+
filename = file.name
411+
with tempfile.NamedTemporaryFile("wb", dir=".", suffix=filename) as temp_file:
412+
shutil.copyfileobj(artifact.file, temp_file)
413+
temp_file.flush()
414+
metadata = get_project_metadata_from_file(temp_file.name)
415+
416+
data["artifact"] = artifact
417+
data["sha256"] = artifact.sha256
418+
data["relative_path"] = file.name
419+
data.update(vars(metadata))
420+
# Overwrite filename from metadata
421+
data["filename"] = filename
422+
return data
423+
424+
360425
class MinimalPythonPackageContentSerializer(PythonPackageContentSerializer):
361426
"""
362427
A Serializer for PythonPackageContent.

pulp_python/app/utils.py

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def parse_metadata(project, version, distribution):
162162
return package
163163

164164

165-
def get_project_metadata_from_artifact(filename, artifact):
165+
def get_project_metadata_from_file(filename):
166166
"""
167167
Gets the metadata of a Python Package.
168168
@@ -173,30 +173,31 @@ def get_project_metadata_from_artifact(filename, artifact):
173173
# If no supported extension is found, ValueError is raised here
174174
pkg_type_index = [filename.endswith(ext) for ext in extensions].index(True)
175175
packagetype = DIST_EXTENSIONS[extensions[pkg_type_index]]
176-
# Copy file to a temp directory under the user provided filename, we do this
177-
# because pkginfo validates that the filename has a valid extension before
178-
# reading it
179-
with tempfile.NamedTemporaryFile("wb", dir=".", suffix=filename) as temp_file:
180-
shutil.copyfileobj(artifact.file, temp_file)
181-
temp_file.flush()
182-
metadata = DIST_TYPES[packagetype](temp_file.name)
183-
metadata.packagetype = packagetype
184-
if packagetype == "sdist":
185-
metadata.python_version = "source"
186-
else:
187-
pyver = ""
188-
regex = DIST_REGEXES[extensions[pkg_type_index]]
189-
if bdist_name := regex.match(filename):
190-
pyver = bdist_name.group("pyver") or ""
191-
metadata.python_version = pyver
192-
return metadata
176+
177+
metadata = DIST_TYPES[packagetype](filename)
178+
metadata.packagetype = packagetype
179+
if packagetype == "sdist":
180+
metadata.python_version = "source"
181+
else:
182+
pyver = ""
183+
regex = DIST_REGEXES[extensions[pkg_type_index]]
184+
if bdist_name := regex.match(filename):
185+
pyver = bdist_name.group("pyver") or ""
186+
metadata.python_version = pyver
187+
return metadata
193188

194189

195190
def artifact_to_python_content_data(filename, artifact, domain=None):
196191
"""
197192
Takes the artifact/filename and returns the metadata needed to create a PythonPackageContent.
198193
"""
199-
metadata = get_project_metadata_from_artifact(filename, artifact)
194+
# Copy file to a temp directory under the user provided filename, we do this
195+
# because pkginfo validates that the filename has a valid extension before
196+
# reading it
197+
with tempfile.NamedTemporaryFile("wb", dir=".", suffix=filename) as temp_file:
198+
shutil.copyfileobj(artifact.file, temp_file)
199+
temp_file.flush()
200+
metadata = get_project_metadata_from_file(temp_file.name)
200201
data = parse_project_metadata(vars(metadata))
201202
data["sha256"] = artifact.sha256
202203
data["filename"] = filename

pulp_python/app/viewsets.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from bandersnatch.configuration import BandersnatchConfig
2+
from django.db import transaction
23
from drf_spectacular.utils import extend_schema
34
from rest_framework import status
45
from rest_framework.decorators import action
@@ -355,10 +356,47 @@ class PythonPackageSingleArtifactContentUploadViewSet(
355356
"has_upload_param_model_or_domain_or_obj_perms:core.change_upload",
356357
],
357358
},
359+
{
360+
"action": ["upload"],
361+
"principal": "authenticated",
362+
"effect": "allow",
363+
"condition": [
364+
"has_model_or_domain_perms:python.upload_python_packages",
365+
],
366+
},
358367
],
359368
"queryset_scoping": {"function": "scope_queryset"},
360369
}
361370

371+
LOCKED_ROLES = {
372+
"python.python_package_uploader": [
373+
"python.upload_python_packages",
374+
],
375+
}
376+
377+
@extend_schema(
378+
summary="Synchronous Python package upload",
379+
request=python_serializers.PythonPackageContentUploadSerializer,
380+
responses={201: python_serializers.PythonPackageContentSerializer},
381+
)
382+
@action(
383+
detail=False,
384+
methods=["post"],
385+
serializer_class=python_serializers.PythonPackageContentUploadSerializer,
386+
)
387+
def upload(self, request):
388+
"""Create a PPC."""
389+
serializer = self.get_serializer(data=request.data)
390+
391+
with transaction.atomic():
392+
# Create the artifact
393+
serializer.is_valid(raise_exception=True)
394+
# Create the Package (ContentArtifact, Content, PPC)
395+
serializer.save()
396+
397+
headers = self.get_success_headers(serializer.data)
398+
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
399+
362400

363401
class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin):
364402
"""
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pytest
2+
from pulp_python.tests.functional.constants import (
3+
PYTHON_EGG_FILENAME,
4+
PYTHON_EGG_URL,
5+
PYTHON_WHEEL_FILENAME,
6+
PYTHON_WHEEL_URL,
7+
)
8+
9+
10+
@pytest.mark.parametrize(
11+
"pkg_filename, pkg_url",
12+
[(PYTHON_WHEEL_FILENAME, PYTHON_WHEEL_URL), (PYTHON_EGG_FILENAME, PYTHON_EGG_URL)],
13+
)
14+
def test_synchronous_package_upload(
15+
delete_orphans_pre, download_python_file, gen_user, python_bindings, pkg_filename, pkg_url
16+
):
17+
"""
18+
Test synchronously uploading a Python package with labels.
19+
"""
20+
python_file = download_python_file(pkg_filename, pkg_url)
21+
22+
# Upload a unit with labels
23+
with gen_user(model_roles=["python.python_package_uploader"]):
24+
labels = {"key_1": "value_1"}
25+
content_body = {"file": python_file, "pulp_labels": labels}
26+
package = python_bindings.ContentPackagesApi.upload(**content_body)
27+
assert package.pulp_labels == labels
28+
assert package.name == "shelf-reader"
29+
assert package.filename == pkg_filename
30+
31+
# Check that uploading the same unit again with different (or same) labels has no effect
32+
with gen_user(model_roles=["python.python_package_uploader"]):
33+
labels_2 = {"key_2": "value_2"}
34+
content_body_2 = {"file": python_file, "pulp_labels": labels_2}
35+
duplicate_package = python_bindings.ContentPackagesApi.upload(**content_body_2)
36+
assert duplicate_package.pulp_href == package.pulp_href
37+
assert duplicate_package.pulp_labels == package.pulp_labels
38+
assert duplicate_package.pulp_labels != labels_2
39+
40+
# Check that the upload fails if the user does not have the required permissions
41+
with gen_user(model_roles=[]):
42+
with pytest.raises(python_bindings.ApiException) as ctx:
43+
python_bindings.ContentPackagesApi.upload(**content_body)
44+
assert ctx.value.status == 403

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ classifiers=[
2626
]
2727
requires-python = ">=3.11"
2828
dependencies = [
29-
"pulpcore>=3.49.0,<3.100",
29+
"pulpcore>=3.81.0,<3.100",
3030
"pkginfo>=1.12.0,<1.13.0",
3131
"bandersnatch>=6.3.0,<6.6", # 6.6 has breaking changes
3232
"pypi-simple>=1.5.0,<2.0",

0 commit comments

Comments
 (0)