Skip to content

Commit 21ad578

Browse files
committed
Add synchronous upload API
closes #933
1 parent 2a40538 commit 21ad578

File tree

5 files changed

+165
-22
lines changed

5 files changed

+165
-22
lines changed

pulp_python/app/models.py

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

231235

232236
class PythonPublication(Publication, AutoAddObjPermsMixin):

pulp_python/app/serializers.py

Lines changed: 61 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,57 @@ 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.update(vars(metadata))
412+
# e.g. '/var/lib/pulp/tmp/tmphexlltu1.upload.gz'
413+
data["relative_path"] = vars(metadata)["filename"]
414+
# e.g. 'shelf-reader-0.1.tar.gz' instead of '/var/lib/pulp/tmp/tmphexlltu1.upload.gz'
415+
data["filename"] = file.name
416+
return data
417+
418+
360419
class MinimalPythonPackageContentSerializer(PythonPackageContentSerializer):
361420
"""
362421
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: 42 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,51 @@ 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+
# todo: proper conditions (copied from "create" for now)
364+
"condition": [
365+
"has_required_repo_perms_on_upload:python.modify_pythonrepository",
366+
"has_required_repo_perms_on_upload:python.view_pythonrepository",
367+
"has_upload_param_model_or_domain_or_obj_perms:core.change_upload",
368+
],
369+
},
358370
],
359371
"queryset_scoping": {"function": "scope_queryset"},
360372
}
361373

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

363405
class PythonRemoteViewSet(core_viewsets.RemoteViewSet, core_viewsets.RolesMixin):
364406
"""
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from pulp_python.tests.functional.constants import (
2+
PYTHON_EGG_FILENAME,
3+
PYTHON_EGG_URL,
4+
)
5+
6+
7+
def test_synchronous_package_upload(
8+
delete_orphans_pre, download_python_file, gen_user, python_bindings
9+
):
10+
"""
11+
Test synchronously uploading a Python package with labels.
12+
"""
13+
# todo: gen_user and perms?
14+
download_python_file(PYTHON_EGG_FILENAME, PYTHON_EGG_URL)
15+
16+
# Upload a unit wih labels
17+
labels = {"key_1": "value_1"}
18+
content_body = {"file": PYTHON_EGG_FILENAME, "pulp_labels": labels}
19+
package = python_bindings.ContentPackagesApi.upload(**content_body)
20+
assert package.pulp_labels == labels
21+
assert package.name == "shelf-reader"
22+
assert package.filename == "shelf-reader-0.1.tar.gz"
23+
24+
# Check that uploading the same unit again with different (or same) labels does nothing
25+
labels_2 = {"key_2": "value_2"}
26+
content_body_2 = {"file": PYTHON_EGG_FILENAME, "pulp_labels": labels_2}
27+
duplicate_package = python_bindings.ContentPackagesApi.upload(**content_body_2)
28+
assert duplicate_package.pulp_href == package.pulp_href
29+
assert duplicate_package.pulp_labels == package.pulp_labels
30+
assert duplicate_package.pulp_labels != labels_2
31+
32+
# todo: should work (i.e. fail) once perms are used correctly in the code
33+
# # Check that the upload fails if user does not have perms
34+
# with pytest.raises(python_bindings.ApiException) as ctx:
35+
# duplicate_package_2 = python_bindings.ContentPackagesApi.upload(**content_body)
36+
# assert ctx.value.status == 403

0 commit comments

Comments
 (0)