cloudinary 1.44.2__tar.gz → 1.44.3__tar.gz

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 (76) hide show
  1. {cloudinary-1.44.2/cloudinary.egg-info → cloudinary-1.44.3}/PKG-INFO +7 -6
  2. {cloudinary-1.44.2 → cloudinary-1.44.3}/README.md +3 -3
  3. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/__init__.py +1 -1
  4. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/uploader.py +30 -2
  5. {cloudinary-1.44.2 → cloudinary-1.44.3/cloudinary.egg-info}/PKG-INFO +7 -6
  6. {cloudinary-1.44.2 → cloudinary-1.44.3}/pyproject.toml +4 -3
  7. {cloudinary-1.44.2 → cloudinary-1.44.3}/setup.py +4 -3
  8. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_metadata.py +8 -15
  9. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_uploader.py +202 -13
  10. {cloudinary-1.44.2 → cloudinary-1.44.3}/LICENSE.txt +0 -0
  11. {cloudinary-1.44.2 → cloudinary-1.44.3}/MANIFEST.in +0 -0
  12. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/api.py +0 -0
  13. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/api_client/__init__.py +0 -0
  14. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/api_client/call_account_api.py +0 -0
  15. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/api_client/call_api.py +0 -0
  16. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/api_client/execute_request.py +0 -0
  17. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/api_client/tcp_keep_alive_manager.py +0 -0
  18. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/auth_token.py +0 -0
  19. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/__init__.py +0 -0
  20. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/adapter/__init__.py +0 -0
  21. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/adapter/cache_adapter.py +0 -0
  22. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/adapter/key_value_cache_adapter.py +0 -0
  23. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/responsive_breakpoints_cache.py +0 -0
  24. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/storage/__init__.py +0 -0
  25. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/storage/file_system_key_value_storage.py +0 -0
  26. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/cache/storage/key_value_storage.py +0 -0
  27. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/compat.py +0 -0
  28. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/exceptions.py +0 -0
  29. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/forms.py +0 -0
  30. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/http_client.py +0 -0
  31. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/models.py +0 -0
  32. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/poster/__init__.py +0 -0
  33. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/poster/encode.py +0 -0
  34. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/poster/streaminghttp.py +0 -0
  35. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/provisioning/__init__.py +0 -0
  36. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/provisioning/account.py +0 -0
  37. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/provisioning/account_config.py +0 -0
  38. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/search.py +0 -0
  39. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/search_folders.py +0 -0
  40. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/html/cloudinary_cors.html +0 -0
  41. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/canvas-to-blob.min.js +0 -0
  42. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/jquery.cloudinary.js +0 -0
  43. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/jquery.fileupload-image.js +0 -0
  44. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/jquery.fileupload-process.js +0 -0
  45. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/jquery.fileupload-validate.js +0 -0
  46. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/jquery.fileupload.js +0 -0
  47. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/jquery.iframe-transport.js +0 -0
  48. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/jquery.ui.widget.js +0 -0
  49. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/static/cloudinary/js/load-image.all.min.js +0 -0
  50. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/templates/cloudinary_direct_upload.html +0 -0
  51. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/templates/cloudinary_includes.html +0 -0
  52. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/templates/cloudinary_js_config.html +0 -0
  53. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/templatetags/__init__.py +0 -0
  54. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/templatetags/cloudinary.py +0 -0
  55. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary/utils.py +0 -0
  56. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary.egg-info/SOURCES.txt +0 -0
  57. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary.egg-info/dependency_links.txt +0 -0
  58. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary.egg-info/not-zip-safe +0 -0
  59. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary.egg-info/requires.txt +0 -0
  60. {cloudinary-1.44.2 → cloudinary-1.44.3}/cloudinary.egg-info/top_level.txt +0 -0
  61. {cloudinary-1.44.2 → cloudinary-1.44.3}/setup.cfg +0 -0
  62. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_api.py +0 -0
  63. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_api_authorization.py +0 -0
  64. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_archive.py +0 -0
  65. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_auth_token.py +0 -0
  66. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_cloudinary_resource.py +0 -0
  67. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_config.py +0 -0
  68. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_expression_normalization.py +0 -0
  69. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_http_client.py +0 -0
  70. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_image.py +0 -0
  71. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_metadata_rules.py +0 -0
  72. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_provisioning_api.py +0 -0
  73. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_search.py +0 -0
  74. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_streaming_profiles.py +0 -0
  75. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_utils.py +0 -0
  76. {cloudinary-1.44.2 → cloudinary-1.44.3}/test/test_video.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary
