Skip to content

Commit 3cfc249

Browse files
committed
Add synchronous upload API
closes #933
1 parent 2a40538 commit 3cfc249

File tree

7 files changed

+183
-22
lines changed

7 files changed

+183
-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: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import logging
12
from gettext import gettext as _
23
from django.conf import settings
4+
from django.db.utils import IntegrityError
35
from packaging.requirements import Requirement
46
from rest_framework import serializers
57

@@ -8,7 +10,13 @@
810
from pulpcore.plugin.util import get_domain
911

1012
from pulp_python.app import models as python_models
11-
from pulp_python.app.utils import artifact_to_python_content_data
13+
from pulp_python.app.utils import (
14+
DIST_EXTENSIONS,
15+
artifact_to_python_content_data,
16+
get_project_metadata_from_file,
17+
)
18+
19+
log = logging.getLogger(__name__)
1220

1321

1422
class PythonRepositorySerializer(core_serializers.RepositorySerializer):
@@ -215,7 +223,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
215223
required=False,
216224
allow_blank=True,
217225
help_text=_(
218-
"A string stating the markup syntax (if any) used in the distributions"
226+
"A string stating the markup syntax (if any) used in the distribution's"
219227
" description, so that tools can intelligently render the description."
220228
),
221229
)
@@ -357,6 +365,55 @@ class Meta:
357365
model = python_models.PythonPackageContent
358366

359367

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

pulp_python/app/utils.py

Lines changed: 22 additions & 20 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(file):
166166
"""
167167
Gets the metadata of a Python Package.
168168
@@ -171,32 +171,34 @@ def get_project_metadata_from_artifact(filename, artifact):
171171
extensions = list(DIST_EXTENSIONS.keys())
172172
# Iterate through extensions since splitext does not support things like .tar.gz
173173
# If no supported extension is found, ValueError is raised here
174-
pkg_type_index = [filename.endswith(ext) for ext in extensions].index(True)
174+
pkg_type_index = [file.name.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+
# todo (note): file.name was replaced by file.file.name
178+
metadata = DIST_TYPES[packagetype](file.file.name)
179+
metadata.packagetype = packagetype
180+
if packagetype == "sdist":
181+
metadata.python_version = "source"
182+
else:
183+
pyver = ""
184+
regex = DIST_REGEXES[extensions[pkg_type_index]]
185+
if bdist_name := regex.match(file.name):
186+
pyver = bdist_name.group("pyver") or ""
187+
metadata.python_version = pyver
188+
return metadata
193189

194190

195191
def artifact_to_python_content_data(filename, artifact, domain=None):
196192
"""
197193
Takes the artifact/filename and returns the metadata needed to create a PythonPackageContent.
198194
"""
199-
metadata = get_project_metadata_from_artifact(filename, artifact)
195+
# Copy file to a temp directory under the user provided filename, we do this
196+
# because pkginfo validates that the filename has a valid extension before
197+
# reading it
198+
with tempfile.NamedTemporaryFile("wb", dir=".", suffix=filename) as temp_file:
199+
shutil.copyfileobj(artifact.file, temp_file)
200+
temp_file.flush()
201+
metadata = get_project_metadata_from_file(temp_file)
200202
data = parse_project_metadata(vars(metadata))
201203
data["sha256"] = artifact.sha256
202204
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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
from pulp_python.tests.functional.constants import (
3+
PYTHON_EGG_FILENAME,
4+
PYTHON_EGG_URL,
5+
)
6+
7+
8+
def test_synchronous_package_upload(
9+
delete_orphans_pre, download_python_file, gen_user, python_bindings
10+
):
11+
"""
12+
Test synchronously uploading a Python package with labels.
13+
"""
14+
python_file = download_python_file(PYTHON_EGG_FILENAME, PYTHON_EGG_URL)
15+
16+
# Upload a unit with labels
17+
with gen_user(model_roles=["python.python_package_uploader"]):
18+
labels = {"key_1": "value_1"}
19+
content_body = {"file": python_file, "pulp_labels": labels}
20+
package = python_bindings.ContentPackagesApi.upload(**content_body)
21+
assert package.pulp_labels == labels
22+
assert package.name == "shelf-reader"
23+
assert package.filename == "shelf-reader-0.1.tar.gz"
24+
25+
# Check that uploading the same unit again with different (or same) labels has no effect
26+
with gen_user(model_roles=["python.python_package_uploader"]):
27+
labels_2 = {"key_2": "value_2"}
28+
content_body_2 = {"file": python_file, "pulp_labels": labels_2}
29+
duplicate_package = python_bindings.ContentPackagesApi.upload(**content_body_2)
30+
assert duplicate_package.pulp_href == package.pulp_href
31+
assert duplicate_package.pulp_labels == package.pulp_labels
32+
assert duplicate_package.pulp_labels != labels_2
33+
34+
# Check that the upload fails if the user does not have the required permissions
35+
with gen_user(model_roles=[]):
36+
with pytest.raises(python_bindings.ApiException) as ctx:
37+
python_bindings.ContentPackagesApi.upload(**content_body)
38+
assert ctx.value.status == 403

0 commit comments

Comments
 (0)