Skip to content

Commit 339fa59

Browse files
authored
Merge pull request #942 from jobselko/fix_933
[PULP-731] Add synchronous upload API
2 parents 105dd46 + fc32363 commit 339fa59

File tree

7 files changed

+191
-6
lines changed

7 files changed

+191
-6
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: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import logging
2+
import os
13
from gettext import gettext as _
24
from django.conf import settings
5+
from django.db.utils import IntegrityError
36
from packaging.requirements import Requirement
47
from rest_framework import serializers
58

@@ -8,7 +11,15 @@
811
from pulpcore.plugin.util import get_domain
912

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

1324

1425
class PythonRepositorySerializer(core_serializers.RepositorySerializer):
@@ -207,15 +218,15 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
207218
required=False,
208219
allow_blank=True,
209220
help_text=_(
210-
"The Python version(s) that the distribution is guaranteed to be " "compatible with."
221+
"The Python version(s) that the distribution is guaranteed to be compatible with."
211222
),
212223
)
213224
# Version 2.1
214225
description_content_type = serializers.CharField(
215226
required=False,
216227
allow_blank=True,
217228
help_text=_(
218-
"A string stating the markup syntax (if any) used in the distributions"
229+
"A string stating the markup syntax (if any) used in the distribution's"
219230
" description, so that tools can intelligently render the description."
220231
),
221232
)
@@ -256,7 +267,7 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
256267
)
257268
packagetype = serializers.CharField(
258269
help_text=_(
259-
"The type of the distribution package " "(e.g. sdist, bdist_wheel, bdist_egg, etc)"
270+
"The type of the distribution package (e.g. sdist, bdist_wheel, bdist_egg, etc)"
260271
),
261272
read_only=True,
262273
)
@@ -357,6 +368,70 @@ class Meta:
357368
model = python_models.PythonPackageContent
358369

359370

371+
class PythonPackageContentUploadSerializer(PythonPackageContentSerializer):
372+
"""
373+
A serializer for requests to synchronously upload Python packages.
374+
"""
375+
376+
def validate(self, data):
377+
"""
378+
Validates an uploaded Python package file, extracts its metadata,
379+
and creates or retrieves an associated Artifact.
380+
381+
Returns updated data with artifact and metadata details.
382+
"""
383+
file = data.pop("file")
384+
filename = file.name
385+
386+
for ext, packagetype in DIST_EXTENSIONS.items():
387+
if filename.endswith(ext):
388+
break
389+
else:
390+
raise serializers.ValidationError(
391+
_(
392+
"Extension on {} is not a valid python extension "
393+
"(.whl, .exe, .egg, .tar.gz, .tar.bz2, .zip)"
394+
).format(filename)
395+
)
396+
397+
# Replace the incorrect file name in the file path with the original file name
398+
original_filepath = file.file.name
399+
path_to_file, tmp_str = original_filepath.rsplit("/", maxsplit=1)
400+
tmp_str = tmp_str.split(".", maxsplit=1)[0] # Remove e.g. ".upload.gz" suffix
401+
new_filepath = f"{path_to_file}/{tmp_str}{filename}"
402+
os.rename(original_filepath, new_filepath)
403+
404+
metadata = get_project_metadata_from_file(new_filepath)
405+
artifact = core_models.Artifact.init_and_validate(new_filepath)
406+
try:
407+
artifact.save()
408+
except IntegrityError:
409+
artifact = core_models.Artifact.objects.get(
410+
sha256=artifact.sha256, pulp_domain=get_domain()
411+
)
412+
artifact.touch()
413+
log.info(f"Artifact for {file.name} already existed in database")
414+
415+
data["artifact"] = artifact
416+
data["sha256"] = artifact.sha256
417+
data["relative_path"] = filename
418+
data.update(parse_project_metadata(vars(metadata)))
419+
# Overwrite filename from metadata
420+
data["filename"] = filename
421+
return data
422+
423+
class Meta(PythonPackageContentSerializer.Meta):
424+
# This API does not support uploading to a repository or using a custom relative_path
425+
fields = tuple(
426+
f
427+
for f in PythonPackageContentSerializer.Meta.fields
428+
if f not in ["repository", "relative_path"]
429+
)
430+
model = python_models.PythonPackageContent
431+
# Name used for the OpenAPI request object
432+
ref_name = "PythonPackageContentUpload"
433+
434+
360435
class MinimalPythonPackageContentSerializer(PythonPackageContentSerializer):
361436
"""
362437
A Serializer for PythonPackageContent.
@@ -503,7 +578,7 @@ class PythonPublicationSerializer(core_serializers.PublicationSerializer):
503578

504579
distributions = core_serializers.DetailRelatedField(
505580
help_text=_(
506-
"This publication is currently being hosted as configured by these " "distributions."
581+
"This publication is currently being hosted as configured by these distributions."
507582
),
508583
source="distribution_set",
509584
view_name="pythondistributions-detail",

pulp_python/app/viewsets.py

Lines changed: 40 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,49 @@ 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+
"""
389+
Create a Python package.
390+
"""
391+
serializer = self.get_serializer(data=request.data)
392+
393+
with transaction.atomic():
394+
# Create the artifact
395+
serializer.is_valid(raise_exception=True)
396+
# Create the package
397+
serializer.save()
398+
399+
headers = self.get_success_headers(serializer.data)
400+
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
401+
362402

363403
class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin):
364404
"""
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)