Update cloudinary to 1.20.0

This commit is contained in:
JonnyWong16 2020-03-21 19:11:41 -07:00
parent 1c56d9c513
commit 2984629b39
27 changed files with 2865 additions and 923 deletions

View file

@ -1,29 +1,43 @@
from __future__ import absolute_import from __future__ import absolute_import
from copy import deepcopy
import os
import re
import logging import logging
import numbers
import certifi
from math import ceil
from six import python_2_unicode_compatible
logger = logging.getLogger("Cloudinary") logger = logging.getLogger("Cloudinary")
ch = logging.StreamHandler() ch = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter) ch.setFormatter(formatter)
logger.addHandler(ch) logger.addHandler(ch)
import os
import re
from six import python_2_unicode_compatible
from cloudinary import utils from cloudinary import utils
from cloudinary.exceptions import GeneralError
from cloudinary.cache import responsive_breakpoints_cache
from cloudinary.http_client import HttpClient
from cloudinary.compat import urlparse, parse_qs from cloudinary.compat import urlparse, parse_qs
from cloudinary.search import Search
from platform import python_version
CERT_KWARGS = {
'cert_reqs': 'CERT_REQUIRED',
'ca_certs': certifi.where(),
}
CF_SHARED_CDN = "d3jpl91pxevbkh.cloudfront.net" CF_SHARED_CDN = "d3jpl91pxevbkh.cloudfront.net"
OLD_AKAMAI_SHARED_CDN = "cloudinary-a.akamaihd.net" OLD_AKAMAI_SHARED_CDN = "cloudinary-a.akamaihd.net"
AKAMAI_SHARED_CDN = "res.cloudinary.com" AKAMAI_SHARED_CDN = "res.cloudinary.com"
SHARED_CDN = AKAMAI_SHARED_CDN SHARED_CDN = AKAMAI_SHARED_CDN
CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
URI_SCHEME = "cloudinary"
VERSION = "1.11.0" VERSION = "1.20.0"
USER_AGENT = "CloudinaryPython/" + VERSION
USER_AGENT = "CloudinaryPython/{} (Python {})".format(VERSION, python_version())
""" :const: USER_AGENT """ """ :const: USER_AGENT """
USER_PLATFORM = "" USER_PLATFORM = ""
@ -39,7 +53,8 @@ The format of the value should be <ProductName>/Version[ (comment)].
def get_user_agent(): def get_user_agent():
"""Provides the `USER_AGENT` string that is passed to the Cloudinary servers. """
Provides the `USER_AGENT` string that is passed to the Cloudinary servers.
Prepends `USER_PLATFORM` if it is defined. Prepends `USER_PLATFORM` if it is defined.
:returns: the user agent :returns: the user agent
@ -54,15 +69,27 @@ def get_user_agent():
def import_django_settings(): def import_django_settings():
try: try:
import django.conf
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
try: try:
if 'CLOUDINARY' in dir(django.conf.settings): from django.conf import settings as _django_settings
return django.conf.settings.CLOUDINARY
# We can get a situation when Django module is installed in the system, but not initialized,
# which means we are running not in a Django process.
# In this case the following line throws ImproperlyConfigured exception
if 'cloudinary' in _django_settings.INSTALLED_APPS:
from django import get_version as _get_django_version
global USER_PLATFORM
USER_PLATFORM = "Django/{django_version}".format(django_version=_get_django_version())
if 'CLOUDINARY' in dir(_django_settings):
return _django_settings.CLOUDINARY
else: else:
return None return None
except ImproperlyConfigured: except ImproperlyConfigured:
return None return None
except ImportError: except ImportError:
return None return None
@ -78,14 +105,18 @@ class Config(object):
api_key=os.environ.get("CLOUDINARY_API_KEY"), api_key=os.environ.get("CLOUDINARY_API_KEY"),
api_secret=os.environ.get("CLOUDINARY_API_SECRET"), api_secret=os.environ.get("CLOUDINARY_API_SECRET"),
secure_distribution=os.environ.get("CLOUDINARY_SECURE_DISTRIBUTION"), secure_distribution=os.environ.get("CLOUDINARY_SECURE_DISTRIBUTION"),
private_cdn=os.environ.get("CLOUDINARY_PRIVATE_CDN") == 'true' private_cdn=os.environ.get("CLOUDINARY_PRIVATE_CDN") == 'true',
api_proxy=os.environ.get("CLOUDINARY_API_PROXY"),
) )
elif os.environ.get("CLOUDINARY_URL"): elif os.environ.get("CLOUDINARY_URL"):
cloudinary_url = os.environ.get("CLOUDINARY_URL") cloudinary_url = os.environ.get("CLOUDINARY_URL")
self._parse_cloudinary_url(cloudinary_url) self._parse_cloudinary_url(cloudinary_url)
def _parse_cloudinary_url(self, cloudinary_url): def _parse_cloudinary_url(self, cloudinary_url):
uri = urlparse(cloudinary_url.replace("cloudinary://", "http://")) 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(): for k, v in parse_qs(uri.query).items():
if self._is_nested_key(k): if self._is_nested_key(k):
self._put_nested_key(k, v) self._put_nested_key(k, v)
@ -115,7 +146,7 @@ class Config(object):
def _put_nested_key(self, key, value): def _put_nested_key(self, key, value):
chain = re.split(r'[\[\]]+', key) chain = re.split(r'[\[\]]+', key)
chain = [key for key in chain if key] chain = [k for k in chain if k]
outer = self.__dict__ outer = self.__dict__
last_key = chain.pop() last_key = chain.pop()
for inner_key in chain: for inner_key in chain:
@ -128,7 +159,21 @@ class Config(object):
if isinstance(value, list): if isinstance(value, list):
value = value[0] value = value[0]
outer[last_key] = value outer[last_key] = value
@staticmethod
def _is_url_scheme_valid(url):
"""
Helper function. Validates url scheme
:param url: A named tuple containing URL components
:return: bool True on success or False on failure
"""
if not url.scheme or url.scheme.lower() != URI_SCHEME:
return False
return True
_config = Config() _config = Config()
@ -143,8 +188,35 @@ def reset_config():
_config = Config() _config = Config()
_http_client = HttpClient()
# FIXME: circular import issue
from cloudinary.search import Search
@python_2_unicode_compatible @python_2_unicode_compatible
class CloudinaryResource(object): class CloudinaryResource(object):
"""
Recommended sources for video tag
"""
default_video_sources = [
{
"type": "mp4",
"codecs": "hev1",
"transformations": {"video_codec": "h265"}
}, {
"type": "webm",
"codecs": "vp9",
"transformations": {"video_codec": "vp9"}
}, {
"type": "mp4",
"transformations": {"video_codec": "auto"}
}, {
"type": "webm",
"transformations": {"video_codec": "auto"}
},
]
def __init__(self, public_id=None, format=None, version=None, def __init__(self, public_id=None, format=None, version=None,
signature=None, url_options=None, metadata=None, type=None, resource_type=None, signature=None, url_options=None, metadata=None, type=None, resource_type=None,
default_resource_type=None): default_resource_type=None):
@ -174,9 +246,11 @@ class CloudinaryResource(object):
return None return None
prep = '' prep = ''
prep = prep + self.resource_type + '/' + self.type + '/' prep = prep + self.resource_type + '/' + self.type + '/'
if self.version: prep = prep + 'v' + str(self.version) + '/' if self.version:
prep = prep + 'v' + str(self.version) + '/'
prep = prep + self.public_id prep = prep + self.public_id
if self.format: prep = prep + '.' + self.format if self.format:
prep = prep + '.' + self.format
return prep return prep
def get_presigned(self): def get_presigned(self):
@ -199,28 +273,283 @@ class CloudinaryResource(object):
def build_url(self, **options): def build_url(self, **options):
return self.__build_url(**options)[0] return self.__build_url(**options)[0]
def default_poster_options(self, options): @staticmethod
def default_poster_options(options):
options["format"] = options.get("format", "jpg") options["format"] = options.get("format", "jpg")
def default_source_types(self): @staticmethod
def default_source_types():
return ['webm', 'mp4', 'ogv'] return ['webm', 'mp4', 'ogv']
@staticmethod
def _validate_srcset_data(srcset_data):
"""
Helper function. Validates srcset_data parameters
:param srcset_data: A dictionary containing the following keys:
breakpoints A list of breakpoints.
min_width Minimal width of the srcset images
max_width Maximal width of the srcset images.
max_images Number of srcset images to generate.
:return: bool True on success or False on failure
"""
if not all(k in srcset_data and isinstance(srcset_data[k], numbers.Number) for k in ("min_width", "max_width",
"max_images")):
logger.warning("Either valid (min_width, max_width, max_images)" +
"or breakpoints must be provided to the image srcset attribute")
return False
if srcset_data["min_width"] > srcset_data["max_width"]:
logger.warning("min_width must be less than max_width")
return False
if srcset_data["max_images"] <= 0:
logger.warning("max_images must be a positive integer")
return False
return True
def _generate_breakpoints(self, srcset_data):
"""
Helper function. Calculates static responsive breakpoints using provided parameters.
Either the breakpoints or min_width, max_width, max_images must be provided.
:param srcset_data: A dictionary containing the following keys:
breakpoints A list of breakpoints.
min_width Minimal width of the srcset images
max_width Maximal width of the srcset images.
max_images Number of srcset images to generate.
:return: A list of breakpoints
:raises ValueError: In case of invalid or missing parameters
"""
breakpoints = srcset_data.get("breakpoints", list())
if breakpoints:
return breakpoints
if not self._validate_srcset_data(srcset_data):
return None
min_width, max_width, max_images = srcset_data["min_width"], srcset_data["max_width"], srcset_data["max_images"]
if max_images == 1:
# if user requested only 1 image in srcset, we return max_width one
min_width = max_width
step_size = int(ceil(float(max_width - min_width) / (max_images - 1 if max_images > 1 else 1)))
curr_breakpoint = min_width
while curr_breakpoint < max_width:
breakpoints.append(curr_breakpoint)
curr_breakpoint += step_size
breakpoints.append(max_width)
return breakpoints
def _fetch_breakpoints(self, srcset_data=None, **options):
"""
Helper function. Retrieves responsive breakpoints list from cloudinary server
When passing special string to transformation `width` parameter of form `auto:breakpoints{parameters}:json`,
the response contains JSON with data of the responsive breakpoints
:param srcset_data: A dictionary containing the following keys:
min_width Minimal width of the srcset images
max_width Maximal width of the srcset images
bytes_step Minimal bytes step between images
max_images Number of srcset images to generate
:param options: Additional options
:return: Resulting breakpoints
"""
if srcset_data is None:
srcset_data = dict()
min_width = srcset_data.get("min_width", 50)
max_width = srcset_data.get("max_width", 1000)
bytes_step = srcset_data.get("bytes_step", 20000)
max_images = srcset_data.get("max_images", 20)
transformation = srcset_data.get("transformation")
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)
breakpoints_url = utils.cloudinary_scaled_url(self.public_id, breakpoints_width_param, transformation, options)
return _http_client.get_json(breakpoints_url).get("breakpoints", None)
def _get_or_generate_breakpoints(self, srcset_data, **options):
"""
Helper function. Gets from cache or calculates srcset breakpoints using provided parameters
:param srcset_data: A dictionary containing the following keys:
breakpoints A list of breakpoints.
min_width Minimal width of the srcset images
max_width Maximal width of the srcset images
max_images Number of srcset images to generate
:param options: Additional options
:return: Resulting breakpoints
"""
breakpoints = srcset_data.get("breakpoints")
if breakpoints:
return breakpoints
if srcset_data.get("use_cache"):
breakpoints = responsive_breakpoints_cache.instance.get(self.public_id, **options)
if not breakpoints:
try:
breakpoints = self._fetch_breakpoints(srcset_data, **options)
except GeneralError as e:
logger.warning("Failed getting responsive breakpoints: {error}".format(error=e.message))
if breakpoints:
responsive_breakpoints_cache.instance.set(self.public_id, breakpoints, **options)
if not breakpoints:
# Static calculation if cache is not enabled or we failed to fetch breakpoints
breakpoints = self._generate_breakpoints(srcset_data)
return breakpoints
def _generate_srcset_attribute(self, breakpoints, transformation=None, **options):
"""
Helper function. Generates srcset attribute value of the HTML img tag.
:param breakpoints: A list of breakpoints.
:param transformation: Custom transformation
:param options: Additional options
:return: Resulting srcset attribute value
:raises ValueError: In case of invalid or missing parameters
"""
if not breakpoints:
return None
if transformation is None:
transformation = dict()
return ", ".join(["{0} {1}w".format(utils.cloudinary_scaled_url(
self.public_id, w, transformation, options), w) for w in breakpoints])
@staticmethod
def _generate_sizes_attribute(breakpoints):
"""
Helper function. Generates sizes attribute value of the HTML img tag.
:param breakpoints: A list of breakpoints.
:return: Resulting 'sizes' attribute value
"""
if not breakpoints:
return None
return ", ".join("(max-width: {bp}px) {bp}px".format(bp=bp) for bp in breakpoints)
def _generate_image_responsive_attributes(self, attributes, srcset_data, **options):
"""
Helper function. Generates srcset and sizes attributes of the image tag
Create both srcset and sizes here to avoid fetching breakpoints twice
:param attributes: Existing attributes
:param srcset_data: A dictionary containing the following keys:
breakpoints A list of breakpoints.
min_width Minimal width of the srcset images
max_width Maximal width of the srcset images.
max_images Number of srcset images to generate.
:param options: Additional options
:return: The responsive attributes
"""
responsive_attributes = dict()
if not srcset_data:
return responsive_attributes
breakpoints = None
if "srcset" not in attributes:
breakpoints = self._get_or_generate_breakpoints(srcset_data, **options)
transformation = srcset_data.get("transformation")
srcset_attr = self._generate_srcset_attribute(breakpoints, transformation, **options)
if srcset_attr:
responsive_attributes["srcset"] = srcset_attr
if "sizes" not in attributes and srcset_data.get("sizes") is True:
if not breakpoints:
breakpoints = self._get_or_generate_breakpoints(srcset_data, **options)
sizes_attr = self._generate_sizes_attribute(breakpoints)
if sizes_attr:
responsive_attributes["sizes"] = sizes_attr
return responsive_attributes
def image(self, **options): def image(self, **options):
"""
Generates HTML img tag
:param options: Additional options
:return: Resulting img tag
"""
if options.get("resource_type", self.resource_type) == "video": if options.get("resource_type", self.resource_type) == "video":
self.default_poster_options(options) self.default_poster_options(options)
custom_attributes = options.pop("attributes", dict())
srcset_option = options.pop("srcset", dict())
srcset_data = dict()
if isinstance(srcset_option, dict):
srcset_data = config().srcset or dict()
srcset_data = srcset_data.copy()
srcset_data.update(srcset_option)
else:
if "srcset" not in custom_attributes:
custom_attributes["srcset"] = srcset_option
src, attrs = self.__build_url(**options) src, attrs = self.__build_url(**options)
client_hints = attrs.pop("client_hints", config().client_hints) client_hints = attrs.pop("client_hints", config().client_hints)
responsive = attrs.pop("responsive", False) responsive = attrs.pop("responsive", False)
hidpi = attrs.pop("hidpi", False) hidpi = attrs.pop("hidpi", False)
if (responsive or hidpi) and not client_hints: if (responsive or hidpi) and not client_hints:
attrs["data-src"] = src attrs["data-src"] = src
classes = "cld-responsive" if responsive else "cld-hidpi"
if "class" in attrs: classes += " " + attrs["class"]
attrs["class"] = classes
src = attrs.pop("responsive_placeholder", config().responsive_placeholder)
if src == "blank": src = CL_BLANK
if src: attrs["src"] = src classes = "cld-responsive" if responsive else "cld-hidpi"
if "class" in attrs:
classes += " " + attrs["class"]
attrs["class"] = classes
src = attrs.pop("responsive_placeholder", config().responsive_placeholder)
if src == "blank":
src = CL_BLANK
responsive_attrs = self._generate_image_responsive_attributes(custom_attributes, srcset_data, **options)
if responsive_attrs:
# width and height attributes override srcset behavior, they should be removed from html attributes.
for key in {"width", "height"}:
attrs.pop(key, None)
attrs.update(responsive_attrs)
# Explicitly provided attributes override options
attrs.update(custom_attributes)
if src:
attrs["src"] = src
return u"<img {0}/>".format(utils.html_attrs(attrs)) return u"<img {0}/>".format(utils.html_attrs(attrs))
@ -228,69 +557,231 @@ class CloudinaryResource(object):
self.default_poster_options(options) self.default_poster_options(options)
return self.build_url(**options) return self.build_url(**options)
# Creates an HTML video tag for the provided +source+ @staticmethod
# def _video_mime_type(video_type, codecs=None):
# ==== Options """
# * <tt>source_types</tt> - Specify which source type the tag should include. defaults to webm, mp4 and ogv. Helper function for video(), generates video MIME type string from video_type and codecs.
# * <tt>source_transformation</tt> - specific transformations to use for a specific source type. Example: video/mp4; codecs=mp4a.40.2
# * <tt>poster</tt> - override default thumbnail:
# * url: provide an ad hoc url
# * options: with specific poster transformations and/or Cloudinary +:public_id+
#
# ==== Examples
# CloudinaryResource("mymovie.mp4").video()
# CloudinaryResource("mymovie.mp4").video(source_types = 'webm')
# CloudinaryResource("mymovie.ogv").video(poster = "myspecialplaceholder.jpg")
# CloudinaryResource("mymovie.webm").video(source_types = ['webm', 'mp4'], poster = {'effect': 'sepia'})
def video(self, **options):
public_id = options.get('public_id', self.public_id)
source = re.sub("\.({0})$".format("|".join(self.default_source_types())), '', public_id)
:param video_type: mp4, webm, ogg etc.
:param codecs: List or string of codecs. E.g.: "avc1.42E01E" or "avc1.42E01E, mp4a.40.2" or
["avc1.42E01E", "mp4a.40.2"]
:return: Resulting mime type
"""
video_type = 'ogg' if video_type == 'ogv' else video_type
if not video_type:
return ""
codecs_str = ", ".join(codecs) if isinstance(codecs, (list, tuple)) else codecs
codecs_attr = "; codecs={codecs_str}".format(codecs_str=codecs_str) if codecs_str else ""
return "video/{}{}".format(video_type, codecs_attr)
@staticmethod
def _collect_video_tag_attributes(video_options):
"""
Helper function for video tag, collects remaining options and returns them as attributes
:param video_options: Remaining options
:return: Resulting attributes
"""
attributes = video_options.copy()
if 'html_width' in attributes:
attributes['width'] = attributes.pop('html_width')
if 'html_height' in attributes:
attributes['height'] = attributes.pop('html_height')
if "poster" in attributes and not attributes["poster"]:
attributes.pop("poster", None)
return attributes
def _generate_video_poster_attr(self, source, video_options):
"""
Helper function for video tag, generates video poster URL
:param source: The public ID of the resource
:param video_options: Additional options
:return: Resulting video poster URL
"""
if 'poster' not in video_options:
return self.video_thumbnail(public_id=source, **video_options)
poster_options = video_options['poster']
if not isinstance(poster_options, dict):
return poster_options
if 'public_id' not in poster_options:
return self.video_thumbnail(public_id=source, **poster_options)
return utils.cloudinary_url(poster_options['public_id'], **poster_options)[0]
def _populate_video_source_tags(self, source, options):
"""
Helper function for video tag, populates source tags from provided options.
source_types and sources are mutually exclusive, only one of them can be used.
If both are not provided, source types are used (for backwards compatibility)
:param source: The public ID of the video
:param options: Additional options
:return: Resulting source tags (may be empty)
"""
source_tags = []
# Consume all relevant options, otherwise they are left and passed as attributes
video_sources = options.pop('sources', [])
source_types = options.pop('source_types', []) source_types = options.pop('source_types', [])
source_transformation = options.pop('source_transformation', {}) source_transformation = options.pop('source_transformation', {})
if video_sources and isinstance(video_sources, list):
# processing new source structure with codecs
for source_data in video_sources:
transformation = options.copy()
transformation.update(source_data.get("transformations", {}))
source_type = source_data.get("type", '')
src = utils.cloudinary_url(source, format=source_type, **transformation)[0]
codecs = source_data.get("codecs", [])
source_tags.append("<source {attributes}>".format(
attributes=utils.html_attrs({'src': src, 'type': self._video_mime_type(source_type, codecs)})))
return source_tags
# processing old source_types structure with out codecs
if not source_types:
source_types = self.default_source_types()
if not isinstance(source_types, (list, tuple)):
return source_tags
for source_type in source_types:
transformation = options.copy()
transformation.update(source_transformation.get(source_type, {}))
src = utils.cloudinary_url(source, format=source_type, **transformation)[0]
source_tags.append("<source {attributes}>".format(
attributes=utils.html_attrs({'src': src, 'type': self._video_mime_type(source_type)})))
return source_tags
def video(self, **options):
"""
Creates an HTML video tag for the provided +source+
Examples:
CloudinaryResource("mymovie.mp4").video()
CloudinaryResource("mymovie.mp4").video(source_types = 'webm')
CloudinaryResource("mymovie.ogv").video(poster = "myspecialplaceholder.jpg")
CloudinaryResource("mymovie.webm").video(source_types = ['webm', 'mp4'], poster = {'effect': 'sepia'})
:param options:
* <tt>source_types</tt> - Specify which source type the tag should include.
defaults to webm, mp4 and ogv.
* <tt>sources</tt> - Similar to source_types, but may contain codecs list.
source_types and sources are mutually exclusive, only one of
them can be used. If both are not provided, default source types
are used.
* <tt>source_transformation</tt> - specific transformations to use
for a specific source type.
* <tt>poster</tt> - override default thumbnail:
* url: provide an ad hoc url
* options: with specific poster transformations and/or Cloudinary +:public_id+
: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)
custom_attributes = options.pop("attributes", dict())
fallback = options.pop('fallback_content', '') fallback = options.pop('fallback_content', '')
options['resource_type'] = options.pop('resource_type', self.resource_type or 'video')
if not source_types: source_types = self.default_source_types() # Save source types for a single video source handling (it can be a single type)
video_options = options.copy() source_types = options.get('source_types', "")
if 'poster' in video_options: poster_options = options.copy()
poster_options = video_options['poster'] if "poster" not in custom_attributes:
if isinstance(poster_options, dict): options["poster"] = self._generate_video_poster_attr(source, poster_options)
if 'public_id' in poster_options:
video_options['poster'] = utils.cloudinary_url(poster_options['public_id'], **poster_options)[0]
else:
video_options['poster'] = self.video_thumbnail(public_id=source, **poster_options)
else:
video_options['poster'] = self.video_thumbnail(public_id=source, **options)
if not video_options['poster']: del video_options['poster'] if "resource_type" not in options:
options["resource_type"] = self.resource_type or "video"
nested_source_types = isinstance(source_types, list) and len(source_types) > 1 # populate video source tags
if not nested_source_types: source_tags = self._populate_video_source_tags(source, options)
if not source_tags:
source = source + '.' + utils.build_array(source_types)[0] source = source + '.' + utils.build_array(source_types)[0]
video_url = utils.cloudinary_url(source, **video_options) video_url, video_options = utils.cloudinary_url(source, **options)
video_options = video_url[1]
if not nested_source_types:
video_options['src'] = video_url[0]
if 'html_width' in video_options: video_options['width'] = video_options.pop('html_width')
if 'html_height' in video_options: video_options['height'] = video_options.pop('html_height')
sources = "" if not source_tags:
if nested_source_types: custom_attributes['src'] = video_url
for source_type in source_types:
transformation = options.copy()
transformation.update(source_transformation.get(source_type, {}))
src = utils.cloudinary_url(source, format=source_type, **transformation)[0]
video_type = "ogg" if source_type == 'ogv' else source_type
mime_type = "video/" + video_type
sources += "<source {attributes}>".format(attributes=utils.html_attrs({'src': src, 'type': mime_type}))
attributes = self._collect_video_tag_attributes(video_options)
attributes.update(custom_attributes)
sources_str = ''.join(str(x) for x in source_tags)
html = "<video {attributes}>{sources}{fallback}</video>".format( html = "<video {attributes}>{sources}{fallback}</video>".format(
attributes=utils.html_attrs(video_options), sources=sources, fallback=fallback) attributes=utils.html_attrs(attributes), sources=sources_str, fallback=fallback)
return html return html
@staticmethod
def __generate_media_attr(**media_options):
media_query_conditions = []
if "min_width" in media_options:
media_query_conditions.append("(min-width: {}px)".format(media_options["min_width"]))
if "max_width" in media_options:
media_query_conditions.append("(max-width: {}px)".format(media_options["max_width"]))
return " and ".join(media_query_conditions)
def source(self, **options):
attrs = options.get("attributes") or {}
srcset_data = config().srcset or dict()
srcset_data = srcset_data.copy()
srcset_data.update(options.pop("srcset", dict()))
responsive_attrs = self._generate_image_responsive_attributes(attrs, srcset_data, **options)
attrs.update(responsive_attrs)
# `source` tag under `picture` tag uses `srcset` attribute for both `srcset` and `src` urls
if "srcset" not in attrs:
attrs["srcset"], _ = self.__build_url(**options)
if "media" not in attrs:
media_attr = self.__generate_media_attr(**(options.get("media", {})))
if media_attr:
attrs["media"] = media_attr
return u"<source {0}>".format(utils.html_attrs(attrs))
def picture(self, **options):
sub_tags = []
sources = options.pop("sources") or list()
for source in sources:
curr_options = deepcopy(options)
if "transformation" in source:
curr_options = utils.chain_transformations(curr_options, source["transformation"])
curr_options["media"] = dict((k, source[k]) for k in ['min_width', 'max_width'] if k in source)
sub_tags.append(self.source(**curr_options))
sub_tags.append(self.image(**options))
return u"<picture>{}</picture>".format("".join(sub_tags))
class CloudinaryImage(CloudinaryResource): class CloudinaryImage(CloudinaryResource):
def __init__(self, public_id=None, **kwargs): def __init__(self, public_id=None, **kwargs):

