cloudinary 1.42.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. cloudinary/__init__.py +841 -0
  2. cloudinary/api.py +1031 -0
  3. cloudinary/api_client/__init__.py +0 -0
  4. cloudinary/api_client/call_account_api.py +34 -0
  5. cloudinary/api_client/call_api.py +77 -0
  6. cloudinary/api_client/execute_request.py +85 -0
  7. cloudinary/api_client/tcp_keep_alive_manager.py +119 -0
  8. cloudinary/auth_token.py +76 -0
  9. cloudinary/cache/__init__.py +0 -0
  10. cloudinary/cache/adapter/__init__.py +0 -0
  11. cloudinary/cache/adapter/cache_adapter.py +63 -0
  12. cloudinary/cache/adapter/key_value_cache_adapter.py +61 -0
  13. cloudinary/cache/responsive_breakpoints_cache.py +124 -0
  14. cloudinary/cache/storage/__init__.py +0 -0
  15. cloudinary/cache/storage/file_system_key_value_storage.py +79 -0
  16. cloudinary/cache/storage/key_value_storage.py +51 -0
  17. cloudinary/compat.py +35 -0
  18. cloudinary/exceptions.py +33 -0
  19. cloudinary/forms.py +142 -0
  20. cloudinary/http_client.py +43 -0
  21. cloudinary/models.py +135 -0
  22. cloudinary/poster/__init__.py +31 -0
  23. cloudinary/poster/encode.py +456 -0
  24. cloudinary/poster/streaminghttp.py +209 -0
  25. cloudinary/provisioning/__init__.py +6 -0
  26. cloudinary/provisioning/account.py +481 -0
  27. cloudinary/provisioning/account_config.py +42 -0
  28. cloudinary/search.py +143 -0
  29. cloudinary/search_folders.py +10 -0
  30. cloudinary/static/cloudinary/html/cloudinary_cors.html +43 -0
  31. cloudinary/static/cloudinary/js/canvas-to-blob.min.js +1 -0
  32. cloudinary/static/cloudinary/js/jquery.cloudinary.js +4780 -0
  33. cloudinary/static/cloudinary/js/jquery.fileupload-image.js +326 -0
  34. cloudinary/static/cloudinary/js/jquery.fileupload-process.js +178 -0
  35. cloudinary/static/cloudinary/js/jquery.fileupload-validate.js +125 -0
  36. cloudinary/static/cloudinary/js/jquery.fileupload.js +1502 -0
  37. cloudinary/static/cloudinary/js/jquery.iframe-transport.js +224 -0
  38. cloudinary/static/cloudinary/js/jquery.ui.widget.js +752 -0
  39. cloudinary/static/cloudinary/js/load-image.all.min.js +1 -0
  40. cloudinary/templates/cloudinary_direct_upload.html +12 -0
  41. cloudinary/templates/cloudinary_includes.html +14 -0
  42. cloudinary/templates/cloudinary_js_config.html +3 -0
  43. cloudinary/templatetags/__init__.py +1 -0
  44. cloudinary/templatetags/cloudinary.py +86 -0
  45. cloudinary/uploader.py +639 -0
  46. cloudinary/utils.py +1695 -0
  47. cloudinary-1.42.1.dist-info/LICENSE.txt +5 -0
  48. cloudinary-1.42.1.dist-info/METADATA +173 -0
  49. cloudinary-1.42.1.dist-info/RECORD +51 -0
  50. cloudinary-1.42.1.dist-info/WHEEL +5 -0
  51. cloudinary-1.42.1.dist-info/top_level.txt +1 -0