3
- Version: 1.44.2
3
+ Version: 1.44.3
4
4
  Summary: Python and Django SDK for Cloudinary
5
5
  Author-email: Cloudinary <info@cloudinary.com>
6
6
  License: Released under the MIT license.
@@ -18,21 +18,22 @@ Classifier: Environment :: Web Environment
18
18
  Classifier: Framework :: Django
19
19
  Classifier: Framework :: Django :: 1.11
20
20
  Classifier: Framework :: Django :: 2.2
21
- Classifier: Framework :: Django :: 3.2
22
21
  Classifier: Framework :: Django :: 4.2
23
22
  Classifier: Framework :: Django :: 5.0
24
23
  Classifier: Framework :: Django :: 5.1
24
+ Classifier: Framework :: Django :: 5.2
25
+ Classifier: Framework :: Django :: 6.0
25
26
  Classifier: Intended Audience :: Developers
26
27
  Classifier: License :: OSI Approved :: MIT License
27
28
  Classifier: Programming Language :: Python
28
29
  Classifier: Programming Language :: Python :: 2
29
30
  Classifier: Programming Language :: Python :: 2.7
30
31
  Classifier: Programming Language :: Python :: 3
31
- Classifier: Programming Language :: Python :: 3.9
32
32
  Classifier: Programming Language :: Python :: 3.10
33
33
  Classifier: Programming Language :: Python :: 3.11
34
34
  Classifier: Programming Language :: Python :: 3.12
35
35
  Classifier: Programming Language :: Python :: 3.13
36
+ Classifier: Programming Language :: Python :: 3.14
36
37
  Classifier: Topic :: Internet :: WWW/HTTP
37
38
  Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
38
39
  Classifier: Topic :: Multimedia :: Graphics
@@ -98,9 +99,9 @@ For the complete documentation, see the [Python SDK Guide](https://cloudinary.co
98
99
  |-------------|------------|------------|
99
100
  | 1.x | ✔ | ✔ |
100
101
 
101
- | SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x |
102
- |-------------|-------------|------------|------------|------------|------------|
103
- | 1.x | ✔ | ✔ | ✔ | ✔ | ✔ |
102
+ | SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | Django 6.x |
103
+ |-------------|-------------|------------|------------|------------|------------|------------|
104
+ | 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ |
104
105
 
105
106
 
106
107
  ## Installation
@@ -44,9 +44,9 @@ For the complete documentation, see the [Python SDK Guide](https://cloudinary.co
44
44
  |-------------|------------|------------|
45
45
  | 1.x | ✔ | ✔ |
46
46
 
47
- | SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x |
48
- |-------------|-------------|------------|------------|------------|------------|
49
- | 1.x | ✔ | ✔ | ✔ | ✔ | ✔ |
47
+ | SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | Django 6.x |
48
+ |-------------|-------------|------------|------------|------------|------------|------------|
49
+ | 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ |
50
50
 
51
51
 
52
52
  ## Installation
@@ -38,7 +38,7 @@ CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAA
38
38
  URI_SCHEME = "cloudinary"
39
39
  API_VERSION = "v1_1"
40
40
 
41
- VERSION = "1.44.2"
41
+ VERSION = "1.44.3"
42
42
 
43
43
  _USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version())))
44
44
 
@@ -11,7 +11,7 @@ import cloudinary
11
11
  from cloudinary import utils
12
12
  from cloudinary.api_client.execute_request import EXCEPTION_CODES
13
13
  from cloudinary.cache.responsive_breakpoints_cache import instance as responsive_breakpoints_cache_instance
