diff --git a/cloudinary/api.py b/cloudinary/api.py index eb6511c..7590bc2 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -450,7 +450,7 @@ def update(public_id, **options): params = only(options, "moderation_status", "raw_convert", "quality_override", "ocr", "categorization", "detection", "similarity_search", - "background_removal", "notification_url") + "background_removal", "notification_url", "batch_id") if "tags" in options: params["tags"] = ",".join(utils.build_array(options["tags"])) if "face_coordinates" in options: @@ -1567,7 +1567,7 @@ def __delete_resource_params(options, **params): :internal """ p = dict(transformations=utils.build_eager(options.get('transformations')), - **only(options, "keep_original", "next_cursor", "invalidate", "batch_id")) + **only(options, "keep_original", "next_cursor", "invalidate", "batch_id", "notification_url")) p.update(params) return p diff --git a/cloudinary/uploader.py b/cloudinary/uploader.py index 379f8d1..2af572b 100644 --- a/cloudinary/uploader.py +++ b/cloudinary/uploader.py @@ -41,6 +41,9 @@ def is_appengine_sandbox(): UPLOAD_LARGE_CHUNK_SIZE = 20000000 +# The tags, context and metadata endpoints accept these params but omit them from signature +_NON_SIGNABLE_NOTIFICATION_PARAMS = ("notification_url", "batch_id") + def upload(file, **options): """ @@ -378,6 +381,9 @@ def destroy(public_id, **options): :param options: Additional options for the deletion. :keyword str type: The storage type (upload, private, authenticated). :keyword bool invalidate: Invalidate cached copies on the CDN if True. + :keyword str notification_url: A URL to notify when the deletion is completed. + :keyword str batch_id: Correlation key for the completion notification, used to address it + when polling. :return: The result of the API call. :rtype: dict """ @@ -385,7 +391,9 @@ def destroy(public_id, **options): "timestamp": utils.now(), "type": options.get("type"), "invalidate": options.get("invalidate"), - "public_id": public_id + "public_id": public_id, + "notification_url": options.get("notification_url"), + "batch_id": options.get("batch_id") } return call_api("destroy", params, **options) @@ -407,6 +415,9 @@ def rename(from_public_id, to_public_id, **options): :keyword str to_type: Change the resource to the specified upload type. :keyword dict context: Set or update contextual metadata. :keyword dict metadata: Set or update structured metadata. + :keyword str notification_url: A URL to notify when the rename is completed. + :keyword str batch_id: Correlation key for the completion notification, used to address it + when polling. :return: The result of the API call. :rtype: dict """ @@ -419,7 +430,9 @@ def rename(from_public_id, to_public_id, **options): "to_public_id": to_public_id, "to_type": options.get("to_type"), "context": options.get("context"), - "metadata": options.get("metadata") + "metadata": options.get("metadata"), + "notification_url": options.get("notification_url"), + "batch_id": options.get("batch_id") } return call_api("rename", params, **options) @@ -438,6 +451,9 @@ def update_metadata(metadata, public_ids, **options): :keyword str resource_type: The resource type (image, raw, video). Default="image". :keyword str type: The storage type (upload, private, authenticated). :keyword bool clear_invalid: If True, remove keys that are not valid. + :keyword str notification_url: A URL to notify when the update is completed. + :keyword str batch_id: Correlation key for the completion notification, used to address it + when polling. :return: A list of public IDs that were updated. :rtype: dict """ @@ -446,8 +462,11 @@ def update_metadata(metadata, public_ids, **options): "metadata": utils.encode_context(metadata), "public_ids": utils.build_array(public_ids), "type": options.get("type"), - "clear_invalid": options.get("clear_invalid") + "clear_invalid": options.get("clear_invalid"), + "notification_url": options.get("notification_url"), + "batch_id": options.get("batch_id") } + options["non_signable"] = _NON_SIGNABLE_NOTIFICATION_PARAMS return call_api("metadata", params, **options) @@ -509,6 +528,8 @@ def generate_sprite(tag=None, urls=None, **options): :param urls: List of URLs to create a sprite from (only if tag not set). :type urls: list[str], optional :param options: Additional sprite configuration. + :keyword str public_id: The public ID to assign to the generated sprite. When omitted, the + server derives one from the tag or URLs. Cannot be used with mode=download. :return: Dictionary with metadata and URLs of generated sprite resources. :rtype: dict """ @@ -543,6 +564,8 @@ def multi(tag=None, urls=None, **options): :param urls: A list of image URLs (if no tag is set). :type urls: list[str], optional :param options: Additional multi-configuration options. + :keyword str public_id: The public ID to assign to the generated file. When omitted, the server + derives one from the tag or URLs. Cannot be used with mode=download. :return: Dictionary with metadata and URLs of the generated file. :rtype: dict """ @@ -575,6 +598,8 @@ def explode(public_id, **options): :param public_id: The public ID of the file to explode. :type public_id: str :param options: Additional explode options (format, notification_url, batch_id, transformation). + :keyword str type: The storage type of the file to explode (upload, private, authenticated). + Default=upload. :keyword str batch_id: Correlation key for the completion notification, used to address it when polling. :return: The result of the API call. @@ -584,6 +609,7 @@ def explode(public_id, **options): "timestamp": utils.now(), "public_id": public_id, "format": options.get("format"), + "type": options.get("type"), "notification_url": options.get("notification_url"), "batch_id": options.get("batch_id"), "transformation": utils.generate_transformation_string(**options)[0] @@ -705,6 +731,9 @@ def call_tags_api(tag, command, public_ids=None, **options): :param public_ids: A list of asset public IDs. :type public_ids: list[str], optional :param options: Additional options (e.g., type). + :keyword str notification_url: A URL to notify when the update is completed. + :keyword str batch_id: Correlation key for the completion notification, used to address it + when polling. :return: The result of the API call. :rtype: dict """ @@ -713,8 +742,11 @@ def call_tags_api(tag, command, public_ids=None, **options): "tag": tag, "public_ids": utils.build_array(public_ids), "command": command, - "type": options.get("type") + "type": options.get("type"), + "notification_url": options.get("notification_url"), + "batch_id": options.get("batch_id") } + options["non_signable"] = _NON_SIGNABLE_NOTIFICATION_PARAMS return call_api("tags", params, **options) @@ -731,6 +763,9 @@ def call_context_api(context, command, public_ids=None, **options): :param public_ids: A list of asset public IDs. :type public_ids: list[str], optional :param options: Additional options (e.g., type). + :keyword str notification_url: A URL to notify when the update is completed. + :keyword str batch_id: Correlation key for the completion notification, used to address it + when polling. :return: The result of the API call. :rtype: dict """ @@ -739,8 +774,11 @@ def call_context_api(context, command, public_ids=None, **options): "context": utils.encode_context(context), "public_ids": utils.build_array(public_ids), "command": command, - "type": options.get("type") + "type": options.get("type"), + "notification_url": options.get("notification_url"), + "batch_id": options.get("batch_id") } + options["non_signable"] = _NON_SIGNABLE_NOTIFICATION_PARAMS return call_api("context", params, **options) @@ -778,6 +816,7 @@ def text(text, **options): _SLIDESHOW_PARAMS = [ "notification_url", + "batch_id", "public_id", "overwrite", "upload_preset", @@ -791,6 +830,8 @@ def create_slideshow(**options): :param options: Additional parameters for the slideshow creation. :keyword str resource_type: The resource type, defaults to "video". :keyword str notification_url: A URL to be notified when the processing is completed. + :keyword str batch_id: Correlation key for the completion notification, used to address it + when polling. :keyword str public_id: The public ID to assign to the generated slideshow. :keyword bool overwrite: Whether to overwrite the slideshow if public_id already exists. :keyword str upload_preset: An upload preset to apply to the slideshow creation. diff --git a/cloudinary/utils.py b/cloudinary/utils.py index aefa0cc..80c3c5e 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -629,6 +629,16 @@ def json_body(params, headers=None): def sign_request(params, options): + """ + Signs Upload API request parameters. + + Keys listed in options["non_signable"] are still sent, but left out of the signature. + + :param params: Params to sign and send. + :param options: Additional options, including non_signable. + :return: The params, with signature and api_key added. + :internal + """ api_key = options.get("api_key", cloudinary.config().api_key) if not api_key: raise ValueError("Must supply api_key") @@ -637,9 +647,11 @@ def sign_request(params, options): raise ValueError("Must supply api_secret") signature_algorithm = options.get("signature_algorithm", cloudinary.config().signature_algorithm) signature_version = options.get("signature_version", cloudinary.config().signature_version) + non_signable = options.get("non_signable") or () params = cleanup_params(params) - params["signature"] = api_sign_request(params, api_secret, signature_algorithm, signature_version) + params_to_sign = params if not non_signable else dict((k, v) for k, v in params.items() if k not in non_signable) + params["signature"] = api_sign_request(params_to_sign, api_secret, signature_algorithm, signature_version) params["api_key"] = api_key return params @@ -1254,6 +1266,7 @@ def build_multi_and_sprite_params(**options): "notification_url": options.get("notification_url"), "tag": tag, "urls": urls, + "public_id": options.get("public_id"), "transformation": generate_transformation_string(fetch_format=options.get("format"), **options)[0] } return params diff --git a/test/test_api.py b/test/test_api.py index ce79f66..055289e 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -522,9 +522,11 @@ def test09c_delete_resources_by_transformations(self, mocker): """ should allow deleting resources by transformations """ mocker.return_value = MOCK_RESPONSE - api.delete_resources(['api_test', 'api_test2'], transformations=['c_crop,w_100']) + api.delete_resources(['api_test', 'api_test2'], transformations=['c_crop,w_100'], + notification_url="http://example.com") self.assertEqual(get_method(mocker), 'DELETE') self.assertEqual(get_param(mocker, 'transformations'), 'c_crop,w_100') + self.assertEqual(get_param(mocker, 'notification_url'), "http://example.com") api.delete_all_resources(transformations=['c_crop,w_100', {"crop": "scale", "width": 107}]) self.assertEqual(get_method(mocker), 'DELETE') @@ -824,11 +826,12 @@ def test20_manual_moderation(self): @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test21_notification_url(self, mocker): - """ should support notification_url param """ + """ should support notification_url and batch_id params """ mocker.return_value = MOCK_RESPONSE - api.update("api_test", notification_url="http://example.com") + api.update("api_test", notification_url="http://example.com", batch_id="batch_1") notification_url = get_param(mocker, 'notification_url') self.assertEqual(notification_url, "http://example.com") + self.assertEqual(get_param(mocker, 'batch_id'), "batch_1") @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") diff --git a/test/test_uploader.py b/test/test_uploader.py index d0f7534..3eadb6e 100644 --- a/test/test_uploader.py +++ b/test/test_uploader.py @@ -386,13 +386,16 @@ def test_rename(self): @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_rename_parameters(self, mocker): - """Should support to_type, invalidate, and overwrite """ + """Should support to_type, invalidate, overwrite, notification_url and batch_id""" mocker.return_value = MOCK_RESPONSE - uploader.rename(TEST_IMAGE, TEST_IMAGE + "2", to_type='raw', invalidate=True, overwrite=False) + uploader.rename(TEST_IMAGE, TEST_IMAGE + "2", to_type='raw', invalidate=True, overwrite=False, + notification_url="poll://*", batch_id="batch_1") self.assertEqual(get_params(mocker)['to_type'], 'raw') self.assertTrue(get_params(mocker)['invalidate']) self.assertTrue(get_params(mocker)['overwrite']) + self.assertEqual(get_params(mocker)['notification_url'], "poll://*") + self.assertEqual(get_params(mocker)['batch_id'], "batch_1") @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -577,7 +580,8 @@ def test_create_slideshow_from_manifest_transformation(self, mocker): } }, transformation={"fetch_format": "auto", "quality": "auto"}, - tags=['tag1', 'tag2', 'tag3'] + tags=['tag1', 'tag2', 'tag3'], + batch_id="batch_1" ) args, _ = mocker.call_args @@ -587,6 +591,7 @@ def test_create_slideshow_from_manifest_transformation(self, mocker): self.assertEqual("fn_render:" + slideshow_manifest, get_params(mocker)['manifest_transformation']) self.assertEqual("f_auto,q_auto", get_params(mocker)['transformation']) self.assertEqual("tag1,tag2,tag3", get_params(mocker)['tags']) + self.assertEqual("batch_1", get_params(mocker)['batch_id']) @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -1194,6 +1199,7 @@ def test_explode_parameters(self, request_mock): TEST_ID, page="all", format="jpg", + type="private", notification_url="poll://*", batch_id=batch_id, ) @@ -1201,6 +1207,7 @@ def test_explode_parameters(self, request_mock): params = get_params(request_mock) self.assertEqual(params["public_id"], TEST_ID) self.assertEqual(params["format"], "jpg") + self.assertEqual(params["type"], "private") self.assertEqual(params["notification_url"], "poll://*") self.assertEqual(params["batch_id"], batch_id) @@ -1237,6 +1244,10 @@ def test_generate_sprite(self): uploader.destroy(result.get("public_id")) self.assertIn("f_jpg,w_100", result["css_url"]) + # Generate sprite with an explicit public_id, instead of one derived from the tag + result = uploader.generate_sprite(sprite_test_tag, public_id=sprite_test_tag + "_named") + self.assertIn(sprite_test_tag + "_named", result["css_url"]) + def test_download_sprite(self): """Should generate signed download url for sprite""" sprite_test_tag = "sprite_tag" @@ -1286,13 +1297,17 @@ def test_multi(self): self.assertTrue(pdf_result.get("url").endswith(".pdf")) self.assertIn("w_111", pdf_result.get("url")) + # Generate multi with an explicit public_id, instead of one derived from the tag + named_result = uploader.multi(tag=multi_test_tag, public_id=multi_test_tag + "_named") + self.assertEqual(multi_test_tag + "_named", named_result.get("public_id")) + def test_download_multi(self): """Should generate signed download url for multi""" multi_test_tag = "multi_test_tag" url_1 = "https://res.cloudinary.com/demo/image/upload/sample" url_2 = "https://res.cloudinary.com/demo/image/upload/car" - url_from_tag = uploader.download_multi(tag=multi_test_tag) + url_from_tag = uploader.download_multi(tag=multi_test_tag, public_id=TEST_ID) url_from_urls = uploader.download_multi(urls=[url_1, url_2]) self.assertTrue(url_from_tag.startswith( @@ -1302,6 +1317,7 @@ def test_download_multi(self): parameters = parse_qs(urlparse(url_from_tag).query) self.assertEqual(multi_test_tag, parameters["tag"][0]) + self.assertEqual(TEST_ID, parameters["public_id"][0]) self.assertEqual("download", parameters["mode"][0]) self.assertIn("timestamp", parameters) self.assertIn("signature", parameters) @@ -1321,6 +1337,54 @@ def test_create_zip_with_target_asset_folder(self, mocker): uploader.create_zip(tags="test-tag", target_asset_folder="test-asset-folder") self.assertEqual("test-asset-folder", get_param(mocker, "target_asset_folder")) + @staticmethod + def sign_request_without(params, *excluded): + """ + Signs the params as sent in the request, leaving out the excluded keys. + + :param params: The params as sent in the request. + :param excluded: Keys to leave out of the signature. + :return: The expected signature. + """ + config = cloudinary.config() + params_to_sign = dict((k, v) for k, v in params.items() + if k not in excluded and k not in ("signature", "api_key")) + return utils.api_sign_request(params_to_sign, config.api_secret, config.signature_algorithm, + config.signature_version) + + def assert_notification_params_unsigned(self, mocker): + """ + Assert notification_url and batch_id were sent, but left out of the signature. + + :param mocker: The mocked request. + """ + params = get_params(mocker) + self.assertEqual("poll://*", params["notification_url"]) + self.assertEqual("batch_1", params["batch_id"]) + self.assertEqual(self.sign_request_without(params, "notification_url", "batch_id"), params["signature"]) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_notification_params_signing(self, mocker): + """Should send notification_url and batch_id, unsigned on tags, context and metadata""" + mocker.return_value = MOCK_RESPONSE + options = {"notification_url": "poll://*", "batch_id": "batch_1"} + + uploader.add_tag("tag1", [TEST_ID], **options) + self.assert_notification_params_unsigned(mocker) + + uploader.add_context({"k": "v"}, [TEST_ID], **options) + self.assert_notification_params_unsigned(mocker) + + uploader.update_metadata({"k": "v"}, [TEST_ID], **options) + self.assert_notification_params_unsigned(mocker) + + uploader.destroy(TEST_ID, **options) + params = get_params(mocker) + self.assertEqual("poll://*", params["notification_url"]) + self.assertEqual("batch_1", params["batch_id"]) + self.assertEqual(self.sign_request_without(params), params["signature"]) + if __name__ == '__main__': unittest.main()