cloudinary 1.46.2__tar.gz → 1.47.0__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 (97) hide show
  1. {cloudinary-1.46.2/cloudinary.egg-info → cloudinary-1.47.0}/PKG-INFO +2 -1
  2. {cloudinary-1.46.2 → cloudinary-1.47.0}/README.md +1 -0
  3. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/__init__.py +1 -1
  4. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/api_client/execute_request.py +9 -1
  5. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/auth_token.py +1 -1
  6. cloudinary-1.47.0/cloudinary/collections.py +289 -0
  7. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/README.md +1 -0
  8. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/import-and-call.md +2 -1
  9. cloudinary-1.47.0/cloudinary/docs/organize-and-share-collections.md +222 -0
  10. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/platform-capabilities.md +1 -0
  11. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/troubleshoot-errors.md +4 -3
  12. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/exceptions.py +8 -0
  13. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/provisioning/account.py +1 -1
  14. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/uploader.py +1 -0
  15. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/utils.py +2 -0
  16. {cloudinary-1.46.2 → cloudinary-1.47.0/cloudinary.egg-info}/PKG-INFO +2 -1
  17. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary.egg-info/SOURCES.txt +3 -0
  18. {cloudinary-1.46.2 → cloudinary-1.47.0}/pyproject.toml +1 -1
  19. {cloudinary-1.46.2 → cloudinary-1.47.0}/setup.py +1 -1
  20. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_auth_token.py +9 -0
  21. cloudinary-1.47.0/test/test_collections.py +351 -0
  22. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_provisioning_api.py +16 -0
  23. {cloudinary-1.46.2 → cloudinary-1.47.0}/LICENSE +0 -0
  24. {cloudinary-1.46.2 → cloudinary-1.47.0}/MANIFEST.in +0 -0
  25. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/api.py +0 -0
  26. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/api_client/__init__.py +0 -0
  27. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/api_client/call_account_api.py +0 -0
  28. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/api_client/call_api.py +0 -0
  29. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/api_client/tcp_keep_alive_manager.py +0 -0
  30. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/__init__.py +0 -0
  31. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/adapter/__init__.py +0 -0
  32. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/adapter/cache_adapter.py +0 -0
  33. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/adapter/key_value_cache_adapter.py +0 -0
  34. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/responsive_breakpoints_cache.py +0 -0
  35. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/storage/__init__.py +0 -0
  36. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/storage/file_system_key_value_storage.py +0 -0
  37. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/cache/storage/key_value_storage.py +0 -0
  38. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/compat.py +0 -0
  39. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/configure-cloudinary.md +0 -0
  40. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/get-credentials.md +0 -0
  41. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/moderate-upload.md +0 -0
  42. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/search-and-manage-assets.md +0 -0
  43. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/sign-browser-upload.md +0 -0
  44. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/transform-and-deliver-image.md +0 -0
  45. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/transform-and-deliver-video.md +0 -0
  46. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/upload-image.md +0 -0
  47. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/upload-large-video.md +0 -0
  48. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/use-structured-metadata.md +0 -0
  49. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/docs/use-with-django.md +0 -0
  50. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/forms.py +0 -0
  51. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/http_client.py +0 -0
  52. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/models.py +0 -0
  53. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/poster/__init__.py +0 -0
  54. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/poster/encode.py +0 -0
  55. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/poster/streaminghttp.py +0 -0
  56. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/provisioning/__init__.py +0 -0
  57. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/provisioning/account_config.py +0 -0
  58. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/search.py +0 -0
  59. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/search_folders.py +0 -0
  60. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/html/cloudinary_cors.html +0 -0
  61. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/canvas-to-blob.min.js +0 -0
  62. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/jquery.cloudinary.js +0 -0
  63. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/jquery.fileupload-image.js +0 -0
  64. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/jquery.fileupload-process.js +0 -0
  65. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/jquery.fileupload-validate.js +0 -0
  66. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/jquery.fileupload.js +0 -0
  67. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/jquery.iframe-transport.js +0 -0
  68. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/jquery.ui.widget.js +0 -0
  69. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/static/cloudinary/js/load-image.all.min.js +0 -0
  70. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/templates/cloudinary_direct_upload.html +0 -0
  71. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/templates/cloudinary_includes.html +0 -0
  72. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/templates/cloudinary_js_config.html +0 -0
  73. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/templatetags/__init__.py +0 -0
  74. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary/templatetags/cloudinary.py +0 -0
  75. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary.egg-info/dependency_links.txt +0 -0
  76. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary.egg-info/not-zip-safe +0 -0
  77. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary.egg-info/requires.txt +0 -0
  78. {cloudinary-1.46.2 → cloudinary-1.47.0}/cloudinary.egg-info/top_level.txt +0 -0
  79. {cloudinary-1.46.2 → cloudinary-1.47.0}/setup.cfg +0 -0
  80. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_api.py +0 -0
  81. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_api_authorization.py +0 -0
  82. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_archive.py +0 -0
  83. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_cloudinary_resource.py +0 -0
  84. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_config.py +0 -0
  85. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_expression_normalization.py +0 -0
  86. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_http_client.py +0 -0
  87. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_image.py +0 -0
  88. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_metadata.py +0 -0
  89. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_metadata_rules.py +0 -0
  90. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_notifications.py +0 -0
  91. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_request_id.py +0 -0
  92. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_search.py +0 -0
  93. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_streaming_profiles.py +0 -0
  94. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_triggers.py +0 -0
  95. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_uploader.py +0 -0
  96. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_utils.py +0 -0
  97. {cloudinary-1.46.2 → cloudinary-1.47.0}/test/test_video.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary
3
- Version: 1.46.2
3
+ Version: 1.47.0
4
4
  Summary: Upload, transform, optimize, and manage images and videos with Cloudinary from Python or Django.
5
5
  Author-email: Cloudinary <info@cloudinary.com>
6
6
  License: MIT License
@@ -142,6 +142,7 @@ Save as `quickstart.py` and run `python quickstart.py`. [Create a free account](
142
142
  - [Transform and deliver an image](cloudinary/docs/transform-and-deliver-image.md)
143
143
  - [Transform and deliver a video](cloudinary/docs/transform-and-deliver-video.md)
144
144
  - [Search and manage assets](cloudinary/docs/search-and-manage-assets.md)
145
+ - [Organize and share collections](cloudinary/docs/organize-and-share-collections.md)
145
146
  - [Moderate an upload](cloudinary/docs/moderate-upload.md)
146
147
  - [Use structured metadata](cloudinary/docs/use-structured-metadata.md)
147
148
  - [Use with Django](cloudinary/docs/use-with-django.md)
@@ -71,6 +71,7 @@ Save as `quickstart.py` and run `python quickstart.py`. [Create a free account](
71
71
  - [Transform and deliver an image](cloudinary/docs/transform-and-deliver-image.md)
72
72
  - [Transform and deliver a video](cloudinary/docs/transform-and-deliver-video.md)
73
73
  - [Search and manage assets](cloudinary/docs/search-and-manage-assets.md)
74
+ - [Organize and share collections](cloudinary/docs/organize-and-share-collections.md)
74
75
  - [Moderate an upload](cloudinary/docs/moderate-upload.md)
75
76
  - [Use structured metadata](cloudinary/docs/use-structured-metadata.md)
76
77
  - [Use with Django](cloudinary/docs/use-with-django.md)
@@ -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.46.2"
41
+ VERSION = "1.47.0"
42
42
 
43
43
  _USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version())))
44
44
 
@@ -7,11 +7,14 @@ from urllib3.exceptions import HTTPError
7
7
 
8
8
  import cloudinary
9
9
  from cloudinary.exceptions import (
10
+ Error,
10
11
  BadRequest,
11
12
  AuthorizationRequired,
12
13
  NotAllowed,
13
14
  NotFound,
15
+ NotAcceptable,
14
16
  AlreadyExists,
17
+ Locked,
15
18
  RateLimited,
16
19
  GeneralError
17
20
  )