View file

@ -4,28 +4,24 @@ import email.utils
import json import json
import socket import socket
import cloudinary
from six import string_types
import urllib3 import urllib3
import certifi from six import string_types
from cloudinary import utils
from urllib3.exceptions import HTTPError from urllib3.exceptions import HTTPError
import cloudinary
from cloudinary import utils
from cloudinary.exceptions import (
BadRequest,
AuthorizationRequired,
NotAllowed,
NotFound,
AlreadyExists,
RateLimited,
GeneralError
)
logger = cloudinary.logger logger = cloudinary.logger
# intentionally one-liners
class Error(Exception): pass
class NotFound(Error): pass
class NotAllowed(Error): pass
class AlreadyExists(Error): pass
class RateLimited(Error): pass
class BadRequest(Error): pass
class GeneralError(Error): pass
class AuthorizationRequired(Error): pass
EXCEPTION_CODES = { EXCEPTION_CODES = {
400: BadRequest, 400: BadRequest,
401: AuthorizationRequired, 401: AuthorizationRequired,
@ -45,10 +41,8 @@ class Response(dict):
self.rate_limit_reset_at = email.utils.parsedate(response.headers["x-featureratelimit-reset"]) self.rate_limit_reset_at = email.utils.parsedate(response.headers["x-featureratelimit-reset"])
self.rate_limit_remaining = int(response.headers["x-featureratelimit-remaining"]) self.rate_limit_remaining = int(response.headers["x-featureratelimit-remaining"])
_http = urllib3.PoolManager(
cert_reqs='CERT_REQUIRED', _http = utils.get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS)
ca_certs=certifi.where()
)
def ping(**options): def ping(**options):
@ -67,23 +61,26 @@ def resources(**options):
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", None) upload_type = options.pop("type", None)
uri = ["resources", resource_type] uri = ["resources", resource_type]
if upload_type: uri.append(upload_type) if upload_type:
params = only(options, uri.append(upload_type)
"next_cursor", "max_results", "prefix", "tags", "context", "moderations", "direction", "start_at") params = only(options, "next_cursor", "max_results", "prefix", "tags",
"context", "moderations", "direction", "start_at")
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
def resources_by_tag(tag, **options): def resources_by_tag(tag, **options):
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "tags", tag] uri = ["resources", resource_type, "tags", tag]
params = only(options, "next_cursor", "max_results", "tags", "context", "moderations", "direction") params = only(options, "next_cursor", "max_results", "tags",
"context", "moderations", "direction")
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
def resources_by_moderation(kind, status, **options): def resources_by_moderation(kind, status, **options):
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "moderations", kind, status] uri = ["resources", resource_type, "moderations", kind, status]
params = only(options, "next_cursor", "max_results", "tags", "context", "moderations", "direction") params = only(options, "next_cursor", "max_results", "tags",
"context", "moderations", "direction")
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
@ -99,7 +96,8 @@ def resource(public_id, **options):
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload") upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type, public_id] uri = ["resources", resource_type, upload_type, public_id]
params = only(options, "exif", "faces", "colors", "image_metadata", "pages", "phash", "coordinates", "max_results") params = only(options, "exif", "faces", "colors", "image_metadata", "cinemagraph_analysis",
"pages", "phash", "coordinates", "max_results", "quality_analysis", "derived_next_cursor")
return call_api("get", uri, params, **options) return call_api("get", uri, params, **options)
@ -114,9 +112,11 @@ def update(public_id, **options):
if "tags" in options: if "tags" in options:
params["tags"] = ",".join(utils.build_array(options["tags"])) params["tags"] = ",".join(utils.build_array(options["tags"]))
if "face_coordinates" in options: 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: 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 "context" in options: if "context" in options:
params["context"] = utils.encode_context(options.get("context")) params["context"] = utils.encode_context(options.get("context"))
if "auto_tagging" in options: if "auto_tagging" in options:
@ -167,8 +167,7 @@ def delete_derived_resources(derived_resource_ids, **options):
def delete_derived_by_transformation(public_ids, transformations, def delete_derived_by_transformation(public_ids, transformations,
resource_type='image', type='upload', invalidate=None, resource_type='image', type='upload', invalidate=None,
**options): **options):
""" """Delete derived resources of public ids, identified by transformations
Delete derived resources of public ids, identified by transformations
:param public_ids: the base resources :param public_ids: the base resources
:type public_ids: list of str :type public_ids: list of str
@ -202,33 +201,49 @@ def tags(**options):
def transformations(**options): def transformations(**options):
uri = ["transformations"] uri = ["transformations"]
return call_api("get", uri, only(options, "next_cursor", "max_results"), **options) params = only(options, "named", "next_cursor", "max_results")
return call_api("get", uri, params, **options)
def transformation(transformation, **options): def transformation(transformation, **options):
uri = ["transformations", transformation_string(transformation)] uri = ["transformations"]
return call_api("get", uri, only(options, "next_cursor", "max_results"), **options)
params = only(options, "next_cursor", "max_results")
params["transformation"] = utils.build_single_eager(transformation)
return call_api("get", uri, params, **options)
def delete_transformation(transformation, **options): def delete_transformation(transformation, **options):
uri = ["transformations", transformation_string(transformation)] uri = ["transformations"]
return call_api("delete", uri, {}, **options)
params = {"transformation": utils.build_single_eager(transformation)}
return call_api("delete", uri, params, **options)
# updates - currently only supported update is the "allowed_for_strict" boolean flag and unsafe_update # updates - currently only supported update is the "allowed_for_strict"
# boolean flag and unsafe_update
def update_transformation(transformation, **options): def update_transformation(transformation, **options):
uri = ["transformations", transformation_string(transformation)] uri = ["transformations"]
updates = only(options, "allowed_for_strict") updates = only(options, "allowed_for_strict")
if "unsafe_update" in options: if "unsafe_update" in options:
updates["unsafe_update"] = transformation_string(options.get("unsafe_update")) updates["unsafe_update"] = transformation_string(options.get("unsafe_update"))
if not updates: raise Exception("No updates given")
updates["transformation"] = utils.build_single_eager(transformation)
return call_api("put", uri, updates, **options) return call_api("put", uri, updates, **options)
def create_transformation(name, definition, **options): def create_transformation(name, definition, **options):
uri = ["transformations", name] uri = ["transformations"]
return call_api("post", uri, {"transformation": transformation_string(definition)}, **options)
params = {"name": name, "transformation": utils.build_single_eager(definition)}
return call_api("post", uri, params, **options)
def publish_by_ids(public_ids, **options): def publish_by_ids(public_ids, **options):
@ -271,7 +286,7 @@ def update_upload_preset(name, **options):
uri = ["upload_presets", name] uri = ["upload_presets", name]
params = utils.build_upload_params(**options) params = utils.build_upload_params(**options)
params = utils.cleanup_params(params) params = utils.cleanup_params(params)
params.update(only(options, "unsigned", "disallow_public_id")) params.update(only(options, "unsigned", "disallow_public_id", "live"))
return call_api("put", uri, params, **options) return call_api("put", uri, params, **options)
@ -279,16 +294,33 @@ def create_upload_preset(**options):
uri = ["upload_presets"] uri = ["upload_presets"]
params = utils.build_upload_params(**options) params = utils.build_upload_params(**options)
params = utils.cleanup_params(params) params = utils.cleanup_params(params)
params.update(only(options, "unsigned", "disallow_public_id", "name")) params.update(only(options, "unsigned", "disallow_public_id", "name", "live"))
return call_api("post", uri, params, **options) return call_api("post", uri, params, **options)
def create_folder(path, **options):
return call_api("post", ["folders", path], {}, **options)
def root_folders(**options): def root_folders(**options):
return call_api("get", ["folders"], {}, **options) return call_api("get", ["folders"], only(options, "next_cursor", "max_results"), **options)
def subfolders(of_folder_path, **options): def subfolders(of_folder_path, **options):
return call_api("get", ["folders", of_folder_path], {}, **options) return call_api("get", ["folders", of_folder_path], only(options, "next_cursor", "max_results"), **options)
def delete_folder(path, **options):
"""Deletes folder
Deleted folder must be empty, but can have descendant empty sub folders
:param path: The folder to delete
:param options: Additional options
:rtype: Response
"""
return call_api("delete", ["folders", path], {}, **options)
def restore(public_ids, **options): def restore(public_ids, **options):
@ -361,29 +393,48 @@ def update_streaming_profile(name, **options):
def call_json_api(method, uri, jsonBody, **options): def call_json_api(method, uri, jsonBody, **options):
logger.debug(jsonBody) logger.debug(jsonBody)
data = json.dumps(jsonBody).encode('utf-8') data = json.dumps(jsonBody).encode('utf-8')
return _call_api(method, uri, body=data, headers={'Content-Type': 'application/json'}, **options) return _call_api(method, uri, body=data,
headers={'Content-Type': 'application/json'}, **options)
def call_api(method, uri, params, **options): def call_api(method, uri, params, **options):
return _call_api(method, uri, params=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): def _call_api(method, uri, params=None, body=None, headers=None, **options):
prefix = options.pop("upload_prefix", prefix = options.pop("upload_prefix",
cloudinary.config().upload_prefix) or "https://api.cloudinary.com" cloudinary.config().upload_prefix) or "https://api.cloudinary.com"
cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name) cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name)
if not cloud_name: raise Exception("Must supply cloud_name") if not cloud_name:
raise Exception("Must supply cloud_name")
api_key = options.pop("api_key", cloudinary.config().api_key) api_key = options.pop("api_key", cloudinary.config().api_key)
if not api_key: raise Exception("Must supply api_key") if not api_key:
raise Exception("Must supply api_key")
api_secret = options.pop("api_secret", cloudinary.config().api_secret) api_secret = options.pop("api_secret", cloudinary.config().api_secret)
if not cloud_name: raise Exception("Must supply api_secret") if not cloud_name:
raise Exception("Must supply api_secret")
api_url = "/".join([prefix, "v1_1", cloud_name] + uri) api_url = "/".join([prefix, "v1_1", cloud_name] + uri)
processed_params = None processed_params = None
if isinstance(params, dict): if isinstance(params, dict):
processed_params = {} processed_params = {}
for key, value in params.items(): for key, value in params.items():
if isinstance(value, list): if isinstance(value, list) or isinstance(value, tuple):
value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)} value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)}
processed_params.update(value_list) processed_params.update(value_list)
elif value: elif value:
@ -437,12 +488,166 @@ def transformation_string(transformation):
def __prepare_streaming_profile_params(**options): def __prepare_streaming_profile_params(**options):
params = only(options, "display_name") params = only(options, "display_name")
if "representations" in options: if "representations" in options:
representations = [{"transformation": transformation_string(trans)} for trans in options["representations"]] representations = [{"transformation": transformation_string(trans)}
for trans in options["representations"]]
params["representations"] = json.dumps(representations) params["representations"] = json.dumps(representations)
return params return params
def __delete_resource_params(options, **params): def __delete_resource_params(options, **params):
p = dict(transformations=utils.build_eager(options.get('transformations')), p = dict(transformations=utils.build_eager(options.get('transformations')),
**only(options, "keep_original", "next_cursor", "invalidate")) **only(options, "keep_original", "next_cursor", "invalidate"))
p.update(params) p.update(params)
return p return p
def list_metadata_fields(**options):
"""Returns a list of all metadata field definitions
See: `Get metadata fields API reference <https://cloudinary.com/documentation/admin_api#get_metadata_fields>`_
:param options: Additional options
:rtype: Response
"""
return call_metadata_api("get", [], {}, **options)
def metadata_field_by_field_id(field_external_id, **options):
"""Gets a metadata field by external id
See: `Get metadata field by external ID API reference
<https://cloudinary.com/documentation/admin_api#get_a_metadata_field_by_external_id>`_
:param field_external_id: The ID of the metadata field to retrieve
:param options: Additional options
:rtype: Response
"""
uri = [field_external_id]
return call_metadata_api("get", uri, {}, **options)
def add_metadata_field(field, **options):
"""Creates a new metadata field definition
See: `Create metadata field API reference <https://cloudinary.com/documentation/admin_api#create_a_metadata_field>`_
:param field: The field to add
:param options: Additional options
:rtype: Response
"""
params = only(field, "type", "external_id", "label", "mandatory",
"default_value", "validation", "datasource")
return call_metadata_api("post", [], params, **options)
def update_metadata_field(field_external_id, field, **options):
"""Updates a metadata field by external id
Updates a metadata field definition (partially, no need to pass the entire
object) passed as JSON data.
See `Generic structure of a metadata field
<https://cloudinary.com/documentation/admin_api#generic_structure_of_a_metadata_field>`_ for details.
:param field_external_id: The id of the metadata field to update
:param field: The field definition
:param options: Additional options
:rtype: Response
"""
uri = [field_external_id]
params = only(field, "label", "mandatory", "default_value", "validation")
return call_metadata_api("put", uri, params, **options)
def delete_metadata_field(field_external_id, **options):
"""Deletes a metadata field definition.
The field should no longer be considered a valid candidate for all other endpoints
See: `Delete metadata field API reference
<https://cloudinary.com/documentation/admin_api#delete_a_metadata_field_by_external_id>`_
:param field_external_id: The external id of the field to delete
:param options: Additional options
:return: An array with a "message" key. "ok" value indicates a successful deletion.
:rtype: Response
"""
uri = [field_external_id]
return call_metadata_api("delete", uri, {}, **options)
def delete_datasource_entries(field_external_id, entries_external_id, **options):
"""Deletes entries in a metadata field datasource
Deletes (blocks) the datasource entries for a specified metadata field
definition. Sets the state of the entries to inactive. This is a soft delete,
the entries still exist under the hood and can be activated again with the
restore datasource entries method.
See: `Delete entries in a metadata field datasource API reference
<https://cloudinary.com/documentation/admin_api#delete_entries_in_a_metadata_field_datasource>`_
:param field_external_id: The id of the field to update
:param entries_external_id: The ids of all the entries to delete from the
datasource
:param options: Additional options
:rtype: Response
"""
uri = [field_external_id, "datasource"]
params = {"external_ids": entries_external_id}
return call_metadata_api("delete", uri, params, **options)
def update_metadata_field_datasource(field_external_id, entries_external_id, **options):
"""Updates a metadata field datasource
Updates the datasource of a supported field type (currently only enum and set),
passed as JSON data. The update is partial: datasource entries with an
existing external_id will be updated and entries with new external_id's (or
without external_id's) will be appended.
See: `Update a metadata field datasource API reference
<https://cloudinary.com/documentation/admin_api#update_a_metadata_field_datasource>`_
:param field_external_id: The external id of the field to update
:param entries_external_id:
:param options: Additional options
:rtype: Response
"""
values = []
for item in entries_external_id:
external = only(item, "external_id", "value")
if external:
values.append(external)
uri = [field_external_id, "datasource"]
params = {"values": values}
return call_metadata_api("put", uri, params, **options)
def restore_metadata_field_datasource(field_external_id, entries_external_ids, **options):
"""Restores entries in a metadata field datasource
Restores (unblocks) any previously deleted datasource entries for a specified
metadata field definition.
Sets the state of the entries to active.
See: `Restore entries in a metadata field datasource API reference
<https://cloudinary.com/documentation/admin_api#restore_entries_in_a_metadata_field_datasource>`_
:param field_external_id: The ID of the metadata field
:param entries_external_ids: An array of IDs of datasource entries to restore
(unblock)
:param options: Additional options
:rtype: Response
"""
uri = [field_external_id, 'datasource_restore']
params = {"external_ids": entries_external_ids}
return call_metadata_api("post", uri, params, **options)

