diff --git a/lib/cloudinary/__init__.py b/lib/cloudinary/__init__.py index 5faf1c0b..4422b38d 100644 --- a/lib/cloudinary/__init__.py +++ b/lib/cloudinary/__init__.py @@ -1,13 +1,15 @@ from __future__ import absolute_import +import abc from copy import deepcopy +import hashlib import os import re import logging import numbers import certifi from math import ceil -from six import python_2_unicode_compatible +from six import python_2_unicode_compatible, add_metaclass logger = logging.getLogger("Cloudinary") ch = logging.StreamHandler() @@ -34,8 +36,9 @@ AKAMAI_SHARED_CDN = "res.cloudinary.com" SHARED_CDN = AKAMAI_SHARED_CDN CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" URI_SCHEME = "cloudinary" +API_VERSION = "v1_1" -VERSION = "1.20.0" +VERSION = "1.26.0" USER_AGENT = "CloudinaryPython/{} (Python {})".format(VERSION, python_version()) """ :const: USER_AGENT """ @@ -94,54 +97,20 @@ def import_django_settings(): return None -class Config(object): +@add_metaclass(abc.ABCMeta) +class BaseConfig(object): def __init__(self): django_settings = import_django_settings() if django_settings: self.update(**django_settings) - elif os.environ.get("CLOUDINARY_CLOUD_NAME"): - self.update( - cloud_name=os.environ.get("CLOUDINARY_CLOUD_NAME"), - api_key=os.environ.get("CLOUDINARY_API_KEY"), - api_secret=os.environ.get("CLOUDINARY_API_SECRET"), - secure_distribution=os.environ.get("CLOUDINARY_SECURE_DISTRIBUTION"), - private_cdn=os.environ.get("CLOUDINARY_PRIVATE_CDN") == 'true', - api_proxy=os.environ.get("CLOUDINARY_API_PROXY"), - ) - elif os.environ.get("CLOUDINARY_URL"): - cloudinary_url = os.environ.get("CLOUDINARY_URL") - self._parse_cloudinary_url(cloudinary_url) - def _parse_cloudinary_url(self, cloudinary_url): - uri = urlparse(cloudinary_url) - if not self._is_url_scheme_valid(uri): - raise ValueError("Invalid CLOUDINARY_URL scheme. Expecting to start with 'cloudinary://'") - - for k, v in parse_qs(uri.query).items(): - if self._is_nested_key(k): - self._put_nested_key(k, v) - else: - self.__dict__[k] = v[0] - self.update( - cloud_name=uri.hostname, - api_key=uri.username, - api_secret=uri.password, - private_cdn=uri.path != '' - ) - if uri.path != '': - self.update(secure_distribution=uri.path[1:]) + self._load_config_from_env() def __getattr__(self, i): - if i in self.__dict__: - return self.__dict__[i] - else: - return None + return self.__dict__.get(i) - def update(self, **keywords): - for k, v in keywords.items(): - self.__dict__[k] = v - - def _is_nested_key(self, key): + @staticmethod + def _is_nested_key(key): return re.match(r'\w+\[\w+\]', key) def _put_nested_key(self, key, value): @@ -160,8 +129,7 @@ class Config(object): value = value[0] outer[last_key] = value - @staticmethod - def _is_url_scheme_valid(url): + def _is_url_scheme_valid(self, url): """ Helper function. Validates url scheme @@ -169,9 +137,81 @@ class Config(object): :return: bool True on success or False on failure """ - if not url.scheme or url.scheme.lower() != URI_SCHEME: - return False - return True + return url.scheme.lower() == self._uri_scheme + + @staticmethod + def _parse_cloudinary_url(cloudinary_url): + return urlparse(cloudinary_url) + + @abc.abstractmethod + def _config_from_parsed_url(self, parsed_url): + """Extract additional config from the parsed URL.""" + raise NotImplementedError() + + def _setup_from_parsed_url(self, parsed_url): + config_from_parsed_url = self._config_from_parsed_url(parsed_url) + self.update(**config_from_parsed_url) + + for k, v in parse_qs(parsed_url.query).items(): + if self._is_nested_key(k): + self._put_nested_key(k, v) + else: + self.__dict__[k] = v[0] + + def _load_from_url(self, url): + parsed_url = self._parse_cloudinary_url(url) + + return self._setup_from_parsed_url(parsed_url) + + @abc.abstractmethod + def _load_config_from_env(self): + """Load config from environment variables or URL.""" + raise NotImplementedError() + + def update(self, **keywords): + for k, v in keywords.items(): + self.__dict__[k] = v + + +class Config(BaseConfig): + def __init__(self): + self._uri_scheme = URI_SCHEME + + super(Config, self).__init__() + + if not self.signature_algorithm: + self.signature_algorithm = utils.SIGNATURE_SHA1 + + def _config_from_parsed_url(self, parsed_url): + if not self._is_url_scheme_valid(parsed_url): + raise ValueError("Invalid CLOUDINARY_URL scheme. Expecting to start with 'cloudinary://'") + + is_private_cdn = parsed_url.path != "" + result = { + "cloud_name": parsed_url.hostname, + "api_key": parsed_url.username, + "api_secret": parsed_url.password, + "private_cdn": is_private_cdn, + } + if is_private_cdn: + result.update({"secure_distribution": parsed_url.path[1:]}) + + return result + + def _load_config_from_env(self): + if os.environ.get("CLOUDINARY_CLOUD_NAME"): + config_keys = [key for key in os.environ.keys() + if key.startswith("CLOUDINARY_") and key != "CLOUDINARY_URL"] + + for full_key in config_keys: + conf_key = full_key[len("CLOUDINARY_"):].lower() + conf_val = os.environ[full_key] + if conf_val in ["true", "false"]: + conf_val = conf_val == "true" + + self.update(**{conf_key: conf_val}) + elif os.environ.get("CLOUDINARY_URL"): + self._load_from_url(os.environ.get("CLOUDINARY_URL")) _config = Config() @@ -257,7 +297,8 @@ class CloudinaryResource(object): return self.get_prep_value() + '#' + self.get_expected_signature() def get_expected_signature(self): - return utils.api_sign_request({"public_id": self.public_id, "version": self.version}, config().api_secret) + return utils.api_sign_request({"public_id": self.public_id, "version": self.version}, config().api_secret, + config().signature_algorithm) @property def url(self): @@ -377,7 +418,7 @@ class CloudinaryResource(object): max_images = srcset_data.get("max_images", 20) transformation = srcset_data.get("transformation") - kbytes_step = int(ceil(float(bytes_step)/1024)) + kbytes_step = int(ceil(float(bytes_step) / 1024)) breakpoints_width_param = "auto:breakpoints_{min_width}_{max_width}_{kbytes_step}_{max_images}:json".format( min_width=min_width, max_width=max_width, kbytes_step=kbytes_step, max_images=max_images) @@ -623,7 +664,7 @@ class CloudinaryResource(object): return utils.cloudinary_url(poster_options['public_id'], **poster_options)[0] - def _populate_video_source_tags(self, source, options): + def _populate_video_source_tags(self, source, options): """ Helper function for video tag, populates source tags from provided options. @@ -750,7 +791,7 @@ class CloudinaryResource(object): srcset_data = srcset_data.copy() srcset_data.update(options.pop("srcset", dict())) - responsive_attrs = self._generate_image_responsive_attributes(attrs, srcset_data, **options) + responsive_attrs = self._generate_image_responsive_attributes(attrs, srcset_data, **options) attrs.update(responsive_attrs) diff --git a/lib/cloudinary/api.py b/lib/cloudinary/api.py index 5d9da408..7c1fd534 100644 --- a/lib/cloudinary/api.py +++ b/lib/cloudinary/api.py @@ -1,5 +1,6 @@ # Copyright Cloudinary +import datetime import email.utils import json import socket @@ -10,6 +11,11 @@ from urllib3.exceptions import HTTPError import cloudinary from cloudinary import utils +from cloudinary.api_client.call_api import ( + call_api, + call_metadata_api, + call_json_api +) from cloudinary.exceptions import ( BadRequest, AuthorizationRequired, @@ -20,37 +26,32 @@ from cloudinary.exceptions import ( GeneralError ) -logger = cloudinary.logger - -EXCEPTION_CODES = { - 400: BadRequest, - 401: AuthorizationRequired, - 403: NotAllowed, - 404: NotFound, - 409: AlreadyExists, - 420: RateLimited, - 500: GeneralError -} - - -class Response(dict): - def __init__(self, result, response, **kwargs): - super(Response, self).__init__(**kwargs) - self.update(result) - self.rate_limit_allowed = int(response.headers["x-featureratelimit-limit"]) - self.rate_limit_reset_at = email.utils.parsedate(response.headers["x-featureratelimit-reset"]) - self.rate_limit_remaining = int(response.headers["x-featureratelimit-remaining"]) - - -_http = utils.get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS) - def ping(**options): return call_api("get", ["ping"], {}, **options) def usage(**options): - return call_api("get", ["usage"], {}, **options) + """Get account usage details. + + Get a report on the status of your Cloudinary account usage details, including storage, credits, bandwidth, + requests, number of resources, and add-on usage. Note that numbers are updated periodically. + + See: `Get account usage details + `_ + + :param options: Additional options + :type options: dict, optional + :return: Detailed usage information + :rtype: Response + """ + date = options.pop("date", None) + uri = ["usage"] + if date: + if isinstance(date, datetime.date): + date = utils.encode_date_to_usage_api_format(date) + uri.append(date) + return call_api("get", uri, {}, **options) def resource_types(**options): @@ -64,7 +65,7 @@ def resources(**options): if upload_type: uri.append(upload_type) params = only(options, "next_cursor", "max_results", "prefix", "tags", - "context", "moderations", "direction", "start_at") + "context", "moderations", "direction", "start_at", "metadata") return call_api("get", uri, params, **options) @@ -72,7 +73,7 @@ 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") + "context", "moderations", "direction", "metadata") return call_api("get", uri, params, **options) @@ -80,7 +81,7 @@ 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") + "context", "moderations", "direction", "metadata") return call_api("get", uri, params, **options) @@ -92,12 +93,39 @@ def resources_by_ids(public_ids, **options): return call_api("get", uri, params, **options) +def resources_by_context(key, value=None, **options): + """Retrieves resources (assets) with a specified context key. + This method does not return deleted assets even if they have been backed up. + + See: `Get resources by context API reference + `_ + + :param key: Only assets with this context key are returned + :type key: str + :param value: Only assets with this value for the context key are returned + :type value: str, optional + :param options: Additional options + :type options: dict, optional + :return: Resources (assets) with a specified context key + :rtype: Response + """ + 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["key"] = key + if value is not None: + params["value"] = value + return call_api("get", uri, params, **options) + + def resource(public_id, **options): resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type, public_id] params = only(options, "exif", "faces", "colors", "image_metadata", "cinemagraph_analysis", - "pages", "phash", "coordinates", "max_results", "quality_analysis", "derived_next_cursor") + "pages", "phash", "coordinates", "max_results", "quality_analysis", "derived_next_cursor", + "accessibility_analysis", "versions") return call_api("get", uri, params, **options) @@ -327,8 +355,8 @@ def restore(public_ids, **options): resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type, "restore"] - params = dict(public_ids=public_ids) - return call_api("post", uri, params, **options) + params = dict(public_ids=public_ids, **only(options, "versions")) + return call_json_api("post", uri, params, **options) def upload_mappings(**options): @@ -390,90 +418,6 @@ def update_streaming_profile(name, **options): return call_api('PUT', uri, params, **options) -def call_json_api(method, uri, jsonBody, **options): - logger.debug(jsonBody) - data = json.dumps(jsonBody).encode('utf-8') - return _call_api(method, uri, body=data, - headers={'Content-Type': 'application/json'}, **options) - - -def call_api(method, uri, params, **options): - return _call_api(method, uri, params=params, **options) - - -def call_metadata_api(method, uri, params, **options): - """Private function that assists with performing an API call to the - metadata_fields part of the Admin API - - :param method: The HTTP method. Valid methods: get, post, put, delete - :param uri: REST endpoint of the API (without 'metadata_fields') - :param params: Query/body parameters passed to the method - :param options: Additional options - - :rtype: Response - """ - uri = ["metadata_fields"] + (uri or []) - return call_json_api(method, uri, params, **options) - - -def _call_api(method, uri, params=None, body=None, headers=None, **options): - prefix = options.pop("upload_prefix", - cloudinary.config().upload_prefix) or "https://api.cloudinary.com" - cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name) - if not cloud_name: - raise Exception("Must supply cloud_name") - api_key = options.pop("api_key", cloudinary.config().api_key) - if not api_key: - raise Exception("Must supply api_key") - api_secret = options.pop("api_secret", cloudinary.config().api_secret) - if not cloud_name: - raise Exception("Must supply api_secret") - api_url = "/".join([prefix, "v1_1", cloud_name] + uri) - - processed_params = None - if isinstance(params, dict): - processed_params = {} - for key, value in params.items(): - if isinstance(value, list) or isinstance(value, tuple): - value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)} - processed_params.update(value_list) - elif value: - processed_params[key] = value - - # Add authentication - req_headers = urllib3.make_headers( - basic_auth="{0}:{1}".format(api_key, api_secret), - user_agent=cloudinary.get_user_agent() - ) - if headers is not None: - req_headers.update(headers) - kw = {} - if 'timeout' in options: - kw['timeout'] = options['timeout'] - if body is not None: - kw['body'] = body - try: - response = _http.request(method.upper(), api_url, processed_params, req_headers, **kw) - body = response.data - except HTTPError as e: - raise GeneralError("Unexpected error {0}", e.message) - except socket.error as e: - raise GeneralError("Socket Error: %s" % (str(e))) - - try: - result = json.loads(body.decode('utf-8')) - except Exception as e: - # Error is parsing json - raise GeneralError("Error parsing server response (%d) - %s. Got - %s" % (response.status, body, e)) - - if "error" in result: - exception_class = EXCEPTION_CODES.get(response.status) or Exception - exception_class = exception_class - raise exception_class("Error {0} - {1}".format(response.status, result["error"]["message"])) - - return Response(result, response) - - def only(source, *keys): return {key: source[key] for key in keys if key in source} diff --git a/lib/cloudinary/api_client/__init__.py b/lib/cloudinary/api_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lib/cloudinary/api_client/call_account_api.py b/lib/cloudinary/api_client/call_account_api.py new file mode 100644 index 00000000..c40aaf3b --- /dev/null +++ b/lib/cloudinary/api_client/call_account_api.py @@ -0,0 +1,35 @@ +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 + + +PROVISIONING_SUB_PATH = "provisioning" +ACCOUNT_SUB_PATH = "accounts" +_http = get_http_connector(account_config(), cloudinary.CERT_KWARGS) + + +def _call_account_api(method, uri, params=None, headers=None, **options): + prefix = options.pop("upload_prefix", + cloudinary.config().upload_prefix) or "https://api.cloudinary.com" + account_id = options.pop("account_id", account_config().account_id) + if not account_id: + raise Exception("Must supply account_id") + provisioning_api_key = options.pop("provisioning_api_key", account_config().provisioning_api_key) + if not provisioning_api_key: + raise Exception("Must supply provisioning_api_key") + provisioning_api_secret = options.pop("provisioning_api_secret", + account_config().provisioning_api_secret) + if not provisioning_api_secret: + raise Exception("Must supply provisioning_api_secret") + provisioning_api_url = "/".join( + [prefix, cloudinary.API_VERSION, PROVISIONING_SUB_PATH, ACCOUNT_SUB_PATH, account_id] + uri) + auth = {"key": provisioning_api_key, "secret": provisioning_api_secret} + + return execute_request(http_connector=_http, + method=method, + params=params, + headers=headers, + auth=auth, + api_url=provisioning_api_url, + **options) diff --git a/lib/cloudinary/api_client/call_api.py b/lib/cloudinary/api_client/call_api.py new file mode 100644 index 00000000..5b5eb4f5 --- /dev/null +++ b/lib/cloudinary/api_client/call_api.py @@ -0,0 +1,70 @@ +import json + +import cloudinary +from cloudinary.api_client.execute_request import execute_request +from cloudinary.utils import get_http_connector + + +logger = cloudinary.logger +_http = get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS) + + +def call_metadata_api(method, uri, params, **options): + """Private function that assists with performing an API call to the + metadata_fields part of the Admin API + :param method: The HTTP method. Valid methods: get, post, put, delete + :param uri: REST endpoint of the API (without 'metadata_fields') + :param params: Query/body parameters passed to the method + :param options: Additional options + :rtype: Response + """ + uri = ["metadata_fields"] + (uri or []) + return call_json_api(method, uri, params, **options) + + +def call_json_api(method, uri, json_body, **options): + data = json.dumps(json_body).encode('utf-8') + return _call_api(method, uri, body=data, headers={'Content-Type': 'application/json'}, **options) + + +def call_api(method, uri, params, **options): + return _call_api(method, uri, params=params, **options) + + +def _call_api(method, uri, params=None, body=None, headers=None, **options): + prefix = options.pop("upload_prefix", + cloudinary.config().upload_prefix) or "https://api.cloudinary.com" + cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name) + if not cloud_name: + raise Exception("Must supply cloud_name") + + api_key = options.pop("api_key", cloudinary.config().api_key) + api_secret = options.pop("api_secret", cloudinary.config().api_secret) + 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} + + if body is not None: + options["body"] = body + + return execute_request(http_connector=_http, + method=method, + params=params, + headers=headers, + auth=auth, + api_url=api_url, + **options) + + +def _validate_authorization(api_key, api_secret, oauth_token): + if oauth_token: + return + + if not api_key: + raise Exception("Must supply api_key") + + if not api_secret: + raise Exception("Must supply api_secret") diff --git a/lib/cloudinary/api_client/execute_request.py b/lib/cloudinary/api_client/execute_request.py new file mode 100644 index 00000000..4d6b8651 --- /dev/null +++ b/lib/cloudinary/api_client/execute_request.py @@ -0,0 +1,86 @@ +import email.utils +import json +import socket + +import urllib3 +from urllib3.exceptions import HTTPError + +import cloudinary +from cloudinary.exceptions import ( + BadRequest, + AuthorizationRequired, + NotAllowed, + NotFound, + AlreadyExists, + RateLimited, + GeneralError +) +from cloudinary.utils import process_params, safe_cast, smart_escape, unquote + +EXCEPTION_CODES = { + 400: BadRequest, + 401: AuthorizationRequired, + 403: NotAllowed, + 404: NotFound, + 409: AlreadyExists, + 420: RateLimited, + 500: GeneralError +} + + +class Response(dict): + def __init__(self, result, response, **kwargs): + super(Response, self).__init__(**kwargs) + self.update(result) + + self.rate_limit_allowed = safe_cast(response.headers.get("x-featureratelimit-limit"), int) + self.rate_limit_reset_at = safe_cast(response.headers.get("x-featureratelimit-reset"), email.utils.parsedate) + self.rate_limit_remaining = safe_cast(response.headers.get("x-featureratelimit-remaining"), int) + + +def execute_request(http_connector, method, params, headers, auth, api_url, **options): + # authentication + key = auth.get("key") + secret = auth.get("secret") + oauth_token = auth.get("oauth_token") + req_headers = urllib3.make_headers( + user_agent=cloudinary.get_user_agent() + ) + if oauth_token: + req_headers["authorization"] = "Bearer {}".format(oauth_token) + else: + req_headers.update(urllib3.make_headers(basic_auth="{0}:{1}".format(key, secret))) + + if headers is not None: + req_headers.update(headers) + + kw = {} + if "timeout" in options: + kw["timeout"] = options["timeout"] + if "body" in options: + kw["body"] = options["body"] + + 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) + body = response.data + except HTTPError as e: + raise GeneralError("Unexpected error {0}", e.message) + except socket.error as e: + raise GeneralError("Socket Error: %s" % (str(e))) + + try: + result = json.loads(body.decode('utf-8')) + except Exception as e: + # Error is parsing json + raise GeneralError("Error parsing server response (%d) - %s. Got - %s" % (response.status, body, e)) + + if "error" in result: + exception_class = EXCEPTION_CODES.get(response.status) or Exception + exception_class = exception_class + raise exception_class("Error {0} - {1}".format(response.status, result["error"]["message"])) + + return Response(result, response) diff --git a/lib/cloudinary/auth_token.py b/lib/cloudinary/auth_token.py index 6ef3874c..3aaf3844 100644 --- a/lib/cloudinary/auth_token.py +++ b/lib/cloudinary/auth_token.py @@ -20,6 +20,9 @@ def generate(url=None, acl=None, start_time=None, duration=None, else: raise Exception("Must provide either expiration or duration") + if url is None and acl is None: + raise Exception("Must provide either acl or url") + token_parts = [] if ip is not None: token_parts.append("ip=" + ip) diff --git a/lib/cloudinary/forms.py b/lib/cloudinary/forms.py index ff83a98f..6c0ffaba 100644 --- a/lib/cloudinary/forms.py +++ b/lib/cloudinary/forms.py @@ -5,7 +5,7 @@ import cloudinary.uploader import cloudinary.utils from cloudinary import CloudinaryResource from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ def cl_init_js_callbacks(form, request): @@ -52,7 +52,7 @@ class CloudinaryInput(forms.TextInput): class CloudinaryJsFileField(forms.Field): default_error_messages = { - 'required': _(u"No file selected!") + "required": _("No file selected!") } def __init__(self, attrs=None, options=None, autosave=True, *args, **kwargs): @@ -121,7 +121,7 @@ class CloudinaryUnsignedJsFileField(CloudinaryJsFileField): class CloudinaryFileField(forms.FileField): my_default_error_messages = { - 'required': _(u"No file selected!") + "required": _("No file selected!") } default_error_messages = forms.FileField.default_error_messages.copy() default_error_messages.update(my_default_error_messages) diff --git a/lib/cloudinary/models.py b/lib/cloudinary/models.py index a3c7591c..5ccb133d 100644 --- a/lib/cloudinary/models.py +++ b/lib/cloudinary/models.py @@ -107,6 +107,8 @@ class CloudinaryField(models.Field): if isinstance(value, UploadedFile): options = {"type": self.type, "resource_type": self.resource_type} options.update(self.options) + if hasattr(value, 'seekable') and value.seekable(): + value.seek(0) instance_value = uploader.upload_resource(value, **options) setattr(model_instance, self.attname, instance_value) if self.width_field: diff --git a/lib/cloudinary/provisioning/__init__.py b/lib/cloudinary/provisioning/__init__.py new file mode 100644 index 00000000..09afc114 --- /dev/null +++ b/lib/cloudinary/provisioning/__init__.py @@ -0,0 +1,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) diff --git a/lib/cloudinary/provisioning/account.py b/lib/cloudinary/provisioning/account.py new file mode 100644 index 00000000..9dc97cf1 --- /dev/null +++ b/lib/cloudinary/provisioning/account.py @@ -0,0 +1,366 @@ +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" + + +class Role(object): + """ + A user role to use in the user management API (create/update user). + """ + MASTER_ADMIN = "master_admin" + ADMIN = "admin" + BILLING = "billing" + TECHNICAL_ADMIN = "technical_admin" + REPORTS = "reports" + MEDIA_LIBRARY_ADMIN = "media_library_admin" + MEDIA_LIBRARY_USER = "media_library_user" + + +def sub_accounts(enabled=None, ids=None, prefix=None, **options): + """ + List all sub accounts + :param enabled: Whether to only return enabled sub-accounts (true) or disabled accounts (false). + Default: all accounts are returned (both enabled and disabled). + :type enabled: bool, optional + :param ids: List of sub-account IDs. Up to 100. When provided, other filters are ignored. + :type ids: list, optional + :param prefix: Search by prefix of the sub-account name. Case-insensitive. + :type prefix: str, optional + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: A list of sub accounts + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH] + params = {"ids": ids, "enabled": enabled, "prefix": prefix} + return _call_account_api("GET", uri, params=params, **options) + + +def create_sub_account(name, cloud_name=None, custom_attributes=None, enabled=None, + base_account=None, **options): + """ + Create a new sub account + :param name: Name of the new sub account + :type name: str + :param cloud_name: A case-insensitive cloud name comprised of alphanumeric and underscore characters. + * Generates an error if the cloud name is not unique across all Cloudinary accounts. + :type cloud_name: str, optional + :param custom_attributes: Any custom attributes you want to associate with the sub-account + :type custom_attributes: dict, optional + :param enabled: Whether to create the account as enabled (default is enabled). + :type enabled: bool, optional + :param base_account: ID of sub-account from which to copy settings + :type base_account: str, optional + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: The created sub account + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH] + params = {"name": name, + "cloud_name": cloud_name, + "custom_attributes": custom_attributes, + "enabled": enabled, + "base_account": base_account} + return _call_account_api("POST", uri, params=params, **options) + + +def delete_sub_account(sub_account_id, **options): + """ + Delete a sub account + :param sub_account_id: The id of the sub account + :type sub_account_id: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: Result message + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id] + return _call_account_api("delete", uri, {}, **options) + + +def sub_account(sub_account_id, **options): + """ + Get information of a sub account + :param sub_account_id: The id of the sub account + :type sub_account_id: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: A sub account + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id] + return _call_account_api("get", uri, {}, **options) + + +def update_sub_account(sub_account_id, name=None, cloud_name=None, custom_attributes=None, + enabled=None, base_account=None, + **options): + """ + Update a sub account + :param sub_account_id: The id of the sub account + :type sub_account_id: str + :param name: Name of the account + :type name: str, optional + :param cloud_name: Unique cloud name + :type cloud_name: str, optional + :param custom_attributes: Any custom attributes you want to associate with the sub-account. + :type custom_attributes: dict, optional + :param enabled: Whether to create the account as enabled (default is enabled). + :type enabled: bool, optional + :param base_account: ID of sub-account from which to copy settings + :type base_account: str, optional + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: Updated sub account + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id] + params = {"name": name, + "cloud_name": cloud_name, + "custom_attributes": custom_attributes, + "enabled": enabled, + "base_account": base_account} + return _call_account_api("put", uri, params=params, **options) + + +def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **options): + """ + List all users + :param user_ids: The ids of the users to fetch + :type user_ids: list, optional + :param sub_account_id: The id of a sub account + :type sub_account_id: str, optional + :param pending: Limit results to pending users (True), + users that are not pending (False), + or all users (None, the default). + :type pending: bool, optional + :param prefix: User prefix + :type prefix: str, optional + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: List of users associated with the account + :rtype: dict + """ + uri = [USERS_SUB_PATH] + user_ids = encode_list(user_ids) + params = {"ids": user_ids, + "sub_account_id": sub_account_id, + "pending": pending, + "prefix": prefix} + return _call_account_api("get", uri, params=params, **options) + + +def create_user(name, email, role, sub_account_ids=None, **options): + """ + Create a user + :param name: Username + :type name: str + :param email: User's email + :type email: str + :param role: User's role + :type role: str + :param sub_account_ids: Optional. Sub accounts to associate with the user + :type sub_account_ids: list, optional + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Details of created user + :rtype: dict + """ + uri = [USERS_SUB_PATH] + params = {"name": name, + "email": email, + "role": role, + "sub_account_ids": sub_account_ids} + return _call_account_api("post", uri, params=params, **options) + + +def delete_user(user_id, **options): + """ + Delete a user + :param user_id: The id of user to delete + :type user_id: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Result message + :rtype: dict + """ + uri = [USERS_SUB_PATH, user_id] + return _call_account_api("delete", uri, {}, **options) + + +def user(user_id, **options): + """ + Get information of a user + :param user_id: The id of the user + :type user_id: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: A user + :rtype: dict + """ + uri = [USERS_SUB_PATH, user_id] + return _call_account_api("get", uri, {}, **options) + + +def update_user(user_id, name=None, email=None, role=None, sub_account_ids=None, **options): + """ + Update a user + :param user_id: The id of the user to update + :type user_id: str + :param name: Username + :type name: str, optional + :param email: User's email + :type email: str, optional + :param role: User's role + :type role: Role, optional + :param sub_account_ids: The list of sub-account IDs that this user can access. + Note: This parameter is ignored if the role is specified as master_admin. + :type sub_account_ids: list, optional + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: The updated user + :rtype: dict + """ + uri = [USERS_SUB_PATH, user_id] + params = {"name": name, + "email": email, + "role": role, + "sub_account_ids": sub_account_ids} + return _call_account_api("put", uri, params=params, **options) + + +def user_groups(**options): + """ + List all user groups + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: List of user groups + :rtype: ProvisioningAPIRespose + """ + uri = [USER_GROUPS_SUB_PATH] + return _call_account_api("get", uri, {}, **options) + + +def create_user_group(name, **options): + """ + Create a new user group + :param name: Name of the user group + :type name: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: The newly created group + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH] + params = {"name": name} + return _call_account_api("post", uri, params, **options) + + +def update_user_group(user_group_id, name, **options): + """ + Update a user group + :param user_group_id: The id of the user group to update + :type user_group_id: str + :param name: Name of the user group + :type name: str, optional + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: The updated group + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH, user_group_id] + params = {"name": name} + return _call_account_api("put", uri, params, **options) + + +def delete_user_group(user_group_id, **options): + """ + Delete a user group + :param user_group_id: The id of the user group to delete + :type user_group_id: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: The result message + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH, user_group_id] + return _call_account_api("delete", uri, {}, **options) + + +def user_group(user_group_id, **options): + """ + Get information of a user group + :param user_group_id: The id of the user group + :type user_group_id: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: Details of the group + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH, user_group_id] + return _call_account_api("get", uri, {}, **options) + + +def add_user_to_group(user_group_id, user_id, **options): + """ + Add a user to a user group + :param user_group_id: The id of the user group to add the user to + :type user_group_id: str + :param user_id: The user id to add + :type user_id: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: List of users in the group + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH, user_group_id, "users", user_id] + return _call_account_api("post", uri, {}, **options) + + +def remove_user_from_group(user_group_id, user_id, **options): + """ + Remove a user from a user group + :param user_group_id: The id of the user group to remove the user from + :type user_group_id: str + :param user_id: The id of the user to remove + :type user_id: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: List of users in the group + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH, user_group_id, "users", user_id] + return _call_account_api("delete", uri, {}, **options) + + +def user_group_users(user_group_id, **options): + """ + Get all users in a user group + :param user_group_id: The id of user group to get list of users + :type user_group_id: str + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: List of users in the group + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH, user_group_id, "users"] + return _call_account_api("get", uri, {}, **options) + + +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 + :param options: Generic advanced options dict, see online documentation + :type options: dict, optional + :return: List of groups user is in + :rtype: dict + """ + uri = [USER_GROUPS_SUB_PATH, user_id] + return _call_account_api("get", uri, {}, **options) diff --git a/lib/cloudinary/provisioning/account_config.py b/lib/cloudinary/provisioning/account_config.py new file mode 100644 index 00000000..0448e60a --- /dev/null +++ b/lib/cloudinary/provisioning/account_config.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import + +import os + +from cloudinary import BaseConfig, import_django_settings + +ACCOUNT_URI_SCHEME = "account" + + +class AccountConfig(BaseConfig): + def __init__(self): + self._uri_scheme = ACCOUNT_URI_SCHEME + + super(AccountConfig, self).__init__() + + def _config_from_parsed_url(self, parsed_url): + if not self._is_url_scheme_valid(parsed_url): + raise ValueError("Invalid CLOUDINARY_ACCOUNT_URL scheme. URL should begin with 'account://'") + + return { + "account_id": parsed_url.hostname, + "provisioning_api_key": parsed_url.username, + "provisioning_api_secret": parsed_url.password, + } + + def _load_config_from_env(self): + if os.environ.get("CLOUDINARY_ACCOUNT_URL"): + self._load_from_url(os.environ.get("CLOUDINARY_ACCOUNT_URL")) + + +def account_config(**keywords): + global _account_config + _account_config.update(**keywords) + return _account_config + + +def reset_config(): + global _account_config + _account_config = AccountConfig() + + +_account_config = AccountConfig() diff --git a/lib/cloudinary/search.py b/lib/cloudinary/search.py index e3a52b49..0b65d3b9 100644 --- a/lib/cloudinary/search.py +++ b/lib/cloudinary/search.py @@ -1,7 +1,7 @@ import json from copy import deepcopy -from cloudinary.api import call_json_api +from cloudinary.api_client.call_api import call_json_api class Search: diff --git a/lib/cloudinary/static/js/jquery.cloudinary.js b/lib/cloudinary/static/js/jquery.cloudinary.js index 9def7cf7..cbe7eaff 100644 --- a/lib/cloudinary/static/js/jquery.cloudinary.js +++ b/lib/cloudinary/static/js/jquery.cloudinary.js @@ -164,7 +164,7 @@ var slice = [].slice, * @returns {boolean} Returns `true` if `value` is correctly classified, else `false`. * @example * - * function Foo(){}; + * function Foo(){}; * isFunction(Foo); * // => true * @@ -1403,7 +1403,8 @@ var slice = [].slice, "*": "mul", "/": "div", "+": "add", - "-": "sub" + "-": "sub", + "^": "pow", }; @@ -1472,30 +1473,37 @@ var slice = [].slice, return new this(expressionStr); }; - /** * Normalize a string expression * @function Cloudinary#normalize * @param {string} expression a expression, e.g. "w gt 100", "width_gt_100", "width > 100" * @return {string} the normalized form of the value expression, e.g. "w_gt_100" */ - Expression.normalize = function(expression) { - var operators, pattern, replaceRE; + var operators, operatorsPattern, operatorsReplaceRE, predefinedVarsPattern, predefinedVarsReplaceRE; if (expression == null) { return expression; } expression = String(expression); - operators = "\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*"; - pattern = "((" + operators + ")(?=[ _])|" + Object.keys(Expression.PREDEFINED_VARS).join("|") + ")"; - replaceRE = new RegExp(pattern, "g"); - expression = expression.replace(replaceRE, function(match) { - return Expression.OPERATORS[match] || Expression.PREDEFINED_VARS[match]; + operators = "\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*|\\^"; + + // operators + operatorsPattern = "((" + operators + ")(?=[ _]))"; + operatorsReplaceRE = new RegExp(operatorsPattern, "g"); + expression = expression.replace(operatorsReplaceRE, function (match) { + return Expression.OPERATORS[match]; }); + + // predefined variables + predefinedVarsPattern = "(" + Object.keys(Expression.PREDEFINED_VARS).join("|") + ")"; + predefinedVarsReplaceRE = new RegExp(predefinedVarsPattern, "g"); + expression = expression.replace(predefinedVarsReplaceRE, function(match, p1, offset){ + return (expression[offset - 1] === '$' ? match : Expression.PREDEFINED_VARS[match]); + }); + return expression.replace(/[ _]+/g, '_'); }; - /** * Serialize the expression * @return {string} the expression as a string @@ -3070,7 +3078,7 @@ var slice = [].slice, * @protected * @param {string} key - attribute name * @param {*|boolean} value - the value of the attribute. If the value is boolean `true`, return the key only. - * @returns {string} the attribute + * @returns {string} the attribute * */ @@ -4294,12 +4302,20 @@ var slice = [].slice, switch (false) { case !/w_auto:breakpoints/.test(dataSrc): requiredWidth = maxWidth(containerWidth, tag); - dataSrc = dataSrc.replace(/w_auto:breakpoints([_0-9]*)(:[0-9]+)?/, "w_auto:breakpoints$1:" + requiredWidth); + if (requiredWidth) { + dataSrc = dataSrc.replace(/w_auto:breakpoints([_0-9]*)(:[0-9]+)?/, "w_auto:breakpoints$1:" + requiredWidth); + } else { + setUrl = false; + } break; case !(match = /w_auto(:(\d+))?/.exec(dataSrc)): requiredWidth = applyBreakpoints.call(this, tag, containerWidth, match[2], options); requiredWidth = maxWidth(requiredWidth, tag); - dataSrc = dataSrc.replace(/w_auto[^,\/]*/g, "w_" + requiredWidth); + if (requiredWidth) { + dataSrc = dataSrc.replace(/w_auto[^,\/]*/g, "w_" + requiredWidth); + } else { + setUrl = false; + } } Util.removeAttribute(tag, 'width'); if (!options.responsive_preserve_height) { diff --git a/lib/cloudinary/templates/cloudinary_includes.html b/lib/cloudinary/templates/cloudinary_includes.html index 6300c037..82361c33 100644 --- a/lib/cloudinary/templates/cloudinary_includes.html +++ b/lib/cloudinary/templates/cloudinary_includes.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %} diff --git a/lib/cloudinary/uploader.py b/lib/cloudinary/uploader.py index a396645c..9ee1c77d 100644 --- a/lib/cloudinary/uploader.py +++ b/lib/cloudinary/uploader.py @@ -58,7 +58,7 @@ def upload_image(file, **options): def upload_resource(file, **options): - result = upload(file, **options) + result = upload_large(file, **options) return cloudinary.CloudinaryResource( result["public_id"], version=str(result["version"]), format=result.get("format"), type=result["type"], @@ -131,7 +131,9 @@ def rename(from_public_id, to_public_id, **options): "invalidate": options.get("invalidate"), "from_public_id": from_public_id, "to_public_id": to_public_id, - "to_type": options.get("to_type") + "to_type": options.get("to_type"), + "context": options.get("context"), + "metadata": options.get("metadata") } return call_api("rename", params, **options) @@ -180,30 +182,81 @@ def create_zip(**options): return create_archive(target_format="zip", **options) -def generate_sprite(tag, **options): - params = { - "timestamp": utils.now(), - "tag": tag, - "async": options.get("async"), - "notification_url": options.get("notification_url"), - "transformation": utils.generate_transformation_string( - fetch_format=options.get("format"), **options)[0] - } +def generate_sprite(tag=None, urls=None, **options): + """ + Generates sprites by merging multiple images into a single large image. + + See: `Sprite method API reference + `_ + + :param tag: The sprite is created from all images with this tag. If not set - `urls` parameter is required + :type tag: str + :param urls: List of URLs to create a sprite from. Can only be used if `tag` is not set + :type urls: list + :param options: Additional options + :type options: dict, optional + :return: Dictionary with meta information URLs of generated sprite resources + :rtype: dict + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) return call_api("sprite", params, **options) -def multi(tag, **options): - params = { - "timestamp": utils.now(), - "tag": tag, - "format": options.get("format"), - "async": options.get("async"), - "notification_url": options.get("notification_url"), - "transformation": utils.generate_transformation_string(**options)[0] - } +def download_generated_sprite(tag=None, urls=None, **options): + """ + Returns signed URL for the sprite endpoint with `mode=download` + + :param tag: The sprite is created from all images with this tag. If not set - `urls` parameter is required + :type tag: str + :param urls: List of URLs to create a sprite from. Can only be used if `tag` is not set + :type urls: list + :param options: Additional options + :type options: dict, optional + :return: The signed URL to download sprite + :rtype: str + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) + return utils.cloudinary_api_download_url(action="sprite", params=params, **options) + + +def multi(tag=None, urls=None, **options): + """ + Creates either a single animated image, video or a PDF. + + See: `Upload method API reference + `_ + + :param tag: The animated image, video or PDF is created from all images with this tag. + If not set - `urls` parameter is required + :type tag: str + :param urls: List of URLs to create an animated image, video or PDF from. Can only be used if `tag` is not set + :type urls: list + :param options: Additional options + :type options: dict, optional + :return: Dictionary with meta information URLs of the generated file + :rtype: dict + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) return call_api("multi", params, **options) +def download_multi(tag=None, urls=None, **options): + """ + Returns signed URL for the multi endpoint with `mode=download` + + :param tag: The sprite is created from all images with this tag. If not set - `urls` parameter is required + :type tag: str + :param urls: List of URLs to create a sprite from. Can only be used if `tag` is not set + :type urls: list + :param options: Additional options + :type options: dict, optional + :return: The signed URL to download multi + :rtype: str + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) + return utils.cloudinary_api_download_url(action="multi", params=params, **options) + + def explode(public_id, **options): params = { "timestamp": utils.now(), @@ -347,80 +400,80 @@ def call_cacheable_api(action, params, http_headers=None, return_error=False, un def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, **options): - if http_headers is None: - http_headers = {} - file_io = None - try: - if unsigned: - params = utils.cleanup_params(params) - else: - params = utils.sign_request(params, options) + params = utils.cleanup_params(params) - param_list = OrderedDict() - for k, v in params.items(): - if isinstance(v, list): - for i in range(len(v)): - param_list["{0}[{1}]".format(k, i)] = v[i] - elif v: - param_list[k] = v + headers = {"User-Agent": cloudinary.get_user_agent()} - api_url = utils.cloudinary_api_url(action, **options) - if file: - filename = options.get("filename") # Custom filename provided by user (relevant only for streams and files) - - if 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["file"] = (name, data) if name else data - - headers = {"User-Agent": cloudinary.get_user_agent()} + if http_headers is not None: headers.update(http_headers) - kw = {} - if timeout is not None: - kw['timeout'] = timeout + oauth_token = options.get("oauth_token", cloudinary.config().oauth_token) - code = 200 - try: - response = _http.request("POST", api_url, param_list, headers, **kw) - except HTTPError as e: - raise Error("Unexpected error - {0!r}".format(e)) - except socket.error as e: - raise Error("Socket error: {0!r}".format(e)) + if oauth_token: + headers["authorization"] = "Bearer {}".format(oauth_token) + elif not unsigned: + params = utils.sign_request(params, options) - try: - result = json.loads(response.data.decode('utf-8')) - except Exception as e: - # Error is parsing json - raise Error("Error parsing server response (%d) - %s. Got - %s" % (response.status, response.data, e)) + param_list = [] + for k, v in params.items(): + if isinstance(v, list): + for i in v: + param_list.append(("{0}[]".format(k), i)) + elif v: + param_list.append((k, v)) - if "error" in result: - if response.status not in [200, 400, 401, 403, 404, 500]: - code = response.status - if return_error: - result["error"]["http_code"] = code + api_url = utils.cloudinary_api_url(action, **options) + + if file: + filename = options.get("filename") # Custom filename provided by user (relevant only for streams and files) + + if isinstance(file, string_types): + if utils.is_remote_url(file): + # URL + name = None + data = file else: - raise Error(result["error"]["message"]) + # 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 result - finally: - if file_io: - file_io.close() + param_list.append(("file", (name, data) if name else data)) + + kw = {} + if timeout is not None: + kw['timeout'] = timeout + + code = 200 + try: + response = _http.request("POST", api_url, param_list, headers, **kw) + except HTTPError as e: + raise Error("Unexpected error - {0!r}".format(e)) + except socket.error as e: + raise Error("Socket error: {0!r}".format(e)) + + try: + result = json.loads(response.data.decode('utf-8')) + except Exception as e: + # Error is parsing json + raise Error("Error parsing server response (%d) - %s. Got - %s" % (response.status, response.data, e)) + + if "error" in result: + if response.status not in [200, 400, 401, 403, 404, 500]: + code = response.status + if return_error: + result["error"]["http_code"] = code + else: + raise Error(result["error"]["message"]) + + return result diff --git a/lib/cloudinary/utils.py b/lib/cloudinary/utils.py index 872a7588..276a0a7d 100644 --- a/lib/cloudinary/utils.py +++ b/lib/cloudinary/utils.py @@ -15,10 +15,10 @@ from collections import OrderedDict from datetime import datetime, date from fractions import Fraction from numbers import Number -from urllib3 import ProxyManager, PoolManager import six.moves.urllib.parse -from six import iteritems +from six import iteritems, string_types +from urllib3 import ProxyManager, PoolManager import cloudinary from cloudinary import auth_token @@ -37,7 +37,7 @@ DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION = {"width": "auto", "crop": "limit"} RANGE_VALUE_RE = r'^(?P(\d+\.)?\d+)(?P[%pP])?$' RANGE_RE = r'^(\d+\.)?\d+[%pP]?\.\.(\d+\.)?\d+[%pP]?$' FLOAT_RE = r'^(\d+)\.(\d+)?$' -REMOTE_URL_RE = r'ftp:|https?:|s3:|gs:|data:([\w-]+\/[\w-]+)?(;[\w-]+=[\w-]+)*;base64,([a-zA-Z0-9\/+\n=]+)$' +REMOTE_URL_RE = r'ftp:|https?:|s3:|gs:|data:([\w-]+\/[\w-]+(\+[\w-]+)?)?(;[\w-]+=[\w-]+)*;base64,([a-zA-Z0-9\/+\n=]+)$' __LAYER_KEYWORD_PARAMS = [("font_weight", "normal"), ("font_style", "normal"), ("text_decoration", "none"), @@ -63,7 +63,9 @@ __URL_KEYS = [ 'type', 'url_suffix', 'use_root_path', - 'version' + 'version', + 'long_url_signature', + 'signature_algorithm', ] __SIMPLE_UPLOAD_PARAMS = [ @@ -79,10 +81,12 @@ __SIMPLE_UPLOAD_PARAMS = [ "use_filename", "unique_filename", "discard_original_filename", + "filename_override", "invalidate", "notification_url", "eager_notification_url", "eager_async", + "eval", "proxy", "folder", "overwrite", @@ -101,6 +105,7 @@ __SIMPLE_UPLOAD_PARAMS = [ "auto_tagging", "async", "cinemagraph_analysis", + "accessibility_analysis", ] __SERIALIZED_UPLOAD_PARAMS = [ @@ -121,16 +126,32 @@ __SERIALIZED_UPLOAD_PARAMS = [ upload_params = __SIMPLE_UPLOAD_PARAMS + __SERIALIZED_UPLOAD_PARAMS +SHORT_URL_SIGNATURE_LENGTH = 8 +LONG_URL_SIGNATURE_LENGTH = 32 -def compute_hex_hash(s): +SIGNATURE_SHA1 = "sha1" +SIGNATURE_SHA256 = "sha256" + +signature_algorithms = { + SIGNATURE_SHA1: hashlib.sha1, + SIGNATURE_SHA256: hashlib.sha256, +} + + +def compute_hex_hash(s, algorithm=SIGNATURE_SHA1): """ - Compute hash and convert the result to HEX string + Computes string hash using specified algorithm and return HEX string representation of hash. - :param s: string to process + :param s: String to compute hash for + :param algorithm: The name of algorithm to use for computing hash - :return: HEX string + :return: HEX string of computed hash value """ - return hashlib.sha1(to_bytes(s)).hexdigest() + try: + hash_fn = signature_algorithms[algorithm] + except KeyError: + raise ValueError('Unsupported hash algorithm: {}'.format(algorithm)) + return hash_fn(to_bytes(s)).hexdigest() def build_array(arg): @@ -189,7 +210,7 @@ def encode_double_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 ",".join([str(i) for i in array]) + return encode_list([str(i) for i in array]) def encode_dict(arg): @@ -203,16 +224,37 @@ def encode_dict(arg): return arg -def encode_context(context): +def normalize_context_value(value): """ - :param context: dict of context to be encoded - :return: a joined string of all keys and values properly escaped and separated by a pipe character + Escape "=" and "|" delimiter characters and json encode lists + + :param value: Value to escape + :type value: int or str or list or tuple + + :return: The normalized value + :rtype: str """ + if isinstance(value, (list, tuple)): + value = json_encode(value) + + return str(value).replace("=", "\\=").replace("|", "\\|") + + +def encode_context(context): + """ + Encode metadata fields based on incoming value. + + List and tuple values are encoded to json strings. + + :param context: dict of context to be encoded + + :return: a joined string of all keys and values properly escaped and separated by a pipe character + """ if not isinstance(context, dict): return context - return "|".join(("{}={}".format(k, v.replace("=", "\\=").replace("|", "\\|"))) for k, v in iteritems(context)) + return "|".join(("{}={}".format(k, normalize_context_value(v))) for k, v in iteritems(context)) def json_encode(value): @@ -226,6 +268,17 @@ def json_encode(value): return json.dumps(value, default=__json_serializer, separators=(',', ':')) +def encode_date_to_usage_api_format(date_obj): + """ + Encodes date object to `dd-mm-yyyy` format string + + :param date_obj: datetime.date object to encode + + :return: Encoded date as a string + """ + return date_obj.strftime('%d-%m-%Y') + + def patch_fetch_format(options): """ When upload type is fetch, remove the format options. @@ -488,6 +541,19 @@ def process_radius(param): return str(param) +def process_params(params): + processed_params = None + if isinstance(params, dict): + processed_params = {} + for key, value in params.items(): + if isinstance(value, list) or isinstance(value, tuple): + value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)} + processed_params.update(value_list) + elif value is not None: + processed_params[key] = value + return processed_params + + def cleanup_params(params): return dict([(k, __safe_value(v)) for (k, v) in params.items() if v is not None and not v == ""]) @@ -499,18 +565,19 @@ def sign_request(params, options): api_secret = options.get("api_secret", cloudinary.config().api_secret) if not api_secret: raise ValueError("Must supply api_secret") + signature_algorithm = options.get("signature_algorithm", cloudinary.config().signature_algorithm) params = cleanup_params(params) - params["signature"] = api_sign_request(params, api_secret) + params["signature"] = api_sign_request(params, api_secret, signature_algorithm) params["api_key"] = api_key return params -def api_sign_request(params_to_sign, api_secret): +def api_sign_request(params_to_sign, api_secret, algorithm=SIGNATURE_SHA1): params = [(k + "=" + (",".join(v) if isinstance(v, list) else str(v))) for k, v in params_to_sign.items() if v] to_sign = "&".join(sorted(params)) - return compute_hex_hash(to_sign + api_secret) + return compute_hex_hash(to_sign + api_secret, algorithm) def breakpoint_settings_mapper(breakpoint_settings): @@ -667,6 +734,8 @@ def cloudinary_url(source, **options): url_suffix = options.pop("url_suffix", None) use_root_path = options.pop("use_root_path", cloudinary.config().use_root_path) auth_token = options.pop("auth_token", None) + long_url_signature = options.pop("long_url_signature", cloudinary.config().long_url_signature) + signature_algorithm = options.pop("signature_algorithm", cloudinary.config().signature_algorithm) if auth_token is not False: auth_token = merge(cloudinary.config().auth_token, auth_token) @@ -692,9 +761,18 @@ def cloudinary_url(source, **options): signature = None if sign_url and not auth_token: to_sign = "/".join(__compact([transformation, source_to_sign])) + if long_url_signature: + # Long signature forces SHA256 + signature_algorithm = SIGNATURE_SHA256 + chars_length = LONG_URL_SIGNATURE_LENGTH + else: + chars_length = SHORT_URL_SIGNATURE_LENGTH + if signature_algorithm not in signature_algorithms: + raise ValueError("Unsupported signature algorithm '{}'".format(signature_algorithm)) + hash_fn = signature_algorithms[signature_algorithm] signature = "s--" + to_string( base64.urlsafe_b64encode( - hashlib.sha1(to_bytes(to_sign + api_secret)).digest())[0:8]) + "--" + hash_fn(to_bytes(to_sign + api_secret)).digest())[0:chars_length]) + "--" prefix = unsigned_download_url_prefix( source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, @@ -708,15 +786,30 @@ def cloudinary_url(source, **options): return source, options -def cloudinary_api_url(action='upload', **options): - cloudinary_prefix = options.get("upload_prefix", cloudinary.config().upload_prefix)\ +def base_api_url(path, **options): + cloudinary_prefix = options.get("upload_prefix", cloudinary.config().upload_prefix) \ or "https://api.cloudinary.com" cloud_name = options.get("cloud_name", cloudinary.config().cloud_name) + if not cloud_name: raise ValueError("Must supply cloud_name") + + path = build_array(path) + + return encode_unicode_url("/".join([cloudinary_prefix, cloudinary.API_VERSION, cloud_name] + path)) + + +def cloudinary_api_url(action='upload', **options): resource_type = options.get("resource_type", "image") - return encode_unicode_url("/".join([cloudinary_prefix, "v1_1", cloud_name, resource_type, action])) + return base_api_url([resource_type, action], **options) + + +def cloudinary_api_download_url(action, params, **options): + params = params.copy() + params["mode"] = "download" + cloudinary_params = sign_request(params, options) + return cloudinary_api_url(action, **options) + "?" + urlencode(bracketize_seq(cloudinary_params), True) def cloudinary_scaled_url(source, width, transformation, options): @@ -817,11 +910,7 @@ def bracketize_seq(params): def download_archive_url(**options): - params = options.copy() - params.update(mode="download") - cloudinary_params = sign_request(archive_params(**params), options) - return cloudinary_api_url("generate_archive", **options) + "?" + \ - urlencode(bracketize_seq(cloudinary_params), True) + return cloudinary_api_download_url(action="generate_archive", params=archive_params(**options), **options) def download_zip_url(**options): @@ -830,6 +919,47 @@ def download_zip_url(**options): return download_archive_url(**new_options) +def download_folder(folder_path, **options): + """ + Creates and returns a URL that when invoked creates an archive of a folder. + :param folder_path: The full path from the root that is used to generate download url. + :type folder_path: str + :param options: Additional options. + :type options: dict, optional + :return: Signed URL to download the folder. + :rtype: str + """ + options["prefixes"] = folder_path + options.setdefault("resource_type", "all") + + return download_archive_url(**options) + + +def download_backedup_asset(asset_id, version_id, **options): + """ + The returned url allows downloading the backedup asset based on the the asset ID and the version ID. + + Parameters asset_id and version_id are returned with api.resource(, versions=True) API call. + + :param asset_id: The asset ID of the asset. + :type asset_id: str + :param version_id: The version ID of the asset. + :type version_id: str + :param options: Additional options. + :type options: dict, optional + :return:The signed URL for downloading backup version of the asset. + :rtype: str + """ + params = { + "timestamp": options.get("timestamp", now()), + "asset_id": asset_id, + "version_id": version_id + } + cloudinary_params = sign_request(params, options) + + return base_api_url("download_backup", **options) + "?" + urlencode(bracketize_seq(cloudinary_params), True) + + def generate_auth_token(**options): token_options = merge(cloudinary.config().auth_token, options) return auth_token.generate(**token_options) @@ -919,8 +1049,8 @@ def build_upload_params(**options): "transformation": generate_transformation_string(**options)[0], "headers": build_custom_headers(options.get("headers")), "eager": build_eager(options.get("eager")), - "tags": options.get("tags") and ",".join(build_array(options["tags"])), - "allowed_formats": options.get("allowed_formats") and ",".join(build_array(options["allowed_formats"])), + "tags": options.get("tags") and encode_list(build_array(options["tags"])), + "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")), "context": encode_context(options.get("context")), @@ -938,6 +1068,26 @@ def build_upload_params(**options): return params +def build_multi_and_sprite_params(**options): + """ + Build params for multi, download_multi, generate_sprite, and download_generated_sprite methods + """ + tag = options.get("tag") + urls = options.get("urls") + if bool(tag) == bool(urls): + raise ValueError("Either 'tag' or 'urls' parameter has to be set but not both") + params = { + "mode": options.get("mode"), + "timestamp": now(), + "async": options.get("async"), + "notification_url": options.get("notification_url"), + "tag": tag, + "urls": urls, + "transformation": generate_transformation_string(fetch_format=options.get("format"), **options)[0] + } + return params + + def __process_text_options(layer, layer_parameter): font_family = layer.get("font_family") font_size = layer.get("font_size") @@ -1067,22 +1217,38 @@ IF_OPERATORS = { PREDEFINED_VARS = { "aspect_ratio": "ar", + "aspectRatio": "ar", "current_page": "cp", + "currentPage": "cp", "face_count": "fc", + "faceCount": "fc", "height": "h", "initial_aspect_ratio": "iar", + "initialAspectRatio": "iar", + "trimmed_aspect_ratio": "tar", + "trimmedAspectRatio": "tar", "initial_height": "ih", + "initialHeight": "ih", "initial_width": "iw", + "initialWidth": "iw", "page_count": "pc", + "pageCount": "pc", "page_x": "px", + "pageX": "px", "page_y": "py", + "pageY": "py", "tags": "tags", "width": "w", "duration": "du", "initial_duration": "idu", + "initialDuration": "idu", + "illustration_score": "ils", + "illustrationScore": "ils", + "context": "ctx" } -replaceRE = "((\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*|\^)(?=[ _])|(?=|<=|&&|!=|>|=|<|/|-|\\+|\\*|\\^)(?=[ _])|(\\$_*[^_ ]+)|(?