@@ -23,8 +26,10 @@ EXCEPTION_CODES = {
23
26
  401: AuthorizationRequired,
24
27
  403: NotAllowed,
25
28
  404: NotFound,
29
+ 406: NotAcceptable,
26
30
  409: AlreadyExists,
27
31
  420: RateLimited,
32
+ 423: Locked,
28
33
  429: RateLimited,
29
34
  500: GeneralError,
30
35
  503: GeneralError
@@ -88,8 +93,11 @@ def execute_request(http_connector, method, params, headers, auth, api_url, **op
88
93
  # Error is parsing json
89
94
  raise GeneralError("Error parsing server response (%d) - %s. Got - %s" % (response.status, body, e))
90
95
 
96
+ if response.status >= 400 and "error" not in result and "message" in result:
97
+ result = {"error": {"message": result["message"]}}
98
+
91
99
  if "error" in result:
92
- exception_class = EXCEPTION_CODES.get(response.status) or Exception
100
+ exception_class = EXCEPTION_CODES.get(response.status) or Error
93
101
  raise exception_class("Error {0} - {1}".format(response.status, result["error"]["message"]))
94
102
 
95
103
  return Response(result, response)
@@ -73,4 +73,4 @@ def _ensure_int(value):
73
73
  try:
74
74
  return int(value)
75
75
  except (ValueError, TypeError):
76
- raise ValueError("Value '" + value + "' must be an integer.")
76
+ raise ValueError("Value '{}' must be an integer.".format(value))
@@ -0,0 +1,289 @@
1
+ # Copyright Cloudinary
2
+
3
+ import json
4
+
5
+ from six import string_types
6
+
7
+ import cloudinary
8
+ from cloudinary.api import only
9
+ from cloudinary.api_client.call_api import _call_v2_api
10
+ from cloudinary.utils import base64url_decode
11
+
12
+ CUSTOMER_ID_HEADER = "X-Cld-Customer-External-Id"
13
+
14
+ __COLLECTION_PARAMS = ["name", "description", "configuration"]
15
+
16
+ __PUBLIC_PAGE_PARAMS = ["name", "available_from", "expires_at", "show_download_original_option",
17
+ "download_transformations", "create_download_transformations"]
18
+
19
+ __PUBLIC_PAGE_UPDATE_PARAMS = __PUBLIC_PAGE_PARAMS + ["protection_mode", "allowed_emails", "allowed_domains",
20
+ "allow_any", "custom_layout_url"]
21
+
22
+
23
+ def collections(**options):
24
+ """
25
+ Lists the collections the caller may read, most-recently-updated first by default.
26
+
27
+ :param options: Additional options.
28
+ :keyword str query: Case-insensitive filter on the collection name. Matches collections with a
29
+ word in their name that starts with this text.
30
+ :keyword str sort_by: Sort expression, "<field> <direction>", optionally comma-separated.
31
+ Field is one of "name", "created_at" or "updated_at"; direction is "asc"
32
+ or "desc". Defaults to "updated_at desc".
33
+ :keyword int max_results: Maximum number of collections to return. 1-100, default 20.
34
+ :keyword str next_cursor: The cursor from a previous response, to fetch the next page.
35
+ :return: The collections, as {"data": [...], "next_cursor": ...}.
36
+ :rtype: Response
37
+ """
38
+ params = only(options, "query", "sort_by", "max_results", "next_cursor")
39
+ return __call_collections_api("get", [], params, **options)
40
+
41
+
42
+ def create_collection(name, **options):
43
+ """
44
+ Creates a collection and makes the caller its owner.
45
+
46
+ :param name: The name of the collection.
47
+ :type name: str
48
+ :param options: Additional options.
49
+ :keyword str description: The description of the collection.
50
+ :keyword str type: "manual" (the default) holds an explicit asset list, populated with
51
+ `add_collection_assets`; "dynamic" is populated by a saved search expression.
52
+ :keyword dict configuration: The configuration of a dynamic collection, as
53
+ {"expression": "<search expression>"}. Required for "dynamic",
54
+ not allowed for "manual".
55
+ :return: The created collection.
56
+ :rtype: Response
57
+ """
58
+ params = only(options, "description", "type", "configuration")
59
+ params["name"] = name
60
+ return __call_collections_api("post", [], params, **options)
61
+
62
+
63
+ def collection(collection_id, **options):
64
+ """
65
+ Returns a single collection.
66
+
67
+ :param collection_id: The external ID of the collection.
68
+ :type collection_id: str
69
+ :param options: Additional options.
70
+ :return: The collection.
71
+ :rtype: Response
72
+ """
73
+ return __call_collections_api("get", [collection_id], {}, **options)
74
+
75
+
76
+ def update_collection(collection_id, **options):
77
+ """
78
+ Updates a collection. Only the fields passed are changed. The type of a collection can't be changed.
79
+
80
+ :param collection_id: The external ID of the collection.
81
+ :type collection_id: str
82
+ :param options: Additional options.
83
+ :keyword str name: The new name of the collection.
84
+ :keyword str description: The new description of the collection.
85
+ :keyword dict configuration: The new configuration of a dynamic collection, as
86
+ {"expression": "<search expression>"}.
87
+ :return: The updated collection.
88
+ :rtype: Response
89
+ """
90
+ params = only(options, *__COLLECTION_PARAMS)
91
+ return __call_collections_api("put", [collection_id], params, **options)
92
+
93
+
94
+ def delete_collection(collection_id, **options):
95
+ """
96
+ Deletes a collection. The assets in it are not modified or deleted.
97
+
98
+ :param collection_id: The external ID of the collection.
99
+ :type collection_id: str
100
+ :param options: Additional options.
101
+ :return: A dictionary with a "message" key.
102
+ :rtype: Response
103
+ """
104
+ return __call_collections_api("delete", [collection_id], {}, **options)
105
+
106
+
107
+ def collection_assets(collection_id, **options):
108
+ """
109
+ Lists the assets in a collection that the caller may view, newest first by default.
110
+
111
+ :param collection_id: The external ID of the collection.
112
+ :type collection_id: str
113
+ :param options: Additional options.
114
+ :keyword str sort_by: Sort expression, "<field> <direction>", optionally comma-separated.
115
+ Field is one of "created_at", "uploaded_at", "public_id", "display_name"
116
+ or "bytes"; direction is "asc" or "desc". Defaults to "created_at desc".
117
+ Send the same sort_by on every page.
118
+ :keyword int max_results: Maximum number of assets to return. 1-100, default 20.
119
+ :keyword str next_cursor: The cursor from a previous response, to fetch the next page.
120
+ :return: The assets, as {"data": [...], "next_cursor": ...}.
121
+ :rtype: Response
122
+ """
123
+ params = only(options, "sort_by", "max_results", "next_cursor")
124
+ return __call_collections_api("get", [collection_id, "assets"], params, **options)
125
+
126
+
127
+ def add_collection_assets(collection_id, asset_ids, **options):
128
+ """
129
+ Adds assets to a collection.
130
+
131
+ :param collection_id: The external ID of the collection.
132
+ :type collection_id: str
133
+ :param asset_ids: The asset IDs of the assets to add. A single ID may be passed as a string.
134
+ :type asset_ids: list[str] or str
135
+ :param options: Additional options.
136
+ :return: The collection.
137
+ :rtype: Response
138
+ """
139
+ return __call_collections_api("post", [collection_id, "assets"], __asset_ids_list(asset_ids), **options)
140
+
141
+
142
+ def remove_collection_assets(collection_id, asset_ids, **options):
143
+ """
144
+ Removes assets from a collection. Assets not in the collection are ignored, and the assets
145
+ themselves are not modified or deleted.
146
+
147
+ :param collection_id: The external ID of the collection.
148
+ :type collection_id: str
149
+ :param asset_ids: The asset IDs of the assets to remove. A single ID may be passed as a string.
150
+ :type asset_ids: list[str] or str
151
+ :param options: Additional options.
152
+ :return: The collection.
153
+ :rtype: Response
154
+ """
155
+ return __call_collections_api("delete", [collection_id, "assets"], __asset_ids_list(asset_ids), **options)
156
+
157
+
158
+ def create_collection_public_page(collection_id, **options):
159
+ """
160
+ Publishes a collection as a shareable public page.
161
+
162
+ :param collection_id: The external ID of the collection.
163
+ :type collection_id: str
164
+ :param options: Additional options.
165
+ :keyword str name: The name of the public page.
166
+ :keyword str available_from: The date-time (ISO 8601) the page becomes available.
167
+ :keyword str expires_at: The date-time (ISO 8601) the page expires.
168
+ :keyword bool show_download_original_option: Whether viewers can download the original assets.
169
+ :keyword list download_transformations: Named transformations viewers can apply when downloading,
170
+ as [{"name": "<name>", "types": ["image", ...]}, ...].
171
+ At most 200.
172
+ :keyword bool create_download_transformations: Whether to create any listed transformation name
173
+ that isn't a transformation template yet.
174
+ :return: The created public page, including its URL.
175
+ :rtype: Response
176
+ """
177
+ params = only(options, *__PUBLIC_PAGE_PARAMS)
178
+ return __call_collections_api("post", [collection_id, "public_pages"], params, **options)
179
+
180
+
181
+ def collection_public_pages(collection_id, **options):
182
+ """
183
+ Lists the public pages of a collection.
184
+
185
+ :param collection_id: The external ID of the collection.
186
+ :type collection_id: str
187
+ :param options: Additional options.
188
+ :return: The public pages, as {"data": [...]}.
189
+ :rtype: Response
190
+ """
191
+ return __call_collections_api("get", [collection_id, "public_pages"], {}, **options)
192
+
193
+
194
+ def collection_public_page(collection_id, public_page_id, **options):
195
+ """
196
+ Returns a single public page of a collection.
197
+
198
+ :param collection_id: The external ID of the collection.
199
+ :type collection_id: str
200
+ :param public_page_id: The external ID of the public page.
201
+ :type public_page_id: str
202
+ :param options: Additional options.
203
+ :return: The public page.
204
+ :rtype: Response
205
+ """
206
+ return __call_collections_api("get", [collection_id, "public_pages", public_page_id], {}, **options)
207
+
208
+
209
+ def update_collection_public_page(collection_id, public_page_id, **options):
210
+ """
211
+ Updates the settings of a public page of a collection.
212
+
213
+ :param collection_id: The external ID of the collection.
214
+ :type collection_id: str
215
+ :param public_page_id: The external ID of the public page.
216
+ :type public_page_id: str
217
+ :param options: Additional options. Accepts the keywords of `create_collection_public_page`, and:
218
+ :keyword str protection_mode: "none" or "email_otp". "email_otp" requires the secured-sharing
219
+ feature on the plan.
220
+ :keyword list allowed_emails: The emails allowed to open a protected page.
221
+ :keyword list allowed_domains: The email domains allowed to open a protected page.
222
+ :keyword bool allow_any: Whether any email may open a protected page.
223
+ :keyword str custom_layout_url: The URL of a custom layout for the page.
224
+ :return: The updated public page.
225
+ :rtype: Response
226
+ """
227
+ params = only(options, *__PUBLIC_PAGE_UPDATE_PARAMS)
228
+ return __call_collections_api("put", [collection_id, "public_pages", public_page_id], params, **options)
229
+
230
+
231
+ def delete_collection_public_page(collection_id, public_page_id, **options):
232
+ """
233
+ Unpublishes a public page of a collection. The collection and its assets are not changed.
234
+
235
+ :param collection_id: The external ID of the collection.
236
+ :type collection_id: str
237
+ :param public_page_id: The external ID of the public page.
238
+ :type public_page_id: str
239
+ :param options: Additional options.
240
+ :return: A dictionary with a "message" key.
241
+ :rtype: Response
242
+ """
243
+ return __call_collections_api("delete", [collection_id, "public_pages", public_page_id], {}, **options)
244
+
245
+
246
+ def __asset_ids_list(asset_ids):
247
+ """
248
+ Wraps a single asset ID in a list.
249
+
250
+ :internal
251
+ """
252
+ return [asset_ids] if isinstance(asset_ids, string_types) else list(asset_ids)
253
+
254
+
255
+ def __call_collections_api(method, uri, params, **options):
256
+ """
257
+ Private function that assists with performing an API call to the collections endpoints.
258
+
259
+ :param method: The HTTP method. Valid methods: get, post, put, delete
260
+ :param uri: REST endpoint of the API (without 'collections', the module or the cloud name)
261
+ :param params: Query/body parameters passed to the method
262
+ :param options: Additional options
263
+ :rtype: Response
264
+ :internal
265
+ """
266
+ oauth_token = options.get("oauth_token", cloudinary.config().oauth_token)
267
+ customer_id = __customer_external_id(oauth_token) if oauth_token else None
268
+ if customer_id:
269
+ extra_headers = {CUSTOMER_ID_HEADER: customer_id}
270
+ extra_headers.update(options.get("extra_headers") or {})
271
+ options["extra_headers"] = extra_headers
272
+
273
+ return _call_v2_api(method, ["collections"] + uri, params, module="asset_management", **options)
274
+
275
+
276
+ def __customer_external_id(oauth_token):
277
+ """
278
+ Reads ext.customer_external_id from an unverified JWT payload.
279
+
280
+ :param oauth_token: The OAuth access token.
281
+ :return: The customer external ID, or None if absent or unreadable.
282
+ :internal
283
+ """
284
+ try:
285
+ value = json.loads(base64url_decode(oauth_token.split(".")[1]))["ext"]["customer_external_id"]
286
+ except (IndexError, KeyError, TypeError, ValueError):
287
+ return None
288
+
289
+ return value if isinstance(value, string_types) and value else None
@@ -40,6 +40,7 @@ directory of the repository.
40
40
  - [Transform and deliver an image](transform-and-deliver-image.md)
41
41
  - [Transform and deliver a video](transform-and-deliver-video.md)
42
42
  - [Search and manage assets](search-and-manage-assets.md)
43
+ - [Organize and share collections](organize-and-share-collections.md)
43
44
  - [Moderate an upload](moderate-upload.md)
44
45
  - [Use structured metadata](use-structured-metadata.md)
45
46
  - [Use with Django](use-with-django.md)
@@ -14,7 +14,8 @@ Import the submodules you use explicitly. `import cloudinary` on its own gives y
14
14
  `config()`, `CloudinaryImage`, `CloudinaryVideo`, `Search`, and `cloudinary.utils`,
15
15
  but **not** `cloudinary.uploader` or `cloudinary.api` — reaching for those raises
16
16
  `AttributeError: module 'cloudinary' has no attribute 'uploader'` until you import
17
- them.
17
+ them. The same applies to `cloudinary.collections` — use
18
+ `from cloudinary import collections`.
18
19
 
19
20
  Calls are synchronous and return plain dictionaries. Positional arguments come first,
20
21
  then keyword options that pass through to the API:
@@ -0,0 +1,222 @@
1
+ # Organize and share collections
2
+
3
+ ## When to use
4
+
5
+ Grouping existing assets into a named set, and sharing that set through a public link.
6
+
7
+ A collection references assets; it does not move, copy, or change them. Pick the right
8
+ tool:
9
+
10
+ - **Collection** (`cloudinary.collections`) — a curated set you name, list, and share as a
11
+ public page. An asset can be in many collections.
12
+ - **Tag** (`uploader.add_tag`) — a label for filtering and bulk operations. No sharing.
13
+ - **Folder** (`api.create_folder`) — where an asset lives. An asset has exactly one.
14
+ - **Search** (`cloudinary.Search`) — a one-off query. Nothing is saved.
15
+
16
+ There are two types of collection, fixed at creation:
17
+
18
+ - **manual** (the default) — holds exactly the assets you add and remove.
19
+ - **dynamic** — holds whatever matches a saved search expression, and updates itself as
20
+ assets start or stop matching. You cannot add or remove assets by hand.
21
+
22
+ ## Rules
23
+
24
+ - `cloudinary.collections` must be imported explicitly. `import cloudinary` alone does not
25
+ load it.
26
+ - Every response wraps its payload in `"data"`: `result["data"]["id"]`, not `result["id"]`.
27
+ - Add and remove assets by `asset_id`. A `public_id` fails with `NotFound: Error 404 -
28
+ Unknown resource`.
29
+ - Store the collection `id` and the public page `id`. Every other call takes them.
30
+ - Never pass `None` as an ID. The SDK drops it from the URL and calls a different endpoint:
31
+ `collections.collection(None)` lists all collections instead of failing.
32
+
33
+ ## Complete flow
34
+
35
+ ```python
36
+ import cloudinary
37
+ import cloudinary.exceptions
38
+ import cloudinary.uploader
39
+ from cloudinary import collections # must be imported explicitly
40
+
41
+ # Configuration is read from CLOUDINARY_URL automatically.
42
+
43
+
44
+ def main():
45
+ asset = cloudinary.uploader.upload("https://res.cloudinary.com/demo/image/upload/sample.jpg")
46
+
47
+ collection = collections.create_collection(
48
+ "Summer campaign",
49
+ description="Approved images for the launch",
50
+ )["data"]
51
+
52
+ collections.add_collection_assets(collection["id"], [asset["asset_id"]])
53
+
54
+ page = collections.create_collection_public_page(collection["id"], name="Launch preview")["data"]
55
+ print(page["url"]) # https://collection.cloudinary.com/<cloud_name>/<page_id>
56
+
57
+ return collection["id"], page["id"]
58
+
59
+
60
+ if __name__ == "__main__":
61
+ try:
62
+ main()
63
+ except cloudinary.exceptions.Error as error:
64
+ print("Collections call failed: {0}".format(error))
65
+ raise SystemExit(1)
66
+ ```
67
+
68
+ ## Result fields to keep
69
+
70
+ | Call | Returns | Keep |
71
+ |---|---|---|
72
+ | `create_collection`, `update_collection`, `add_collection_assets`, `remove_collection_assets` | `{"data": {collection}}` | `data.id` |
73
+ | `collection` | `{"data": {collection}}`, plus `asset_count` and `cover_asset_url` | |
74
+ | `collections`, `collection_assets` | `{"data": [...], "total_count": N, "next_cursor": "..."}` | `next_cursor` |
75
+ | `create_collection_public_page`, `update_collection_public_page`, `collection_public_page` | `{"data": {page}}` | `data.id`, `data.url` |
76
+ | `collection_public_pages` | `{"data": [...]}` | |
77
+ | `delete_collection`, `delete_collection_public_page` | `{"message": "..."}` | |
78
+
79
+ A collection has `id`, `name`, `description`, `type`, `configuration`, `created_at`, and
80
+ `updated_at`. An asset in `collection_assets` has `asset_id`, `public_id`, `display_name`,
81
+ `resource_type`, `type`, `format`, `bytes`, `width`, `height`, `secure_url`, and
82
+ `created_at`.
83
+
84
+ ## Managing assets in a manual collection
85
+
86
+ ```python
87
+ collections.add_collection_assets(collection_id, ["asset_id_1", "asset_id_2"])
88
+ collections.add_collection_assets(collection_id, "asset_id_3") # a single ID works too
89
+ collections.remove_collection_assets(collection_id, ["asset_id_1"])
90
+ ```
91
+
92
+ Adding an asset that is already in the collection succeeds and changes nothing. Removing an
93
+ asset does not delete it.
94
+
95
+ Changes can take a few seconds to show up in `collection_assets`. For read-after-write, poll
96
+ until the asset appears instead of reading once.
97
+
98
+ ## Dynamic collections
99
+
100
+ ```python
101
+ collection = collections.create_collection(
102
+ "Summer images",
103
+ type="dynamic",
104
+ configuration={"expression": "tags=summer AND resource_type:image"},
105
+ )["data"]
106
+
107
+ collections.update_collection(collection["id"], configuration={"expression": "tags=summer"})
108
+ ```
109
+
110
+ The expression uses the same syntax as `cloudinary.Search`. A new dynamic collection takes a few seconds to populate.
111
+
112
+ ## Listing and paging
113
+
114
+ ```python
115
+ cursor = None
116
+ while True:
117
+ page = collections.collection_assets(collection_id, sort_by="created_at desc",
118
+ max_results=100, next_cursor=cursor)
119
+ for asset in page["data"]:
120
+ print(asset["asset_id"], asset["secure_url"])
121
+ cursor = page.get("next_cursor")
122
+ if not cursor:
123
+ break
124
+ ```
125
+
126
+ `next_cursor` is absent on the last page; read it with `.get()`. Send the same `sort_by` on
127
+ every page — a cursor is only valid for the sort it was issued with, and the server does not
128
+ reject a mismatch.
129
+
130
+ | Option | `collections` | `collection_assets` |
131
+ |---|---|---|
132
+ | `max_results` | 1-100, default 20 | 1-100, default 20 |
133
+ | `sort_by` | `name`, `created_at`, `updated_at` + `asc`/`desc`; default `updated_at desc` | `created_at`, `uploaded_at`, `public_id`, `display_name`, `bytes` + `asc`/`desc`; default `created_at desc` |
134
+ | `query` | matches a word in the name that starts with the text: `"sum"` matches "Summer campaign", `"umm"` does not | — |
135
+
136
+ Combine sort keys with commas: `sort_by="name asc, created_at desc"`.
137
+
138
+ ## Sharing with public pages
139
+
140
+ ```python
141
+ page = collections.create_collection_public_page(
142
+ collection_id,
143
+ name="Press kit",
144
+ expires_at="2026-12-31T00:00:00Z",
145
+ show_download_original_option=False,
146
+ download_transformations=[{"name": "thumbnail_400", "types": ["image"]}],
147
+ create_download_transformations=True,
148
+ )["data"]
149
+
150
+ collections.update_collection_public_page(collection_id, page["id"], name="Press kit (final)")
151
+ collections.collection_public_pages(collection_id)
152
+ collections.delete_collection_public_page(collection_id, page["id"]) # unpublish
153
+ ```
154
+
155
+ Anyone with `page["url"]` can open the page until it expires or is unpublished. Unpublishing
156
+ does not change the collection or its assets.
157
+
158
+ - `download_transformations` must name existing named transformations, or the call fails with
159
+ `BadRequest: Error 400 - Unknown named transformation '<name>'`. Create one with
160
+ `cloudinary.api.create_transformation("thumbnail_400", "c_thumb,w_400")`.
161
+ - The first time a named transformation is offered for download, pass
162
+ `create_download_transformations=True`. Without it the call fails with
163
+ `BadRequest: Error 400 - Named transformation '<name>' is not available for download yet`.
164
+ - `types` (`["image"]`, `["video"]`, or omitted for both) is fixed the first time a name is
165
+ offered for download, on any public page. Later requests must omit it or send the same
166
+ list.
167
+ - `show_download_original_option` defaults to `True`.
168
+ - Restricting a page to specific viewers is update-only:
169
+ `update_collection_public_page(..., protection_mode="email_otp", allowed_emails=[...],
170
+ allowed_domains=[...])`. It needs the secured-sharing feature on your plan, or it fails
171
+ with `NotAllowed: Error 403 - Feature is not allowed`.
172
+
173
+ ## Updating and deleting
174
+
175
+ ```python
176
+ collections.update_collection(collection_id, name="Summer campaign (final)")
177
+ collections.delete_collection(collection_id)
178
+ ```
179
+
180
+ Updates change only the fields you pass: `name`, `description`, and, for dynamic collections,
181
+ `configuration`. The type cannot change; `type` is ignored on update. Deleting a collection
182
+ does not delete its assets.
183
+
184
+ ## Authentication and plans
185
+
186
+ The methods work with an API key and secret, or with an OAuth token
187
+ (`cloudinary.config(oauth_token=...)`, scope `asset_management`).
188
+
189
+ Collections are a premium Assets Enterprise feature. On the Assets Free plan you can have at
190
+ most 3 collections, with up to 50 assets each. Dynamic collections and protected public pages
191
+ must be enabled for your product environment.
192
+
193
+ ## Troubleshooting
194
+
195
+ - `NotAllowed: Error 403 - Cannot have more than 3 classic collections` — the plan's
196
+ collection limit. Delete a collection or upgrade.
197
+ - `NotAllowed: Error 403 - Feature is not allowed` — dynamic collections or `email_otp`
198
+ protection are not enabled for your product environment.
199
+ - ``NotFound: Error 404 - We can`t find a collection with this id ...`` — wrong or deleted
200
+ collection ID.
201
+ - `NotFound: Error 404 - Unknown resource` from add or remove — you passed a `public_id`, or
202
+ an `asset_id` that does not exist.
203
+ - `NotAcceptable: Error 406 - unsupported operation for dynamic collection` — you tried to add
204
+ or remove assets in a dynamic collection. Change its expression instead.
205
+ - `BadRequest: Error 400 - dynamic collections require a configuration with a non-empty
206
+ expression` — `type="dynamic"` without `configuration`.
207
+ - `BadRequest: Error 400 - configuration is only allowed for dynamic collections` —
208
+ `configuration` on a manual collection.
209
+ - `BadRequest: Error 400 - Unknown named transformation ...` or `... is not available for
210
+ download yet` — see [Sharing with public pages](#sharing-with-public-pages).
211
+ - `BadRequest: Error 400 - invalid sort_by field ...` or `... max_results ...` — see the
212
+ options table above.
213
+ - An asset you just added is missing from `collection_assets` — wait a few seconds and read
214
+ again.
215
+ - A call returned every collection instead of one — the ID was `None`.
216
+
217
+ ## Related
218
+
219
+ - Runnable example: `examples/organize-and-share-collections.py`
220
+ - [Search and manage assets](search-and-manage-assets.md) — finding the `asset_id` values to
221
+ add, and the expression syntax for dynamic collections.
222
+ - [Import and call the SDK](import-and-call.md)
@@ -99,6 +99,7 @@ URL building is local: no network call, no `api_secret`.
99
99
  | Read, update, restore, or delete an asset; manage folders and presets | `api` — the Assets Admin API | [Search and manage assets](search-and-manage-assets.md) |
100
100
  | Attach and query typed metadata fields | `api` + upload options | [Use structured metadata](use-structured-metadata.md) |
101
101
  | Find visually similar assets | `api.visual_search` — needs the feature enabled | [Visual Search](https://cloudinary.com/documentation/visual_search.md) |
102
+ | Group assets into manual or dynamic collections and share them as public pages | `collections` — import explicitly, needs Collections on your plan | [Organize and share collections](organize-and-share-collections.md) |
102
103
 
103
104
  ## Analyze
104
105
 
@@ -10,9 +10,12 @@ Failures raise exceptions from `cloudinary.exceptions`, mapped from the HTTP sta
10
10
  | `AuthorizationRequired` | 401 | Missing or wrong credentials, bad signature |
11
11
  | `NotAllowed` | 403 | Valid credentials without permission for this operation |
12
12
  | `NotFound` | 404 | No such asset, folder, or preset |
13
+ | `NotAcceptable` | 406 | The operation is not supported for this resource (for example, adding assets to a dynamic collection) |
13
14
  | `AlreadyExists` | 409 | The resource exists (duplicate `external_id`, folder) |
14
15
  | `RateLimited` | 420 / 429 | Hourly API limit reached, **or** an add-on you are not subscribed to |
16
+ | `Locked` | 423 | The asset is still processing |
15
17
  | `GeneralError` | 500 / 503 | A server-side failure |
18
+ | `Error` | any other status | |
16
19
 
17
20
  All of them subclass `cloudinary.exceptions.Error`, so one handler catches everything:
18
21
 
@@ -126,9 +129,7 @@ uploading a large video, or while an eager or add-on-driven transformation is st
126
129
  running. This is transient: retry with backoff rather than treating it as a failure. For
127
130
  long jobs, prefer `eager_async=True` with a `notification_url` over polling.
128
131
 
129
- Note that 423 is not in this SDK's status-to-exception map, so it surfaces as a plain
130
- `Exception` rather than a `cloudinary.exceptions.Error` subclass — a handler that catches
131
- only `cloudinary.exceptions.Error` will miss it.
132
+ It raises `cloudinary.exceptions.Locked`, so you can retry on that class specifically.
132
133
 
133
134
  ### A transformation is missing from the generated URL
134
135
 
@@ -25,6 +25,14 @@ class BadRequest(Error):
25
25
  pass
26
26
 
27
27
 
28
+ class NotAcceptable(Error):
29
+ pass
30
+
31
+
32
+ class Locked(Error):
33
+ pass
34
+
35
+
28
36
  class GeneralError(Error):
29
37
  pass
30
38
 
@@ -464,7 +464,7 @@ def user_in_user_groups(user_id, **options):
464
464
  :return: List of groups user is in
465
465
  :rtype: dict
466
466
  """
467
- uri = [USER_GROUPS_SUB_PATH, user_id]
467
+ uri = [USERS_SUB_PATH, user_id, "groups"]
468
468
  return _call_account_api("get", uri, {}, **options)
469
469
 
470
470