14
- from cloudinary.exceptions import Error
14
+ from cloudinary.exceptions import Error, AuthorizationRequired
15
15
  from cloudinary.utils import build_eager
16
16
 
17
17
  try:
@@ -262,6 +262,32 @@ def upload_resource(file, **options):
262
262
  )
263
263
 
264
264
 
265
+ def _upload_large_part_with_auth_retry(file, http_headers, options):
266
+ """
267
+ Uploads a single chunk, recovering once from an expired OAuth token via the
268
+ optional oauth_token_refresh_callback config hook. Retries the same chunk
269
+ (same http_headers, so same X-Unique-Upload-Id) to resume the upload.
270
+
271
+ :param file: The chunk to upload.
272
+ :param http_headers: Per-chunk headers, reused on retry.
273
+ :param options: Upload options (must not contain an oauth_token key).
274
+ :return: The result of the chunk upload API call.
275
+ :rtype: dict
276
+ """
277
+ # Pin the token so the value handed to the callback is the one actually sent.
278
+ token = cloudinary.config().oauth_token
279
+ pinned = dict(options, oauth_token=token) if token else options
280
+ try:
281
+ return upload_large_part(file, http_headers=http_headers, **pinned)
282
+ except AuthorizationRequired:
283
+ callback = cloudinary.config().oauth_token_refresh_callback
284
+ if not callback:
285
+ raise
286
+ callback(token)
287
+ # Retry with the original options so call_api re-reads the refreshed token from config.
288
+ return upload_large_part(file, http_headers=http_headers, **options)
289
+
290
+
265
291
  def upload_large(file, **options):