View file

@ -3,33 +3,37 @@ import hmac
import re import re
import time import time
from binascii import a2b_hex from binascii import a2b_hex
from cloudinary.compat import quote_plus
AUTH_TOKEN_NAME = "__cld_token__" AUTH_TOKEN_NAME = "__cld_token__"
AUTH_TOKEN_SEPARATOR = "~"
AUTH_TOKEN_UNSAFE_RE = r'([ "#%&\'\/:;<=>?@\[\\\]^`{\|}~]+)'
def generate(url=None, acl=None, start_time=None, duration=None,
def generate(url=None, acl=None, start_time=None, duration=None, expiration=None, ip=None, key=None, expiration=None, ip=None, key=None, token_name=AUTH_TOKEN_NAME):
token_name=AUTH_TOKEN_NAME):
if expiration is None: if expiration is None:
if duration is not None: if duration is not None:
start = start_time if start_time is not None else int(time.mktime(time.gmtime())) start = start_time if start_time is not None else int(time.time())
expiration = start + duration expiration = start + duration
else: else:
raise Exception("Must provide either expiration or duration") raise Exception("Must provide either expiration or duration")
token_parts = [] token_parts = []
if ip is not None: token_parts.append("ip=" + ip) if ip is not None:
if start_time is not None: token_parts.append("st=%d" % start_time) token_parts.append("ip=" + ip)
if start_time is not None:
token_parts.append("st=%d" % start_time)
token_parts.append("exp=%d" % expiration) token_parts.append("exp=%d" % expiration)
if acl is not None: token_parts.append("acl=%s" % _escape_to_lower(acl)) if acl is not None:
token_parts.append("acl=%s" % _escape_to_lower(acl))
to_sign = list(token_parts) to_sign = list(token_parts)
if url is not None: if url is not None and acl is None:
to_sign.append("url=%s" % _escape_to_lower(url)) to_sign.append("url=%s" % _escape_to_lower(url))
auth = _digest("~".join(to_sign), key) auth = _digest(AUTH_TOKEN_SEPARATOR.join(to_sign), key)
token_parts.append("hmac=%s" % auth) token_parts.append("hmac=%s" % auth)
return "%(token_name)s=%(token)s" % {"token_name": token_name, "token": "~".join(token_parts)} return "%(token_name)s=%(token)s" % {"token_name": token_name, "token": AUTH_TOKEN_SEPARATOR.join(token_parts)}
def _digest(message, key): def _digest(message, key):
@ -38,10 +42,8 @@ def _digest(message, key):
def _escape_to_lower(url): def _escape_to_lower(url):
escaped_url = quote_plus(url) # There is a circular import issue in this file, need to resolve it in the next major release
from cloudinary.utils import smart_escape
def toLowercase(match): escaped_url = smart_escape(url, unsafe=AUTH_TOKEN_UNSAFE_RE)
return match.group(0).lower() escaped_url = re.sub(r"%[0-9A-F]{2}", lambda x: x.group(0).lower(), escaped_url)
escaped_url = re.sub(r'%..', toLowercase, escaped_url)
return escaped_url return escaped_url

0
lib/cloudinary/cache/__init__.py vendored Normal file
View file

View file

View file

@ -0,0 +1,63 @@
from abc import ABCMeta, abstractmethod
class CacheAdapter:
"""
CacheAdapter Abstract Base Class
"""
__metaclass__ = ABCMeta
@abstractmethod
def get(self, public_id, type, resource_type, transformation, format):
"""
Gets value specified by parameters
:param public_id: The public ID of the resource
:param type: The storage type
:param resource_type: The type of the resource
:param transformation: The transformation string
:param format: The format of the resource
:return: None|mixed value, None if not found
"""
raise NotImplementedError
@abstractmethod
def set(self, public_id, type, resource_type, transformation, format, value):
"""
Sets value specified by parameters
:param public_id: The public ID of the resource
:param type: The storage type
:param resource_type: The type of the resource
:param transformation: The transformation string
:param format: The format of the resource
:param value: The value to set
:return: bool True on success or False on failure
"""
raise NotImplementedError
@abstractmethod
def delete(self, public_id, type, resource_type, transformation, format):
"""
Deletes entry specified by parameters
:param public_id: The public ID of the resource
:param type: The storage type
:param resource_type: The type of the resource
:param transformation: The transformation string
:param format: The format of the resource
:return: bool True on success or False on failure
"""
raise NotImplementedError
@abstractmethod
def flush_all(self):
"""
Flushes all entries from cache
:return: bool True on success or False on failure
"""
raise NotImplementedError

View file

@ -0,0 +1,61 @@
import json
from hashlib import sha1
from cloudinary.cache.adapter.cache_adapter import CacheAdapter
from cloudinary.cache.storage.key_value_storage import KeyValueStorage
from cloudinary.utils import check_property_enabled
class KeyValueCacheAdapter(CacheAdapter):
"""
A cache adapter for a key-value storage type
"""
def __init__(self, storage):
"""Create a new adapter for the provided storage interface"""
if not isinstance(storage, KeyValueStorage):
raise ValueError("An instance of valid KeyValueStorage must be provided")
self._key_value_storage = storage
@property
def enabled(self):
return self._key_value_storage is not None
@check_property_enabled
def get(self, public_id, type, resource_type, transformation, format):
key = self.generate_cache_key(public_id, type, resource_type, transformation, format)
value_str = self._key_value_storage.get(key)
return json.loads(value_str) if value_str else value_str
@check_property_enabled
def set(self, public_id, type, resource_type, transformation, format, value):
key = self.generate_cache_key(public_id, type, resource_type, transformation, format)
return self._key_value_storage.set(key, json.dumps(value))
@check_property_enabled
def delete(self, public_id, type, resource_type, transformation, format):
return self._key_value_storage.delete(
self.generate_cache_key(public_id, type, resource_type, transformation, format)
)
@check_property_enabled
def flush_all(self):
return self._key_value_storage.clear()
@staticmethod
def generate_cache_key(public_id, type, resource_type, transformation, format):
"""
Generates key-value storage key from parameters
:param public_id: The public ID of the resource
:param type: The storage type
:param resource_type: The type of the resource
:param transformation: The transformation string
:param format: The format of the resource
:return: Resulting cache key
"""
valid_params = [p for p in [public_id, type, resource_type, transformation, format] if p]
return sha1("/".join(valid_params).encode("utf-8")).hexdigest()

View file

@ -0,0 +1,124 @@
import copy
import collections
import cloudinary
from cloudinary.cache.adapter.cache_adapter import CacheAdapter
from cloudinary.utils import check_property_enabled
class ResponsiveBreakpointsCache:
"""
Caches breakpoint values for image resources
"""
def __init__(self, **cache_options):
"""
Initialize the cache
:param cache_options: Cache configuration options
"""
self._cache_adapter = None
cache_adapter = cache_options.get("cache_adapter")
self.set_cache_adapter(cache_adapter)
def set_cache_adapter(self, cache_adapter):
"""
Assigns cache adapter
:param cache_adapter: The cache adapter used to store and retrieve values
:return: Returns True if the cache_adapter is valid
"""
if cache_adapter is None or not isinstance(cache_adapter, CacheAdapter):
return False
self._cache_adapter = cache_adapter
return True
@property
def enabled(self):
"""
Indicates whether cache is enabled or not
:return: Rrue if a _cache_adapter has been set
"""
return self._cache_adapter is not None
@staticmethod
def _options_to_parameters(**options):
"""
Extract the parameters required in order to calculate the key of the cache.
:param options: Input options
:return: A list of values used to calculate the cache key
"""
options_copy = copy.deepcopy(options)
transformation, _ = cloudinary.utils.generate_transformation_string(**options_copy)
file_format = options.get("format", "")
storage_type = options.get("type", "upload")
resource_type = options.get("resource_type", "image")
return storage_type, resource_type, transformation, file_format
@check_property_enabled
def get(self, public_id, **options):
"""
Retrieve the breakpoints of a particular derived resource identified by the public_id and options
:param public_id: The public ID of the resource
:param options: The public ID of the resource
:return: Array of responsive breakpoints, None if not found
"""
params = self._options_to_parameters(**options)
return self._cache_adapter.get(public_id, *params)
@check_property_enabled
def set(self, public_id, value, **options):
"""
Set responsive breakpoints identified by public ID and options
:param public_id: The public ID of the resource
:param value: Array of responsive breakpoints to set
:param options: Additional options
:return: True on success or False on failure
"""
if not (isinstance(value, (list, tuple))):
raise ValueError("A list of breakpoints is expected")
storage_type, resource_type, transformation, file_format = self._options_to_parameters(**options)
return self._cache_adapter.set(public_id, storage_type, resource_type, transformation, file_format, value)
@check_property_enabled
def delete(self, public_id, **options):
"""
Delete responsive breakpoints identified by public ID and options
:param public_id: The public ID of the resource
:param options: Additional options
:return: True on success or False on failure
"""
params = self._options_to_parameters(**options)
return self._cache_adapter.delete(public_id, *params)
@check_property_enabled
def flush_all(self):
"""
Flush all entries from cache
:return: True on success or False on failure
"""
return self._cache_adapter.flush_all()
instance = ResponsiveBreakpointsCache()

View file

View file

@ -0,0 +1,79 @@
import glob
from tempfile import gettempdir
import os
import errno
from cloudinary.cache.storage.key_value_storage import KeyValueStorage
class FileSystemKeyValueStorage(KeyValueStorage):
"""File-based key-value storage"""
_item_ext = ".cldci"
def __init__(self, root_path):
"""
Create a new Storage object.
All files will be stored under the root_path location
:param root_path: The base folder for all storage files
"""
if root_path is None:
root_path = gettempdir()
if not os.path.isdir(root_path):
os.makedirs(root_path)
self._root_path = root_path
def get(self, key):
if not self._exists(key):
return None
with open(self._get_key_full_path(key), 'r') as f:
value = f.read()
return value
def set(self, key, value):
with open(self._get_key_full_path(key), 'w') as f:
f.write(value)
return True
def delete(self, key):
try:
os.remove(self._get_key_full_path(key))
except OSError as e:
if e.errno != errno.ENOENT: # errno.ENOENT - no such file or directory
raise # re-raise exception if a different error occurred
return True
def clear(self):
for cache_item_path in glob.iglob(os.path.join(self._root_path, '*' + self._item_ext)):
os.remove(cache_item_path)
return True
def _get_key_full_path(self, key):
"""
Generate the file path for the key
:param key: The key
:return: The absolute path of the value file associated with the key
"""
return os.path.join(self._root_path, key + self._item_ext)
def _exists(self, key):
"""
Indicate whether key exists
:param key: The key
:return: bool True if the file for the given key exists
"""
return os.path.isfile(self._get_key_full_path(key))

View file

@ -0,0 +1,51 @@
from abc import ABCMeta, abstractmethod
class KeyValueStorage:
"""
A simple key-value storage abstract base class
"""
__metaclass__ = ABCMeta
@abstractmethod
def get(self, key):
"""
Get a value identified by the given key
:param key: The unique identifier
:return: The value identified by key or None if no value was found
"""
raise NotImplementedError
@abstractmethod
def set(self, key, value):
"""
Store the value identified by the key
:param key: The unique identifier
:param value: Value to store
:return: bool True on success or False on failure
"""
raise NotImplementedError
@abstractmethod
def delete(self, key):
"""
Deletes item by key
:param key: The unique identifier
:return: bool True on success or False on failure
"""
raise NotImplementedError
@abstractmethod
def clear(self):
"""
Clears all entries
:return: bool True on success or False on failure
"""
raise NotImplementedError

View file

@ -1,5 +1,7 @@
# Copyright Cloudinary # Copyright Cloudinary
import six.moves.urllib.parse import six.moves.urllib.parse
from six import PY3, string_types, StringIO, BytesIO
urlencode = six.moves.urllib.parse.urlencode urlencode = six.moves.urllib.parse.urlencode
unquote = six.moves.urllib.parse.unquote unquote = six.moves.urllib.parse.unquote
urlparse = six.moves.urllib.parse.urlparse urlparse = six.moves.urllib.parse.urlparse
@ -7,7 +9,6 @@ parse_qs = six.moves.urllib.parse.parse_qs
parse_qsl = six.moves.urllib.parse.parse_qsl parse_qsl = six.moves.urllib.parse.parse_qsl
quote_plus = six.moves.urllib.parse.quote_plus quote_plus = six.moves.urllib.parse.quote_plus
httplib = six.moves.http_client httplib = six.moves.http_client
from six import PY3, string_types, StringIO, BytesIO
urllib2 = six.moves.urllib.request urllib2 = six.moves.urllib.request
NotConnected = six.moves.http_client.NotConnected NotConnected = six.moves.http_client.NotConnected