cloudinary/__init__.py ADDED
@@ -0,0 +1,841 @@
1
+ from __future__ import absolute_import
2
+
3
+ import abc
4
+ import logging
5
+ import numbers
6
+ import os
7
+ import re
8
+ from copy import deepcopy
9
+ from math import ceil
10
+
11
+ import certifi
12
+ from six import python_2_unicode_compatible, add_metaclass
13
+
14
+ logger = logging.getLogger("Cloudinary")
15
+ ch = logging.StreamHandler()
16
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
17
+ ch.setFormatter(formatter)
18
+ logger.addHandler(ch)
19
+
20
+ from cloudinary import utils
21
+ from cloudinary.exceptions import GeneralError
22
+ from cloudinary.cache import responsive_breakpoints_cache
23
+ from cloudinary.http_client import HttpClient
24
+ from cloudinary.compat import urlparse, parse_qs
25
+
26
+ from platform import python_version, platform
27
+
28
+ CERT_KWARGS = {
29
+ 'cert_reqs': 'CERT_REQUIRED',
30
+ 'ca_certs': certifi.where(),
31
+ }
32
+
33
+ CF_SHARED_CDN = "d3jpl91pxevbkh.cloudfront.net"
34
+ OLD_AKAMAI_SHARED_CDN = "cloudinary-a.akamaihd.net"
35
+ AKAMAI_SHARED_CDN = "res.cloudinary.com"
36
+ SHARED_CDN = AKAMAI_SHARED_CDN
37
+ CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
38
+ URI_SCHEME = "cloudinary"
39
+ API_VERSION = "v1_1"
40
+
41
+ VERSION = "1.42.1"
42
+
43
+ _USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version())))
44
+
45
+ USER_AGENT = "CloudinaryPython/{} ({})".format(VERSION, _USER_PLATFORM_DETAILS)
46
+ """ :const: USER_AGENT """
47
+
48
+ USER_PLATFORM = ""
49
+ """
50
+ Additional information to be passed with the USER_AGENT, e.g. "CloudinaryCLI/1.2.3".
51
+ This value is set in platform-specific implementations that use pycloudinary.
52
+
53
+ The format of the value should be <ProductName>/Version[ (comment)].
54
+ @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.43
55
+
56
+ **Do not set this value in application code!**
57
+ """
58
+
59
+
60
+ def get_user_agent():
61
+ """
62
+ Provides the `USER_AGENT` string that is passed to the Cloudinary servers.
63
+ Prepends `USER_PLATFORM` if it is defined.
64
+
65
+ :returns: the user agent
66
+ :rtype: str
67
+ """
68
+
69
+ if USER_PLATFORM == "":
70
+ return USER_AGENT
71
+ else:
72
+ return USER_PLATFORM + " " + USER_AGENT
73
+
74
+
75
+ def import_django_settings():
76
+ try:
77
+ from django.core.exceptions import ImproperlyConfigured
78
+
79
+ try:
80
+ from django.conf import settings as _django_settings
81
+
82
+ # We can get a situation when Django module is installed in the system, but not initialized,
83
+ # which means we are running not in a Django process.
84
+ # In this case the following line throws ImproperlyConfigured exception
85
+ if 'cloudinary' in _django_settings.INSTALLED_APPS:
86
+ from django import get_version as _get_django_version
87
+ global USER_PLATFORM
88
+ USER_PLATFORM = "Django/{django_version}".format(django_version=_get_django_version())
89
+
90
+ if 'CLOUDINARY' in dir(_django_settings):
91
+ return _django_settings.CLOUDINARY
92
+ else:
93
+ return None
94
+
95
+ except ImproperlyConfigured:
96
+ return None
97
+
98
+ except ImportError:
99
+ return None
100
+
101
+
102
+ @add_metaclass(abc.ABCMeta)
103
+ class BaseConfig(object):
104
+ def __init__(self):
105
+ django_settings = import_django_settings()
106
+ if django_settings:
107
+ self.update(**django_settings)
108
+
109
+ self._load_config_from_env()
110
+
111
+ def __getattr__(self, i):
112
+ return self.__dict__.get(i)
113
+
114
+ @staticmethod
115
+ def _is_nested_key(key):
116
+ return re.match(r'\w+\[\w+\]', key)
117
+
118
+ def _put_nested_key(self, key, value):
119
+ chain = re.split(r'[\[\]]+', key)
120
+ chain = [k for k in chain if k]
121
+ outer = self.__dict__
122
+ last_key = chain.pop()
123
+ for inner_key in chain:
124
+ if inner_key in outer:
125
+ inner = outer[inner_key]
126
+ else:
127
+ inner = dict()
128
+ outer[inner_key] = inner
129
+ outer = inner
130
+ if isinstance(value, list):
131
+ value = value[0]
132
+ outer[last_key] = value
133
+
134
+ def _is_url_scheme_valid(self, url):
135
+ """
136
+ Helper function. Validates url scheme
137
+
138
+ :param url: A named tuple containing URL components
139
+
140
+ :return: bool True on success or False on failure
141
+ """
142
+ return url.scheme.lower() == self._uri_scheme
143
+
144
+ @staticmethod
145
+ def _parse_cloudinary_url(cloudinary_url):
146
+ return urlparse(cloudinary_url)
147
+
148
+ @abc.abstractmethod
149
+ def _config_from_parsed_url(self, parsed_url):
150
+ """Extract additional config from the parsed URL."""
151
+ raise NotImplementedError()
152
+
153
+ def _setup_from_parsed_url(self, parsed_url):
154
+ config_from_parsed_url = self._config_from_parsed_url(parsed_url)
155
+ self.update(**config_from_parsed_url)
156
+
157
+ for k, v in parse_qs(parsed_url.query).items():
158
+ if self._is_nested_key(k):
159
+ self._put_nested_key(k, v)
160
+ else:
161
+ self.__dict__[k] = v[0]
162
+
163
+ def _load_from_url(self, url):
164
+ parsed_url = self._parse_cloudinary_url(url)
165
+
166
+ return self._setup_from_parsed_url(parsed_url)
167
+
168
+ @abc.abstractmethod
169
+ def _load_config_from_env(self):
170
+ """Load config from environment variables or URL."""
171
+ raise NotImplementedError()
172
+
173
+ def update(self, **keywords):
174
+ for k, v in keywords.items():
175
+ self.__dict__[k] = v
176
+
177
+
178
+ class Config(BaseConfig):
179
+ def __init__(self):
180
+ self._uri_scheme = URI_SCHEME
181
+
182
+ super(Config, self).__init__()
183
+
184
+ if not self.signature_algorithm:
185
+ self.signature_algorithm = utils.SIGNATURE_SHA1
186
+
187
+ def _config_from_parsed_url(self, parsed_url):
188
+ if not self._is_url_scheme_valid(parsed_url):
189
+ raise ValueError("Invalid CLOUDINARY_URL scheme. Expecting to start with 'cloudinary://'")
190
+
191
+ is_private_cdn = parsed_url.path != ""
192
+ result = {
193
+ "cloud_name": parsed_url.hostname,
194
+ "api_key": parsed_url.username,
195
+ "api_secret": parsed_url.password,
196
+ "private_cdn": is_private_cdn,
197
+ }
198
+ if is_private_cdn:
199
+ result.update({"secure_distribution": parsed_url.path[1:]})
200
+
201
+ return result
202
+
203
+ def _load_config_from_env(self):
204
+ if os.environ.get("CLOUDINARY_CLOUD_NAME"):
205
+ config_keys = [key for key in os.environ.keys()
206
+ if key.startswith("CLOUDINARY_") and key != "CLOUDINARY_URL"]
207
+
208
+ for full_key in config_keys:
209
+ conf_key = full_key[len("CLOUDINARY_"):].lower()
210
+ conf_val = os.environ[full_key]
211
+ if conf_val in ["true", "false"]:
212
+ conf_val = conf_val == "true"
213
+
214
+ self.update(**{conf_key: conf_val})
215
+ elif os.environ.get("CLOUDINARY_URL"):
216
+ self._load_from_url(os.environ.get("CLOUDINARY_URL"))
217
+
218
+
219
+ _config = Config()
220
+
221
+
222
+ def config(**keywords):
223
+ global _config
224
+ _config.update(**keywords)
225
+ return _config
226
+
227
+
228
+ def reset_config():
229
+ global _config
230
+ _config = Config()
231
+
232
+
233
+ _http_client = HttpClient()
234
+
235
+ # FIXME: circular import issue
236
+ from cloudinary.search import Search
237
+ from cloudinary.search_folders import SearchFolders
238
+
239
+
240
+ @python_2_unicode_compatible
241
+ class CloudinaryResource(object):
242
+ """
243
+ Recommended sources for video tag
244
+ """
245
+ default_video_sources = [
246
+ {
247
+ "type": "mp4",
248
+ "codecs": "hev1",
249
+ "transformations": {"video_codec": "h265"}
250
+ }, {
251
+ "type": "webm",
252
+ "codecs": "vp9",
253
+ "transformations": {"video_codec": "vp9"}
254
+ }, {
255
+ "type": "mp4",
256
+ "transformations": {"video_codec": "auto"}
257
+ }, {
258
+ "type": "webm",
259
+ "transformations": {"video_codec": "auto"}
260
+ },
261
+ ]
262
+
263
+ def __init__(self, public_id=None, format=None, version=None,
264
+ signature=None, url_options=None, metadata=None, type=None, resource_type=None,
265
+ default_resource_type=None):
266
+ self.metadata = metadata
267
+ metadata = metadata or {}
268
+ self.public_id = public_id or metadata.get('public_id')
269
+ self.format = format or metadata.get('format')
270
+ self.version = version or metadata.get('version')
271
+ self.signature = signature or metadata.get('signature')
272
+ self.type = type or metadata.get('type') or "upload"
273
+ self.resource_type = resource_type or metadata.get('resource_type') or default_resource_type
274
+ self.url_options = url_options or {}
275
+
276
+ def __str__(self):
277
+ return self.public_id
278
+
279
+ def __len__(self):
280
+ return len(self.public_id) if self.public_id is not None else 0
281
+
282
+ def validate(self):
283
+ return self.signature == self.get_expected_signature()
284
+
285
+ def get_prep_value(self):
286
+ if None in [self.public_id,
287
+ self.type,
288
+ self.resource_type]:
289
+ return None
290
+ prep = ''
291
+ prep = prep + self.resource_type + '/' + self.type + '/'
292
+ if self.version:
293
+ prep = prep + 'v' + str(self.version) + '/'
294
+ prep = prep + self.public_id
295
+ if self.format:
296
+ prep = prep + '.' + self.format
297
+ return prep
298
+
299
+ def get_presigned(self):
300
+ return self.get_prep_value() + '#' + self.get_expected_signature()
301
+
302
+ def get_expected_signature(self):
303
+ return utils.api_sign_request({"public_id": self.public_id, "version": self.version}, config().api_secret,
304
+ config().signature_algorithm)
305
+
306
+ @property
307
+ def url(self):
308
+ return self.build_url(**self.url_options)
309
+
310
+ def __build_url(self, **options):
311
+ combined_options = dict(format=self.format, version=self.version, type=self.type,
312
+ resource_type=self.resource_type or "image")
313
+ combined_options.update(options)
314
+ public_id = combined_options.get('public_id') or self.public_id
315
+ return utils.cloudinary_url(public_id, **combined_options)
316
+
317
+ def build_url(self, **options):
318
+ return self.__build_url(**options)[0]
319
+
320
+ @staticmethod
321
+ def default_poster_options(options):
322
+ options["format"] = options.get("format", "jpg")
323
+
324
+ @staticmethod
325
+ def default_source_types():
326
+ return ['webm', 'mp4', 'ogv']
327
+
328
+ @staticmethod
329
+ def _validate_srcset_data(srcset_data):
330
+ """
331
+ Helper function. Validates srcset_data parameters
332
+
333
+ :param srcset_data: A dictionary containing the following keys:
334
+ breakpoints A list of breakpoints.
335
+ min_width Minimal width of the srcset images
336
+ max_width Maximal width of the srcset images.
337
+ max_images Number of srcset images to generate.
338
+
339
+ :return: bool True on success or False on failure
340
+ """
341
+ if not all(k in srcset_data and isinstance(srcset_data[k], numbers.Number) for k in ("min_width", "max_width",
342
+ "max_images")):
343
+ logger.warning("Either valid (min_width, max_width, max_images)" +
344
+ "or breakpoints must be provided to the image srcset attribute")
345
+ return False
346
+
347
+ if srcset_data["min_width"] > srcset_data["max_width"]:
348
+ logger.warning("min_width must be less than max_width")
349
+ return False
350
+
351
+ if srcset_data["max_images"] <= 0:
352
+ logger.warning("max_images must be a positive integer")
353
+ return False
354
+
355
+ return True
356
+
357
+ def _generate_breakpoints(self, srcset_data):
358
+ """
359
+ Helper function. Calculates static responsive breakpoints using provided parameters.
360
+
361
+ Either the breakpoints or min_width, max_width, max_images must be provided.
362
+
363
+ :param srcset_data: A dictionary containing the following keys:
364
+ breakpoints A list of breakpoints.
365
+ min_width Minimal width of the srcset images
366
+ max_width Maximal width of the srcset images.
367
+ max_images Number of srcset images to generate.
368
+
369
+ :return: A list of breakpoints
370
+
371
+ :raises ValueError: In case of invalid or missing parameters
372
+ """
373
+ breakpoints = srcset_data.get("breakpoints", list())
374
+
375
+ if breakpoints:
376
+ return breakpoints
377
+
378
+ if not self._validate_srcset_data(srcset_data):
379
+ return None
380
+
381
+ min_width, max_width, max_images = srcset_data["min_width"], srcset_data["max_width"], srcset_data["max_images"]
382
+
383
+ if max_images == 1:
384
+ # if user requested only 1 image in srcset, we return max_width one
385
+ min_width = max_width
386
+
387
+ step_size = int(ceil(float(max_width - min_width) / (max_images - 1 if max_images > 1 else 1)))
388
+
389
+ curr_breakpoint = min_width
390
+
391
+ while curr_breakpoint < max_width:
392
+ breakpoints.append(curr_breakpoint)
393
+ curr_breakpoint += step_size
394
+
395
+ breakpoints.append(max_width)
396
+
397
+ return breakpoints
398
+
399
+ def _fetch_breakpoints(self, srcset_data=None, **options):
400
+ """
401
+ Helper function. Retrieves responsive breakpoints list from cloudinary server
402
+
403
+ When passing special string to transformation `width` parameter of form `auto:breakpoints{parameters}:json`,
404
+ the response contains JSON with data of the responsive breakpoints
405
+
406
+ :param srcset_data: A dictionary containing the following keys:
407
+ min_width Minimal width of the srcset images
408
+ max_width Maximal width of the srcset images
409
+ bytes_step Minimal bytes step between images
410
+ max_images Number of srcset images to generate
411
+ :param options: Additional options
412
+
413
+ :return: Resulting breakpoints
414
+ """
415
+ if srcset_data is None:
416
+ srcset_data = dict()
417
+
418
+ min_width = srcset_data.get("min_width", 50)
419
+ max_width = srcset_data.get("max_width", 1000)
420
+ bytes_step = srcset_data.get("bytes_step", 20000)
421
+ max_images = srcset_data.get("max_images", 20)
422
+ transformation = srcset_data.get("transformation")
423
+
424
+ kbytes_step = int(ceil(float(bytes_step) / 1024))
425
+
426
+ breakpoints_width_param = "auto:breakpoints_{min_width}_{max_width}_{kbytes_step}_{max_images}:json".format(
427
+ min_width=min_width, max_width=max_width, kbytes_step=kbytes_step, max_images=max_images)
428
+ breakpoints_url = utils.cloudinary_scaled_url(self.public_id, breakpoints_width_param, transformation, options)
429
+
430
+ return _http_client.get_json(breakpoints_url).get("breakpoints", None)
431
+
432
+ def _get_or_generate_breakpoints(self, srcset_data, **options):
433
+ """
434
+ Helper function. Gets from cache or calculates srcset breakpoints using provided parameters
435
+
436
+ :param srcset_data: A dictionary containing the following keys:
437
+ breakpoints A list of breakpoints.
438
+ min_width Minimal width of the srcset images
439
+ max_width Maximal width of the srcset images
440
+ max_images Number of srcset images to generate
441
+ :param options: Additional options
442
+
443
+ :return: Resulting breakpoints
444
+ """
445
+
446
+ breakpoints = srcset_data.get("breakpoints")
447
+
448
+ if breakpoints:
449
+ return breakpoints
450
+
451
+ if srcset_data.get("use_cache"):
452
+ breakpoints = responsive_breakpoints_cache.instance.get(self.public_id, **options)
453
+ if not breakpoints:
454
+ try:
455
+ breakpoints = self._fetch_breakpoints(srcset_data, **options)
456
+ except GeneralError as e:
457
+ logger.warning("Failed getting responsive breakpoints: {error}".format(error=e.message))
458
+
459
+ if breakpoints:
460
+ responsive_breakpoints_cache.instance.set(self.public_id, breakpoints, **options)
461
+
462
+ if not breakpoints:
463
+ # Static calculation if cache is not enabled or we failed to fetch breakpoints
464
+ breakpoints = self._generate_breakpoints(srcset_data)
465
+
466
+ return breakpoints
467
+
468
+ def _generate_srcset_attribute(self, breakpoints, transformation=None, **options):
469
+ """
470
+ Helper function. Generates srcset attribute value of the HTML img tag.
471
+
472
+ :param breakpoints: A list of breakpoints.
473
+ :param transformation: Custom transformation
474
+ :param options: Additional options
475
+
476
+ :return: Resulting srcset attribute value
477
+
478
+ :raises ValueError: In case of invalid or missing parameters
479
+ """
480
+ if not breakpoints:
481
+ return None
482
+
483
+ if transformation is None:
484
+ transformation = dict()
485
+
486
+ return ", ".join(["{0} {1}w".format(utils.cloudinary_scaled_url(
487
+ self.public_id, w, transformation, options), w) for w in breakpoints])
488
+
489
+ @staticmethod
490
+ def _generate_sizes_attribute(breakpoints):
491
+ """
492
+ Helper function. Generates sizes attribute value of the HTML img tag.
493
+
494
+ :param breakpoints: A list of breakpoints.
495
+
496
+ :return: Resulting 'sizes' attribute value
497
+ """
498
+ if not breakpoints:
499
+ return None
500
+
501
+ return ", ".join("(max-width: {bp}px) {bp}px".format(bp=bp) for bp in breakpoints)
502
+
503
+ def _generate_image_responsive_attributes(self, attributes, srcset_data, **options):
504
+ """
505
+ Helper function. Generates srcset and sizes attributes of the image tag
506
+
507
+ Create both srcset and sizes here to avoid fetching breakpoints twice
508
+
509
+ :param attributes: Existing attributes
510
+ :param srcset_data: A dictionary containing the following keys:
511
+ breakpoints A list of breakpoints.
512
+ min_width Minimal width of the srcset images
513
+ max_width Maximal width of the srcset images.
514
+ max_images Number of srcset images to generate.
515
+ :param options: Additional options
516
+
517
+ :return: The responsive attributes
518
+ """
519
+ responsive_attributes = dict()
520
+
521
+ if not srcset_data:
522
+ return responsive_attributes
523
+
524
+ breakpoints = None
525
+
526
+ if "srcset" not in attributes:
527
+ breakpoints = self._get_or_generate_breakpoints(srcset_data, **options)
528
+ transformation = srcset_data.get("transformation")
529
+ srcset_attr = self._generate_srcset_attribute(breakpoints, transformation, **options)
530
+ if srcset_attr:
531
+ responsive_attributes["srcset"] = srcset_attr
532
+
533
+ if "sizes" not in attributes and srcset_data.get("sizes") is True:
534
+ if not breakpoints:
535
+ breakpoints = self._get_or_generate_breakpoints(srcset_data, **options)
536
+ sizes_attr = self._generate_sizes_attribute(breakpoints)
537
+ if sizes_attr:
538
+ responsive_attributes["sizes"] = sizes_attr
539
+
540
+ return responsive_attributes
541
+
542
+ def image(self, **options):
543
+ """
544
+ Generates HTML img tag
545
+
546
+ :param options: Additional options
547
+
548
+ :return: Resulting img tag
549
+ """
550
+ if options.get("resource_type", self.resource_type) == "video":
551
+ self.default_poster_options(options)
552
+
553
+ custom_attributes = options.pop("attributes", dict())
554
+
555
+ srcset_option = options.pop("srcset", dict())
556
+ srcset_data = dict()
557
+
558
+ if isinstance(srcset_option, dict):
559
+ srcset_data = config().srcset or dict()
560
+ srcset_data = srcset_data.copy()
561
+ srcset_data.update(srcset_option)
562
+ else:
563
+ if "srcset" not in custom_attributes:
564
+ custom_attributes["srcset"] = srcset_option
565
+
566
+ src, attrs = self.__build_url(**options)
567
+
568
+ client_hints = attrs.pop("client_hints", config().client_hints)
569
+ responsive = attrs.pop("responsive", False)
570
+ hidpi = attrs.pop("hidpi", False)
571
+
572
+ if (responsive or hidpi) and not client_hints:
573
+ attrs["data-src"] = src
574
+
575
+ classes = "cld-responsive" if responsive else "cld-hidpi"
576
+ if "class" in attrs:
577
+ classes += " " + attrs["class"]
578
+ attrs["class"] = classes
579
+
580
+ src = attrs.pop("responsive_placeholder", config().responsive_placeholder)
581
+ if src == "blank":
582
+ src = CL_BLANK
583
+
584
+ responsive_attrs = self._generate_image_responsive_attributes(custom_attributes, srcset_data, **options)
585
+
586
+ if responsive_attrs:
587
+ # width and height attributes override srcset behavior, they should be removed from html attributes.
588
+ for key in {"width", "height"}:
589
+ attrs.pop(key, None)
590
+
591
+ attrs.update(responsive_attrs)
592
+ # Explicitly provided attributes override options
593
+ attrs.update(custom_attributes)
594
+
595
+ if src:
596
+ attrs["src"] = src
597
+
598
+ return u"<img {0}/>".format(utils.html_attrs(attrs))
599
+
600
+ def video_thumbnail(self, **options):
601
+ self.default_poster_options(options)
602
+ return self.build_url(**options)
603
+
604
+ @staticmethod
605
+ def _video_mime_type(video_type, codecs=None):
606
+ """
607
+ Helper function for video(), generates video MIME type string from video_type and codecs.
608
+ Example: video/mp4; codecs=mp4a.40.2
609
+
610
+ :param video_type: mp4, webm, ogg etc.
611
+ :param codecs: List or string of codecs. E.g.: "avc1.42E01E" or "avc1.42E01E, mp4a.40.2" or
612
+ ["avc1.42E01E", "mp4a.40.2"]
613
+
614
+ :return: Resulting mime type
615
+ """
616
+
617
+ video_type = 'ogg' if video_type == 'ogv' else video_type
618
+
619
+ if not video_type:
620
+ return ""
621
+
622
+ codecs_str = ", ".join(codecs) if isinstance(codecs, (list, tuple)) else codecs
623
+ codecs_attr = "; codecs={codecs_str}".format(codecs_str=codecs_str) if codecs_str else ""
624
+
625
+ return "video/{}{}".format(video_type, codecs_attr)
626
+
627
+ @staticmethod
628
+ def _collect_video_tag_attributes(video_options):
629
+ """
630
+ Helper function for video tag, collects remaining options and returns them as attributes
631
+
632
+ :param video_options: Remaining options
633
+
634
+ :return: Resulting attributes
635
+ """
636
+ attributes = video_options.copy()
637
+
638
+ if 'html_width' in attributes:
639
+ attributes['width'] = attributes.pop('html_width')
640
+ if 'html_height' in attributes:
641
+ attributes['height'] = attributes.pop('html_height')
642
+
643
+ if "poster" in attributes and not attributes["poster"]:
644
+ attributes.pop("poster", None)
645
+
646
+ return attributes
647
+
648
+ def _generate_video_poster_attr(self, source, video_options):
649
+ """
650
+ Helper function for video tag, generates video poster URL
651
+
652
+ :param source: The public ID of the resource
653
+ :param video_options: Additional options
654
+
655
+ :return: Resulting video poster URL
656
+ """
657
+ if 'poster' not in video_options:
658
+ return self.video_thumbnail(public_id=source, **video_options)
659
+
660
+ poster_options = video_options['poster']
661
+
662
+ if not isinstance(poster_options, dict):
663
+ return poster_options
664
+
665
+ if 'public_id' not in poster_options:
666
+ return self.video_thumbnail(public_id=source, **poster_options)
667
+
668
+ return utils.cloudinary_url(poster_options['public_id'], **poster_options)[0]
669
+
670
+ def _populate_video_source_tags(self, source, options):
671
+ """
672
+ Helper function for video tag, populates source tags from provided options.
673
+
674
+ source_types and sources are mutually exclusive, only one of them can be used.
675
+ If both are not provided, source types are used (for backwards compatibility)
676
+
677
+ :param source: The public ID of the video
678
+ :param options: Additional options
679
+
680
+ :return: Resulting source tags (may be empty)
681
+ """
682
+ source_tags = []
683
+
684
+ # Consume all relevant options, otherwise they are left and passed as attributes
685
+ video_sources = options.pop('sources', [])
686
+ source_types = options.pop('source_types', [])
687
+ source_transformation = options.pop('source_transformation', {})
688
+
689
+ if video_sources and isinstance(video_sources, list):
690
+ # processing new source structure with codecs
691
+ for source_data in video_sources:
692
+ transformation = options.copy()
693
+ transformation.update(source_data.get("transformations", {}))
694
+ source_type = source_data.get("type", '')
695
+ src = utils.cloudinary_url(source, format=source_type, **transformation)[0]
696
+ codecs = source_data.get("codecs", [])
697
+ source_tags.append("<source {attributes}>".format(
698
+ attributes=utils.html_attrs({'src': src, 'type': self._video_mime_type(source_type, codecs)})))
699
+
700
+ return source_tags
701
+
702
+ # processing old source_types structure with out codecs
703
+ if not source_types:
704
+ source_types = self.default_source_types()
705
+
706
+ if not isinstance(source_types, (list, tuple)):
707
+ return source_tags
708
+
709
+ for source_type in source_types:
710
+ transformation = options.copy()
711
+ transformation.update(source_transformation.get(source_type, {}))
712
+ src = utils.cloudinary_url(source, format=source_type, **transformation)[0]
713
+ source_tags.append("<source {attributes}>".format(
714
+ attributes=utils.html_attrs({'src': src, 'type': self._video_mime_type(source_type)})))
715
+
716
+ return source_tags
717
+
718
+ def video(self, **options):
719
+ """
720
+ Creates an HTML video tag for the provided +source+
721
+
722
+ Examples:
723
+ CloudinaryResource("mymovie.mp4").video()
724
+ CloudinaryResource("mymovie.mp4").video(source_types = 'webm')
725
+ CloudinaryResource("mymovie.ogv").video(poster = "myspecialplaceholder.jpg")
726
+ CloudinaryResource("mymovie.webm").video(source_types = ['webm', 'mp4'], poster = {'effect': 'sepia'})
727
+
728
+ :param options:
729
+ * <tt>source_types</tt> - Specify which source type the tag should include.
730
+ defaults to webm, mp4 and ogv.
731
+ * <tt>sources</tt> - Similar to source_types, but may contain codecs list.
732
+ source_types and sources are mutually exclusive, only one of
733
+ them can be used. If both are not provided, default source types
734
+ are used.
735
+ * <tt>source_transformation</tt> - specific transformations to use
736
+ for a specific source type.
737
+ * <tt>poster</tt> - override default thumbnail:
738
+ * url: provide an ad hoc url
739
+ * options: with specific poster transformations and/or Cloudinary +:public_id+
740
+
741
+ :return: Video tag
742
+ """
743
+ public_id = options.get('public_id', self.public_id)
744
+ use_fetch_format = options.get('use_fetch_format', config().use_fetch_format)
745
+ if not use_fetch_format:
746
+ source = re.sub(r"\.({0})$".format("|".join(self.default_source_types())), '', public_id)
747
+ else:
748
+ source = public_id
749
+
750
+ custom_attributes = options.pop("attributes", dict())
751
+
752
+ fallback = options.pop('fallback_content', '')
753
+
754
+ # Save source types for a single video source handling (it can be a single type)
755
+ source_types = options.get('source_types', "")
756
+
757
+ poster_options = options.copy()
758
+ if "poster" not in custom_attributes:
759
+ options["poster"] = self._generate_video_poster_attr(source, poster_options)
760
+
761
+ if "resource_type" not in options:
762
+ options["resource_type"] = self.resource_type or "video"
763
+
764
+ # populate video source tags
765
+ source_tags = self._populate_video_source_tags(source, options)
766
+
767
+ if not source_tags:
768
+ source = source + '.' + utils.build_array(source_types)[0]
769
+
770
+ video_url, video_options = utils.cloudinary_url(source, **options)
771
+
772
+ if not source_tags:
773
+ custom_attributes['src'] = video_url
774
+
775
+ attributes = self._collect_video_tag_attributes(video_options)
776
+ attributes.update(custom_attributes)
777
+
778
+ sources_str = ''.join(str(x) for x in source_tags)
779
+ html = "<video {attributes}>{sources}{fallback}</video>".format(
780
+ attributes=utils.html_attrs(attributes), sources=sources_str, fallback=fallback)
781
+
782
+ return html
783
+
784
+ @staticmethod
785
+ def __generate_media_attr(**media_options):
786
+ media_query_conditions = []
787
+ if "min_width" in media_options:
788
+ media_query_conditions.append("(min-width: {}px)".format(media_options["min_width"]))
789
+ if "max_width" in media_options:
790
+ media_query_conditions.append("(max-width: {}px)".format(media_options["max_width"]))
791
+
792
+ return " and ".join(media_query_conditions)
793
+
794
+ def source(self, **options):
795
+ attrs = options.get("attributes") or {}
796
+
797
+ srcset_data = config().srcset or dict()
798
+ srcset_data = srcset_data.copy()
799
+ srcset_data.update(options.pop("srcset", dict()))
800
+
801
+ responsive_attrs = self._generate_image_responsive_attributes(attrs, srcset_data, **options)
802
+
803
+ attrs.update(responsive_attrs)
804
+
805
+ # `source` tag under `picture` tag uses `srcset` attribute for both `srcset` and `src` urls
806
+ if "srcset" not in attrs:
807
+ attrs["srcset"], _ = self.__build_url(**options)
808
+
809
+ if "media" not in attrs:
810
+ media_attr = self.__generate_media_attr(**(options.get("media", {})))
811
+ if media_attr:
812
+ attrs["media"] = media_attr
813
+
814
+ return u"<source {0}>".format(utils.html_attrs(attrs))
815
+
816
+ def picture(self, **options):
817
+ sub_tags = []
818
+ sources = options.pop("sources") or list()
819
+ for source in sources:
820
+ curr_options = deepcopy(options)
821
+
822
+ if "transformation" in source:
823
+ curr_options = utils.chain_transformations(curr_options, source["transformation"])
824
+
825
+ curr_options["media"] = dict((k, source[k]) for k in ['min_width', 'max_width'] if k in source)
826
+
827
+ sub_tags.append(self.source(**curr_options))
828
+
829
+ sub_tags.append(self.image(**options))
830
+
831
+ return u"<picture>{}</picture>".format("".join(sub_tags))
832
+
833
+
834
+ class CloudinaryImage(CloudinaryResource):
835
+ def __init__(self, public_id=None, **kwargs):
836
+ super(CloudinaryImage, self).__init__(public_id=public_id, default_resource_type="image", **kwargs)
837
+
838
+
839
+ class CloudinaryVideo(CloudinaryResource):
840
+ def __init__(self, public_id=None, **kwargs):
841
+ super(CloudinaryVideo, self).__init__(public_id=public_id, default_resource_type="video", **kwargs)