diff --git a/lib/cloudinary/__init__.py b/lib/cloudinary/__init__.py index 2cc24dc4..7702ca9f 100644 --- a/lib/cloudinary/__init__.py +++ b/lib/cloudinary/__init__.py @@ -38,7 +38,7 @@ CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAA URI_SCHEME = "cloudinary" API_VERSION = "v1_1" -VERSION = "1.34.0" +VERSION = "1.39.1" _USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version()))) @@ -741,7 +741,11 @@ class CloudinaryResource(object): :return: Video tag """ public_id = options.get('public_id', self.public_id) - source = re.sub(r"\.({0})$".format("|".join(self.default_source_types())), '', public_id) + use_fetch_format = options.get('use_fetch_format', config().use_fetch_format) + if not use_fetch_format: + source = re.sub(r"\.({0})$".format("|".join(self.default_source_types())), '', public_id) + else: + source = public_id custom_attributes = options.pop("attributes", dict()) diff --git a/lib/cloudinary/api.py b/lib/cloudinary/api.py index 8f07ee9e..cf5b2fca 100644 --- a/lib/cloudinary/api.py +++ b/lib/cloudinary/api.py @@ -14,7 +14,8 @@ from cloudinary import utils from cloudinary.api_client.call_api import ( call_api, call_metadata_api, - call_json_api + call_json_api, + _call_v2_api ) from cloudinary.exceptions import ( BadRequest, @@ -54,6 +55,19 @@ def usage(**options): return call_api("get", uri, {}, **options) +def config(**options): + """ + Get account config details. + + :param options: Additional options. + :type options: dict, optional + :return: Detailed config information. + :rtype: Response + """ + params = only(options, "settings") + return call_api("get", ["config"], params, **options) + + def resource_types(**options): return call_api("get", ["resources"], {}, **options) @@ -64,24 +78,22 @@ def resources(**options): uri = ["resources", resource_type] if upload_type: uri.append(upload_type) - params = only(options, "next_cursor", "max_results", "prefix", "tags", - "context", "moderations", "direction", "start_at", "metadata") + params = __list_resources_params(**options) + params.update(only(options, "prefix", "start_at")) return call_api("get", uri, params, **options) def resources_by_tag(tag, **options): resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "tags", tag] - params = only(options, "next_cursor", "max_results", "tags", - "context", "moderations", "direction", "metadata") + params = __list_resources_params(**options) return call_api("get", uri, params, **options) def resources_by_moderation(kind, status, **options): resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "moderations", kind, status] - params = only(options, "next_cursor", "max_results", "tags", - "context", "moderations", "direction", "metadata") + params = __list_resources_params(**options) return call_api("get", uri, params, **options) @@ -89,7 +101,7 @@ def resources_by_ids(public_ids, **options): resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type] - params = dict(only(options, "tags", "moderations", "context"), public_ids=public_ids) + params = dict(__resources_params(**options), public_ids=public_ids) return call_api("get", uri, params, **options) @@ -105,7 +117,7 @@ def resources_by_asset_folder(asset_folder, **options): :rtype: Response """ uri = ["resources", "by_asset_folder"] - params = only(options, "max_results", "tags", "moderations", "context", "next_cursor") + params = __list_resources_params(**options) params["asset_folder"] = asset_folder return call_api("get", uri, params, **options) @@ -125,7 +137,7 @@ def resources_by_asset_ids(asset_ids, **options): :rtype: Response """ uri = ["resources", 'by_asset_ids'] - params = dict(only(options, "tags", "moderations", "context"), asset_ids=asset_ids) + params = dict(__resources_params(**options), asset_ids=asset_ids) return call_api("get", uri, params, **options) @@ -147,15 +159,43 @@ def resources_by_context(key, value=None, **options): """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "context"] - params = only(options, "next_cursor", "max_results", "tags", - "context", "moderations", "direction", "metadata") + params = __list_resources_params(**options) params["key"] = key if value is not None: params["value"] = value return call_api("get", uri, params, **options) -def visual_search(image_url=None, image_asset_id=None, text=None, **options): +def __resources_params(**options): + """ + Prepares optional parameters for resources_* API calls. + + :param options: Additional options + :return: Optional parameters + + :internal + """ + params = only(options, "tags", "context", "metadata", "moderations") + params["fields"] = options.get("fields") and utils.encode_list(utils.build_array(options["fields"])) + return params + + +def __list_resources_params(**options): + """ + Prepares optional parameters for resources_* API calls. + + :param options: Additional options + :return: Optional parameters + + :internal + """ + resources_params = __resources_params(**options) + resources_params.update(only(options, "next_cursor", "max_results", "direction")) + + return resources_params + + +def visual_search(image_url=None, image_asset_id=None, text=None, image_file=None, **options): """ Find images based on their visual content. @@ -165,14 +205,17 @@ def visual_search(image_url=None, image_asset_id=None, text=None, **options): :type image_asset_id: str :param text: A textual description, e.g., "cat" :type text: str + :param image_file: The image file. + :type image_file: str|callable|Path|bytes :param options: Additional options :type options: dict, optional :return: Resources (assets) that were found :rtype: Response """ uri = ["resources", "visual_search"] - params = {"image_url": image_url, "image_asset_id": image_asset_id, "text": text} - return call_api("get", uri, params, **options) + params = {"image_url": image_url, "image_asset_id": image_asset_id, "text": text, + "image_file": utils.handle_file_parameter(image_file, "file")} + return call_api("post", uri, params, **options) def resource(public_id, **options): @@ -224,11 +267,11 @@ def update(public_id, **options): if "tags" in options: params["tags"] = ",".join(utils.build_array(options["tags"])) if "face_coordinates" in options: - params["face_coordinates"] = utils.encode_double_array( - options.get("face_coordinates")) + params["face_coordinates"] = utils.encode_double_array(options.get("face_coordinates")) if "custom_coordinates" in options: - params["custom_coordinates"] = utils.encode_double_array( - options.get("custom_coordinates")) + params["custom_coordinates"] = utils.encode_double_array(options.get("custom_coordinates")) + if "regions" in options: + params["regions"] = utils.json_encode(options.get("regions")) if "context" in options: params["context"] = utils.encode_context(options.get("context")) if "metadata" in options: @@ -656,9 +699,8 @@ def add_metadata_field(field, **options): :rtype: Response """ - params = only(field, "type", "external_id", "label", "mandatory", - "default_value", "validation", "datasource") - return call_metadata_api("post", [], params, **options) + + return call_metadata_api("post", [], __metadata_field_params(field), **options) def update_metadata_field(field_external_id, field, **options): @@ -677,8 +719,13 @@ def update_metadata_field(field_external_id, field, **options): :rtype: Response """ uri = [field_external_id] - params = only(field, "label", "mandatory", "default_value", "validation") - return call_metadata_api("put", uri, params, **options) + + return call_metadata_api("put", uri, __metadata_field_params(field), **options) + + +def __metadata_field_params(field): + return only(field, "type", "external_id", "label", "mandatory", "restrictions", + "default_value", "validation", "datasource") def delete_metadata_field(field_external_id, **options): @@ -798,3 +845,18 @@ def reorder_metadata_fields(order_by, direction=None, **options): uri = ['order'] params = {'order_by': order_by, 'direction': direction} return call_metadata_api('put', uri, params, **options) + + +def analyze(input_type, analysis_type, uri=None, **options): + """Analyzes an asset with the requested analysis type. + + :param input_type: The type of input for the asset to analyze ('uri'). + :param analysis_type: The type of analysis to run ('google_tagging', 'captioning', 'fashion'). + :param uri: The URI of the asset to analyze. + :param options: Additional options. + + :rtype: Response + """ + api_uri = ['analysis', 'analyze', input_type] + params = {'analysis_type': analysis_type, 'uri': uri, 'parameters': options.get("parameters")} + return _call_v2_api('post', api_uri, params, **options) diff --git a/lib/cloudinary/api_client/call_account_api.py b/lib/cloudinary/api_client/call_account_api.py index c40aaf3b..5a6cf3ab 100644 --- a/lib/cloudinary/api_client/call_account_api.py +++ b/lib/cloudinary/api_client/call_account_api.py @@ -1,8 +1,7 @@ import cloudinary from cloudinary.api_client.execute_request import execute_request from cloudinary.provisioning.account_config import account_config -from cloudinary.utils import get_http_connector - +from cloudinary.utils import get_http_connector, normalize_params PROVISIONING_SUB_PATH = "provisioning" ACCOUNT_SUB_PATH = "accounts" @@ -28,7 +27,7 @@ def _call_account_api(method, uri, params=None, headers=None, **options): return execute_request(http_connector=_http, method=method, - params=params, + params=normalize_params(params), headers=headers, auth=auth, api_url=provisioning_api_url, diff --git a/lib/cloudinary/api_client/call_api.py b/lib/cloudinary/api_client/call_api.py index 916a396a..94a3c9ec 100644 --- a/lib/cloudinary/api_client/call_api.py +++ b/lib/cloudinary/api_client/call_api.py @@ -2,8 +2,7 @@ import json import cloudinary from cloudinary.api_client.execute_request import execute_request -from cloudinary.utils import get_http_connector - +from cloudinary.utils import get_http_connector, normalize_params logger = cloudinary.logger _http = get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS) @@ -27,6 +26,10 @@ def call_json_api(method, uri, json_body, **options): return _call_api(method, uri, body=data, headers={'Content-Type': 'application/json'}, **options) +def _call_v2_api(method, uri, json_body, **options): + return call_json_api(method, uri, json_body=json_body, api_version='v2', **options) + + def call_api(method, uri, params, **options): return _call_api(method, uri, params=params, **options) @@ -43,10 +46,11 @@ def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=N oauth_token = options.pop("oauth_token", cloudinary.config().oauth_token) _validate_authorization(api_key, api_secret, oauth_token) - - api_url = "/".join([prefix, cloudinary.API_VERSION, cloud_name] + uri) auth = {"key": api_key, "secret": api_secret, "oauth_token": oauth_token} + api_version = options.pop("api_version", cloudinary.API_VERSION) + api_url = "/".join([prefix, api_version, cloud_name] + uri) + if body is not None: options["body"] = body @@ -55,7 +59,7 @@ def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=N return execute_request(http_connector=_http, method=method, - params=params, + params=normalize_params(params), headers=headers, auth=auth, api_url=api_url, diff --git a/lib/cloudinary/api_client/execute_request.py b/lib/cloudinary/api_client/execute_request.py index 1bd52a25..97aba8cb 100644 --- a/lib/cloudinary/api_client/execute_request.py +++ b/lib/cloudinary/api_client/execute_request.py @@ -63,9 +63,8 @@ def execute_request(http_connector, method, params, headers, auth, api_url, **op processed_params = process_params(params) api_url = smart_escape(unquote(api_url)) - try: - response = http_connector.request(method.upper(), api_url, processed_params, req_headers, **kw) + response = http_connector.request(method=method.upper(), url=api_url, fields=processed_params, headers=req_headers, **kw) body = response.data except HTTPError as e: raise GeneralError("Unexpected error %s" % str(e)) diff --git a/lib/cloudinary/http_client.py b/lib/cloudinary/http_client.py index 4355b017..b5f6e1b3 100644 --- a/lib/cloudinary/http_client.py +++ b/lib/cloudinary/http_client.py @@ -24,7 +24,7 @@ class HttpClient: def get_json(self, url): try: - response = self._http_client.request("GET", url, timeout=self.timeout) + response = self._http_client.request(method="GET", url=url, timeout=self.timeout) body = response.data except HTTPError as e: raise GeneralError("Unexpected error %s" % str(e)) diff --git a/lib/cloudinary/provisioning/__init__.py b/lib/cloudinary/provisioning/__init__.py index 09afc114..7016343a 100644 --- a/lib/cloudinary/provisioning/__init__.py +++ b/lib/cloudinary/provisioning/__init__.py @@ -2,4 +2,5 @@ from .account_config import AccountConfig, account_config, reset_config from .account import (sub_accounts, create_sub_account, delete_sub_account, sub_account, update_sub_account, user_groups, create_user_group, update_user_group, delete_user_group, user_group, add_user_to_group, remove_user_from_group, user_group_users, user_in_user_groups, - users, create_user, delete_user, user, update_user, Role) + users, create_user, delete_user, user, update_user, access_keys, generate_access_key, + update_access_key, delete_access_key, Role) diff --git a/lib/cloudinary/provisioning/account.py b/lib/cloudinary/provisioning/account.py index 414c2727..90b1c385 100644 --- a/lib/cloudinary/provisioning/account.py +++ b/lib/cloudinary/provisioning/account.py @@ -1,10 +1,10 @@ from cloudinary.api_client.call_account_api import _call_account_api from cloudinary.utils import encode_list - SUB_ACCOUNTS_SUB_PATH = "sub_accounts" USERS_SUB_PATH = "users" USER_GROUPS_SUB_PATH = "user_groups" +ACCESS_KEYS = "access_keys" class Role(object): @@ -123,7 +123,8 @@ def update_sub_account(sub_account_id, name=None, cloud_name=None, custom_attrib return _call_account_api("put", uri, params=params, **options) -def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **options): +def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, last_login=None, from_date=None, to_date=None, + **options): """ List all users :param user_ids: The ids of the users to fetch @@ -136,6 +137,13 @@ def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **optio :type pending: bool, optional :param prefix: User prefix :type prefix: str, optional + :param last_login: Return only users that last logged in in the specified range of dates (true), + users that didn't last logged in in that range (false), or all users (None). + :type last_login: bool, optional + :param from_date: Last login start date. + :type from_date: datetime, optional + :param to_date: Last login end date. + :type to_date: datetime, optional :param options: Generic advanced options dict, see online documentation. :type options: dict, optional :return: List of users associated with the account @@ -146,7 +154,10 @@ def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **optio params = {"ids": user_ids, "sub_account_id": sub_account_id, "pending": pending, - "prefix": prefix} + "prefix": prefix, + "last_login": last_login, + "from": from_date, + "to": to_date} return _call_account_api("get", uri, params=params, **options) @@ -351,7 +362,7 @@ def user_in_user_groups(user_id, **options): """ Get all user groups a user belongs to :param user_id: The id of user - :param user_id: str + :type user_id: str :param options: Generic advanced options dict, see online documentation :type options: dict, optional :return: List of groups user is in @@ -359,3 +370,112 @@ def user_in_user_groups(user_id, **options): """ uri = [USER_GROUPS_SUB_PATH, user_id] return _call_account_api("get", uri, {}, **options) + + +def access_keys(sub_account_id, page_size=None, page=None, sort_by=None, sort_order=None, **options): + """ + Get sub account access keys. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param page_size: How many entries to display on each page. + :type page_size: int + :param page: Which page to return (maximum pages: 100). **Default**: All pages are returned. + :type page: int + :param sort_by: Which response parameter to sort by. + **Possible values**: `api_key`, `created_at`, `name`, `enabled`. + :type sort_by: str + :param sort_order: Control the order of returned keys. **Possible values**: `desc` (default), `asc`. + :type sort_order: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: List of access keys + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS] + params = { + "page_size": page_size, + "page": page, + "sort_by": sort_by, + "sort_order": sort_order, + } + return _call_account_api("get", uri, params, **options) + + +def generate_access_key(sub_account_id, name=None, enabled=None, **options): + """ + Generate a new access key. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param name: The name of the new access key. + :type name: str + :param enabled: Whether the new access key is enabled or disabled. + :type enabled: bool + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Access key details + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS] + params = { + "name": name, + "enabled": enabled, + } + return _call_account_api("post", uri, params, **options) + + +def update_access_key(sub_account_id, api_key, name=None, enabled=None, dedicated_for=None, **options): + """ + Update the name and/or status of an existing access key. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param api_key: The API key of the access key. + :type api_key: str|int + :param name: The updated name of the access key. + :type name: str + :param enabled: Enable or disable the access key. + :type enabled: bool + :param dedicated_for: Designates the access key for a specific purpose while allowing it to be used for + other purposes, as well. This action replaces any previously assigned key. + **Possible values**: `webhooks` + :type dedicated_for: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Access key details + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS, str(api_key)] + params = { + "name": name, + "enabled": enabled, + "dedicated_for": dedicated_for, + } + return _call_account_api("put", uri, params, **options) + + +def delete_access_key(sub_account_id, api_key=None, name=None, **options): + """ + Delete an existing access key by api_key or by name. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param api_key: The API key of the access key. + :type api_key: str|int + :param name: The name of the access key. + :type name: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Operation status. + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS] + + if api_key is not None: + uri.append(str(api_key)) + + params = { + "name": name + } + return _call_account_api("delete", uri, params, **options) diff --git a/lib/cloudinary/search.py b/lib/cloudinary/search.py index 7af1773c..4e83af68 100644 --- a/lib/cloudinary/search.py +++ b/lib/cloudinary/search.py @@ -3,8 +3,8 @@ import json import cloudinary from cloudinary.api_client.call_api import call_json_api -from cloudinary.utils import unique, unsigned_download_url_prefix, build_distribution_domain, base64url_encode, \ - json_encode, compute_hex_hash, SIGNATURE_SHA256 +from cloudinary.utils import (unique, build_distribution_domain, base64url_encode, json_encode, compute_hex_hash, + SIGNATURE_SHA256, build_array) class Search(object): @@ -16,6 +16,7 @@ class Search(object): 'sort_by': lambda x: next(iter(x)), 'aggregate': None, 'with_field': None, + 'fields': None, } _ttl = 300 # Used for search URLs @@ -57,6 +58,11 @@ class Search(object): self._add("with_field", value) return self + def fields(self, value): + """Request which fields to return in the result set.""" + self._add("fields", value) + return self + def ttl(self, ttl): """ Sets the time to live of the search URL. @@ -133,5 +139,5 @@ class Search(object): def _add(self, name, value): if name not in self.query: self.query[name] = [] - self.query[name].append(value) + self.query[name].extend(build_array(value)) return self diff --git a/lib/cloudinary/uploader.py b/lib/cloudinary/uploader.py index d4039ccc..a2c91ad5 100644 --- a/lib/cloudinary/uploader.py +++ b/lib/cloudinary/uploader.py @@ -23,11 +23,6 @@ try: # Python 2.7+ except ImportError: from urllib3.packages.ordered_dict import OrderedDict -try: # Python 3.4+ - from pathlib import Path as PathLibPathType -except ImportError: - PathLibPathType = None - if is_appengine_sandbox(): # AppEngineManager uses AppEngine's URLFetch API behind the scenes _http = AppEngineManager() @@ -503,32 +498,7 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal if file: filename = options.get("filename") # Custom filename provided by user (relevant only for streams and files) - - if PathLibPathType and isinstance(file, PathLibPathType): - name = filename or file.name - data = file.read_bytes() - elif isinstance(file, string_types): - if utils.is_remote_url(file): - # URL - name = None - data = file - else: - # file path - name = filename or file - with open(file, "rb") as opened: - data = opened.read() - elif hasattr(file, 'read') and callable(file.read): - # stream - data = file.read() - name = filename or (file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream") - elif isinstance(file, tuple): - name, data = file - else: - # Not a string, not a stream - name = filename or "file" - data = file - - param_list.append(("file", (name, data) if name else data)) + param_list.append(("file", utils.handle_file_parameter(file, filename))) kw = {} if timeout is not None: @@ -536,7 +506,7 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal code = 200 try: - response = _http.request("POST", api_url, param_list, headers, **kw) + response = _http.request(method="POST", url=api_url, fields=param_list, headers=headers, **kw) except HTTPError as e: raise Error("Unexpected error - {0!r}".format(e)) except socket.error as e: diff --git a/lib/cloudinary/utils.py b/lib/cloudinary/utils.py index 680c175e..1b7b7215 100644 --- a/lib/cloudinary/utils.py +++ b/lib/cloudinary/utils.py @@ -25,6 +25,11 @@ from cloudinary import auth_token from cloudinary.api_client.tcp_keep_alive_manager import TCPKeepAlivePoolManager, TCPKeepAliveProxyManager from cloudinary.compat import PY3, to_bytes, to_bytearray, to_string, string_types, urlparse +try: # Python 3.4+ + from pathlib import Path as PathLibPathType +except ImportError: + PathLibPathType = None + VAR_NAME_RE = r'(\$\([a-zA-Z]\w+\))' urlencode = six.moves.urllib.parse.urlencode @@ -127,6 +132,7 @@ __SERIALIZED_UPLOAD_PARAMS = [ "allowed_formats", "face_coordinates", "custom_coordinates", + "regions", "context", "auto_tagging", "responsive_breakpoints", @@ -181,12 +187,11 @@ def compute_hex_hash(s, algorithm=SIGNATURE_SHA1): def build_array(arg): - if isinstance(arg, list): + if isinstance(arg, (list, tuple)): return arg elif arg is None: return [] - else: - return [arg] + return [arg] def build_list_of_dicts(val): @@ -235,8 +240,7 @@ def encode_double_array(array): array = build_array(array) if len(array) > 0 and isinstance(array[0], list): return "|".join([",".join([str(i) for i in build_array(inner)]) for inner in array]) - else: - return encode_list([str(i) for i in array]) + return encode_list([str(i) for i in array]) def encode_dict(arg): @@ -246,8 +250,7 @@ def encode_dict(arg): else: items = arg.iteritems() return "|".join((k + "=" + v) for k, v in items) - else: - return arg + return arg def normalize_context_value(value): @@ -288,9 +291,14 @@ def json_encode(value, sort_keys=False): Converts value to a json encoded string :param value: value to be encoded + :param sort_keys: whether to sort keys :return: JSON encoded string """ + + if isinstance(value, str) or value is None: + return value + return json.dumps(value, default=__json_serializer, separators=(',', ':'), sort_keys=sort_keys) @@ -309,11 +317,13 @@ def patch_fetch_format(options): """ When upload type is fetch, remove the format options. In addition, set the fetch_format options to the format value unless it was already set. - Mutates the options parameter! + Mutates the "options" parameter! :param options: URL and transformation options """ - if options.get("type", "upload") != "fetch": + use_fetch_format = options.pop("use_fetch_format", cloudinary.config().use_fetch_format) + + if options.get("type", "upload") != "fetch" and not use_fetch_format: return resource_format = options.pop("format", None) @@ -351,8 +361,7 @@ def generate_transformation_string(**options): def recurse(bs): if isinstance(bs, dict): return generate_transformation_string(**bs)[0] - else: - return generate_transformation_string(transformation=bs)[0] + return generate_transformation_string(transformation=bs)[0] base_transformations = list(map(recurse, base_transformations)) named_transformation = None @@ -375,7 +384,7 @@ def generate_transformation_string(**options): flags = ".".join(build_array(options.pop("flags", None))) dpr = options.pop("dpr", cloudinary.config().dpr) duration = norm_range_value(options.pop("duration", None)) - + so_raw = options.pop("start_offset", None) start_offset = norm_auto_range_value(so_raw) if start_offset == None: @@ -513,8 +522,7 @@ def split_range(range): return [range[0], range[-1]] elif isinstance(range, string_types) and re.match(RANGE_RE, range): return range.split("..", 1) - else: - return None + return None def norm_range_value(value): @@ -570,6 +578,9 @@ def process_params(params): processed_params = {} for key, value in params.items(): if isinstance(value, list) or isinstance(value, tuple): + if len(value) == 2 and value[0] == "file": # keep file parameter as is. + processed_params[key] = value + continue value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)} processed_params.update(value_list) elif value is not None: @@ -578,9 +589,28 @@ def process_params(params): def cleanup_params(params): + """ + Cleans and normalizes parameters when calculating signature in Upload API. + + :param params: + :return: + """ return dict([(k, __safe_value(v)) for (k, v) in params.items() if v is not None and not v == ""]) +def normalize_params(params): + """ + Normalizes Admin API parameters. + + :param params: + :return: + """ + if not params or not isinstance(params, dict): + return params + + return dict([(k, __bool_string(v)) for (k, v) in params.items() if v is not None and not v == ""]) + + def sign_request(params, options): api_key = options.get("api_key", cloudinary.config().api_key) if not api_key: @@ -1086,6 +1116,7 @@ def build_upload_params(**options): "allowed_formats": options.get("allowed_formats") and encode_list(build_array(options["allowed_formats"])), "face_coordinates": encode_double_array(options.get("face_coordinates")), "custom_coordinates": encode_double_array(options.get("custom_coordinates")), + "regions": json_encode(options.get("regions")), "context": encode_context(options.get("context")), "auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")), "responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")), @@ -1101,6 +1132,37 @@ def build_upload_params(**options): return params +def handle_file_parameter(file, filename): + if not file: + return None + + if PathLibPathType and isinstance(file, PathLibPathType): + name = filename or file.name + data = file.read_bytes() + elif isinstance(file, string_types): + if is_remote_url(file): + # URL + name = None + data = file + else: + # file path + name = filename or file + with open(file, "rb") as opened: + data = opened.read() + elif hasattr(file, 'read') and callable(file.read): + # stream + data = file.read() + name = filename or (file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream") + elif isinstance(file, tuple): + name, data = file + else: + # Not a string, not a stream + name = filename or "file" + data = file + + return (name, data) if name else data + + def build_multi_and_sprite_params(**options): """ Build params for multi, download_multi, generate_sprite, and download_generated_sprite methods @@ -1166,8 +1228,21 @@ def __process_text_options(layer, layer_parameter): def process_layer(layer, layer_parameter): - if isinstance(layer, string_types) and layer.startswith("fetch:"): - layer = {"url": layer[len('fetch:'):]} + if isinstance(layer, string_types): + resource_type = None + if layer.startswith("fetch:"): + url = layer[len('fetch:'):] + elif layer.find(":fetch:", 0, 12) != -1: + resource_type, _, url = layer.split(":", 2) + else: + # nothing to process, a raw string, keep as is. + return layer + + # handle remote fetch URL + layer = {"url": url, "type": "fetch"} + if resource_type: + layer["resource_type"] = resource_type + if not isinstance(layer, dict): return layer @@ -1176,19 +1251,19 @@ def process_layer(layer, layer_parameter): type = layer.get("type") public_id = layer.get("public_id") format = layer.get("format") - fetch = layer.get("url") + fetch_url = layer.get("url") components = list() if text is not None and resource_type is None: resource_type = "text" - if fetch and resource_type is None: - resource_type = "fetch" + if fetch_url and type is None: + type = "fetch" if public_id is not None and format is not None: public_id = public_id + "." + format - if public_id is None and resource_type != "text" and resource_type != "fetch": + if public_id is None and resource_type != "text" and type != "fetch": raise ValueError("Must supply public_id for for non-text " + layer_parameter) if resource_type is not None and resource_type != "image": @@ -1212,8 +1287,6 @@ def process_layer(layer, layer_parameter): if text is not None: var_pattern = VAR_NAME_RE - match = re.findall(var_pattern, text) - parts = filter(lambda p: p is not None, re.split(var_pattern, text)) encoded_text = [] for part in parts: @@ -1223,11 +1296,9 @@ def process_layer(layer, layer_parameter): encoded_text.append(smart_escape(smart_escape(part, r"([,/])"))) text = ''.join(encoded_text) - # text = text.replace("%2C", "%252C") - # text = text.replace("/", "%252F") components.append(text) - elif resource_type == "fetch": - b64 = base64_encode_url(fetch) + elif type == "fetch": + b64 = base64url_encode(fetch_url) components.append(b64) else: public_id = public_id.replace("/", ':') @@ -1359,8 +1430,7 @@ def normalize_expression(expression): result = re.sub(replaceRE, translate_if, result) result = re.sub('[ _]+', '_', result) return result - else: - return expression + return expression def __join_pair(key, value): @@ -1368,8 +1438,7 @@ def __join_pair(key, value): return None elif value is True: return key - else: - return u"{0}=\"{1}\"".format(key, value) + return u"{0}=\"{1}\"".format(key, value) def html_attrs(attrs, only=None): @@ -1379,10 +1448,15 @@ def html_attrs(attrs, only=None): def __safe_value(v): if isinstance(v, bool): return "1" if v else "0" - else: - return v + return v +def __bool_string(v): + if isinstance(v, bool): + return "true" if v else "false" + + return v + def __crc(source): return str((zlib.crc32(to_bytearray(source)) & 0xffffffff) % 5 + 1) diff --git a/requirements.txt b/requirements.txt index 760747ea..a91534d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ bleach==6.1.0 certifi==2024.2.2 cheroot==10.0.0 cherrypy==18.9.0 -cloudinary==1.34.0 +cloudinary==1.39.1 distro==1.9.0 dnspython==2.6.1 facebook-sdk==3.1.0