View file

@ -0,0 +1,33 @@
# Copyright Cloudinary
class Error(Exception):
pass
class NotFound(Error):
pass
class NotAllowed(Error):
pass
class AlreadyExists(Error):
pass
class RateLimited(Error):
pass
class BadRequest(Error):
pass
class GeneralError(Error):
pass
class AuthorizationRequired(Error):
pass

View file

@ -1,9 +1,10 @@
from django import forms import json
from cloudinary import CloudinaryResource import re
import cloudinary.uploader import cloudinary.uploader
import cloudinary.utils import cloudinary.utils
import re from cloudinary import CloudinaryResource
import json from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -16,8 +17,8 @@ def cl_init_js_callbacks(form, request):
class CloudinaryInput(forms.TextInput): class CloudinaryInput(forms.TextInput):
input_type = 'file' input_type = 'file'
def render(self, name, value, attrs=None): def render(self, name, value, attrs=None, renderer=None):
attrs = self.build_attrs(attrs) attrs = dict(self.attrs, **attrs)
options = attrs.get('options', {}) options = attrs.get('options', {})
attrs["options"] = '' attrs["options"] = ''
@ -27,14 +28,16 @@ class CloudinaryInput(forms.TextInput):
else: else:
params = cloudinary.utils.sign_request(params, options) params = cloudinary.utils.sign_request(params, options)
if 'resource_type' not in options: options['resource_type'] = 'auto' if 'resource_type' not in options:
options['resource_type'] = 'auto'
cloudinary_upload_url = cloudinary.utils.cloudinary_api_url("upload", **options) cloudinary_upload_url = cloudinary.utils.cloudinary_api_url("upload", **options)
attrs["data-url"] = cloudinary_upload_url attrs["data-url"] = cloudinary_upload_url
attrs["data-form-data"] = json.dumps(params) attrs["data-form-data"] = json.dumps(params)
attrs["data-cloudinary-field"] = name attrs["data-cloudinary-field"] = name
chunk_size = options.get("chunk_size", None) chunk_size = options.get("chunk_size", None)
if chunk_size: attrs["data-max-chunk-size"] = chunk_size if chunk_size:
attrs["data-max-chunk-size"] = chunk_size
attrs["class"] = " ".join(["cloudinary-fileupload", attrs.get("class", "")]) attrs["class"] = " ".join(["cloudinary-fileupload", attrs.get("class", "")])
widget = super(CloudinaryInput, self).render("file", None, attrs=attrs) widget = super(CloudinaryInput, self).render("file", None, attrs=attrs)
@ -53,8 +56,10 @@ class CloudinaryJsFileField(forms.Field):
} }
def __init__(self, attrs=None, options=None, autosave=True, *args, **kwargs): def __init__(self, attrs=None, options=None, autosave=True, *args, **kwargs):
if attrs is None: attrs = {} if attrs is None:
if options is None: options = {} attrs = {}
if options is None:
options = {}
self.autosave = autosave self.autosave = autosave
attrs = attrs.copy() attrs = attrs.copy()
attrs["options"] = options.copy() attrs["options"] = options.copy()
@ -70,7 +75,8 @@ class CloudinaryJsFileField(forms.Field):
def to_python(self, value): def to_python(self, value):
"""Convert to CloudinaryResource""" """Convert to CloudinaryResource"""
if not value: return None if not value:
return None
m = re.search(r'^([^/]+)/([^/]+)/v(\d+)/([^#]+)#([^/]+)$', value) m = re.search(r'^([^/]+)/([^/]+)/v(\d+)/([^#]+)#([^/]+)$', value)
if not m: if not m:
raise forms.ValidationError("Invalid format") raise forms.ValidationError("Invalid format")
@ -95,7 +101,8 @@ class CloudinaryJsFileField(forms.Field):
"""Validate the signature""" """Validate the signature"""
# Use the parent's handling of required fields, etc. # Use the parent's handling of required fields, etc.
super(CloudinaryJsFileField, self).validate(value) super(CloudinaryJsFileField, self).validate(value)
if not value: return if not value:
return
if not value.validate(): if not value.validate():
raise forms.ValidationError("Signature mismatch") raise forms.ValidationError("Signature mismatch")
@ -108,7 +115,8 @@ class CloudinaryUnsignedJsFileField(CloudinaryJsFileField):
options = {} options = {}
options = options.copy() options = options.copy()
options.update({"unsigned": True, "upload_preset": upload_preset}) options.update({"unsigned": True, "upload_preset": upload_preset})
super(CloudinaryUnsignedJsFileField, self).__init__(attrs, options, autosave, *args, **kwargs) super(CloudinaryUnsignedJsFileField, self).__init__(
attrs, options, autosave, *args, **kwargs)
class CloudinaryFileField(forms.FileField): class CloudinaryFileField(forms.FileField):
@ -117,7 +125,7 @@ class CloudinaryFileField(forms.FileField):
} }
default_error_messages = forms.FileField.default_error_messages.copy() default_error_messages = forms.FileField.default_error_messages.copy()
default_error_messages.update(my_default_error_messages) default_error_messages.update(my_default_error_messages)
def __init__(self, options=None, autosave=True, *args, **kwargs): def __init__(self, options=None, autosave=True, *args, **kwargs):
self.autosave = autosave self.autosave = autosave
self.options = options or {} self.options = options or {}

View file

@ -0,0 +1,43 @@
import json
import socket
import certifi
from urllib3 import PoolManager
from urllib3.exceptions import HTTPError
from cloudinary.exceptions import GeneralError
class HttpClient:
DEFAULT_HTTP_TIMEOUT = 60
def __init__(self, **options):
# Lazy initialization of the client, to improve performance when HttpClient is initialized but not used
self._http_client_instance = None
self.timeout = options.get("timeout", self.DEFAULT_HTTP_TIMEOUT)
@property
def _http_client(self):
if self._http_client_instance is None:
self._http_client_instance = PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where())
return self._http_client_instance
def get_json(self, url):
try:
response = self._http_client.request("GET", url, timeout=self.timeout)
body = response.data
except HTTPError as e:
raise GeneralError("Unexpected error %s" % str(e))
except socket.error as e:
raise GeneralError("Socket Error: %s" % str(e))
if response.status != 200:
raise GeneralError("Server returned unexpected status code - {} - {}".format(response.status,
response.data))
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))
return result

View file