266
292
  """
267
293
  Uploads a large file (in chunks) to Cloudinary.
@@ -308,7 +334,9 @@ def upload_large(file, **options):
308
334
  "X-Unique-Upload-Id": upload_id
309
335
  }
310
336
 
311
- upload_result = upload_large_part((file_name, chunk), http_headers=http_headers, **options)
337
+ upload_result = _upload_large_part_with_auth_retry(
338
+ (file_name, chunk), http_headers, options
339
+ )
312
340
 
313
341
  options["public_id"] = upload_result.get("public_id")
314
342
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary
3
- Version: 1.44.2
3
+ Version: 1.44.3
4
4
  Summary: Python and Django SDK for Cloudinary
5
5
  Author-email: Cloudinary <info@cloudinary.com>
6
6
  License: Released under the MIT license.
@@ -18,21 +18,22 @@ Classifier: Environment :: Web Environment
18
18
  Classifier: Framework :: Django
19
19
  Classifier: Framework :: Django :: 1.11
20
20
  Classifier: Framework :: Django :: 2.2
21
- Classifier: Framework :: Django :: 3.2
22
21
  Classifier: Framework :: Django :: 4.2
23
22
  Classifier: Framework :: Django :: 5.0
24
23
  Classifier: Framework :: Django :: 5.1
24
+ Classifier: Framework :: Django :: 5.2
25
+ Classifier: Framework :: Django :: 6.0
25
26
  Classifier: Intended Audience :: Developers
26
27
  Classifier: License :: OSI Approved :: MIT License
27
28
  Classifier: Programming Language :: Python
28
29
  Classifier: Programming Language :: Python :: 2
29
30
  Classifier: Programming Language :: Python :: 2.7
30
31
  Classifier: Programming Language :: Python :: 3
31
- Classifier: Programming Language :: Python :: 3.9
32
32
  Classifier: Programming Language :: Python :: 3.10
33
33
  Classifier: Programming Language :: Python :: 3.11
34
34
  Classifier: Programming Language :: Python :: 3.12
35
35
  Classifier: Programming Language :: Python :: 3.13
36
+ Classifier: Programming Language :: Python :: 3.14
36
37
  Classifier: Topic :: Internet :: WWW/HTTP
37
38
  Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
38
39
  Classifier: Topic :: Multimedia :: Graphics
@@ -98,9 +99,9 @@ For the complete documentation, see the [Python SDK Guide](https://cloudinary.co
98
99
  |-------------|------------|------------|
99
100
  | 1.x | ✔ | ✔ |
100
101
 
101
- | SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x |
102
- |-------------|-------------|------------|------------|------------|------------|
103
- | 1.x | ✔ | ✔ | ✔ | ✔ | ✔ |
102
+ | SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | Django 6.x |
103
+ |-------------|-------------|------------|------------|------------|------------|------------|
104
+ | 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ |
104
105
 
105
106
 
106
107
  ## Installation
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "cloudinary"
3
3
  description = "Python and Django SDK for Cloudinary"
4
- version = "1.44.2"
4
+ version = "1.44.3"
5
5
 
6
6
  authors = [{ name = "Cloudinary", email = "info@cloudinary.com" }]
7
7
  license = { file = "LICENSE.txt" }
@@ -13,21 +13,22 @@ classifiers = [
13
13
  "Framework :: Django",
14
14
  "Framework :: Django :: 1.11",
15
15
  "Framework :: Django :: 2.2",
16
- "Framework :: Django :: 3.2",
17
16
  "Framework :: Django :: 4.2",
18
17
  "Framework :: Django :: 5.0",
19
18
  "Framework :: Django :: 5.1",
19
+ "Framework :: Django :: 5.2",
20
+ "Framework :: Django :: 6.0",
20
21
  "Intended Audience :: Developers",
21
22
  "License :: OSI Approved :: MIT License",
22
23
  "Programming Language :: Python",
23
24
  "Programming Language :: Python :: 2",
24
25
  "Programming Language :: Python :: 2.7",
25
26
  "Programming Language :: Python :: 3",
26
- "Programming Language :: Python :: 3.9",
27
27
  "Programming Language :: Python :: 3.10",
28
28
  "Programming Language :: Python :: 3.11",
29
29
  "Programming Language :: Python :: 3.12",
30
30
  "Programming Language :: Python :: 3.13",
31
+ "Programming Language :: Python :: 3.14",
31
32
  "Topic :: Internet :: WWW/HTTP",
32
33
  "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
33
34
  "Topic :: Multimedia :: Graphics",
@@ -7,7 +7,7 @@ if version_info[0] >= 3:
7
7
  else:
8
8
  # Following code is legacy (Python 2.7 compatibility) and will be removed in the future!
9
9
  # TODO: Remove in next major update (when dropping Python 2.7 compatibility)
10
- version = "1.44.2"
10
+ version = "1.44.3"
11
11
 
12
12
  with open('README.md') as file:
13
13
  long_description = file.read()
@@ -33,21 +33,22 @@ else:
33
33
  "Framework :: Django",
34
34
  "Framework :: Django :: 1.11",
35
35
  "Framework :: Django :: 2.2",
36
- "Framework :: Django :: 3.2",
37
36
  "Framework :: Django :: 4.2",
38
37
  "Framework :: Django :: 5.0",
39
38
  "Framework :: Django :: 5.1",
39
+ "Framework :: Django :: 5.2",
40
+ "Framework :: Django :: 6.0",
40
41
  "Intended Audience :: Developers",
41
42
  "License :: OSI Approved :: MIT License",
42
43
  "Programming Language :: Python",
43
44
  "Programming Language :: Python :: 2",
44
45
  "Programming Language :: Python :: 2.7",
45
46
  "Programming Language :: Python :: 3",
46
- "Programming Language :: Python :: 3.9",
47
47
  "Programming Language :: Python :: 3.10",
48
48
  "Programming Language :: Python :: 3.11",
49
49
  "Programming Language :: Python :: 3.12",
50
50
  "Programming Language :: Python :: 3.13",
51
+ "Programming Language :: Python :: 3.14",
51
52
  "Topic :: Internet :: WWW/HTTP",
52
53
  "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
53
54
  "Topic :: Multimedia :: Graphics",
@@ -10,7 +10,7 @@ from cloudinary import api
10
10
  from cloudinary.exceptions import BadRequest, NotFound
11
11
  from test.helper_test import (
12
12
  UNIQUE_TEST_ID, get_uri, get_params, get_method, api_response_mock, ignore_exception, get_json_body,
13
- URLLIB3_REQUEST, patch
13
+ URLLIB3_REQUEST, patch, CldTestCase
14
14
  )
15
15
 
16
16
  MOCK_RESPONSE = api_response_mock()
@@ -105,7 +105,7 @@ METADATA_FIELDS_TO_CREATE = [
105
105
  disable_warnings()
106
106
 
107
107
 
108
- class MetadataTest(unittest.TestCase):
108
+ class MetadataTest(CldTestCase):
109
109
  @classmethod
110
110
  def setUpClass(cls):
111
111
  cloudinary.reset_config()
@@ -151,9 +151,7 @@ class MetadataTest(unittest.TestCase):
151
151
  if metadata_field["type"] in ["enum", "set"]:
152
152
  self.assert_metadata_field_datasource(metadata_field["datasource"])
153
153
 
154
- values = values or {}
155
- for key, value in values.items():
156
- self.assertEqual(metadata_field[key], value)
154
+ self.assertObjectContainsSubset(metadata_field, values or {})
157
155
 
158
156
  def assert_metadata_field_datasource(self, datasource):
159
157
  """Asserts that a given object fits the generic structure of a metadata field datasource
