From c0838afb76d3d54e4a5215b0c7b15046e25c6273 Mon Sep 17 00:00:00 2001 From: Dana Walker Date: Tue, 11 Jun 2019 14:53:43 -0400 Subject: [PATCH] Add one-shot upload Adds one-shot upload feature, optionally specifying a repo Updates tests and docs accordingly fixes #4396 https://pulp.plan.io/issues/4396 --- CHANGES/4396.feature | 2 + docs/_scripts/base.sh | 41 ++++-- docs/_scripts/upload.sh | 17 +++ docs/_scripts/upload_with_repo.sh | 20 +++ docs/workflows/upload.rst | 93 ++++++++---- pulp_python/app/serializers.py | 18 +++ pulp_python/app/tasks/upload.py | 80 ++++++++++ pulp_python/app/urls.py | 8 + pulp_python/app/viewsets.py | 95 +++++------- .../functional/api/test_crud_content_unit.py | 139 +++++++++++++----- pulp_python/tests/functional/constants.py | 3 + shelf_reader-0.1-py2-none-any.whl | Bin 0 -> 22455 bytes 12 files changed, 386 insertions(+), 130 deletions(-) create mode 100644 CHANGES/4396.feature mode change 100644 => 100755 docs/_scripts/base.sh create mode 100755 docs/_scripts/upload.sh create mode 100755 docs/_scripts/upload_with_repo.sh create mode 100644 pulp_python/app/tasks/upload.py create mode 100644 pulp_python/app/urls.py create mode 100644 shelf_reader-0.1-py2-none-any.whl diff --git a/CHANGES/4396.feature b/CHANGES/4396.feature new file mode 100644 index 00000000..93ddbb82 --- /dev/null +++ b/CHANGES/4396.feature @@ -0,0 +1,2 @@ +Users can upload a file to create content and optionally add to a repo in one step known as +one-shot upload \ No newline at end of file diff --git a/docs/_scripts/base.sh b/docs/_scripts/base.sh old mode 100644 new mode 100755 index 18704397..f660fc15 --- a/docs/_scripts/base.sh +++ b/docs/_scripts/base.sh @@ -1,13 +1,34 @@ -export BASE_ADDR=http://localhost:24817 -export CONTENT_ADDR=http://localhost:24816 +#!/usr/bin/env bash -wait_for_pulp() { - unset CREATED_RESOURCE - local task_url=$1 - while [ -z "$CREATED_RESOURCE" ] +echo "Setting environment variables for default hostname/port for the API and the Content app" +export BASE_ADDR=${BASE_ADDR:-http://localhost:24817} +export CONTENT_ADDR=${CONTENT_ADDR:-http://localhost:24816} - do - sleep 1 - export CREATED_RESOURCE=$(http $BASE_ADDR$task_url | jq -r '.created_resources | first') - done +# Necessary for `django-admin` +export DJANGO_SETTINGS_MODULE=pulpcore.app.settings + +# Poll a Pulp task until it is finished. +wait_until_task_finished() { + echo "Polling the task until it has reached a final state." + local task_url=$1 + while true + do + local response=$(http $task_url) + local state=$(jq -r .state <<< ${response}) + jq . <<< "${response}" + case ${state} in + failed|canceled) + echo "Task in final state: ${state}" + exit 1 + ;; + completed) + echo "$task_url complete." + break + ;; + *) + echo "Still waiting..." + sleep 1 + ;; + esac + done } diff --git a/docs/_scripts/upload.sh b/docs/_scripts/upload.sh new file mode 100755 index 00000000..7b8d6975 --- /dev/null +++ b/docs/_scripts/upload.sh @@ -0,0 +1,17 @@ +pclean +prestart + +source ./base.sh + +# Upload your file, optionally specifying a repository +export TASK_URL=$(http --form POST $BASE_ADDR/pulp/api/v3/python/upload/ file@../../shelf_reader-0.1-py2-none-any.whl filename=shelf_reader-0.1-py2-none-any.whl | \ + jq -r '.task') + +wait_until_task_finished $BASE_ADDR$TASK_URL + +# If you want to copy/paste your way through the guide, +# create an environment variable for the repository URI. +export CONTENT_HREF=$(http $BASE_ADDR$TASK_URL | jq -r '.created_resources | first') + +# Let's inspect our newly created content. +http $BASE_ADDR$CONTENT_HREF diff --git a/docs/_scripts/upload_with_repo.sh b/docs/_scripts/upload_with_repo.sh new file mode 100755 index 00000000..b485cd40 --- /dev/null +++ b/docs/_scripts/upload_with_repo.sh @@ -0,0 +1,20 @@ +pclean +prestart + +source base.sh + +source repo.sh + +#Upload your file, optionally specifying a repository +export TASK_URL=$(http --form POST $BASE_ADDR/pulp/api/v3/python/upload/ file@../../shelf_reader-0.1-py2-none-any.whl filename=shelf_reader-0.1-py2-none-any.whl repository=$REPO_HREF | \ + jq -r '.task') + +wait_until_task_finished $BASE_ADDR$TASK_URL + +# If you want to copy/paste your way through the guide, +# create an environment variable for the repository URI. +export CONTENT_HREF=$(http $BASE_ADDR$TASK_URL | \ + jq -r '.created_resources | first') + +#Let's inspect our newly created content. +http $BASE_ADDR$CONTENT_HREF diff --git a/docs/workflows/upload.rst b/docs/workflows/upload.rst index 2bc190cf..bdd9f53c 100644 --- a/docs/workflows/upload.rst +++ b/docs/workflows/upload.rst @@ -1,49 +1,84 @@ Upload Content ============== -Upload a file to Pulp ---------------------- +One-shot upload a file to Pulp +------------------------------ -Each artifact in Pulp represents a file. They can be created during sync or created manually by uploading a file:: +Each artifact in Pulp represents a file. They can be created during sync or created manually by uploading a file via +one-shot upload. One-shot upload takes a file you specify, creates an artifact, and creates content from that artifact. +The python plugin will inspect the file and populate its metadata. - $ export ARTIFACT_HREF=$(http --form POST $BASE_ADDR/pulp/api/v3/artifacts/ file@./shelf_reader-0.1-py2-none-any.whl | jq -r '._href') +.. literalinclude:: ../_scripts/upload.sh + :language: bash -Response:: +Content GET Response:: { - "_href": "/pulp/api/v3/artifacts/1/", - ... + "_artifact": null, + "_created": "2019-07-25T13:57:55.178993Z", + "_href": "/pulp/api/v3/content/python/packages/6172ff0f-3e11-4b5f-8460-bd6a72616747/", + "_type": "python.python", + "author": "", + "author_email": "", + "classifiers": [], + "description": "", + "download_url": "", + "filename": "shelf_reader-0.1-py2-none-any.whl", + "home_page": "", + "keywords": "", + "license": "", + "maintainer": "", + "maintainer_email": "", + "metadata_version": "", + "name": "[]", + "obsoletes_dist": "[]", + "packagetype": "bdist_wheel", + "platform": "", + "project_url": "", + "provides_dist": "[]", + "requires_dist": "[]", + "requires_external": "[]", + "requires_python": "", + "summary": "", + "supported_platform": "", + "version": "0.1" } +Reference: `Python Content Usage <../restapi.html#tag/content>`_ -Reference (pulpcore): `Artifact API Usage -`_ - -Create content from an artifact -------------------------------- +Add content to a repository during one-shot upload +-------------------------------------------------- -Now that Pulp has the wheel, its time to make it into a unit of content. The python plugin will -inspect the file and populate its metadata:: +One-shot upload can also optionally add the content being created to a repository you specify. - $ http POST $BASE_ADDR/pulp/api/v3/content/python/packages/ _artifact=$ARTIFACT_HREF filename=shelf_reader-0.1-py2-none-any.whl +.. literalinclude:: ../_scripts/upload_with_repo.sh + :language: bash -Response:: +Repository GET Response:: { - "_href": "/pulp/api/v3/content/python/packages/1/", - "_artifact": "/pulp/api/v3/artifacts/1/", - "digest": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", - "filename": "shelf_reader-0.1-py2-none-any.whl", - "type": "python" + "_created": "2019-07-25T14:03:48.378437Z", + "_href": "/pulp/api/v3/repositories/135f468f-0c61-4337-9f37-0cd911244bec/versions/1/", + "base_version": null, + "content_summary": { + "added": { + "python.python": { + "count": 1, + "href": "/pulp/api/v3/content/python/packages/?repository_version_added=/pulp/api/v3/repositories/135f468f-0c61-4337-9f37-0cd911244bec/versions/1/" + } + }, + "present": { + "python.python": { + "count": 1, + "href": "/pulp/api/v3/content/python/packages/?repository_version=/pulp/api/v3/repositories/135f468f-0c61-4337-9f37-0cd911244bec/versions/1/" + } + }, + "removed": {} + }, + "number": 1 } -Create a variable for convenience:: - - $ export CONTENT_HREF=$(http $BASE_ADDR/pulp/api/v3/content/python/packages/ | jq -r '.results[] | select(.filename == "shelf_reader-0.1-py2-none-any.whl") | ._href') - -Reference: `Python Content API Usage <../restapi.html#tag/content>`_ -Add content to a repository ---------------------------- +Reference: `Python Repository Usage <../restapi.html#tag/repositories>`_ -See :ref:`add-remove` +For other ways to add content to a repository, see :ref:`add-remove` diff --git a/pulp_python/app/serializers.py b/pulp_python/app/serializers.py index 8522f1de..efb543a3 100644 --- a/pulp_python/app/serializers.py +++ b/pulp_python/app/serializers.py @@ -5,6 +5,7 @@ from rest_framework import serializers from pulpcore.plugin import models as core_models +from pulpcore.plugin.models import Repository from pulpcore.plugin import serializers as core_serializers from pulp_python.app import models as python_models @@ -214,6 +215,23 @@ class Meta: model = python_models.PythonPackageContent +class PythonOneShotUploadSerializer(serializers.Serializer): + """ + A Serializer for PythonOneShotUpload. + """ + + repository = serializers.HyperlinkedRelatedField( + help_text=_('A URI of the repository.'), + required=False, + queryset=Repository.objects.all(), + view_name='repositories-detail', + ) + file = serializers.FileField( + help_text=_("The python file (i.e. .whl or .tar.gz)."), + required=True, + ) + + class MinimalPythonPackageContentSerializer(PythonPackageContentSerializer): """ A Serializer for PythonPackageContent. diff --git a/pulp_python/app/tasks/upload.py b/pulp_python/app/tasks/upload.py new file mode 100644 index 00000000..b2b57cf7 --- /dev/null +++ b/pulp_python/app/tasks/upload.py @@ -0,0 +1,80 @@ +import os +from gettext import gettext as _ +import pkginfo +import shutil +import tempfile + +from pulpcore.plugin.models import Artifact, CreatedResource, Repository, RepositoryVersion +from rest_framework import serializers + +from pulp_python.app.models import PythonPackageContent +from pulp_python.app.utils import parse_project_metadata + + +DIST_EXTENSIONS = { + ".whl": "bdist_wheel", + ".exe": "bdist_wininst", + ".egg": "bdist_egg", + ".tar.bz2": "sdist", + ".tar.gz": "sdist", + ".zip": "sdist", +} + +DIST_TYPES = { + "bdist_wheel": pkginfo.Wheel, + "bdist_wininst": pkginfo.Distribution, + "bdist_egg": pkginfo.BDist, + "sdist": pkginfo.SDist, +} + + +def one_shot_upload(artifact_pk, filename, repository_pk=None): + """ + One shot upload for pulp_python + + Args: + artifact_pk: validated artifact + filename: file name + repository_pk: optional repository to add Content to + """ + # iterate through extensions since splitext does not support things like .tar.gz + for ext, packagetype in DIST_EXTENSIONS.items(): + if filename.endswith(ext): + # Copy file to a temp directory under the user provided filename, we do this + # because pkginfo validates that the filename has a valid extension before + # reading it + with tempfile.TemporaryDirectory() as td: + temp_path = os.path.join(td, filename) + artifact = Artifact.objects.get(pk=artifact_pk) + shutil.copy2(artifact.file.path, temp_path) + metadata = DIST_TYPES[packagetype](temp_path) + metadata.packagetype = packagetype + break + else: + raise serializers.ValidationError(_( + "Extension on {} is not a valid python extension " + "(.whl, .exe, .egg, .tar.gz, .tar.bz2, .zip)").format(filename) + ) + data = parse_project_metadata(vars(metadata)) + data['classifiers'] = [{'name': classifier} for classifier in metadata.classifiers] + data['packagetype'] = metadata.packagetype + data['version'] = metadata.version + data['filename'] = filename + data['_relative_path'] = filename + + new_content = PythonPackageContent.objects.create( + filename=filename, + packagetype=metadata.packagetype, + name=data['classifiers'], + version=data['version'] + ) + + queryset = PythonPackageContent.objects.filter(pk=new_content.pk) + + if repository_pk: + repository = Repository.objects.get(pk=repository_pk) + with RepositoryVersion.create(repository) as new_version: + new_version.add_content(queryset) + + resource = CreatedResource(content_object=new_content) + resource.save() diff --git a/pulp_python/app/urls.py b/pulp_python/app/urls.py new file mode 100644 index 00000000..ead02585 --- /dev/null +++ b/pulp_python/app/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url + +from .viewsets import PythonOneShotUploadViewSet + + +urlpatterns = [ + url(r'python/upload/$', PythonOneShotUploadViewSet.as_view({'post': 'create'})) +] diff --git a/pulp_python/app/viewsets.py b/pulp_python/app/viewsets.py index 16837ee7..87c89ca5 100644 --- a/pulp_python/app/viewsets.py +++ b/pulp_python/app/viewsets.py @@ -1,14 +1,9 @@ -import os from gettext import gettext as _ -import pkginfo -import shutil -import tempfile -from django.db import transaction +from django.db.utils import IntegrityError from drf_yasg.utils import swagger_auto_schema -from rest_framework import status, serializers +from rest_framework import serializers, viewsets from rest_framework.decorators import action -from rest_framework.response import Response from pulpcore.plugin import viewsets as platform from pulpcore.plugin.models import Artifact, RepositoryVersion @@ -21,23 +16,7 @@ from pulp_python.app import models as python_models from pulp_python.app import serializers as python_serializers from pulp_python.app import tasks -from pulp_python.app.utils import parse_project_metadata - -DIST_EXTENSIONS = { - ".whl": "bdist_wheel", - ".exe": "bdist_wininst", - ".egg": "bdist_egg", - ".tar.bz2": "sdist", - ".tar.gz": "sdist", - ".zip": "sdist", -} - -DIST_TYPES = { - "bdist_wheel": pkginfo.Wheel, - "bdist_wininst": pkginfo.Distribution, - "bdist_egg": pkginfo.BDist, - "sdist": pkginfo.SDist, -} +from pulp_python.app.tasks.upload import one_shot_upload class PythonDistributionViewSet(platform.BaseDistributionViewSet): @@ -88,7 +67,15 @@ class PythonPackageContentViewSet(platform.ContentViewSet): minimal_serializer_class = python_serializers.MinimalPythonPackageContentSerializer filterset_class = PythonPackageContentFilter - @transaction.atomic + +class PythonOneShotUploadViewSet(viewsets.ViewSet): + """ + ViewSet for OneShotUpload + """ + + endpoint_name = 'upload' + serializer_class = python_serializers.PythonOneShotUploadSerializer + def create(self, request): """ @@ -98,7 +85,7 @@ def create(self, request): """ try: - artifact = self.get_resource(request.data['_artifact'], Artifact) + artifact = Artifact.init_and_validate(request.data['file']) except KeyError: raise serializers.ValidationError(detail={'_artifact': _('This field is required')}) @@ -107,39 +94,33 @@ def create(self, request): except KeyError: raise serializers.ValidationError(detail={'filename': _('This field is required')}) - # iterate through extensions since splitext does not support things like .tar.gz - for ext, packagetype in DIST_EXTENSIONS.items(): - if filename.endswith(ext): - - # Copy file to a temp directory under the user provided filename, we do this - # because pkginfo validates that the filename has a valid extension before - # reading it - with tempfile.TemporaryDirectory() as td: - temp_path = os.path.join(td, filename) - shutil.copy2(artifact.file.path, temp_path) - metadata = DIST_TYPES[packagetype](temp_path) - metadata.packagetype = packagetype - break + if python_models.PythonPackageContent.objects.filter(filename=filename): + raise serializers.ValidationError(detail={'filename': _('This field must be unique')}) + + if 'repository' in request.data: + serializer = python_serializers.PythonOneShotUploadSerializer( + data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + repository = serializer.validated_data['repository'] + repository_pk = repository.pk else: - raise serializers.ValidationError(_( - "Extension on {} is not a valid python extension " - "(.whl, .exe, .egg, .tar.gz, .tar.bz2, .zip)").format(filename) - ) - - data = parse_project_metadata(vars(metadata)) - data['classifiers'] = [{'name': classifier} for classifier in metadata.classifiers] - data['packagetype'] = metadata.packagetype - data['version'] = metadata.version - data['filename'] = filename - data['_artifact'] = request.data['_artifact'] - data['_relative_path'] = filename - - serializer = self.get_serializer(data=data) - serializer.is_valid(raise_exception=True) - serializer.save() + repository_pk = None + + try: + artifact.save() + except IntegrityError: + artifact = Artifact.objects.get(sha256=artifact.sha256) - headers = self.get_success_headers(request.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + result = enqueue_with_reservation( + one_shot_upload, + [artifact], + kwargs={ + 'artifact_pk': artifact.pk, + 'filename': filename, + 'repository_pk': repository_pk, + } + ) + return platform.OperationPostponedResponse(result, request) class PythonRemoteFilter(platform.RemoteFilter): diff --git a/pulp_python/tests/functional/api/test_crud_content_unit.py b/pulp_python/tests/functional/api/test_crud_content_unit.py index ba8e56f0..67055cc8 100644 --- a/pulp_python/tests/functional/api/test_crud_content_unit.py +++ b/pulp_python/tests/functional/api/test_crud_content_unit.py @@ -3,14 +3,106 @@ from requests.exceptions import HTTPError from pulp_smash import api, config, utils -from pulp_smash.pulp3.constants import ARTIFACTS_PATH +from pulp_smash.pulp3.constants import REPO_PATH from pulp_smash.pulp3.utils import delete_orphans -from pulp_python.tests.functional.constants import PYTHON_CONTENT_PATH, PYTHON_WHEEL_URL -from pulp_python.tests.functional.utils import gen_python_package_attrs, skip_if +from pulp_python.tests.functional.constants import (PYTHON_CONTENT_PATH, PYTHON_UPLOAD_PATH, + PYTHON_WHEEL_URL, PYTHON_WHEEL_FILENAME) +from pulp_python.tests.functional.utils import skip_if from pulp_python.tests.functional.utils import set_up_module as setUpModule # noqa:F401 +class OneShotUploadTestCase(unittest.TestCase): + """ + Test one-shot upload endpoint + """ + + @classmethod + def setUp(cls): + """ + Create class-wide variable. + """ + cls.cfg = config.get_config() + delete_orphans(cls.cfg) + cls.client = api.Client(cls.cfg, api.json_handler) + cls.test_file = {'file': utils.http_get(PYTHON_WHEEL_URL)} + + @classmethod + def tearDown(cls): + """ + Clean class-wide variable. + """ + delete_orphans(cls.cfg) + + def test_01_upload_file_without_repo(self): + """ + 1) returns a task + 2) check task status complete + 3) check created resource of completed task + 4) ensure only one and it's a content unit + """ + task_url = self.client.post(PYTHON_UPLOAD_PATH, + files=self.test_file, + data={'filename': PYTHON_WHEEL_FILENAME}) + task = self.client.get(task_url['task']) + created_resource = task['created_resources'][0] + content_unit = self.client.get(created_resource) + new_filename = content_unit['filename'] + self.assertEqual(new_filename, PYTHON_WHEEL_FILENAME) + self.assertEqual(len(task['created_resources']), 1) + + def test_02_upload_file_with_repo(self): + """ + 1) returns a task + 2) check task status complete + 3) check created resource of completed task + 4) ensure two and it's a content unit and a repository version + 5) ? + """ + repo = self.client.post(REPO_PATH, data={'name': 'foo'}) + self.addCleanup(self.client.delete, repo['_href']) + task_url = self.client.post(PYTHON_UPLOAD_PATH, + files=self.test_file, + data={'filename': PYTHON_WHEEL_FILENAME, + 'repository': repo['_href']}) + task = self.client.get(task_url['task']) + new_repo_version = task['created_resources'][0] + version_content_query = self.client.get( + new_repo_version + )['content_summary']['added']['python.python']['href'] + version_content_url = self.client.get(version_content_query)['results'][0]['_href'] + version_content_unit = self.client.get(version_content_url) + version_content_filename = version_content_unit['filename'] + new_content_url = task['created_resources'][1] + content_unit = self.client.get(new_content_url) + new_filename = content_unit['filename'] + self.assertEqual(new_filename, PYTHON_WHEEL_FILENAME) + self.assertEqual(version_content_filename, PYTHON_WHEEL_FILENAME) + self.assertEqual(len(task['created_resources']), 2) + + def test_03_upload_duplicate_file_without_repo(self): + """ + 1) upload file + 2) upload the same file again + 3) this should fail/send an error + """ + task_url = self.client.post(PYTHON_UPLOAD_PATH, + files=self.test_file, + data={'filename': PYTHON_WHEEL_FILENAME}) + task = self.client.get(task_url['task']) + created_resource = task['created_resources'][0] + content_unit = self.client.get(created_resource) + new_filename = content_unit['filename'] + self.assertEqual(new_filename, PYTHON_WHEEL_FILENAME) + self.assertEqual(len(task['created_resources']), 1) + try: + self.client.post(PYTHON_UPLOAD_PATH, + files=self.test_file, + data={'filename': PYTHON_WHEEL_FILENAME}) + except Exception as e: + self.assertEqual(e.response.status_code, 400) + + class ContentUnitTestCase(unittest.TestCase): """ CRUD content unit. @@ -29,10 +121,14 @@ def setUpClass(cls): """ cls.cfg = config.get_config() delete_orphans(cls.cfg) - cls.content_unit = {} cls.client = api.Client(cls.cfg, api.json_handler) - files = {'file': utils.http_get(PYTHON_WHEEL_URL)} - cls.artifact = cls.client.post(ARTIFACTS_PATH, files=files) + cls.test_file = {'file': utils.http_get(PYTHON_WHEEL_URL)} + task_url = cls.client.post(PYTHON_UPLOAD_PATH, + files=cls.test_file, + data={'filename': PYTHON_WHEEL_FILENAME}) + task = cls.client.get(task_url['task']) + created_resource = task['created_resources'][0] + cls.content_unit = cls.client.get(created_resource) @classmethod def tearDownClass(cls): @@ -41,38 +137,15 @@ def tearDownClass(cls): """ delete_orphans(cls.cfg) - def test_01_create_content_unit(self): - """ - Create content unit. - """ - attrs = gen_python_package_attrs(self.artifact) - self.content_unit.update(self.client.post(PYTHON_CONTENT_PATH, attrs)) - for key, val in attrs.items(): - with self.subTest(key=key): - self.assertEqual(self.content_unit[key], val) - - @skip_if(bool, 'content_unit', False) - def test_02_read_content_unit(self): - """ - Read a content unit by its href. - """ - content_unit = self.client.get(self.content_unit['_href']) - for key, val in self.content_unit.items(): - with self.subTest(key=key): - self.assertEqual(content_unit[key], val) - @skip_if(bool, 'content_unit', False) def test_02_read_content_units(self): """ Read a content unit by its filename. """ page = self.client.get(PYTHON_CONTENT_PATH, params={ - 'filename': self.content_unit['filename'] + 'filename': PYTHON_WHEEL_FILENAME }) self.assertEqual(len(page['results']), 1) - for key, val in self.content_unit.items(): - with self.subTest(key=key): - self.assertEqual(page['results'][0][key], val) @skip_if(bool, 'content_unit', False) def test_03_partially_update(self): @@ -82,9 +155,8 @@ def test_03_partially_update(self): This HTTP method is not supported and a HTTP exception is expected. """ - attrs = gen_python_package_attrs(self.artifact) with self.assertRaises(HTTPError) as exc: - self.client.patch(self.content_unit['_href'], attrs) + self.client.patch(self.content_unit['_href'], {}) self.assertEqual(exc.exception.response.status_code, 405) @skip_if(bool, 'content_unit', False) @@ -95,9 +167,8 @@ def test_03_fully_update(self): This HTTP method is not supported and a HTTP exception is expected. """ - attrs = gen_python_package_attrs(self.artifact) with self.assertRaises(HTTPError) as exc: - self.client.put(self.content_unit['_href'], attrs) + self.client.put(self.content_unit['_href'], {}) self.assertEqual(exc.exception.response.status_code, 405) @skip_if(bool, 'content_unit', False) diff --git a/pulp_python/tests/functional/constants.py b/pulp_python/tests/functional/constants.py index 518cd6c0..52b577ca 100644 --- a/pulp_python/tests/functional/constants.py +++ b/pulp_python/tests/functional/constants.py @@ -3,6 +3,7 @@ from pulp_smash.constants import PULP_FIXTURES_BASE_URL from pulp_smash.pulp3.constants import ( BASE_DISTRIBUTION_PATH, + BASE_PATH, BASE_PUBLICATION_PATH, BASE_REMOTE_PATH, CONTENT_PATH @@ -23,6 +24,8 @@ PYTHON_REMOTE_PATH = urljoin(BASE_REMOTE_PATH, 'python/python/') +PYTHON_UPLOAD_PATH = urljoin(BASE_PATH, 'python/upload/') + # Specifier for testing empty syncs, or no excludes PYTHON_EMPTY_PROJECT_SPECIFIER = [] diff --git a/shelf_reader-0.1-py2-none-any.whl b/shelf_reader-0.1-py2-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..bff61c1819d27b6de70e88351506c26bc054de1c GIT binary patch literal 22455 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 S^F88pf<*Li6WZ&1U;PVtpwK%2 literal 0 HcmV?d00001