@ -1,10 +1,10 @@
import re import re
from cloudinary import CloudinaryResource, forms, uploader from cloudinary import CloudinaryResource, forms, uploader
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.db import models from django.db import models
from cloudinary.uploader import upload_options
from cloudinary.utils import upload_params
# Add introspection rules for South, if it's installed. # Add introspection rules for South, if it's installed.
try: try:
@ -13,15 +13,23 @@ try:
except ImportError: except ImportError:
pass pass
CLOUDINARY_FIELD_DB_RE = r'(?:(?P<resource_type>image|raw|video)/(?P<type>upload|private|authenticated)/)?(?:v(?P<version>\d+)/)?(?P<public_id>.*?)(\.(?P<format>[^.]+))?$' CLOUDINARY_FIELD_DB_RE = r'(?:(?P<resource_type>image|raw|video)/' \
r'(?P<type>upload|private|authenticated)/)?' \
r'(?:v(?P<version>\d+)/)?' \
r'(?P<public_id>.*?)' \
r'(\.(?P<format>[^.]+))?$'
# Taken from six - https://pythonhosted.org/six/
def with_metaclass(meta, *bases): def with_metaclass(meta, *bases):
"""Create a base class with a metaclass.""" """
# This requires a bit of explanation: the basic idea is to make a dummy Create a base class with a metaclass.
# metaclass for one level of class instantiation that replaces itself with
# the actual metaclass. This requires a bit of explanation: the basic idea is to make a dummy
metaclass for one level of class instantiation that replaces itself with
the actual metaclass.
Taken from six - https://pythonhosted.org/six/
"""
class metaclass(meta): class metaclass(meta):
def __new__(cls, name, this_bases, d): def __new__(cls, name, this_bases, d):
return meta(name, bases, d) return meta(name, bases, d)
@ -32,23 +40,32 @@ class CloudinaryField(models.Field):
description = "A resource stored in Cloudinary" description = "A resource stored in Cloudinary"
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
options = {'max_length': 255}
self.default_form_class = kwargs.pop("default_form_class", forms.CloudinaryFileField) self.default_form_class = kwargs.pop("default_form_class", forms.CloudinaryFileField)
options.update(kwargs) self.type = kwargs.pop("type", "upload")
self.type = options.pop("type", "upload") self.resource_type = kwargs.pop("resource_type", "image")
self.resource_type = options.pop("resource_type", "image") self.width_field = kwargs.pop("width_field", None)
self.width_field = options.pop("width_field", None) self.height_field = kwargs.pop("height_field", None)
self.height_field = options.pop("height_field", None) # Collect all options related to Cloudinary upload
super(CloudinaryField, self).__init__(*args, **options) self.options = {key: kwargs.pop(key) for key in set(kwargs.keys()) if key in upload_params + upload_options}
field_options = kwargs
field_options['max_length'] = 255
super(CloudinaryField, self).__init__(*args, **field_options)
def get_internal_type(self): def get_internal_type(self):
return 'CharField' return 'CharField'
def value_to_string(self, obj): def value_to_string(self, obj):
# We need to support both legacy `_get_val_from_obj` and new `value_from_object` models.Field methods. """
# It would be better to wrap it with try -> except AttributeError -> fallback to legacy. We need to support both legacy `_get_val_from_obj` and new `value_from_object` models.Field methods.
# Unfortunately, we can catch AttributeError exception from `value_from_object` function itself. It would be better to wrap it with try -> except AttributeError -> fallback to legacy.
# Parsing exception string is an overkill here, that's why we check for attribute existence Unfortunately, we can catch AttributeError exception from `value_from_object` function itself.
Parsing exception string is an overkill here, that's why we check for attribute existence
:param obj: Value to serialize
:return: Serialized value
"""
if hasattr(self, 'value_from_object'): if hasattr(self, 'value_from_object'):
value = self.value_from_object(obj) value = self.value_from_object(obj)
@ -69,38 +86,33 @@ class CloudinaryField(models.Field):
format=m.group('format') format=m.group('format')
) )
def from_db_value(self, value, expression, connection, context): def from_db_value(self, value, expression, connection, *args, **kwargs):
if value is None: # TODO: when dropping support for versions prior to 2.0, you may return
return value # the signature to from_db_value(value, expression, connection)
return self.parse_cloudinary_resource(value) if value is not None:
return self.parse_cloudinary_resource(value)
def to_python(self, value): def to_python(self, value):
if isinstance(value, CloudinaryResource): if isinstance(value, CloudinaryResource):
return value return value
elif isinstance(value, UploadedFile): elif isinstance(value, UploadedFile):
return value return value
elif value is None: elif value is None or value is False:
return value return value
else: else:
return self.parse_cloudinary_resource(value) return self.parse_cloudinary_resource(value)
def upload_options_with_filename(self, model_instance, filename):
return self.upload_options(model_instance)
def upload_options(self, model_instance):
return {}
def pre_save(self, model_instance, add): def pre_save(self, model_instance, add):
value = super(CloudinaryField, self).pre_save(model_instance, add) value = super(CloudinaryField, self).pre_save(model_instance, add)
if isinstance(value, UploadedFile): if isinstance(value, UploadedFile):
options = {"type": self.type, "resource_type": self.resource_type} options = {"type": self.type, "resource_type": self.resource_type}
options.update(self.upload_options_with_filename(model_instance, value.name)) options.update(self.options)
instance_value = uploader.upload_resource(value, **options) instance_value = uploader.upload_resource(value, **options)
setattr(model_instance, self.attname, instance_value) setattr(model_instance, self.attname, instance_value)
if self.width_field: if self.width_field:
setattr(model_instance, self.width_field, instance_value.metadata['width']) setattr(model_instance, self.width_field, instance_value.metadata.get('width'))
if self.height_field: if self.height_field:
setattr(model_instance, self.height_field, instance_value.metadata['height']) setattr(model_instance, self.height_field, instance_value.metadata.get('height'))
return self.get_prep_value(instance_value) return self.get_prep_value(instance_value)
else: else:
return value return value

View file

@ -1,17 +1,17 @@
# MIT licensed code copied from https://bitbucket.org/chrisatlee/poster # MIT licensed code copied from https://bitbucket.org/chrisatlee/poster
# #
# Copyright (c) 2011 Chris AtLee # Copyright (c) 2011 Chris AtLee
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights # in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is # copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions: # furnished to do so, subject to the following conditions:
# #
# The above copyright notice and this permission notice shall be included in # The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software. # all copies or substantial portions of the Software.
# #
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@ -28,7 +28,4 @@ New releases of poster will always have a version number that compares greater
than an older version of poster. than an older version of poster.
New in version 0.6.""" New in version 0.6."""
import cloudinary.poster.streaminghttp version = (0, 8, 2) # Thanks JP!
import cloudinary.poster.encode
version = (0, 8, 2) # Thanks JP!

View file

@ -6,9 +6,17 @@ as multipart/form-data suitable for a HTTP POST or PUT request.
multipart/form-data is the standard way to upload files over HTTP""" multipart/form-data is the standard way to upload files over HTTP"""
__all__ = ['gen_boundary', 'encode_and_quote', 'MultipartParam', import mimetypes
'encode_string', 'encode_file_header', 'get_body_size', 'get_headers', import os
'multipart_encode'] import re
from email.header import Header
from cloudinary.compat import (PY3, advance_iterator, quote_plus, to_bytearray,
to_bytes, to_string)
__all__ = [
'gen_boundary', 'encode_and_quote', 'MultipartParam', 'encode_string',
'encode_file_header', 'get_body_size', 'get_headers', 'multipart_encode']
try: try:
from io import UnsupportedOperation from io import UnsupportedOperation
@ -17,25 +25,19 @@ except ImportError:
try: try:
import uuid import uuid
def gen_boundary(): def gen_boundary():
"""Returns a random string to use as the boundary for a message""" """Returns a random string to use as the boundary for a message"""
return uuid.uuid4().hex return uuid.uuid4().hex
except ImportError: except ImportError:
import random, sha import random
import sha
def gen_boundary(): def gen_boundary():
"""Returns a random string to use as the boundary for a message""" """Returns a random string to use as the boundary for a message"""
bits = random.getrandbits(160) bits = random.getrandbits(160)
return sha.new(str(bits)).hexdigest() return sha.new(str(bits)).hexdigest()
import re, os, mimetypes
from cloudinary.compat import (PY3, string_types, to_bytes, to_string,
to_bytearray, quote_plus, advance_iterator)
try:
from email.header import Header
except ImportError:
# Python 2.4
from email.Header import Header
if PY3: if PY3:
def encode_and_quote(data): def encode_and_quote(data):
if data is None: if data is None:
@ -47,7 +49,7 @@ else:
"""If ``data`` is unicode, return quote_plus(data.encode("utf-8")) otherwise return quote_plus(data)""" """If ``data`` is unicode, return quote_plus(data.encode("utf-8")) otherwise return quote_plus(data)"""
if data is None: if data is None:
return None return None
if isinstance(data, unicode): if isinstance(data, unicode):
data = data.encode("utf-8") data = data.encode("utf-8")
return quote_plus(data) return quote_plus(data)
@ -65,13 +67,15 @@ if PY3:
return to_bytes(str(s)) return to_bytes(str(s))
else: else:
def _strify(s): def _strify(s):
"""If s is a unicode string, encode it to UTF-8 and return the results, otherwise return str(s), or None if s is None""" """If s is a unicode string, encode it to UTF-8 and return the results,
otherwise return str(s), or None if s is None"""
if s is None: if s is None:
return None return None
if isinstance(s, unicode): if isinstance(s, unicode):
return s.encode("utf-8") return s.encode("utf-8")
return str(s) return str(s)
class MultipartParam(object): class MultipartParam(object):
"""Represents a single parameter in a multipart/form-data request """Represents a single parameter in a multipart/form-data request
@ -105,7 +109,7 @@ class MultipartParam(object):
transferred, and the total size. transferred, and the total size.
""" """
def __init__(self, name, value=None, filename=None, filetype=None, def __init__(self, name, value=None, filename=None, filetype=None,
filesize=None, fileobj=None, cb=None): filesize=None, fileobj=None, cb=None):
self.name = Header(name).encode() self.name = Header(name).encode()
self.value = _strify(value) self.value = _strify(value)
if filename is None: if filename is None:
@ -141,7 +145,7 @@ class MultipartParam(object):
fileobj.seek(0, 2) fileobj.seek(0, 2)
self.filesize = fileobj.tell() self.filesize = fileobj.tell()
fileobj.seek(0) fileobj.seek(0)
except: except Exception:
raise ValueError("Could not determine filesize") raise ValueError("Could not determine filesize")
def __cmp__(self, other): def __cmp__(self, other):
@ -169,9 +173,9 @@ class MultipartParam(object):
""" """
return cls(paramname, filename=os.path.basename(filename), return cls(paramname, filename=os.path.basename(filename),
filetype=mimetypes.guess_type(filename)[0], filetype=mimetypes.guess_type(filename)[0],
filesize=os.path.getsize(filename), filesize=os.path.getsize(filename),
fileobj=open(filename, "rb")) fileobj=open(filename, "rb"))
@classmethod @classmethod
def from_params(cls, params): def from_params(cls, params):
@ -204,7 +208,7 @@ class MultipartParam(object):
filetype = None filetype = None
retval.append(cls(name=name, filename=filename, retval.append(cls(name=name, filename=filename,
filetype=filetype, fileobj=value)) filetype=filetype, fileobj=value))
else: else:
retval.append(cls(name, value)) retval.append(cls(name, value))
return retval return retval
@ -216,8 +220,8 @@ class MultipartParam(object):
headers = ["--%s" % boundary] headers = ["--%s" % boundary]
if self.filename: if self.filename:
disposition = 'form-data; name="%s"; filename="%s"' % (self.name, disposition = 'form-data; name="%s"; filename="%s"' % (
to_string(self.filename)) self.name, to_string(self.filename))
else: else:
disposition = 'form-data; name="%s"' % self.name disposition = 'form-data; name="%s"' % self.name
@ -267,8 +271,8 @@ class MultipartParam(object):
self.cb(self, current, total) self.cb(self, current, total)
last_block = to_bytearray("") last_block = to_bytearray("")
encoded_boundary = "--%s" % encode_and_quote(boundary) encoded_boundary = "--%s" % encode_and_quote(boundary)
boundary_exp = re.compile(to_bytes("^%s$" % re.escape(encoded_boundary)), boundary_exp = re.compile(
re.M) to_bytes("^%s$" % re.escape(encoded_boundary)), re.M)
while True: while True:
block = self.fileobj.read(blocksize) block = self.fileobj.read(blocksize)
if not block: if not block:
@ -296,6 +300,7 @@ class MultipartParam(object):
return len(self.encode_hdr(boundary)) + 2 + valuesize return len(self.encode_hdr(boundary)) + 2 + valuesize
def encode_string(boundary, name, value): def encode_string(boundary, name, value):
"""Returns ``name`` and ``value`` encoded as a multipart/form-data """Returns ``name`` and ``value`` encoded as a multipart/form-data
variable. ``boundary`` is the boundary string used throughout variable. ``boundary`` is the boundary string used throughout
@ -303,8 +308,8 @@ def encode_string(boundary, name, value):
return MultipartParam(name, value).encode(boundary) return MultipartParam(name, value).encode(boundary)
def encode_file_header(boundary, paramname, filesize, filename=None,
filetype=None): def encode_file_header(boundary, paramname, filesize, filename=None, filetype=None):
"""Returns the leading data for a multipart/form-data field that contains """Returns the leading data for a multipart/form-data field that contains
file data. file data.
@ -324,7 +329,8 @@ def encode_file_header(boundary, paramname, filesize, filename=None,
""" """
return MultipartParam(paramname, filesize=filesize, filename=filename, return MultipartParam(paramname, filesize=filesize, filename=filename,
filetype=filetype).encode_hdr(boundary) filetype=filetype).encode_hdr(boundary)
def get_body_size(params, boundary): def get_body_size(params, boundary):
"""Returns the number of bytes that the multipart/form-data encoding """Returns the number of bytes that the multipart/form-data encoding
@ -332,6 +338,7 @@ def get_body_size(params, boundary):
size = sum(p.get_size(boundary) for p in MultipartParam.from_params(params)) size = sum(p.get_size(boundary) for p in MultipartParam.from_params(params))
return size + len(boundary) + 6 return size + len(boundary) + 6
def get_headers(params, boundary): def get_headers(params, boundary):
"""Returns a dictionary with Content-Type and Content-Length headers """Returns a dictionary with Content-Type and Content-Length headers
for the multipart/form-data encoding of ``params``.""" for the multipart/form-data encoding of ``params``."""
@ -341,6 +348,7 @@ def get_headers(params, boundary):
headers['Content-Length'] = str(get_body_size(params, boundary)) headers['Content-Length'] = str(get_body_size(params, boundary))
return headers return headers
class multipart_yielder: class multipart_yielder:
def __init__(self, params, boundary, cb): def __init__(self, params, boundary, cb):
self.params = params self.params = params
@ -396,6 +404,7 @@ class multipart_yielder:
for param in self.params: for param in self.params:
param.reset() param.reset()
def multipart_encode(params, boundary=None, cb=None): def multipart_encode(params, boundary=None, cb=None):
"""Encode ``params`` as multipart/form-data. """Encode ``params`` as multipart/form-data.

View file

@ -27,15 +27,18 @@ Example usage:
... {'Content-Length': str(len(s))}) ... {'Content-Length': str(len(s))})
""" """
import sys, socket import socket
from cloudinary.compat import httplib, urllib2, NotConnected import sys
from cloudinary.compat import NotConnected, httplib, urllib2
__all__ = ['StreamingHTTPConnection', 'StreamingHTTPRedirectHandler', __all__ = ['StreamingHTTPConnection', 'StreamingHTTPRedirectHandler',
'StreamingHTTPHandler', 'register_openers'] 'StreamingHTTPHandler', 'register_openers']
if hasattr(httplib, 'HTTPS'): if hasattr(httplib, 'HTTPS'):
__all__.extend(['StreamingHTTPSHandler', 'StreamingHTTPSConnection']) __all__.extend(['StreamingHTTPSHandler', 'StreamingHTTPSConnection'])
class _StreamingHTTPMixin: class _StreamingHTTPMixin:
"""Mixin class for HTTP and HTTPS connections that implements a streaming """Mixin class for HTTP and HTTPS connections that implements a streaming
send method.""" send method."""
@ -62,7 +65,7 @@ class _StreamingHTTPMixin:
print("send:", repr(value)) print("send:", repr(value))
try: try:
blocksize = 8192 blocksize = 8192
if hasattr(value, 'read') : if hasattr(value, 'read'):
if hasattr(value, 'seek'): if hasattr(value, 'seek'):
value.seek(0) value.seek(0)
if self.debuglevel > 0: if self.debuglevel > 0:
@ -86,10 +89,12 @@ class _StreamingHTTPMixin:
self.close() self.close()
raise raise
class StreamingHTTPConnection(_StreamingHTTPMixin, httplib.HTTPConnection): class StreamingHTTPConnection(_StreamingHTTPMixin, httplib.HTTPConnection):
"""Subclass of `httplib.HTTPConnection` that overrides the `send()` method """Subclass of `httplib.HTTPConnection` that overrides the `send()` method
to support iterable body objects""" to support iterable body objects"""
class StreamingHTTPRedirectHandler(urllib2.HTTPRedirectHandler): class StreamingHTTPRedirectHandler(urllib2.HTTPRedirectHandler):
"""Subclass of `urllib2.HTTPRedirectHandler` that overrides the """Subclass of `urllib2.HTTPRedirectHandler` that overrides the
`redirect_request` method to properly handle redirected POST requests `redirect_request` method to properly handle redirected POST requests
@ -114,7 +119,7 @@ class StreamingHTTPRedirectHandler(urllib2.HTTPRedirectHandler):
""" """
m = req.get_method() m = req.get_method()
if (code in (301, 302, 303, 307) and m in ("GET", "HEAD") if (code in (301, 302, 303, 307) and m in ("GET", "HEAD")
or code in (301, 302, 303) and m == "POST"): or code in (301, 302, 303) and m == "POST"):
# Strictly (according to RFC 2616), 301 or 302 in response # Strictly (according to RFC 2616), 301 or 302 in response
# to a POST MUST NOT cause a redirection without confirmation # to a POST MUST NOT cause a redirection without confirmation
# from the user (of urllib2, in this case). In practice, # from the user (of urllib2, in this case). In practice,
@ -125,14 +130,16 @@ class StreamingHTTPRedirectHandler(urllib2.HTTPRedirectHandler):
newheaders = dict((k, v) for k, v in req.headers.items() newheaders = dict((k, v) for k, v in req.headers.items()
if k.lower() not in ( if k.lower() not in (
"content-length", "content-type") "content-length", "content-type")
) )
return urllib2.Request(newurl, return urllib2.Request(
headers=newheaders, newurl,
origin_req_host=req.get_origin_req_host(), headers=newheaders,
unverifiable=True) origin_req_host=req.get_origin_req_host(),
unverifiable=True)
else: else:
raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
class StreamingHTTPHandler(urllib2.HTTPHandler): class StreamingHTTPHandler(urllib2.HTTPHandler):
"""Subclass of `urllib2.HTTPHandler` that uses """Subclass of `urllib2.HTTPHandler` that uses
StreamingHTTPConnection as its http connection class.""" StreamingHTTPConnection as its http connection class."""
@ -156,9 +163,9 @@ class StreamingHTTPHandler(urllib2.HTTPHandler):
"No Content-Length specified for iterable body") "No Content-Length specified for iterable body")
return urllib2.HTTPHandler.do_request_(self, req) return urllib2.HTTPHandler.do_request_(self, req)
if hasattr(httplib, 'HTTPS'): if hasattr(httplib, 'HTTPS'):
class StreamingHTTPSConnection(_StreamingHTTPMixin, class StreamingHTTPSConnection(_StreamingHTTPMixin, httplib.HTTPSConnection):
httplib.HTTPSConnection):
"""Subclass of `httplib.HTTSConnection` that overrides the `send()` """Subclass of `httplib.HTTSConnection` that overrides the `send()`
method to support iterable body objects""" method to support iterable body objects"""
@ -179,7 +186,7 @@ if hasattr(httplib, 'HTTPS'):
if hasattr(data, 'read') or hasattr(data, 'next'): if hasattr(data, 'read') or hasattr(data, 'next'):
if not req.has_header('Content-length'): if not req.has_header('Content-length'):
raise ValueError( raise ValueError(
"No Content-Length specified for iterable body") "No Content-Length specified for iterable body")
return urllib2.HTTPSHandler.do_request_(self, req) return urllib2.HTTPSHandler.do_request_(self, req)
@ -188,7 +195,8 @@ def get_handlers():
if hasattr(httplib, "HTTPS"): if hasattr(httplib, "HTTPS"):
handlers.append(StreamingHTTPSHandler) handlers.append(StreamingHTTPSHandler)
return handlers return handlers
def register_openers(): def register_openers():
"""Register the streaming http handlers in the global urllib2 default """Register the streaming http handlers in the global urllib2 default
opener object. opener object.

View file

@ -1,6 +1,7 @@
import json import json
from copy import deepcopy from copy import deepcopy
from . import api
from cloudinary.api import call_json_api
class Search: class Search:
@ -46,8 +47,8 @@ class Search:
def execute(self, **options): def execute(self, **options):
"""Execute the search and return results.""" """Execute the search and return results."""
options["content_type"] = 'application/json' options["content_type"] = 'application/json'
uri = ['resources','search'] uri = ['resources', 'search']
return api.call_json_api('post', uri, self.as_dict(), **options) return call_json_api('post', uri, self.as_dict(), **options)
def _add(self, name, value): def _add(self, name, value):
if name not in self.query: if name not in self.query:
@ -56,4 +57,4 @@ class Search:
return self return self
def as_dict(self): def as_dict(self):
return deepcopy(self.query) return deepcopy(self.query)

View file

@ -802,7 +802,7 @@ var slice = [].slice,
function TextLayer(options) { function TextLayer(options) {
var keys; var keys;
TextLayer.__super__.constructor.call(this, options); TextLayer.__super__.constructor.call(this, options);
keys = ["resourceType", "resourceType", "fontFamily", "fontSize", "fontWeight", "fontStyle", "textDecoration", "textAlign", "stroke", "letterSpacing", "lineSpacing", "text"]; keys = ["resourceType", "resourceType", "fontFamily", "fontSize", "fontWeight", "fontStyle", "textDecoration", "textAlign", "stroke", "letterSpacing", "lineSpacing", "fontHinting", "fontAntialiasing", "text"];
if (options != null) { if (options != null) {
keys.forEach((function(_this) { keys.forEach((function(_this) {
return function(key) { return function(key) {
@ -871,6 +871,16 @@ var slice = [].slice,
return this; return this;
}; };
TextLayer.prototype.fontAntialiasing = function(fontAntialiasing){
this.options.fontAntialiasing = fontAntialiasing;
return this;
};
TextLayer.prototype.fontHinting = function(fontHinting ){
this.options.fontHinting = fontHinting ;
return this;
};
TextLayer.prototype.text = function(text) { TextLayer.prototype.text = function(text) {
this.options.text = text; this.options.text = text;
return this; return this;
@ -932,6 +942,12 @@ var slice = [].slice,
if (!(Util.isEmpty(this.options.lineSpacing) && !Util.isNumberLike(this.options.lineSpacing))) { if (!(Util.isEmpty(this.options.lineSpacing) && !Util.isNumberLike(this.options.lineSpacing))) {
components.push("line_spacing_" + this.options.lineSpacing); components.push("line_spacing_" + this.options.lineSpacing);
} }
if (this.options.fontAntialiasing !== "none") {
components.push("antialias_"+this.options.fontAntialiasing);
}
if (this.options.fontHinting !== "none") {
components.push("hinting_"+this.options.fontHinting);
}
if (!Util.isEmpty(Util.compact(components))) { if (!Util.isEmpty(Util.compact(components))) {
if (Util.isEmpty(this.options.fontFamily)) { if (Util.isEmpty(this.options.fontFamily)) {
throw "Must supply fontFamily. " + components; throw "Must supply fontFamily. " + components;
@ -2780,6 +2796,20 @@ var slice = [].slice,
return this.param(value, "gravity", "g"); return this.param(value, "gravity", "g");
}; };
Transformation.prototype.fps = function(value) {
return this.param(value, "fps", "fps", (function(_this) {
return function(fps) {
if (Util.isString(fps)) {
return fps;
} else if (Util.isArray(fps)) {
return fps.join("-");
} else {
return fps;
}
};
})(this));
};
Transformation.prototype.height = function(value) { Transformation.prototype.height = function(value) {
return this.param(value, "height", "h", (function(_this) { return this.param(value, "height", "h", (function(_this) {
return function() { return function() {

View file

@ -43,7 +43,7 @@
'|(Kindle/(1\\.0|2\\.[05]|3\\.0))' '|(Kindle/(1\\.0|2\\.[05]|3\\.0))'
).test(window.navigator.userAgent) || ).test(window.navigator.userAgent) ||
// Feature detection for all other devices: // Feature detection for all other devices:
$('<input type="file">').prop('disabled')); $('<input type="file"/>').prop('disabled'));
// The FileReader API is not actually used, but works as feature detection, // The FileReader API is not actually used, but works as feature detection,
// as some Safari versions (5?) support XHR file uploads via the FormData API, // as some Safari versions (5?) support XHR file uploads via the FormData API,
@ -261,6 +261,9 @@
// Callback for dragover events of the dropZone(s): // Callback for dragover events of the dropZone(s):
// dragover: function (e) {}, // .bind('fileuploaddragover', func); // dragover: function (e) {}, // .bind('fileuploaddragover', func);
// Callback before the start of each chunk upload request (before form data initialization):
// chunkbeforesend: function (e, data) {}, // .bind('fileuploadchunkbeforesend', func);
// Callback for the start of each chunk upload request: // Callback for the start of each chunk upload request:
// chunksend: function (e, data) {}, // .bind('fileuploadchunksend', func); // chunksend: function (e, data) {}, // .bind('fileuploadchunksend', func);
@ -434,6 +437,13 @@
} }
}, },
_deinitProgressListener: function (options) {
var xhr = options.xhr ? options.xhr() : $.ajaxSettings.xhr();
if (xhr.upload) {
$(xhr.upload).unbind('progress');
}
},
_isInstanceOf: function (type, obj) { _isInstanceOf: function (type, obj) {
// Cross-frame instanceof check // Cross-frame instanceof check
return Object.prototype.toString.call(obj) === '[object ' + type + ']'; return Object.prototype.toString.call(obj) === '[object ' + type + ']';
@ -453,7 +463,7 @@
} }
if (!multipart || options.blob || !this._isInstanceOf('File', file)) { if (!multipart || options.blob || !this._isInstanceOf('File', file)) {
options.headers['Content-Disposition'] = 'attachment; filename="' + options.headers['Content-Disposition'] = 'attachment; filename="' +
encodeURI(file.name) + '"'; encodeURI(file.uploadName || file.name) + '"';
} }
if (!multipart) { if (!multipart) {
options.contentType = file.type || 'application/octet-stream'; options.contentType = file.type || 'application/octet-stream';
@ -489,7 +499,11 @@
}); });
} }
if (options.blob) { if (options.blob) {
formData.append(paramName, options.blob, file.name); formData.append(
paramName,
options.blob,
file.uploadName || file.name
);
} else { } else {
$.each(options.files, function (index, file) { $.each(options.files, function (index, file) {
// This check allows the tests to run with // This check allows the tests to run with
@ -762,6 +776,8 @@
// Expose the chunk bytes position range: // Expose the chunk bytes position range:
o.contentRange = 'bytes ' + ub + '-' + o.contentRange = 'bytes ' + ub + '-' +
(ub + o.chunkSize - 1) + '/' + fs; (ub + o.chunkSize - 1) + '/' + fs;
// Trigger chunkbeforesend to allow form data to be updated for this chunk
that._trigger('chunkbeforesend', null, o);
// Process the upload data (the blob and potential form data): // Process the upload data (the blob and potential form data):
that._initXHRData(o); that._initXHRData(o);
// Add progress listeners for this chunk upload: // Add progress listeners for this chunk upload:
@ -808,6 +824,9 @@
o.context, o.context,
[jqXHR, textStatus, errorThrown] [jqXHR, textStatus, errorThrown]
); );
})
.always(function () {
that._deinitProgressListener(o);
}); });
}; };
this._enhancePromise(promise); this._enhancePromise(promise);
@ -909,6 +928,7 @@
}).fail(function (jqXHR, textStatus, errorThrown) { }).fail(function (jqXHR, textStatus, errorThrown) {
that._onFail(jqXHR, textStatus, errorThrown, options); that._onFail(jqXHR, textStatus, errorThrown, options);
}).always(function (jqXHRorResult, textStatus, jqXHRorError) { }).always(function (jqXHRorResult, textStatus, jqXHRorError) {
that._deinitProgressListener(options);
that._onAlways( that._onAlways(
jqXHRorResult, jqXHRorResult,
textStatus, textStatus,
@ -1126,7 +1146,7 @@
dirReader = entry.createReader(); dirReader = entry.createReader();
readEntries(); readEntries();
} else { } else {
// Return an empy list for file system items // Return an empty list for file system items
// other than files or directories: // other than files or directories:
dfd.resolve([]); dfd.resolve([]);
} }

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -2,15 +2,14 @@ from __future__ import absolute_import
import json import json
import cloudinary
from cloudinary import CloudinaryResource, utils
from cloudinary.compat import PY3
from cloudinary.forms import CloudinaryJsFileField, cl_init_js_callbacks
from django import template from django import template
from django.forms import Form from django.forms import Form
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
import cloudinary
from cloudinary import CloudinaryResource, utils, uploader
from cloudinary.forms import CloudinaryJsFileField, cl_init_js_callbacks
from cloudinary.compat import PY3
register = template.Library() register = template.Library()
@ -57,9 +56,9 @@ def cloudinary_direct_upload_field(field_name="image", request=None):
return value return value
"""Deprecated - please use cloudinary_direct_upload_field, or a proper form"""
@register.inclusion_tag('cloudinary_direct_upload.html') @register.inclusion_tag('cloudinary_direct_upload.html')
def cloudinary_direct_upload(callback_url, **options): def cloudinary_direct_upload(callback_url, **options):
"""Deprecated - please use cloudinary_direct_upload_field, or a proper form"""
params = utils.build_upload_params(callback=callback_url, **options) params = utils.build_upload_params(callback=callback_url, **options)
params = utils.sign_request(params, options) params = utils.sign_request(params, options)
@ -75,6 +74,8 @@ def cloudinary_includes(processing=False):
CLOUDINARY_JS_CONFIG_PARAMS = ("api_key", "cloud_name", "private_cdn", "secure_distribution", "cdn_subdomain") CLOUDINARY_JS_CONFIG_PARAMS = ("api_key", "cloud_name", "private_cdn", "secure_distribution", "cdn_subdomain")
@register.inclusion_tag('cloudinary_js_config.html') @register.inclusion_tag('cloudinary_js_config.html')
def cloudinary_js_config(): def cloudinary_js_config():
config = cloudinary.config() config = cloudinary.config()

View file

@ -1,17 +1,17 @@
# Copyright Cloudinary # Copyright Cloudinary
import json import json
import re import os
import socket import socket
from os.path import getsize
import certifi
from six import string_types
from urllib3 import PoolManager, ProxyManager
from urllib3.exceptions import HTTPError
import cloudinary import cloudinary
import urllib3
import certifi
from cloudinary import utils from cloudinary import utils
from cloudinary.api import Error from cloudinary.exceptions import Error
from cloudinary.compat import string_types from cloudinary.cache.responsive_breakpoints_cache import instance as responsive_breakpoints_cache_instance
from urllib3.exceptions import HTTPError
from urllib3 import PoolManager
try: try:
from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox
@ -29,15 +29,21 @@ if is_appengine_sandbox():
_http = AppEngineManager() _http = AppEngineManager()
else: else:
# PoolManager uses a socket-level API behind the scenes # PoolManager uses a socket-level API behind the scenes
_http = PoolManager( _http = utils.get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS)
cert_reqs='CERT_REQUIRED',
ca_certs=certifi.where() upload_options = [
) "filename",
"timeout",
"chunk_size",
"use_cache"
]
UPLOAD_LARGE_CHUNK_SIZE = 20000000
def upload(file, **options): def upload(file, **options):
params = utils.build_upload_params(**options) params = utils.build_upload_params(**options)
return call_api("upload", params, file=file, **options) return call_cacheable_api("upload", params, file=file, **options)
def unsigned_upload(file, upload_preset, **options): def unsigned_upload(file, upload_preset, **options):
@ -55,35 +61,56 @@ def upload_resource(file, **options):
result = upload(file, **options) result = upload(file, **options)
return cloudinary.CloudinaryResource( return cloudinary.CloudinaryResource(
result["public_id"], version=str(result["version"]), result["public_id"], version=str(result["version"]),
format=result.get("format"), type=result["type"], resource_type=result["resource_type"], metadata=result) format=result.get("format"), type=result["type"],
resource_type=result["resource_type"], metadata=result)
def upload_large(file, **options): def upload_large(file, **options):
""" Upload large files. """ """ Upload large files. """
upload_id = utils.random_public_id() if utils.is_remote_url(file):
with open(file, 'rb') as file_io: return upload(file, **options)
results = None
current_loc = 0 if hasattr(file, 'read') and callable(file.read):
chunk_size = options.get("chunk_size", 20000000) file_io = file
file_size = getsize(file) else:
chunk = file_io.read(chunk_size) file_io = open(file, 'rb')
while chunk:
range = "bytes {0}-{1}/{2}".format(current_loc, current_loc + len(chunk) - 1, file_size) upload_result = None
current_loc += len(chunk)
with file_io:
upload_id = utils.random_public_id()
current_loc = 0
chunk_size = options.get("chunk_size", UPLOAD_LARGE_CHUNK_SIZE)
file_size = utils.file_io_size(file_io)
file_name = options.get(
"filename",
file_io.name if hasattr(file_io, 'name') and isinstance(file_io.name, str) else "stream")
chunk = file_io.read(chunk_size)
while chunk:
content_range = "bytes {0}-{1}/{2}".format(current_loc, current_loc + len(chunk) - 1, file_size)
current_loc += len(chunk)
http_headers = {"Content-Range": content_range, "X-Unique-Upload-Id": upload_id}
upload_result = upload_large_part((file_name, chunk), http_headers=http_headers, **options)
options["public_id"] = upload_result.get("public_id")
results = upload_large_part((file, chunk),
http_headers={"Content-Range": range, "X-Unique-Upload-Id": upload_id},
**options)
options["public_id"] = results.get("public_id")
chunk = file_io.read(chunk_size) chunk = file_io.read(chunk_size)
return results
return upload_result
def upload_large_part(file, **options): def upload_large_part(file, **options):
""" Upload large files. """ """ Upload large files. """
params = utils.build_upload_params(**options) params = utils.build_upload_params(**options)
if 'resource_type' not in options: options['resource_type'] = "raw"
return call_api("upload", params, file=file, **options) if 'resource_type' not in options:
options['resource_type'] = "raw"
return call_cacheable_api("upload", params, file=file, **options)
def destroy(public_id, **options): def destroy(public_id, **options):
@ -91,7 +118,7 @@ def destroy(public_id, **options):
"timestamp": utils.now(), "timestamp": utils.now(),
"type": options.get("type"), "type": options.get("type"),
"invalidate": options.get("invalidate"), "invalidate": options.get("invalidate"),
"public_id": public_id "public_id": public_id
} }
return call_api("destroy", params, **options) return call_api("destroy", params, **options)
@ -103,15 +130,43 @@ def rename(from_public_id, to_public_id, **options):
"overwrite": options.get("overwrite"), "overwrite": options.get("overwrite"),
"invalidate": options.get("invalidate"), "invalidate": options.get("invalidate"),
"from_public_id": from_public_id, "from_public_id": from_public_id,
"to_public_id": to_public_id "to_public_id": to_public_id,
"to_type": options.get("to_type")
} }
return call_api("rename", params, **options) return call_api("rename", params, **options)
def update_metadata(metadata, public_ids, **options):
"""
Populates metadata fields with the given values. Existing values will be overwritten.
Any metadata-value pairs given are merged with any existing metadata-value pairs
(an empty value for an existing metadata field clears the value)
:param metadata: A list of custom metadata fields (by external_id) and the values to assign to each
of them.
:param public_ids: An array of Public IDs of assets uploaded to Cloudinary.
:param options: Options such as
*resource_type* (the type of file. Default: image. Valid values: image, raw, or video) and
*type* (The storage type. Default: upload. Valid values: upload, private, or authenticated.)
:return: A list of public IDs that were updated
:rtype: mixed
"""
params = {
"timestamp": utils.now(),
"metadata": utils.encode_context(metadata),
"public_ids": utils.build_array(public_ids),
"type": options.get("type")
}
return call_api("metadata", params, **options)
def explicit(public_id, **options): def explicit(public_id, **options):
params = utils.build_upload_params(**options) params = utils.build_upload_params(**options)
params["public_id"] = public_id params["public_id"] = public_id
return call_api("explicit", params, **options) return call_cacheable_api("explicit", params, **options)
def create_archive(**options): def create_archive(**options):
@ -131,7 +186,8 @@ def generate_sprite(tag, **options):
"tag": tag, "tag": tag,
"async": options.get("async"), "async": options.get("async"),
"notification_url": options.get("notification_url"), "notification_url": options.get("notification_url"),
"transformation": utils.generate_transformation_string(fetch_format=options.get("format"), **options)[0] "transformation": utils.generate_transformation_string(
fetch_format=options.get("format"), **options)[0]
} }
return call_api("sprite", params, **options) return call_api("sprite", params, **options)
@ -177,8 +233,10 @@ def replace_tag(tag, public_ids=None, **options):
def remove_all_tags(public_ids, **options): def remove_all_tags(public_ids, **options):
""" """
Remove all tags from the specified public IDs. Remove all tags from the specified public IDs.
:param public_ids: the public IDs of the resources to update :param public_ids: the public IDs of the resources to update
:param options: additional options passed to the request :param options: additional options passed to the request
:return: dictionary with a list of public IDs that were updated :return: dictionary with a list of public IDs that were updated
""" """
return call_tags_api(None, "remove_all", public_ids, **options) return call_tags_api(None, "remove_all", public_ids, **options)
@ -187,9 +245,11 @@ def remove_all_tags(public_ids, **options):
def add_context(context, public_ids, **options): def add_context(context, public_ids, **options):
""" """
Add a context keys and values. If a particular key already exists, the value associated with the key is updated. Add a context keys and values. If a particular key already exists, the value associated with the key is updated.
:param context: dictionary of context :param context: dictionary of context
:param public_ids: the public IDs of the resources to update :param public_ids: the public IDs of the resources to update
:param options: additional options passed to the request :param options: additional options passed to the request
:return: dictionary with a list of public IDs that were updated :return: dictionary with a list of public IDs that were updated
""" """
return call_context_api(context, "add", public_ids, **options) return call_context_api(context, "add", public_ids, **options)
@ -198,8 +258,10 @@ def add_context(context, public_ids, **options):
def remove_all_context(public_ids, **options): def remove_all_context(public_ids, **options):
""" """
Remove all custom context from the specified public IDs. Remove all custom context from the specified public IDs.
:param public_ids: the public IDs of the resources to update :param public_ids: the public IDs of the resources to update
:param options: additional options passed to the request :param options: additional options passed to the request
:return: dictionary with a list of public IDs that were updated :return: dictionary with a list of public IDs that were updated
""" """
return call_context_api(None, "remove_all", public_ids, **options) return call_context_api(None, "remove_all", public_ids, **options)
@ -227,17 +289,18 @@ def call_context_api(context, command, public_ids=None, **options):
return call_api("context", params, **options) return call_api("context", params, **options)
TEXT_PARAMS = ["public_id", TEXT_PARAMS = [
"font_family", "public_id",
"font_size", "font_family",
"font_color", "font_size",
"text_align", "font_color",
"font_weight", "text_align",
"font_style", "font_weight",
"background", "font_style",
"opacity", "background",
"text_decoration" "opacity",
] "text_decoration"
]
def text(text, **options): def text(text, **options):
@ -247,6 +310,42 @@ def text(text, **options):
return call_api("text", params, **options) return call_api("text", params, **options)
def _save_responsive_breakpoints_to_cache(result):
"""
Saves responsive breakpoints parsed from upload result to cache
:param result: Upload result
"""
if "responsive_breakpoints" not in result:
return
if "public_id" not in result:
# We have some faulty result, nothing to cache
return
options = dict((k, result[k]) for k in ["type", "resource_type"] if k in result)
for transformation in result.get("responsive_breakpoints", []):
options["raw_transformation"] = transformation.get("transformation", "")
options["format"] = os.path.splitext(transformation["breakpoints"][0]["url"])[1][1:]
breakpoints = [bp["width"] for bp in transformation["breakpoints"]]
responsive_breakpoints_cache_instance.set(result["public_id"], breakpoints, **options)
def call_cacheable_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None,
**options):
"""
Calls Upload API and saves results to cache (if enabled)
"""
result = call_api(action, params, http_headers, return_error, unsigned, file, timeout, **options)
if "use_cache" in options or cloudinary.config().use_cache:
_save_responsive_breakpoints_to_cache(result)
return result
def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, **options): def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, **options):
if http_headers is None: if http_headers is None:
http_headers = {} http_headers = {}
@ -267,26 +366,27 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal
api_url = utils.cloudinary_api_url(action, **options) api_url = utils.cloudinary_api_url(action, **options)
if file: if file:
filename = options.get("filename") # Custom filename provided by user (relevant only for streams and files)
if isinstance(file, string_types): if isinstance(file, string_types):
if re.match(r'ftp:|https?:|s3:|data:[^;]*;base64,([a-zA-Z0-9\/+\n=]+)$', file): if utils.is_remote_url(file):
# URL # URL
name = None name = None
data = file data = file
else: else:
# file path # file path
name = file name = filename or file
with open(file, "rb") as opened: with open(file, "rb") as opened:
data = opened.read() data = opened.read()
elif hasattr(file, 'read') and callable(file.read): elif hasattr(file, 'read') and callable(file.read):
# stream # stream
data = file.read() data = file.read()
name = file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream" name = filename or (file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream")
elif isinstance(file, tuple): elif isinstance(file, tuple):
name = None name, data = file
data = file
else: else:
# Not a string, not a stream # Not a string, not a stream
name = "file" name = filename or "file"
data = file data = file
param_list["file"] = (name, data) if name else data param_list["file"] = (name, data) if name else data
@ -310,16 +410,17 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal
result = json.loads(response.data.decode('utf-8')) result = json.loads(response.data.decode('utf-8'))
except Exception as e: except Exception as e:
# Error is parsing json # Error is parsing json
raise Error("Error parsing server response (%d) - %s. Got - %s", response.status, response, e) raise Error("Error parsing server response (%d) - %s. Got - %s" % (response.status, response.data, e))
if "error" in result: if "error" in result:
if response.status not in [200, 400, 401, 403, 404, 500]: if response.status not in [200, 400, 401, 403, 404, 500]:
code = response.status code = response.status
if return_error: if return_error:
result["error"]["http_code"] = code result["error"]["http_code"] = code
else: else:
raise Error(result["error"]["message"]) raise Error(result["error"]["message"])
return result return result
finally: finally:
if file_io: file_io.close() if file_io:
file_io.close()

View file

@ -3,15 +3,19 @@ import base64
import copy import copy
import hashlib import hashlib
import json import json
import os
import random import random
import re import re
import string import string
import struct import struct
import time import time
import urllib
import zlib import zlib
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime, date from datetime import datetime, date
from fractions import Fraction from fractions import Fraction
from numbers import Number
from urllib3 import ProxyManager, PoolManager
import six.moves.urllib.parse import six.moves.urllib.parse
from six import iteritems from six import iteritems
@ -33,12 +37,101 @@ DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION = {"width": "auto", "crop": "limit"}
RANGE_VALUE_RE = r'^(?P<value>(\d+\.)?\d+)(?P<modifier>[%pP])?$' RANGE_VALUE_RE = r'^(?P<value>(\d+\.)?\d+)(?P<modifier>[%pP])?$'
RANGE_RE = r'^(\d+\.)?\d+[%pP]?\.\.(\d+\.)?\d+[%pP]?$' RANGE_RE = r'^(\d+\.)?\d+[%pP]?\.\.(\d+\.)?\d+[%pP]?$'
FLOAT_RE = r'^(\d+)\.(\d+)?$' FLOAT_RE = r'^(\d+)\.(\d+)?$'
REMOTE_URL_RE = r'ftp:|https?:|s3:|gs:|data:([\w-]+\/[\w-]+)?(;[\w-]+=[\w-]+)*;base64,([a-zA-Z0-9\/+\n=]+)$'
__LAYER_KEYWORD_PARAMS = [("font_weight", "normal"), __LAYER_KEYWORD_PARAMS = [("font_weight", "normal"),
("font_style", "normal"), ("font_style", "normal"),
("text_decoration", "none"), ("text_decoration", "none"),
("text_align", None), ("text_align", None),
("stroke", "none")] ("stroke", "none")]
# a list of keys used by the cloudinary_url function
__URL_KEYS = [
'api_secret',
'auth_token',
'cdn_subdomain',
'cloud_name',
'cname',
'format',
'private_cdn',
'resource_type',
'secure',
'secure_cdn_subdomain',
'secure_distribution',
'shorten',
'sign_url',
'ssl_detected',
'type',
'url_suffix',
'use_root_path',
'version'
]
__SIMPLE_UPLOAD_PARAMS = [
"public_id",
"callback",
"format",
"type",
"backup",
"faces",
"image_metadata",
"exif",
"colors",
"use_filename",
"unique_filename",
"discard_original_filename",
"invalidate",
"notification_url",
"eager_notification_url",
"eager_async",
"proxy",
"folder",
"overwrite",
"moderation",
"raw_convert",
"quality_override",
"quality_analysis",
"ocr",
"categorization",
"detection",
"similarity_search",
"background_removal",
"upload_preset",
"phash",
"return_delete_token",
"auto_tagging",
"async",
"cinemagraph_analysis",
]
__SERIALIZED_UPLOAD_PARAMS = [
"timestamp",
"transformation",
"headers",
"eager",
"tags",
"allowed_formats",
"face_coordinates",
"custom_coordinates",
"context",
"auto_tagging",
"responsive_breakpoints",
"access_control",
"metadata",
]
upload_params = __SIMPLE_UPLOAD_PARAMS + __SERIALIZED_UPLOAD_PARAMS
def compute_hex_hash(s):
"""
Compute hash and convert the result to HEX string
:param s: string to process
:return: HEX string
"""
return hashlib.sha1(to_bytes(s)).hexdigest()
def build_array(arg): def build_array(arg):
if isinstance(arg, list): if isinstance(arg, list):
@ -133,6 +226,22 @@ def json_encode(value):
return json.dumps(value, default=__json_serializer, separators=(',', ':')) return json.dumps(value, default=__json_serializer, separators=(',', ':'))
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!
:param options: URL and transformation options
"""
if options.get("type", "upload") != "fetch":
return
resource_format = options.pop("format", None)
if "fetch_format" not in options:
options["fetch_format"] = resource_format
def generate_transformation_string(**options): def generate_transformation_string(**options):
responsive_width = options.pop("responsive_width", cloudinary.config().responsive_width) responsive_width = options.pop("responsive_width", cloudinary.config().responsive_width)
size = options.pop("size", None) size = options.pop("size", None)
@ -165,6 +274,7 @@ def generate_transformation_string(**options):
return generate_transformation_string(**bs)[0] return generate_transformation_string(**bs)[0]
else: else:
return generate_transformation_string(transformation=bs)[0] return generate_transformation_string(transformation=bs)[0]
base_transformations = list(map(recurse, base_transformations)) base_transformations = list(map(recurse, base_transformations))
named_transformation = None named_transformation = None
else: else:
@ -186,11 +296,11 @@ def generate_transformation_string(**options):
flags = ".".join(build_array(options.pop("flags", None))) flags = ".".join(build_array(options.pop("flags", None)))
dpr = options.pop("dpr", cloudinary.config().dpr) dpr = options.pop("dpr", cloudinary.config().dpr)
duration = norm_range_value(options.pop("duration", None)) duration = norm_range_value(options.pop("duration", None))
start_offset = norm_range_value(options.pop("start_offset", None)) start_offset = norm_auto_range_value(options.pop("start_offset", None))
end_offset = norm_range_value(options.pop("end_offset", None)) end_offset = norm_range_value(options.pop("end_offset", None))
offset = split_range(options.pop("offset", None)) offset = split_range(options.pop("offset", None))
if offset: if offset:
start_offset = norm_range_value(offset[0]) start_offset = norm_auto_range_value(offset[0])
end_offset = norm_range_value(offset[1]) end_offset = norm_range_value(offset[1])
video_codec = process_video_codec_param(options.pop("video_codec", None)) video_codec = process_video_codec_param(options.pop("video_codec", None))
@ -202,6 +312,9 @@ def generate_transformation_string(**options):
overlay = process_layer(options.pop("overlay", None), "overlay") overlay = process_layer(options.pop("overlay", None), "overlay")
underlay = process_layer(options.pop("underlay", None), "underlay") underlay = process_layer(options.pop("underlay", None), "underlay")
if_value = process_conditional(options.pop("if", None)) if_value = process_conditional(options.pop("if", None))
custom_function = process_custom_function(options.pop("custom_function", None))
custom_pre_function = process_custom_pre_function(options.pop("custom_pre_function", None))
fps = process_fps(options.pop("fps", None))
params = { params = {
"a": normalize_expression(angle), "a": normalize_expression(angle),
@ -215,19 +328,22 @@ def generate_transformation_string(**options):
"e": normalize_expression(effect), "e": normalize_expression(effect),
"eo": normalize_expression(end_offset), "eo": normalize_expression(end_offset),
"fl": flags, "fl": flags,
"fn": custom_function or custom_pre_function,
"fps": fps,
"h": normalize_expression(height), "h": normalize_expression(height),
"ki": process_ki(options.pop("keyframe_interval", None)),
"l": overlay, "l": overlay,
"o": normalize_expression(options.pop('opacity',None)), "o": normalize_expression(options.pop('opacity', None)),
"q": normalize_expression(options.pop('quality',None)), "q": normalize_expression(options.pop('quality', None)),
"r": normalize_expression(options.pop('radius',None)), "r": process_radius(options.pop('radius', None)),
"so": normalize_expression(start_offset), "so": normalize_expression(start_offset),
"t": named_transformation, "t": named_transformation,
"u": underlay, "u": underlay,
"w": normalize_expression(width), "w": normalize_expression(width),
"x": normalize_expression(options.pop('x',None)), "x": normalize_expression(options.pop('x', None)),
"y": normalize_expression(options.pop('y',None)), "y": normalize_expression(options.pop('y', None)),
"vc": video_codec, "vc": video_codec,
"z": normalize_expression(options.pop('zoom',None)) "z": normalize_expression(options.pop('zoom', None))
} }
simple_params = { simple_params = {
"ac": "audio_codec", "ac": "audio_codec",
@ -239,7 +355,6 @@ def generate_transformation_string(**options):
"dn": "density", "dn": "density",
"f": "fetch_format", "f": "fetch_format",
"g": "gravity", "g": "gravity",
"ki": "keyframe_interval",
"p": "prefix", "p": "prefix",
"pg": "page", "pg": "page",
"sp": "streaming_profile", "sp": "streaming_profile",
@ -249,9 +364,9 @@ def generate_transformation_string(**options):
for param, option in simple_params.items(): for param, option in simple_params.items():
params[param] = options.pop(option, None) params[param] = options.pop(option, None)
variables = options.pop('variables',{}) variables = options.pop('variables', {})
var_params = [] var_params = []
for key,value in options.items(): for key, value in options.items():
if re.match(r'^\$', key): if re.match(r'^\$', key):
var_params.append(u"{0}_{1}".format(key, normalize_expression(str(value)))) var_params.append(u"{0}_{1}".format(key, normalize_expression(str(value))))
@ -261,7 +376,6 @@ def generate_transformation_string(**options):
for var in variables: for var in variables:
var_params.append(u"{0}_{1}".format(var[0], normalize_expression(str(var[1])))) var_params.append(u"{0}_{1}".format(var[0], normalize_expression(str(var[1]))))
variables = ','.join(var_params) variables = ','.join(var_params)
sorted_params = sorted([param + "_" + str(value) for param, value in params.items() if (value or value == 0)]) sorted_params = sorted([param + "_" + str(value) for param, value in params.items() if (value or value == 0)])
@ -270,10 +384,14 @@ def generate_transformation_string(**options):
if if_value is not None: if if_value is not None:
sorted_params.insert(0, "if_" + str(if_value)) sorted_params.insert(0, "if_" + str(if_value))
if "raw_transformation" in options and (options["raw_transformation"] or options["raw_transformation"] == 0):
sorted_params.append(options.pop("raw_transformation"))
transformation = ",".join(sorted_params) transformation = ",".join(sorted_params)
if "raw_transformation" in options:
transformation = transformation + "," + options.pop("raw_transformation")
transformations = base_transformations + [transformation] transformations = base_transformations + [transformation]
if responsive_width: if responsive_width:
responsive_width_transformation = cloudinary.config().responsive_width_transformation \ responsive_width_transformation = cloudinary.config().responsive_width_transformation \
or DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION or DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION
@ -287,6 +405,31 @@ def generate_transformation_string(**options):
return url, options return url, options
def chain_transformations(options, transformations):
"""
Helper function, allows chaining transformations to the end of transformations list
The result of this function is an updated options parameter
:param options: Original options
:param transformations: Transformations to chain at the end
:return: Resulting options
"""
transformations = copy.deepcopy(transformations)
transformations = build_array(transformations)
# preserve url options
url_options = dict((o, options[o]) for o in __URL_KEYS if o in options)
transformations.insert(0, options)
url_options["transformation"] = transformations
return url_options
def is_fraction(width): def is_fraction(width):
width = str(width) width = str(width)
return re.match(FLOAT_RE, width) and float(width) < 1 return re.match(FLOAT_RE, width) and float(width) < 1
@ -302,18 +445,26 @@ def split_range(range):
def norm_range_value(value): def norm_range_value(value):
if value is None: return None if value is None:
return None
match = re.match(RANGE_VALUE_RE, str(value)) match = re.match(RANGE_VALUE_RE, str(value))
if match is None: return None if match is None:
return None
modifier = '' modifier = ''
if match.group('modifier') is not None: if match.group('modifier') is not None:
modifier = 'p' modifier = 'p'
return match.group('value') + modifier return match.group('value') + modifier
def norm_auto_range_value(value):
if value == "auto":
return value
return norm_range_value(value)
def process_video_codec_param(param): def process_video_codec_param(param):
out_param = param out_param = param
if isinstance(out_param, dict): if isinstance(out_param, dict):
@ -325,15 +476,29 @@ def process_video_codec_param(param):
return out_param return out_param
def process_radius(param):
if param is None:
return
if isinstance(param, (list, tuple)):
if not 1 <= len(param) <= 4:
raise ValueError("Invalid radius param")
return ':'.join(normalize_expression(t) for t in param)
return str(param)
def cleanup_params(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 == ""]) return dict([(k, __safe_value(v)) for (k, v) in params.items() if v is not None and not v == ""])
def sign_request(params, options): def sign_request(params, options):
api_key = options.get("api_key", cloudinary.config().api_key) api_key = options.get("api_key", cloudinary.config().api_key)
if not api_key: raise ValueError("Must supply api_key") if not api_key:
raise ValueError("Must supply api_key")
api_secret = options.get("api_secret", cloudinary.config().api_secret) api_secret = options.get("api_secret", cloudinary.config().api_secret)
if not api_secret: raise ValueError("Must supply api_secret") if not api_secret:
raise ValueError("Must supply api_secret")
params = cleanup_params(params) params = cleanup_params(params)
params["signature"] = api_sign_request(params, api_secret) params["signature"] = api_sign_request(params, api_secret)
@ -345,7 +510,7 @@ def sign_request(params, options):
def api_sign_request(params_to_sign, api_secret): def api_sign_request(params_to_sign, api_secret):
params = [(k + "=" + (",".join(v) if isinstance(v, list) else str(v))) for k, v in params_to_sign.items() if v] 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)) to_sign = "&".join(sorted(params))
return hashlib.sha1(to_bytes(to_sign + api_secret)).hexdigest() return compute_hex_hash(to_sign + api_secret)
def breakpoint_settings_mapper(breakpoint_settings): def breakpoint_settings_mapper(breakpoint_settings):
@ -370,11 +535,13 @@ def finalize_source(source, format, url_suffix):
source_to_sign = source source_to_sign = source
else: else:
source = unquote(source) source = unquote(source)
if not PY3: source = source.encode('utf8') if not PY3:
source = source.encode('utf8')
source = smart_escape(source) source = smart_escape(source)
source_to_sign = source source_to_sign = source
if url_suffix is not None: if url_suffix is not None:
if re.search(r'[\./]', url_suffix): raise ValueError("url_suffix should not include . or /") if re.search(r'[\./]', url_suffix):
raise ValueError("url_suffix should not include . or /")
source = source + "/" + url_suffix source = source + "/" + url_suffix
if format is not None: if format is not None:
source = source + "." + format source = source + "." + format
@ -396,7 +563,8 @@ def finalize_resource_type(resource_type, type, url_suffix, use_root_path, short
raise ValueError("URL Suffix only supported for image/upload and raw/upload") raise ValueError("URL Suffix only supported for image/upload and raw/upload")
if use_root_path: if use_root_path:
if (resource_type == "image" and upload_type == "upload") or (resource_type == "images" and upload_type is None): if (resource_type == "image" and upload_type == "upload") or (
resource_type == "images" and upload_type is None):
resource_type = None resource_type = None
upload_type = None upload_type = None
else: else:
@ -409,28 +577,33 @@ def finalize_resource_type(resource_type, type, url_suffix, use_root_path, short
return resource_type, upload_type return resource_type, upload_type
def unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, cname, secure, def unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain,
secure_distribution): secure_cdn_subdomain, cname, secure, secure_distribution):
"""cdn_subdomain and secure_cdn_subdomain """cdn_subdomain and secure_cdn_subdomain
1) Customers in shared distribution (e.g. res.cloudinary.com) 1) Customers in shared distribution (e.g. res.cloudinary.com)
if cdn_domain is true uses res-[1-5].cloudinary.com for both http and https. Setting secure_cdn_subdomain to false disables this for https. if cdn_domain is true uses res-[1-5].cloudinary.com for both http and https.
Setting secure_cdn_subdomain to false disables this for https.
2) Customers with private cdn 2) Customers with private cdn
if cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for http if cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for http
if secure_cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for https (please contact support if you require this) if secure_cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for https
(please contact support if you require this)
3) Customers with cname 3) Customers with cname
if cdn_domain is true uses a[1-5].cname for http. For https, uses the same naming scheme as 1 for shared distribution and as 2 for private distribution.""" if cdn_domain is true uses a[1-5].cname for http. For https, uses the same naming scheme
as 1 for shared distribution and as 2 for private distribution."""
shared_domain = not private_cdn shared_domain = not private_cdn
shard = __crc(source) shard = __crc(source)
if secure: if secure:
if secure_distribution is None or secure_distribution == cloudinary.OLD_AKAMAI_SHARED_CDN: if secure_distribution is None or secure_distribution == cloudinary.OLD_AKAMAI_SHARED_CDN:
secure_distribution = cloud_name + "-res.cloudinary.com" if private_cdn else cloudinary.SHARED_CDN secure_distribution = cloud_name + "-res.cloudinary.com" \
if private_cdn else cloudinary.SHARED_CDN
shared_domain = shared_domain or secure_distribution == cloudinary.SHARED_CDN shared_domain = shared_domain or secure_distribution == cloudinary.SHARED_CDN
if secure_cdn_subdomain is None and shared_domain: if secure_cdn_subdomain is None and shared_domain:
secure_cdn_subdomain = cdn_subdomain secure_cdn_subdomain = cdn_subdomain
if secure_cdn_subdomain: if secure_cdn_subdomain:
secure_distribution = re.sub('res.cloudinary.com', "res-" + shard + ".cloudinary.com", secure_distribution) secure_distribution = re.sub('res.cloudinary.com', "res-" + shard + ".cloudinary.com",
secure_distribution)
prefix = "https://" + secure_distribution prefix = "https://" + secure_distribution
elif cname: elif cname:
@ -438,10 +611,12 @@ def unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain,
prefix = "http://" + subdomain + cname prefix = "http://" + subdomain + cname
else: else:
subdomain = cloud_name + "-res" if private_cdn else "res" subdomain = cloud_name + "-res" if private_cdn else "res"
if cdn_subdomain: subdomain = subdomain + "-" + shard if cdn_subdomain:
subdomain = subdomain + "-" + shard
prefix = "http://" + subdomain + ".cloudinary.com" prefix = "http://" + subdomain + ".cloudinary.com"
if shared_domain: prefix += "/" + cloud_name if shared_domain:
prefix += "/" + cloud_name
return prefix return prefix
@ -460,16 +635,23 @@ def merge(*dict_args):
def cloudinary_url(source, **options): def cloudinary_url(source, **options):
original_source = source original_source = source
patch_fetch_format(options)
type = options.pop("type", "upload") type = options.pop("type", "upload")
if type == 'fetch':
options["fetch_format"] = options.get("fetch_format", options.pop("format", None))
transformation, options = generate_transformation_string(**options) transformation, options = generate_transformation_string(**options)
resource_type = options.pop("resource_type", "image") resource_type = options.pop("resource_type", "image")
force_version = options.pop("force_version", cloudinary.config().force_version)
if force_version is None:
force_version = True
version = options.pop("version", None) version = options.pop("version", None)
format = options.pop("format", None) format = options.pop("format", None)
cdn_subdomain = options.pop("cdn_subdomain", cloudinary.config().cdn_subdomain) cdn_subdomain = options.pop("cdn_subdomain", cloudinary.config().cdn_subdomain)
secure_cdn_subdomain = options.pop("secure_cdn_subdomain", cloudinary.config().secure_cdn_subdomain) secure_cdn_subdomain = options.pop("secure_cdn_subdomain",
cloudinary.config().secure_cdn_subdomain)
cname = options.pop("cname", cloudinary.config().cname) cname = options.pop("cname", cloudinary.config().cname)
shorten = options.pop("shorten", cloudinary.config().shorten) shorten = options.pop("shorten", cloudinary.config().shorten)
@ -478,7 +660,8 @@ def cloudinary_url(source, **options):
raise ValueError("Must supply cloud_name in tag or in configuration") raise ValueError("Must supply cloud_name in tag or in configuration")
secure = options.pop("secure", cloudinary.config().secure) secure = options.pop("secure", cloudinary.config().secure)
private_cdn = options.pop("private_cdn", cloudinary.config().private_cdn) private_cdn = options.pop("private_cdn", cloudinary.config().private_cdn)
secure_distribution = options.pop("secure_distribution", cloudinary.config().secure_distribution) secure_distribution = options.pop("secure_distribution",
cloudinary.config().secure_distribution)
sign_url = options.pop("sign_url", cloudinary.config().sign_url) sign_url = options.pop("sign_url", cloudinary.config().sign_url)
api_secret = options.pop("api_secret", cloudinary.config().api_secret) api_secret = options.pop("api_secret", cloudinary.config().api_secret)
url_suffix = options.pop("url_suffix", None) url_suffix = options.pop("url_suffix", None)
@ -490,15 +673,19 @@ def cloudinary_url(source, **options):
if (not source) or type == "upload" and re.match(r'^https?:', source): if (not source) or type == "upload" and re.match(r'^https?:', source):
return original_source, options return original_source, options
resource_type, type = finalize_resource_type(resource_type, type, url_suffix, use_root_path, shorten) resource_type, type = finalize_resource_type(
resource_type, type, url_suffix, use_root_path, shorten)
source, source_to_sign = finalize_source(source, format, url_suffix) source, source_to_sign = finalize_source(source, format, url_suffix)
if source_to_sign.find("/") >= 0 \ if not version and force_version \
and source_to_sign.find("/") >= 0 \
and not re.match(r'^https?:/', source_to_sign) \ and not re.match(r'^https?:/', source_to_sign) \
and not re.match(r'^v[0-9]+', source_to_sign) \ and not re.match(r'^v[0-9]+', source_to_sign):
and not version:
version = "1" version = "1"
if version: version = "v" + str(version) if version:
version = "v" + str(version)
else:
version = None
transformation = re.sub(r'([^:])/+', r'\1/', transformation) transformation = re.sub(r'([^:])/+', r'\1/', transformation)
@ -506,35 +693,84 @@ def cloudinary_url(source, **options):
if sign_url and not auth_token: if sign_url and not auth_token:
to_sign = "/".join(__compact([transformation, source_to_sign])) to_sign = "/".join(__compact([transformation, source_to_sign]))
signature = "s--" + to_string( signature = "s--" + to_string(
base64.urlsafe_b64encode(hashlib.sha1(to_bytes(to_sign + api_secret)).digest())[0:8]) + "--" base64.urlsafe_b64encode(
hashlib.sha1(to_bytes(to_sign + api_secret)).digest())[0:8]) + "--"
prefix = unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, cname, prefix = unsigned_download_url_prefix(
secure, secure_distribution) source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain,
source = "/".join(__compact([prefix, resource_type, type, signature, transformation, version, source])) cname, secure, secure_distribution)
source = "/".join(__compact(
[prefix, resource_type, type, signature, transformation, version, source]))
if sign_url and auth_token: if sign_url and auth_token:
path = urlparse(source).path path = urlparse(source).path
token = cloudinary.auth_token.generate( **merge(auth_token, {"url": path})) token = cloudinary.auth_token.generate(**merge(auth_token, {"url": path}))
source = "%s?%s" % (source, token) source = "%s?%s" % (source, token)
return source, options return source, options
def cloudinary_api_url(action='upload', **options): def cloudinary_api_url(action='upload', **options):
cloudinary_prefix = options.get("upload_prefix", cloudinary.config().upload_prefix) or "https://api.cloudinary.com" 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) cloud_name = options.get("cloud_name", cloudinary.config().cloud_name)
if not cloud_name: raise ValueError("Must supply cloud_name") if not cloud_name:
raise ValueError("Must supply cloud_name")
resource_type = options.get("resource_type", "image") resource_type = options.get("resource_type", "image")
return "/".join([cloudinary_prefix, "v1_1", cloud_name, resource_type, action])
return encode_unicode_url("/".join([cloudinary_prefix, "v1_1", cloud_name, resource_type, action]))
# Based on ruby's CGI::unescape. In addition does not escape / : def cloudinary_scaled_url(source, width, transformation, options):
def smart_escape(source,unsafe = r"([^a-zA-Z0-9_.\-\/:]+)"): """
Generates a cloudinary url scaled to specified width.
:param source: The resource
:param width: Width in pixels of the srcset item
:param transformation: Custom transformation that overrides transformations provided in options
:param options: A dict with additional options
:return: Resulting URL of the item
"""
# preserve options from being destructed
options = copy.deepcopy(options)
if transformation:
if isinstance(transformation, string_types):
transformation = {"raw_transformation": transformation}
# Remove all transformation related options
options = dict((o, options[o]) for o in __URL_KEYS if o in options)
options.update(transformation)
scale_transformation = {"crop": "scale", "width": width}
url_options = options
patch_fetch_format(url_options)
url_options = chain_transformations(url_options, scale_transformation)
return cloudinary_url(source, **url_options)[0]
def smart_escape(source, unsafe=r"([^a-zA-Z0-9_.\-\/:]+)"):
"""
Based on ruby's CGI::unescape. In addition does not escape / :
:param source: Source string to escape
:param unsafe: Unsafe characters
:return: Escaped string
"""
def pack(m): def pack(m):
return to_bytes('%' + "%".join(["%02X" % x for x in struct.unpack('B' * len(m.group(1)), m.group(1))]).upper()) return to_bytes('%' + "%".join(
["%02X" % x for x in struct.unpack('B' * len(m.group(1)), m.group(1))]
).upper())
return to_string(re.sub(to_bytes(unsafe), pack, to_bytes(source))) return to_string(re.sub(to_bytes(unsafe), pack, to_bytes(source)))
def random_public_id(): def random_public_id():
return ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(16)) return ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits)
for _ in range(16))
def signed_preloaded_image(result): def signed_preloaded_image(result):
@ -584,7 +820,8 @@ def download_archive_url(**options):
params = options.copy() params = options.copy()
params.update(mode="download") params.update(mode="download")
cloudinary_params = sign_request(archive_params(**params), options) cloudinary_params = sign_request(archive_params(**params), options)
return cloudinary_api_url("generate_archive", **options) + "?" + urlencode(bracketize_seq(cloudinary_params), True) return cloudinary_api_url("generate_archive", **options) + "?" + \
urlencode(bracketize_seq(cloudinary_params), True)
def download_zip_url(**options): def download_zip_url(**options):
@ -592,10 +829,12 @@ def download_zip_url(**options):
new_options.update(target_format="zip") new_options.update(target_format="zip")
return download_archive_url(**new_options) return download_archive_url(**new_options)
def generate_auth_token(**options): def generate_auth_token(**options):
token_options = merge(cloudinary.config().auth_token, options) token_options = merge(cloudinary.config().auth_token, options)
return auth_token.generate(**token_options) return auth_token.generate(**token_options)
def archive_params(**options): def archive_params(**options):
if options.get("timestamp") is None: if options.get("timestamp") is None:
timestamp = now() timestamp = now()
@ -613,6 +852,8 @@ def archive_params(**options):
"phash": options.get("phash"), "phash": options.get("phash"),
"prefixes": options.get("prefixes") and build_array(options.get("prefixes")), "prefixes": options.get("prefixes") and build_array(options.get("prefixes")),
"public_ids": options.get("public_ids") and build_array(options.get("public_ids")), "public_ids": options.get("public_ids") and build_array(options.get("public_ids")),
"fully_qualified_public_ids": options.get("fully_qualified_public_ids") and build_array(
options.get("fully_qualified_public_ids")),
"skip_transformation_name": options.get("skip_transformation_name"), "skip_transformation_name": options.get("skip_transformation_name"),
"tags": options.get("tags") and build_array(options.get("tags")), "tags": options.get("tags") and build_array(options.get("tags")),
"target_format": options.get("target_format"), "target_format": options.get("target_format"),
@ -629,15 +870,32 @@ def archive_params(**options):
def build_eager(transformations): def build_eager(transformations):
if transformations is None: if transformations is None:
return None return None
eager = []
for tr in build_array(transformations): return "|".join([build_single_eager(et) for et in build_array(transformations)])
if isinstance(tr, string_types):
single_eager = tr
else: def build_single_eager(options):
ext = tr.get("format") """
single_eager = "/".join([x for x in [generate_transformation_string(**tr)[0], ext] if x]) Builds a single eager transformation which consists of transformation and (optionally) format joined by "/"
eager.append(single_eager)
return "|".join(eager) :param options: Options containing transformation parameters and (optionally) a "format" key
format can be a string value (jpg, gif, etc) or can be set to "" (empty string).
The latter leads to transformation ending with "/", which means "No extension, use original format"
If format is not provided or set to None, only transformation is used (without the trailing "/")
:return: Resulting eager transformation string
"""
if isinstance(options, string_types):
return options
trans_str = generate_transformation_string(**options)[0]
if not trans_str:
return ""
file_format = options.get("format")
return trans_str + ("/" + file_format if file_format is not None else "")
def build_custom_headers(headers): def build_custom_headers(headers):
@ -653,49 +911,30 @@ def build_custom_headers(headers):
def build_upload_params(**options): def build_upload_params(**options):
params = {"timestamp": now(), params = {param_name: options.get(param_name) for param_name in __SIMPLE_UPLOAD_PARAMS}
"transformation": generate_transformation_string(**options)[0],
"public_id": options.get("public_id"), serialized_params = {
"callback": options.get("callback"), "timestamp": now(),
"format": options.get("format"), "metadata": encode_context(options.get("metadata")),
"type": options.get("type"), "transformation": generate_transformation_string(**options)[0],
"backup": options.get("backup"), "headers": build_custom_headers(options.get("headers")),
"faces": options.get("faces"), "eager": build_eager(options.get("eager")),
"image_metadata": options.get("image_metadata"), "tags": options.get("tags") and ",".join(build_array(options["tags"])),
"exif": options.get("exif"), "allowed_formats": options.get("allowed_formats") and ",".join(build_array(options["allowed_formats"])),
"colors": options.get("colors"), "face_coordinates": encode_double_array(options.get("face_coordinates")),
"headers": build_custom_headers(options.get("headers")), "custom_coordinates": encode_double_array(options.get("custom_coordinates")),
"eager": build_eager(options.get("eager")), "context": encode_context(options.get("context")),
"use_filename": options.get("use_filename"), "auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")),
"unique_filename": options.get("unique_filename"), "responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")),
"discard_original_filename": options.get("discard_original_filename"), "access_control": options.get("access_control") and json_encode(
"invalidate": options.get("invalidate"), build_list_of_dicts(options.get("access_control")))
"notification_url": options.get("notification_url"), }
"eager_notification_url": options.get("eager_notification_url"),
"eager_async": options.get("eager_async"), # make sure that we are in-sync with __SERIALIZED_UPLOAD_PARAMS which are in use by other methods
"proxy": options.get("proxy"), serialized_params = {param_name: serialized_params[param_name] for param_name in __SERIALIZED_UPLOAD_PARAMS}
"folder": options.get("folder"),
"overwrite": options.get("overwrite"), params.update(serialized_params)
"tags": options.get("tags") and ",".join(build_array(options["tags"])),
"allowed_formats": options.get("allowed_formats") and ",".join(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")),
"moderation": options.get("moderation"),
"raw_convert": options.get("raw_convert"),
"quality_override": options.get("quality_override"),
"ocr": options.get("ocr"),
"categorization": options.get("categorization"),
"detection": options.get("detection"),
"similarity_search": options.get("similarity_search"),
"background_removal": options.get("background_removal"),
"upload_preset": options.get("upload_preset"),
"phash": options.get("phash"),
"return_delete_token": options.get("return_delete_token"),
"auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")),
"responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")),
"async": options.get("async"),
"access_control": options.get("access_control") and json_encode(build_list_of_dicts(options.get("access_control")))}
return params return params
@ -716,6 +955,14 @@ def __process_text_options(layer, layer_parameter):
if line_spacing is not None: if line_spacing is not None:
keywords.append("line_spacing_" + str(line_spacing)) keywords.append("line_spacing_" + str(line_spacing))
font_antialiasing = layer.get("font_antialiasing")
if font_antialiasing is not None:
keywords.append("antialias_" + str(font_antialiasing))
font_hinting = layer.get("font_hinting")
if font_hinting is not None:
keywords.append("hinting_" + str(font_hinting))
if font_size is None and font_family is None and len(keywords) == 0: if font_size is None and font_family is None and len(keywords) == 0:
return None return None
@ -778,12 +1025,12 @@ def process_layer(layer, layer_parameter):
if text is not None: if text is not None:
var_pattern = VAR_NAME_RE var_pattern = VAR_NAME_RE
match = re.findall(var_pattern,text) match = re.findall(var_pattern, text)
parts= filter(lambda p: p is not None, re.split(var_pattern,text)) parts = filter(lambda p: p is not None, re.split(var_pattern, text))
encoded_text = [] encoded_text = []
for part in parts: for part in parts:
if re.match(var_pattern,part): if re.match(var_pattern, part):
encoded_text.append(part) encoded_text.append(part)
else: else:
encoded_text.append(smart_escape(smart_escape(part, r"([,/])"))) encoded_text.append(smart_escape(smart_escape(part, r"([,/])")))
@ -801,6 +1048,7 @@ def process_layer(layer, layer_parameter):
return ':'.join(components) return ':'.join(components)
IF_OPERATORS = { IF_OPERATORS = {
"=": 'eq', "=": 'eq',
"!=": 'ne', "!=": 'ne',
@ -813,7 +1061,8 @@ IF_OPERATORS = {
"*": 'mul', "*": 'mul',
"/": 'div', "/": 'div',
"+": 'add', "+": 'add',
"-": 'sub' "-": 'sub',
"^": 'pow'
} }
PREDEFINED_VARS = { PREDEFINED_VARS = {
@ -828,17 +1077,69 @@ PREDEFINED_VARS = {
"page_x": "px", "page_x": "px",
"page_y": "py", "page_y": "py",
"tags": "tags", "tags": "tags",
"width": "w" "width": "w",
"duration": "du",
"initial_duration": "idu",
} }
replaceRE = "((\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*)(?=[ _])|" + '|'.join(PREDEFINED_VARS.keys())+ ")" replaceRE = "((\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*|\^)(?=[ _])|(?<!\$)(" + '|'.join(PREDEFINED_VARS.keys()) + "))"
def translate_if(match): def translate_if(match):
name = match.group(0) name = match.group(0)
return IF_OPERATORS.get(name, return IF_OPERATORS.get(name,
PREDEFINED_VARS.get(name, PREDEFINED_VARS.get(name,
name)) name))
def process_custom_function(custom_function):
if not isinstance(custom_function, dict):
return custom_function
function_type = custom_function.get("function_type")
source = custom_function.get("source")
if function_type == "remote":
source = base64url_encode(source)
return ":".join([function_type, source])
def process_custom_pre_function(custom_function):
value = process_custom_function(custom_function)
return "pre:{0}".format(value) if value else None
def process_fps(fps):
"""
Serializes fps transformation parameter
:param fps: A single number, a list of mixed type, a string, including open-ended and closed range values
Examples: '24-29.97', 24, 24.973, '-24', [24, 29.97]
:return: string
"""
if not isinstance(fps, (list, tuple)):
return fps
return "-".join(normalize_expression(f) for f in fps)
def process_ki(ki):
"""
Serializes keyframe_interval parameter
:param ki: Keyframe interval. Should be either a string or a positive real number.
:return: string
"""
if ki is None:
return None
if isinstance(ki, string_types):
return ki
if not isinstance(ki, Number):
raise ValueError("Keyframe interval should be a number or a string")
if ki <= 0:
raise ValueError("Keyframe interval should be greater than zero")
return str(float(ki))
def process_conditional(conditional): def process_conditional(conditional):
if conditional is None: if conditional is None:
@ -846,8 +1147,9 @@ def process_conditional(conditional):
result = normalize_expression(conditional) result = normalize_expression(conditional)
return result return result
def normalize_expression(expression): def normalize_expression(expression):
if re.match(r'^!.+!$',str(expression)): # quoted string if re.match(r'^!.+!$', str(expression)): # quoted string
return expression return expression
elif expression: elif expression:
result = str(expression) result = str(expression)
@ -857,6 +1159,7 @@ def normalize_expression(expression):
else: else:
return expression return expression
def __join_pair(key, value): def __join_pair(key, value):
if value is None or value == "": if value is None or value == "":
return None return None
@ -898,15 +1201,134 @@ def base64_encode_url(url):
try: try:
url = unquote(url) url = unquote(url)
except: except Exception:
pass pass
url = smart_escape(url) url = smart_escape(url)
b64 = base64.b64encode(url.encode('utf-8')) b64 = base64.b64encode(url.encode('utf-8'))
return b64.decode('ascii') return b64.decode('ascii')
def base64url_encode(data):
"""
Url safe version of urlsafe_b64encode with stripped `=` sign at the end.
:param data: input data
:return: Base64 URL safe encoded string
"""
return to_string(base64.urlsafe_b64encode(to_bytes(data)))
def encode_unicode_url(url_str):
"""
Quote and encode possible unicode url string (applicable for python2)
:param url_str: Url string to encode
:return: Encoded string
"""
if six.PY2:
url_str = urllib.quote(url_str.encode('utf-8'), ":/?#[]@!$&'()*+,;=")
return url_str
def __json_serializer(obj): def __json_serializer(obj):
"""JSON serializer for objects not serializable by default json code""" """JSON serializer for objects not serializable by default json code"""
if isinstance(obj, (datetime, date)): if isinstance(obj, (datetime, date)):
return obj.isoformat() return obj.isoformat()
raise TypeError("Object of type %s is not JSON serializable" % type(obj)) raise TypeError("Object of type %s is not JSON serializable" % type(obj))
def is_remote_url(file):
"""Basic URL scheme check to define if it's remote URL"""
return isinstance(file, string_types) and re.match(REMOTE_URL_RE, file)
def file_io_size(file_io):
"""
Helper function for getting file-like object size(suitable for both files and streams)
:param file_io: io.IOBase
:return: size
"""
initial_position = file_io.tell()
file_io.seek(0, os.SEEK_END)
size = file_io.tell()
file_io.seek(initial_position, os.SEEK_SET)
return size
def check_property_enabled(f):
"""
Used as a class method decorator to check whether class is enabled(self.enabled is True)
:param f: function to call
:return: None if not enabled, otherwise calls function f
"""
def wrapper(*args, **kwargs):
if not args[0].enabled:
return None
return f(*args, **kwargs)
return wrapper
def verify_api_response_signature(public_id, version, signature):
"""
Verifies the authenticity of an API response signature
:param public_id: The public id of the asset as returned in the API response
:param version: The version of the asset as returned in the API response
:param signature: Actual signature. Can be retrieved from the X-Cld-Signature header
:return: Boolean result of the validation
"""
if not cloudinary.config().api_secret:
raise Exception('Api secret key is empty')
parameters_to_sign = {'public_id': public_id,
'version': version}
return signature == api_sign_request(parameters_to_sign, cloudinary.config().api_secret)
def verify_notification_signature(body, timestamp, signature, valid_for=7200):
"""
Verifies the authenticity of a notification signature
:param body: Json of the request's body
:param timestamp: Unix timestamp. Can be retrieved from the X-Cld-Timestamp header
:param signature: Actual signature. Can be retrieved from the X-Cld-Signature header
:param valid_for: The desired time in seconds for considering the request valid
:return: Boolean result of the validation
"""
if not cloudinary.config().api_secret:
raise Exception('Api secret key is empty')
if timestamp < time.time() - valid_for:
return False
if not isinstance(body, str):
raise ValueError('Body should be type of string')
return signature == compute_hex_hash('{}{}{}'.format(body, timestamp, cloudinary.config().api_secret))
def get_http_connector(conf, options):
"""
Used to create http connector, depends on api_proxy configuration parameter
:param conf: configuration object
:param options: additional options
:return: ProxyManager if api_proxy is set, otherwise PoolManager object
"""
if conf.api_proxy:
return ProxyManager(conf.api_proxy, **options)
else:
return PoolManager(**options)