@@ -308,17 +306,15 @@ class MetadataTest(unittest.TestCase):
308
306
  "external_id": EXTERNAL_ID_SET,
309
307
  "label": new_label,
310
308
  "type": "integer",
311
- "mandatory": True,
312
309
  "default_value": new_default_value,
313
- "restrictions": {"readonly_ui": True}
310
+ "restrictions": {"readonly_ui": True},
314
311
  })
315
312
 
316
313
  self.assert_metadata_field(result, "string", {
317
314
  "external_id": EXTERNAL_ID_GENERAL,
318
315
  "label": new_label,
319
316
  "default_value": new_default_value,
320
- "mandatory": True,
321
- "restrictions": {"readonly_ui": True}
317
+ "restrictions": {"readonly_ui": True},
322
318
  })
323
319
 
324
320
  @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret")
@@ -501,8 +497,7 @@ class MetadataTest(unittest.TestCase):
501
497
 
502
498
  self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order"))
503
499
  self.assertEqual(get_method(mocker), "PUT")
504
- self.assertEqual(get_json_body(mocker)['order_by'], "label")
505
- self.assertEqual(get_json_body(mocker)['direction'], "asc")
500
+ self.assertObjectContainsSubset(get_json_body(mocker), {"order_by": "label", "direction": "asc"})
506
501
 
507
502
  @patch(URLLIB3_REQUEST)
508
503
  @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret")
@@ -513,8 +508,7 @@ class MetadataTest(unittest.TestCase):
513
508
 
514
509
  self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order"))
515
510
  self.assertEqual(get_method(mocker), "PUT")
516
- self.assertEqual(get_json_body(mocker)['order_by'], "external_id")
517
- self.assertEqual(get_json_body(mocker)['direction'], "desc")
511
+ self.assertObjectContainsSubset(get_json_body(mocker), {"order_by": "external_id", "direction": "desc"})
518
512
 
519
513
  @patch(URLLIB3_REQUEST)
520
514
  @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret")
@@ -525,8 +519,7 @@ class MetadataTest(unittest.TestCase):
525
519
 
526
520
  self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order"))
527
521
  self.assertEqual(get_method(mocker), "PUT")
528
- self.assertEqual(get_json_body(mocker)['order_by'], "created_at")
529
- self.assertEqual(get_json_body(mocker)['direction'], "asc")
522
+ self.assertObjectContainsSubset(get_json_body(mocker), {"order_by": "created_at", "direction": "asc"})
530
523
 
531
524
 
532
525
  if __name__ == "__main__":
@@ -18,7 +18,7 @@ from test.cache.storage.dummy_cache_storage import DummyCacheStorage
18
18
  from test.helper_test import uploader_response_mock, SUFFIX, TEST_IMAGE, get_params, get_headers, TEST_ICON, TEST_DOC, \
