From 45124a65b2d98778cb54a9345a673e679d9f45f4 Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Tue, 26 Nov 2019 17:09:39 -0800 Subject: [PATCH 01/10] Add structured metadata functions --- cloudinary/api.py | 59 ++++++++++++++++++++++++++++++++++++++++-- cloudinary/uploader.py | 15 +++++++++++ cloudinary/utils.py | 9 +++++-- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/cloudinary/api.py b/cloudinary/api.py index 9010bb59..8b196fd2 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -11,7 +11,6 @@ import cloudinary from cloudinary import utils from cloudinary.exceptions import ( - Error, BadRequest, AuthorizationRequired, NotAllowed, @@ -23,7 +22,6 @@ logger = cloudinary.logger - EXCEPTION_CODES = { 400: BadRequest, 401: AuthorizationRequired, @@ -394,6 +392,63 @@ def update_streaming_profile(name, **options): return call_api('PUT', uri, params, **options) +def metadata_fields(**options): + uri = ["metadata_fields"] + return call_api('GET', uri, None, **options) + + +def metadata_field(external_id, **options): + uri = ["metadata_fields", external_id] + return call_api('GET', uri, None, **options) + + +def create_metadata_field(label, type, external_id=None, mandatory=False, datasource=None, default_value=None, + validation=None, **options): + uri = ["metadata_fields"] + params = dict( + label=label, + type=type, + external_id=external_id, + default_value=default_value, + mandatory=mandatory, + datasource=datasource, + validation=validation + ) + return call_json_api('POST', uri, jsonBody=params, **options) + + +def update_metadata_field(external_id, type=None, label=None, mandatory=None, datasource=None, default_value=None, + validation=None, **options): + uri = ["metadata_fields", external_id] + params = dict( + label=label, + type=type, + default_value=default_value, + mandatory=mandatory, + datasource=datasource, + validation=validation + ) + return call_json_api('PUT', uri, jsonBody=params, **options) + + +def delete_metadata_field(external_id, **options): + uri = ["metadata_fields", external_id] + return call_api('DELETE', uri, params=None, **options) + + +def update_metadata_field_datasource(external_id, values, **options): + uri = ["metadata_fields", external_id, 'datasource'] + return call_json_api('PUT', uri, jsonBody=dict(values=values), **options) + + +def delete_metadata_field_datasource_entry(external_id, external_ids, **options): + return + + +def restore_metadata_field_datasource_entry(external_id, external_ids, **options): + return + + def call_json_api(method, uri, jsonBody, **options): logger.debug(jsonBody) data = json.dumps(jsonBody).encode('utf-8') diff --git a/cloudinary/uploader.py b/cloudinary/uploader.py index c3be11c8..c9e898a6 100644 --- a/cloudinary/uploader.py +++ b/cloudinary/uploader.py @@ -240,6 +240,21 @@ def remove_all_context(public_ids, **options): return call_context_api(None, "remove_all", public_ids, **options) +def update_metadata(public_ids, metadata, resource_type="image", type="upload"): + return call_metadata_api(metadata, None, public_ids=public_ids, resource_type=resource_type, type=type) + + +def call_metadata_api(metadata, command, public_ids=None, **options): + params = { + "timestamp": utils.now(), + "metadata": utils.encode_context(metadata), + "public_ids": utils.build_array(public_ids), + # "command": command, + "type": options.get("type") + } + return call_api("metadata", params, **options) + + def call_tags_api(tag, command, public_ids=None, **options): params = { "timestamp": utils.now(), diff --git a/cloudinary/utils.py b/cloudinary/utils.py index 54dffc22..f9ebdef2 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -115,7 +115,8 @@ "context", "auto_tagging", "responsive_breakpoints", - "access_control" + "access_control", + "metadata" ] upload_params = __SIMPLE_UPLOAD_PARAMS + __SERIALIZED_UPLOAD_PARAMS @@ -198,7 +199,10 @@ def encode_context(context): """ if not isinstance(context, dict): - return context + try: + context = json.loads(context) + except: + return context return "|".join(("{}={}".format(k, v.replace("=", "\\=").replace("|", "\\|"))) for k, v in iteritems(context)) @@ -911,6 +915,7 @@ def build_upload_params(**options): "face_coordinates": encode_double_array(options.get("face_coordinates")), "custom_coordinates": encode_double_array(options.get("custom_coordinates")), "context": encode_context(options.get("context")), + "metadata": encode_context(options.get("metadata")), "auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")), "responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")), "access_control": options.get("access_control") and json_encode( From 7a609349c710a6d192fde1ccbab29c7bcd3f3efe Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 11:25:21 -0800 Subject: [PATCH 02/10] Add `delete_metadata_field_datasource_entry` and `restore_metadata_field_datasource_entry` methods. --- cloudinary/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cloudinary/api.py b/cloudinary/api.py index 8b196fd2..b013d398 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -442,11 +442,13 @@ def update_metadata_field_datasource(external_id, values, **options): def delete_metadata_field_datasource_entry(external_id, external_ids, **options): - return + uri = ["metadata_fields", external_id, 'datasource'] + return call_json_api('DELETE', uri, jsonBody=dict(external_ids=external_ids), **options) def restore_metadata_field_datasource_entry(external_id, external_ids, **options): - return + uri = ["metadata_fields", external_id, 'datasource_restore'] + return call_json_api('POST', uri, jsonBody=dict(external_ids=external_ids), **options) def call_json_api(method, uri, jsonBody, **options): From c2fcff673cce47b25d4aadf59a5322563993cc7e Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 14:32:07 -0800 Subject: [PATCH 03/10] Remove call_metadata_api method --- cloudinary/uploader.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/cloudinary/uploader.py b/cloudinary/uploader.py index c9e898a6..b52a19a0 100644 --- a/cloudinary/uploader.py +++ b/cloudinary/uploader.py @@ -3,15 +3,13 @@ import os import socket -import certifi from six import string_types -from urllib3 import PoolManager, ProxyManager from urllib3.exceptions import HTTPError import cloudinary from cloudinary import utils -from cloudinary.exceptions import Error from cloudinary.cache.responsive_breakpoints_cache import instance as responsive_breakpoints_cache_instance +from cloudinary.exceptions import Error try: from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox @@ -240,19 +238,26 @@ def remove_all_context(public_ids, **options): return call_context_api(None, "remove_all", public_ids, **options) -def update_metadata(public_ids, metadata, resource_type="image", type="upload"): - return call_metadata_api(metadata, None, public_ids=public_ids, resource_type=resource_type, type=type) - +def update_metadata(public_ids, metadata, **options): + """ -def call_metadata_api(metadata, command, public_ids=None, **options): + :param public_ids: The public IDs of the resources to update + :type public_ids: list + :param metadata: The metadata to update, where external IDs are the keys + :param options: dict + :return: List of resources updated + :rtype: list + """ + for key, value in metadata.items(): + if isinstance(value, list): + metadata[key] = json.dumps(value) params = { "timestamp": utils.now(), "metadata": utils.encode_context(metadata), "public_ids": utils.build_array(public_ids), - # "command": command, "type": options.get("type") } - return call_api("metadata", params, **options) + return call_api("metadata", params=params) def call_tags_api(tag, command, public_ids=None, **options): From 5b0c45c8417f5a83d910f35dc2fb40f734e682eb Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 14:33:00 -0800 Subject: [PATCH 04/10] Make encode_context compatible with encoding structured metadata --- cloudinary/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cloudinary/utils.py b/cloudinary/utils.py index f9ebdef2..3b677138 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -204,6 +204,17 @@ def encode_context(context): except: return context + if PY3: + items = context.items() + else: + items = context.iteritems() + + for key, value in items: + if isinstance(value, list): + context[key] = json.dumps(value).replace("\"", "\\\"") + elif isinstance(value, int): + context[key] = str(value) + return "|".join(("{}={}".format(k, v.replace("=", "\\=").replace("|", "\\|"))) for k, v in iteritems(context)) From b998376f2b627fa5869b583403fd443fe6bdc638 Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 14:33:18 -0800 Subject: [PATCH 05/10] Add unit tests --- test/helper_test.py | 10 +++- test/test_api.py | 132 ++++++++++++++++++++++++++++++++++++++---- test/test_uploader.py | 30 ++++++++++ 3 files changed, 160 insertions(+), 12 deletions(-) diff --git a/test/helper_test.py b/test/helper_test.py index 37f76bb9..dab97698 100644 --- a/test/helper_test.py +++ b/test/helper_test.py @@ -1,13 +1,14 @@ # -*- coding: latin-1 -*- -from contextlib import contextmanager import os import random import re import sys import time +import traceback +from contextlib import contextmanager from datetime import timedelta, tzinfo from functools import wraps -import traceback +from json import loads import six from urllib3 import HTTPResponse @@ -63,6 +64,10 @@ def get_params(args): return args[2] or {} +def get_body(args): + return loads(args[1]['body']) or {} + + def get_param(mocker, name): """ Return the value of the parameter @@ -133,6 +138,7 @@ def retry_assertion(num_tries=3, delay=3): :param num_tries: Number of tries to perform :param delay: Delay in seconds between retries """ + def retry_decorator(func): @wraps(func) def retry_func(*args, **kwargs): diff --git a/test/test_api.py b/test/test_api.py index 5d243bfa..5da9fb4a 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -8,9 +8,9 @@ import cloudinary from cloudinary import api, uploader, utils -from test.helper_test import SUFFIX, TEST_IMAGE, get_uri, get_params, get_list_param, get_param, TEST_DOC, get_method, \ - UNIQUE_TAG, api_response_mock, ignore_exception, cleanup_test_resources_by_tag, cleanup_test_transformation, \ - cleanup_test_resources, UNIQUE_TEST_FOLDER +from test.helper_test import SUFFIX, TEST_IMAGE, get_uri, get_params, get_body, get_list_param, get_param, TEST_DOC, \ + get_method, UNIQUE_TAG, api_response_mock, ignore_exception, cleanup_test_resources_by_tag, \ + cleanup_test_transformation, cleanup_test_resources, UNIQUE_TEST_FOLDER MOCK_RESPONSE = api_response_mock() @@ -32,6 +32,17 @@ API_TEST_TRANS_SEPIA = {"crop": "lfill", "width": 400, "effect": "sepia"} API_TEST_TRANS_SEPIA_STR = "c_lfill,e_sepia,w_400" API_TEST_PRESET = "api_test_upload_preset" + +API_TEST_EXTERNAL_ID = "SAMPLE_EXTERNAL_ID" +API_TEST_EXTERNAL_ID_1 = "SAMPLE_EXTERNAL_ID_1" +API_TEST_EXTERNAL_ID_2 = "SAMPLE_EXTERNAL_ID_2" +API_TEST_STRUCTURED_METADATA_INTEGER_FIELD = dict(label="SKU", type="integer", external_id=API_TEST_EXTERNAL_ID, + default_value=0) +API_TEST_STRUCTURED_METADATA_DATASOURCE = dict( + values=[dict(value="red", external_id="color1"), dict(value="blue", external_id="color2")]) +API_TEST_STRUCTURED_METADATA_SET_FIELD = dict(label="colors", type="set", external_id="colors", + datasource=API_TEST_STRUCTURED_METADATA_DATASOURCE) + PREFIX = "test_folder_{}".format(SUFFIX) MAPPING_TEST_ID = "api_test_upload_mapping_{}".format(SUFFIX) RESTORE_TEST_ID = "api_test_restore_{}".format(SUFFIX) @@ -202,7 +213,7 @@ def test07b_resource_allows_derived_next_cursor_parameter(self, mocker): api.resource(API_TEST_ID, derived_next_cursor=NEXT_CURSOR) args, kwargs = mocker.call_args self.assertTrue("derived_next_cursor" in get_params(args)) - + @patch('urllib3.request.RequestMethods.request') @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test08_delete_derived(self, mocker): @@ -673,11 +684,11 @@ def test_delete_folder(self, mocker): def test_root_folders_allows_next_cursor_and_max_results_parameter(self, mocker): """ should allow next_cursor and max_results parameters """ mocker.return_value = MOCK_RESPONSE - + api.root_folders(next_cursor=NEXT_CURSOR, max_results=10) - + args, kwargs = mocker.call_args - + self.assertTrue("next_cursor" in get_params(args)) self.assertTrue("max_results" in get_params(args)) @@ -686,11 +697,11 @@ def test_root_folders_allows_next_cursor_and_max_results_parameter(self, mocker) def test_subfolders_allows_next_cursor_and_max_results_parameter(self, mocker): """ should allow next_cursor and max_results parameters """ mocker.return_value = MOCK_RESPONSE - + api.subfolders(API_TEST_ID, next_cursor=NEXT_CURSOR, max_results=10) - + args, kwargs = mocker.call_args - + self.assertTrue("next_cursor" in get_params(args)) self.assertTrue("max_results" in get_params(args)) @@ -795,5 +806,106 @@ def test_cinemagraph_analysis_resource(self, mocker): self.assertIn("cinemagraph_analysis", params) + @patch('urllib3.request.RequestMethods.request') + def test_metadata_fields(self, mocker): + """ should allow listing metadata_fields """ + mocker.return_value = MOCK_RESPONSE + api.metadata_fields() + args, kwargs = mocker.call_args + self.assertTrue(get_uri(args).endswith('/metadata_fields')) + + @patch('urllib3.request.RequestMethods.request') + def test_metadata_field(self, mocker): + """ should allow getting a single metadata field """ + mocker.return_value = MOCK_RESPONSE + api.metadata_field(API_TEST_EXTERNAL_ID) + args, kwargs = mocker.call_args + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}'.format(API_TEST_EXTERNAL_ID))) + + @patch('urllib3.request.RequestMethods.request') + def test_create_metadata_field(self, mocker): + """ should allow the user to create a metadata field """ + mocker.return_value = MOCK_RESPONSE + + api.create_metadata_field(**API_TEST_STRUCTURED_METADATA_INTEGER_FIELD) + + body = get_body(mocker.call_args) + + self.assertIn("label", body) + self.assertIn("type", body) + self.assertIn("default_value", body) + self.assertIn("external_id", body) + + @patch('urllib3.request.RequestMethods.request') + def test_update_metadata_field(self, mocker): + """ should allow the user to update a metadata field """ + mocker.return_value = MOCK_RESPONSE + + api.update_metadata_field(API_TEST_EXTERNAL_ID, default_value=0) + + body = get_body(mocker.call_args) + + self.assertIn("default_value", body) + + @patch('urllib3.request.RequestMethods.request') + def test_delete_metadata_field(self, mocker): + """ should allow the user to delete a metadata field """ + mocker.return_value = MOCK_RESPONSE + + api.delete_metadata_field(API_TEST_EXTERNAL_ID) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}'.format(API_TEST_EXTERNAL_ID))) + + @patch('urllib3.request.RequestMethods.request') + def test_update_metadata_field_datasource(self, mocker): + """ should allow the user to update a metadata field datasource """ + mocker.return_value = MOCK_RESPONSE + + api.update_metadata_field_datasource(API_TEST_EXTERNAL_ID, + values=API_TEST_STRUCTURED_METADATA_DATASOURCE['values']) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}/datasource'.format(API_TEST_EXTERNAL_ID))) + + body = get_body(mocker.call_args) + + self.assertIn("values", body) + + @patch('urllib3.request.RequestMethods.request') + def test_delete_metadata_field_datasource_entry(self, mocker): + """ should allow the user to delete a metadata field datasource entry by external IDs """ + mocker.return_value = MOCK_RESPONSE + + api.delete_metadata_field_datasource_entry(API_TEST_EXTERNAL_ID, + external_ids=[API_TEST_EXTERNAL_ID_1, API_TEST_EXTERNAL_ID_2]) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}/datasource'.format(API_TEST_EXTERNAL_ID))) + + body = get_body(mocker.call_args) + + self.assertIn("external_ids", body) + + @patch('urllib3.request.RequestMethods.request') + def test_restore_metadata_field_datasource_entry(self, mocker): + """ should allow the user to restore a metadata field datasource entry by external IDs """ + mocker.return_value = MOCK_RESPONSE + + api.restore_metadata_field_datasource_entry(API_TEST_EXTERNAL_ID, + external_ids=[API_TEST_EXTERNAL_ID_1, API_TEST_EXTERNAL_ID_2]) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}/datasource_restore'.format(API_TEST_EXTERNAL_ID))) + + body = get_body(mocker.call_args) + + self.assertIn("external_ids", body) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_uploader.py b/test/test_uploader.py index a99f4816..d234e291 100644 --- a/test/test_uploader.py +++ b/test/test_uploader.py @@ -33,6 +33,8 @@ TEST_TRANS_SCALE2_PNG = dict(crop="scale", width="2.0", format="png", overlay=TEST_TRANS_OVERLAY_STR) TEST_TRANS_SCALE2_PNG_STR = "c_scale,l_{},w_2.0/png".format(TEST_TRANS_OVERLAY_STR) +TEST_STRUCTURED_METADATA = dict(sku=1, color="blue") + UNIQUE_TAG = "up_test_uploader_{}".format(SUFFIX) UNIQUE_ID = UNIQUE_TAG TEST_DOCX_ID = "test_docx_{}".format(SUFFIX) @@ -625,7 +627,9 @@ def test_access_control(self, request_mock): @patch('urllib3.request.RequestMethods.request') def test_cinemagraph_analysis(self, request_mock): + """ should support cinemagraph analysis in upload and explicit""" + request_mock.return_value = MOCK_RESPONSE uploader.upload(TEST_IMAGE, cinemagraph_analysis=True) @@ -638,5 +642,31 @@ def test_cinemagraph_analysis(self, request_mock): params = request_mock.call_args[0][2] self.assertIn("cinemagraph_analysis", params) + @patch('urllib3.request.RequestMethods.request') + def test_metadata_upload_option(self, request_mock): + """ should support metadata argument in upload and explicit """ + request_mock.return_value = MOCK_RESPONSE + + uploader.upload(TEST_IMAGE, metadata=TEST_STRUCTURED_METADATA) + + params = request_mock.call_args[0][2] + self.assertIn("metadata", params) + + uploader.upload(TEST_IMAGE, metadata=TEST_STRUCTURED_METADATA) + + params = request_mock.call_args[0][2] + self.assertIn("metadata", params) + + @patch('urllib3.request.RequestMethods.request') + def test_update_metadata(self, request_mock): + """ should pass metadata as to update_metadata """ + request_mock.return_value = MOCK_RESPONSE + + uploader.update_metadata([TEST_IMAGE], TEST_STRUCTURED_METADATA) + + params = request_mock.call_args[0][2] + self.assertIn("metadata", params) + + if __name__ == '__main__': unittest.main() From d1a7c333ba5fa86b6b494ea1841f7b156a61b27e Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Tue, 26 Nov 2019 17:09:39 -0800 Subject: [PATCH 06/10] Add structured metadata functions --- cloudinary/api.py | 59 ++++++++++++++++++++++++++++++++++++++++-- cloudinary/uploader.py | 15 +++++++++++ cloudinary/utils.py | 9 +++++-- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/cloudinary/api.py b/cloudinary/api.py index 9010bb59..8b196fd2 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -11,7 +11,6 @@ import cloudinary from cloudinary import utils from cloudinary.exceptions import ( - Error, BadRequest, AuthorizationRequired, NotAllowed, @@ -23,7 +22,6 @@ logger = cloudinary.logger - EXCEPTION_CODES = { 400: BadRequest, 401: AuthorizationRequired, @@ -394,6 +392,63 @@ def update_streaming_profile(name, **options): return call_api('PUT', uri, params, **options) +def metadata_fields(**options): + uri = ["metadata_fields"] + return call_api('GET', uri, None, **options) + + +def metadata_field(external_id, **options): + uri = ["metadata_fields", external_id] + return call_api('GET', uri, None, **options) + + +def create_metadata_field(label, type, external_id=None, mandatory=False, datasource=None, default_value=None, + validation=None, **options): + uri = ["metadata_fields"] + params = dict( + label=label, + type=type, + external_id=external_id, + default_value=default_value, + mandatory=mandatory, + datasource=datasource, + validation=validation + ) + return call_json_api('POST', uri, jsonBody=params, **options) + + +def update_metadata_field(external_id, type=None, label=None, mandatory=None, datasource=None, default_value=None, + validation=None, **options): + uri = ["metadata_fields", external_id] + params = dict( + label=label, + type=type, + default_value=default_value, + mandatory=mandatory, + datasource=datasource, + validation=validation + ) + return call_json_api('PUT', uri, jsonBody=params, **options) + + +def delete_metadata_field(external_id, **options): + uri = ["metadata_fields", external_id] + return call_api('DELETE', uri, params=None, **options) + + +def update_metadata_field_datasource(external_id, values, **options): + uri = ["metadata_fields", external_id, 'datasource'] + return call_json_api('PUT', uri, jsonBody=dict(values=values), **options) + + +def delete_metadata_field_datasource_entry(external_id, external_ids, **options): + return + + +def restore_metadata_field_datasource_entry(external_id, external_ids, **options): + return + + def call_json_api(method, uri, jsonBody, **options): logger.debug(jsonBody) data = json.dumps(jsonBody).encode('utf-8') diff --git a/cloudinary/uploader.py b/cloudinary/uploader.py index c3be11c8..c9e898a6 100644 --- a/cloudinary/uploader.py +++ b/cloudinary/uploader.py @@ -240,6 +240,21 @@ def remove_all_context(public_ids, **options): return call_context_api(None, "remove_all", public_ids, **options) +def update_metadata(public_ids, metadata, resource_type="image", type="upload"): + return call_metadata_api(metadata, None, public_ids=public_ids, resource_type=resource_type, type=type) + + +def call_metadata_api(metadata, command, public_ids=None, **options): + params = { + "timestamp": utils.now(), + "metadata": utils.encode_context(metadata), + "public_ids": utils.build_array(public_ids), + # "command": command, + "type": options.get("type") + } + return call_api("metadata", params, **options) + + def call_tags_api(tag, command, public_ids=None, **options): params = { "timestamp": utils.now(), diff --git a/cloudinary/utils.py b/cloudinary/utils.py index 9d63a09f..896ee4bf 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -115,7 +115,8 @@ "context", "auto_tagging", "responsive_breakpoints", - "access_control" + "access_control", + "metadata" ] upload_params = __SIMPLE_UPLOAD_PARAMS + __SERIALIZED_UPLOAD_PARAMS @@ -209,7 +210,10 @@ def encode_context(context): """ if not isinstance(context, dict): - return context + try: + context = json.loads(context) + except: + return context return "|".join(("{}={}".format(k, v.replace("=", "\\=").replace("|", "\\|"))) for k, v in iteritems(context)) @@ -922,6 +926,7 @@ def build_upload_params(**options): "face_coordinates": encode_double_array(options.get("face_coordinates")), "custom_coordinates": encode_double_array(options.get("custom_coordinates")), "context": encode_context(options.get("context")), + "metadata": encode_context(options.get("metadata")), "auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")), "responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")), "access_control": options.get("access_control") and json_encode( From 88152faae126631401a79d8288d3e1cb0d6f3ad2 Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 11:25:21 -0800 Subject: [PATCH 07/10] Add `delete_metadata_field_datasource_entry` and `restore_metadata_field_datasource_entry` methods. --- cloudinary/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cloudinary/api.py b/cloudinary/api.py index 8b196fd2..b013d398 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -442,11 +442,13 @@ def update_metadata_field_datasource(external_id, values, **options): def delete_metadata_field_datasource_entry(external_id, external_ids, **options): - return + uri = ["metadata_fields", external_id, 'datasource'] + return call_json_api('DELETE', uri, jsonBody=dict(external_ids=external_ids), **options) def restore_metadata_field_datasource_entry(external_id, external_ids, **options): - return + uri = ["metadata_fields", external_id, 'datasource_restore'] + return call_json_api('POST', uri, jsonBody=dict(external_ids=external_ids), **options) def call_json_api(method, uri, jsonBody, **options): From 2064fc0b78c28167ce152a21a84a98077bb9fb7c Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 14:32:07 -0800 Subject: [PATCH 08/10] Remove call_metadata_api method --- cloudinary/uploader.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/cloudinary/uploader.py b/cloudinary/uploader.py index c9e898a6..b52a19a0 100644 --- a/cloudinary/uploader.py +++ b/cloudinary/uploader.py @@ -3,15 +3,13 @@ import os import socket -import certifi from six import string_types -from urllib3 import PoolManager, ProxyManager from urllib3.exceptions import HTTPError import cloudinary from cloudinary import utils -from cloudinary.exceptions import Error from cloudinary.cache.responsive_breakpoints_cache import instance as responsive_breakpoints_cache_instance +from cloudinary.exceptions import Error try: from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox @@ -240,19 +238,26 @@ def remove_all_context(public_ids, **options): return call_context_api(None, "remove_all", public_ids, **options) -def update_metadata(public_ids, metadata, resource_type="image", type="upload"): - return call_metadata_api(metadata, None, public_ids=public_ids, resource_type=resource_type, type=type) - +def update_metadata(public_ids, metadata, **options): + """ -def call_metadata_api(metadata, command, public_ids=None, **options): + :param public_ids: The public IDs of the resources to update + :type public_ids: list + :param metadata: The metadata to update, where external IDs are the keys + :param options: dict + :return: List of resources updated + :rtype: list + """ + for key, value in metadata.items(): + if isinstance(value, list): + metadata[key] = json.dumps(value) params = { "timestamp": utils.now(), "metadata": utils.encode_context(metadata), "public_ids": utils.build_array(public_ids), - # "command": command, "type": options.get("type") } - return call_api("metadata", params, **options) + return call_api("metadata", params=params) def call_tags_api(tag, command, public_ids=None, **options): From 85496cc3a12f3d370460013beab1f4631a8dfe5a Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 14:33:00 -0800 Subject: [PATCH 09/10] Make encode_context compatible with encoding structured metadata --- cloudinary/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cloudinary/utils.py b/cloudinary/utils.py index 896ee4bf..930f469c 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -215,6 +215,17 @@ def encode_context(context): except: return context + if PY3: + items = context.items() + else: + items = context.iteritems() + + for key, value in items: + if isinstance(value, list): + context[key] = json.dumps(value).replace("\"", "\\\"") + elif isinstance(value, int): + context[key] = str(value) + return "|".join(("{}={}".format(k, v.replace("=", "\\=").replace("|", "\\|"))) for k, v in iteritems(context)) From e3c0d48a7afcfed463492ed78c53097ad04b4b15 Mon Sep 17 00:00:00 2001 From: Brian Luk Date: Mon, 27 Jan 2020 14:33:18 -0800 Subject: [PATCH 10/10] Add unit tests --- test/helper_test.py | 10 +++- test/test_api.py | 132 ++++++++++++++++++++++++++++++++++++++---- test/test_uploader.py | 30 ++++++++++ 3 files changed, 160 insertions(+), 12 deletions(-) diff --git a/test/helper_test.py b/test/helper_test.py index 37f76bb9..dab97698 100644 --- a/test/helper_test.py +++ b/test/helper_test.py @@ -1,13 +1,14 @@ # -*- coding: latin-1 -*- -from contextlib import contextmanager import os import random import re import sys import time +import traceback +from contextlib import contextmanager from datetime import timedelta, tzinfo from functools import wraps -import traceback +from json import loads import six from urllib3 import HTTPResponse @@ -63,6 +64,10 @@ def get_params(args): return args[2] or {} +def get_body(args): + return loads(args[1]['body']) or {} + + def get_param(mocker, name): """ Return the value of the parameter @@ -133,6 +138,7 @@ def retry_assertion(num_tries=3, delay=3): :param num_tries: Number of tries to perform :param delay: Delay in seconds between retries """ + def retry_decorator(func): @wraps(func) def retry_func(*args, **kwargs): diff --git a/test/test_api.py b/test/test_api.py index 5d243bfa..5da9fb4a 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -8,9 +8,9 @@ import cloudinary from cloudinary import api, uploader, utils -from test.helper_test import SUFFIX, TEST_IMAGE, get_uri, get_params, get_list_param, get_param, TEST_DOC, get_method, \ - UNIQUE_TAG, api_response_mock, ignore_exception, cleanup_test_resources_by_tag, cleanup_test_transformation, \ - cleanup_test_resources, UNIQUE_TEST_FOLDER +from test.helper_test import SUFFIX, TEST_IMAGE, get_uri, get_params, get_body, get_list_param, get_param, TEST_DOC, \ + get_method, UNIQUE_TAG, api_response_mock, ignore_exception, cleanup_test_resources_by_tag, \ + cleanup_test_transformation, cleanup_test_resources, UNIQUE_TEST_FOLDER MOCK_RESPONSE = api_response_mock() @@ -32,6 +32,17 @@ API_TEST_TRANS_SEPIA = {"crop": "lfill", "width": 400, "effect": "sepia"} API_TEST_TRANS_SEPIA_STR = "c_lfill,e_sepia,w_400" API_TEST_PRESET = "api_test_upload_preset" + +API_TEST_EXTERNAL_ID = "SAMPLE_EXTERNAL_ID" +API_TEST_EXTERNAL_ID_1 = "SAMPLE_EXTERNAL_ID_1" +API_TEST_EXTERNAL_ID_2 = "SAMPLE_EXTERNAL_ID_2" +API_TEST_STRUCTURED_METADATA_INTEGER_FIELD = dict(label="SKU", type="integer", external_id=API_TEST_EXTERNAL_ID, + default_value=0) +API_TEST_STRUCTURED_METADATA_DATASOURCE = dict( + values=[dict(value="red", external_id="color1"), dict(value="blue", external_id="color2")]) +API_TEST_STRUCTURED_METADATA_SET_FIELD = dict(label="colors", type="set", external_id="colors", + datasource=API_TEST_STRUCTURED_METADATA_DATASOURCE) + PREFIX = "test_folder_{}".format(SUFFIX) MAPPING_TEST_ID = "api_test_upload_mapping_{}".format(SUFFIX) RESTORE_TEST_ID = "api_test_restore_{}".format(SUFFIX) @@ -202,7 +213,7 @@ def test07b_resource_allows_derived_next_cursor_parameter(self, mocker): api.resource(API_TEST_ID, derived_next_cursor=NEXT_CURSOR) args, kwargs = mocker.call_args self.assertTrue("derived_next_cursor" in get_params(args)) - + @patch('urllib3.request.RequestMethods.request') @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test08_delete_derived(self, mocker): @@ -673,11 +684,11 @@ def test_delete_folder(self, mocker): def test_root_folders_allows_next_cursor_and_max_results_parameter(self, mocker): """ should allow next_cursor and max_results parameters """ mocker.return_value = MOCK_RESPONSE - + api.root_folders(next_cursor=NEXT_CURSOR, max_results=10) - + args, kwargs = mocker.call_args - + self.assertTrue("next_cursor" in get_params(args)) self.assertTrue("max_results" in get_params(args)) @@ -686,11 +697,11 @@ def test_root_folders_allows_next_cursor_and_max_results_parameter(self, mocker) def test_subfolders_allows_next_cursor_and_max_results_parameter(self, mocker): """ should allow next_cursor and max_results parameters """ mocker.return_value = MOCK_RESPONSE - + api.subfolders(API_TEST_ID, next_cursor=NEXT_CURSOR, max_results=10) - + args, kwargs = mocker.call_args - + self.assertTrue("next_cursor" in get_params(args)) self.assertTrue("max_results" in get_params(args)) @@ -795,5 +806,106 @@ def test_cinemagraph_analysis_resource(self, mocker): self.assertIn("cinemagraph_analysis", params) + @patch('urllib3.request.RequestMethods.request') + def test_metadata_fields(self, mocker): + """ should allow listing metadata_fields """ + mocker.return_value = MOCK_RESPONSE + api.metadata_fields() + args, kwargs = mocker.call_args + self.assertTrue(get_uri(args).endswith('/metadata_fields')) + + @patch('urllib3.request.RequestMethods.request') + def test_metadata_field(self, mocker): + """ should allow getting a single metadata field """ + mocker.return_value = MOCK_RESPONSE + api.metadata_field(API_TEST_EXTERNAL_ID) + args, kwargs = mocker.call_args + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}'.format(API_TEST_EXTERNAL_ID))) + + @patch('urllib3.request.RequestMethods.request') + def test_create_metadata_field(self, mocker): + """ should allow the user to create a metadata field """ + mocker.return_value = MOCK_RESPONSE + + api.create_metadata_field(**API_TEST_STRUCTURED_METADATA_INTEGER_FIELD) + + body = get_body(mocker.call_args) + + self.assertIn("label", body) + self.assertIn("type", body) + self.assertIn("default_value", body) + self.assertIn("external_id", body) + + @patch('urllib3.request.RequestMethods.request') + def test_update_metadata_field(self, mocker): + """ should allow the user to update a metadata field """ + mocker.return_value = MOCK_RESPONSE + + api.update_metadata_field(API_TEST_EXTERNAL_ID, default_value=0) + + body = get_body(mocker.call_args) + + self.assertIn("default_value", body) + + @patch('urllib3.request.RequestMethods.request') + def test_delete_metadata_field(self, mocker): + """ should allow the user to delete a metadata field """ + mocker.return_value = MOCK_RESPONSE + + api.delete_metadata_field(API_TEST_EXTERNAL_ID) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}'.format(API_TEST_EXTERNAL_ID))) + + @patch('urllib3.request.RequestMethods.request') + def test_update_metadata_field_datasource(self, mocker): + """ should allow the user to update a metadata field datasource """ + mocker.return_value = MOCK_RESPONSE + + api.update_metadata_field_datasource(API_TEST_EXTERNAL_ID, + values=API_TEST_STRUCTURED_METADATA_DATASOURCE['values']) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}/datasource'.format(API_TEST_EXTERNAL_ID))) + + body = get_body(mocker.call_args) + + self.assertIn("values", body) + + @patch('urllib3.request.RequestMethods.request') + def test_delete_metadata_field_datasource_entry(self, mocker): + """ should allow the user to delete a metadata field datasource entry by external IDs """ + mocker.return_value = MOCK_RESPONSE + + api.delete_metadata_field_datasource_entry(API_TEST_EXTERNAL_ID, + external_ids=[API_TEST_EXTERNAL_ID_1, API_TEST_EXTERNAL_ID_2]) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}/datasource'.format(API_TEST_EXTERNAL_ID))) + + body = get_body(mocker.call_args) + + self.assertIn("external_ids", body) + + @patch('urllib3.request.RequestMethods.request') + def test_restore_metadata_field_datasource_entry(self, mocker): + """ should allow the user to restore a metadata field datasource entry by external IDs """ + mocker.return_value = MOCK_RESPONSE + + api.restore_metadata_field_datasource_entry(API_TEST_EXTERNAL_ID, + external_ids=[API_TEST_EXTERNAL_ID_1, API_TEST_EXTERNAL_ID_2]) + + args, kwargs = mocker.call_args + + self.assertTrue(get_uri(args).endswith('/metadata_fields/{}/datasource_restore'.format(API_TEST_EXTERNAL_ID))) + + body = get_body(mocker.call_args) + + self.assertIn("external_ids", body) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_uploader.py b/test/test_uploader.py index a99f4816..d234e291 100644 --- a/test/test_uploader.py +++ b/test/test_uploader.py @@ -33,6 +33,8 @@ TEST_TRANS_SCALE2_PNG = dict(crop="scale", width="2.0", format="png", overlay=TEST_TRANS_OVERLAY_STR) TEST_TRANS_SCALE2_PNG_STR = "c_scale,l_{},w_2.0/png".format(TEST_TRANS_OVERLAY_STR) +TEST_STRUCTURED_METADATA = dict(sku=1, color="blue") + UNIQUE_TAG = "up_test_uploader_{}".format(SUFFIX) UNIQUE_ID = UNIQUE_TAG TEST_DOCX_ID = "test_docx_{}".format(SUFFIX) @@ -625,7 +627,9 @@ def test_access_control(self, request_mock): @patch('urllib3.request.RequestMethods.request') def test_cinemagraph_analysis(self, request_mock): + """ should support cinemagraph analysis in upload and explicit""" + request_mock.return_value = MOCK_RESPONSE uploader.upload(TEST_IMAGE, cinemagraph_analysis=True) @@ -638,5 +642,31 @@ def test_cinemagraph_analysis(self, request_mock): params = request_mock.call_args[0][2] self.assertIn("cinemagraph_analysis", params) + @patch('urllib3.request.RequestMethods.request') + def test_metadata_upload_option(self, request_mock): + """ should support metadata argument in upload and explicit """ + request_mock.return_value = MOCK_RESPONSE + + uploader.upload(TEST_IMAGE, metadata=TEST_STRUCTURED_METADATA) + + params = request_mock.call_args[0][2] + self.assertIn("metadata", params) + + uploader.upload(TEST_IMAGE, metadata=TEST_STRUCTURED_METADATA) + + params = request_mock.call_args[0][2] + self.assertIn("metadata", params) + + @patch('urllib3.request.RequestMethods.request') + def test_update_metadata(self, request_mock): + """ should pass metadata as to update_metadata """ + request_mock.return_value = MOCK_RESPONSE + + uploader.update_metadata([TEST_IMAGE], TEST_STRUCTURED_METADATA) + + params = request_mock.call_args[0][2] + self.assertIn("metadata", params) + + if __name__ == '__main__': unittest.main()