19
19
  REMOTE_TEST_IMAGE, UTC, populate_large_file, TEST_UNICODE_IMAGE, get_uri, get_method, get_param, \
20
20
  cleanup_test_resources_by_tag, cleanup_test_transformation, cleanup_test_resources, EVAL_STR, ON_SUCCESS_STR, \
21
- URLLIB3_REQUEST, patch, retry_assertion, CldTestCase
21
+ URLLIB3_REQUEST, patch, retry_assertion, CldTestCase, http_response_mock
22
22
  from test.test_utils import TEST_ID, TEST_FOLDER
23
23
 
24
24
  MOCK_RESPONSE = uploader_response_mock()
@@ -196,11 +196,12 @@ class UploaderTest(CldTestCase):
196
196
 
197
197
  result = uploader.upload(TEST_UNICODE_IMAGE, tags=[UNIQUE_TAG], use_filename=True, unique_filename=False)
198
198
 
199
- self.assertEqual(result["width"], TEST_IMAGE_WIDTH)
200
- self.assertEqual(result["height"], TEST_IMAGE_HEIGHT)
201
-
202
- self.assertEqual(expected_name, result["public_id"])
203
- self.assertEqual(expected_name, result["original_filename"])
199
+ self.assertObjectContainsSubset(result, {
200
+ "width": TEST_IMAGE_WIDTH,
201
+ "height": TEST_IMAGE_HEIGHT,
202
+ "public_id": expected_name,
203
+ "original_filename": expected_name,
204
+ })
204
205
 
205
206
  @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret")
206
207
  def test_upload_file_io_without_filename(self):
@@ -211,9 +212,11 @@ class UploaderTest(CldTestCase):
211
212
 
212
213
  result = uploader.upload(temp_file, tags=[UNIQUE_TAG])
213
214
 
214
- self.assertEqual(result["width"], TEST_IMAGE_WIDTH)
215
- self.assertEqual(result["height"], TEST_IMAGE_HEIGHT)
216
- self.assertEqual('stream', result["original_filename"])
215
+ self.assertObjectContainsSubset(result, {
216
+ "width": TEST_IMAGE_WIDTH,
217
+ "height": TEST_IMAGE_HEIGHT,
218
+ "original_filename": "stream",
219
+ })
217
220
 
218
221
  @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret")
219
222
  def test_upload_custom_filename(self):
@@ -794,11 +797,13 @@ P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC\
794
797
  use_filename=True, unique_filename=False, filename=filename)
795
798
 
796
799
  self.assertCountEqual(resource2["tags"], ["upload_large_tag", UNIQUE_TAG])
797
- self.assertEqual(resource2["resource_type"], "image")
798
- self.assertEqual(resource2["original_filename"], filename)
800
+ self.assertObjectContainsSubset(resource2, {
801
+ "resource_type": "image",
802
+ "original_filename": filename,
803
+ "width": LARGE_FILE_WIDTH,
804
+ "height": LARGE_FILE_HEIGHT,
805
+ })
799
806
  self.assertEqual(resource2["original_filename"], resource2["public_id"])
800
- self.assertEqual(resource2["width"], LARGE_FILE_WIDTH)
801
- self.assertEqual(resource2["height"], LARGE_FILE_HEIGHT)
802
807
 
803
808
  resource3 = uploader.upload_large(temp_file_name, chunk_size=LARGE_FILE_SIZE,
804
809
  tags=["upload_large_tag", UNIQUE_TAG])
@@ -832,6 +837,190 @@ P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC\
832
837
  self.assertEqual(resource["width"], LARGE_FILE_WIDTH)
833
838
  self.assertEqual(resource["height"], LARGE_FILE_HEIGHT)
834
839
 
840
+ _OAUTH_CHUNK_SIZE = 4096
841
+ _OAUTH_FILE_SIZE = _OAUTH_CHUNK_SIZE * 3
842
+
843
+ @staticmethod
844
+ def _oauth_part_response(public_id="test_public_id"):
845
+ return {"public_id": public_id, "done": True}
846
+
847
+ def _run_upload_large_with_oauth(self, side_effect, chunk_size=None):
848
+ """Runs upload_large over a 3-chunk in-memory file with upload_large_part mocked"""
849
+ chunk_size = chunk_size or self._OAUTH_CHUNK_SIZE
850
+ with io.BytesIO() as temp_file:
851
+ populate_large_file(temp_file, self._OAUTH_FILE_SIZE)
852
+ with patch("cloudinary.uploader.upload_large_part") as part_mock:
853
+ part_mock.side_effect = side_effect
854
+ result = uploader.upload_large(temp_file, chunk_size=chunk_size,
855
+ tags=[UNIQUE_TAG], resource_type="image")
856
+ return result, part_mock
857
+
858
+ def test_upload_large_oauth_resume_on_mid_stream_401(self):
859
+ """Should refresh once and resume the same upload when a chunk gets a 401"""
860
+ cloudinary.config(oauth_token="expired-token")
861
+
862
+ rejected_tokens = []
863
+
864
+ def refresh(rejected):
865
+ rejected_tokens.append(rejected)
866
+ cloudinary.config(oauth_token="fresh-token")
867
+
868
+ cloudinary.config().oauth_token_refresh_callback = refresh
869
+
870
+ calls = {"n": 0}
871
+
872
+ def side_effect(file, http_headers=None, **options):
873
+ calls["n"] += 1
874
+ if calls["n"] == 2:
875
+ raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
876
+ return self._oauth_part_response()
877
+
878
+ result, part_mock = self._run_upload_large_with_oauth(side_effect)
879
+
880
+ self.assertEqual(rejected_tokens, ["expired-token"])
881
+ self.assertEqual(part_mock.call_count, 4)
882
+
883
+ # The retry must reuse the failed chunk's X-Unique-Upload-Id and Content-Range to resume.
884
+ upload_ids = [c[1]["http_headers"]["X-Unique-Upload-Id"] for c in part_mock.call_args_list]
885
+ self.assertEqual(len(set(upload_ids)), 1)
886
+ ranges = [c[1]["http_headers"]["Content-Range"] for c in part_mock.call_args_list]
887
+ self.assertEqual(ranges[1], ranges[2])
888
+
889
+ self.assertEqual(result, self._oauth_part_response())
890
+
891
+ def test_upload_large_oauth_resume_on_first_chunk_401(self):
892
+ """Should refresh and resume when the first chunk (no public_id yet) gets a 401"""
893
+ cloudinary.config(oauth_token="expired-token")
894
+
895
+ refresh_calls = {"n": 0}
896
+
897
+ def refresh(rejected):
898
+ refresh_calls["n"] += 1
899
+ cloudinary.config(oauth_token="fresh-token")
900
+
901
+ cloudinary.config().oauth_token_refresh_callback = refresh
902
+
903
+ calls = {"n": 0}
904
+
905
+ def side_effect(file, http_headers=None, **options):
906
+ calls["n"] += 1
907
+ if calls["n"] == 1:
908
+ self.assertIsNone(options.get("public_id"))
909
+ raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
910
+ return self._oauth_part_response()
911
+
912
+ result, part_mock = self._run_upload_large_with_oauth(side_effect)
913
+
914
+ self.assertEqual(refresh_calls["n"], 1)
915
+ self.assertEqual(part_mock.call_count, 4)
916
+ upload_ids = [c[1]["http_headers"]["X-Unique-Upload-Id"] for c in part_mock.call_args_list]
917
+ self.assertEqual(len(set(upload_ids)), 1)
918
+ self.assertEqual(result, self._oauth_part_response())
919
+
920
+ def test_upload_large_oauth_single_retry_then_propagate(self):
921
+ """Should retry once then propagate when the token stays rejected"""
922
+ cloudinary.config(oauth_token="expired-token")
923
+
924
+ refresh_calls = {"n": 0}
925
+
926
+ def refresh(rejected):
927
+ refresh_calls["n"] += 1
928
+ cloudinary.config(oauth_token="still-bad-token")
929
+
930
+ cloudinary.config().oauth_token_refresh_callback = refresh
931
+
932
+ def side_effect(file, http_headers=None, **options):
933
+ raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
934
+
935
+ with self.assertRaises(exceptions.AuthorizationRequired):
936
+ self._run_upload_large_with_oauth(side_effect)
937
+
938
+ self.assertEqual(refresh_calls["n"], 1)
939
+
940
+ def test_upload_large_oauth_no_hook_propagates(self):
941
+ """Should propagate the first 401 with no retry when no callback is registered"""
942
+ cloudinary.config(oauth_token="expired-token")
943
+
944
+ calls = {"n": 0}
945
+
946
+ def side_effect(file, http_headers=None, **options):
947
+ calls["n"] += 1
948
+ raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401")
949
+
950
+ with self.assertRaises(exceptions.AuthorizationRequired):
951
+ self._run_upload_large_with_oauth(side_effect)
952
+
953
+ self.assertEqual(calls["n"], 1)
954
+
955
+ def test_upload_large_oauth_non_auth_error_not_retried(self):
956
+ """Should not retry non-auth errors even when a callback is registered"""
957
+ cloudinary.config(oauth_token="some-token")
958
+
959
+ refresh_calls = {"n": 0}
960
+
961
+ def refresh(rejected):
962
+ refresh_calls["n"] += 1
963
+
964
+ cloudinary.config().oauth_token_refresh_callback = refresh
965
+
966
+ for error in (exceptions.BadRequest("bad request"),
967
+ exceptions.Error("Socket error: some transient failure")):
968
+ refresh_calls["n"] = 0
969
+ calls = {"n": 0}
970
+
971
+ def side_effect(file, http_headers=None, _err=error, **options):
972
+ calls["n"] += 1
973
+ raise _err
974
+
975
+ with self.assertRaises(type(error)):
976
+ self._run_upload_large_with_oauth(side_effect)
977
+
978
+ self.assertEqual(calls["n"], 1)
979
+ self.assertEqual(refresh_calls["n"], 0)
980
+
981
+ @patch(URLLIB3_REQUEST)
982
+ def test_upload_large_oauth_rejected_token_is_the_one_sent(self, request_mock):
983
+ """Should pass the token actually sent (pinned) to the callback when it rotates per read"""
984
+ token_counter = {"n": 0}
985
+
986
+ class RotatingConfig(cloudinary.Config):
987
+ @property
988
+ def oauth_token(self):
989
+ token_counter["n"] += 1
990
+ return "token-{}".format(token_counter["n"])
991
+
992
+ @oauth_token.setter
993
+ def oauth_token(self, value):
994
+ pass
995
+
996
+ rotating = RotatingConfig()
997
+ rejected_seen = {}
998
+
999
+ def refresh(rejected):
1000
+ rejected_seen["token"] = rejected
1001
+ request_mock.return_value = uploader_response_mock()
1002
+
1003
+ rotating.oauth_token_refresh_callback = refresh
1004
+
1005
+ responses = {"first": True}
1006
+
1007
+ def request_side_effect(*args, **kwargs):
1008
+ if responses["first"]:
1009
+ responses["first"] = False
1010
+ rejected_seen["sent"] = kwargs["headers"].get("authorization", "").replace("Bearer ", "")
1011
+ return http_response_mock('{"error":{"message":"401"}}', status=401)
1012
+ return uploader_response_mock()
1013
+
1014
+ request_mock.side_effect = request_side_effect
1015
+
1016
+ with patch("cloudinary.config", return_value=rotating):
1017
+ with io.BytesIO() as temp_file:
1018
+ populate_large_file(temp_file, self._OAUTH_CHUNK_SIZE)
1019
+ uploader.upload_large(temp_file, chunk_size=self._OAUTH_CHUNK_SIZE,
1020
+ tags=[UNIQUE_TAG], resource_type="image")
1021
+
1022
+ self.assertEqual(rejected_seen["token"], rejected_seen["sent"])
1023
+
835
1024
  @patch(URLLIB3_REQUEST)
836
1025
  @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret")
837
1026
  def test_upload_preset(self, mocker):
File without changes
File without changes
File without changes