diff --git a/AGENTS.md b/AGENTS.md index 2815a26..37c3e5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,8 @@ offline or mocked tier. CI allocates a throwaway cloud per job via ## Project structure - `cloudinary/` — the package. `uploader.py` (Upload API), `api.py` (Admin API), - `utils.py` (URL building, signing, transformation strings), `search.py`. + `utils.py` (URL building, signing, transformation strings), `search.py`, + `collections.py` (Collections API, imported explicitly like `api`). - `cloudinary/docs/` — version-matched Markdown task docs, shipped inside the published package. Keep in sync with the code they document. - `cloudinary/models.py`, `forms.py`, `templatetags/`, `templates/`, `static/` — the @@ -67,6 +68,11 @@ offline or mocked tier. CI allocates a throwaway cloud per job via - Python 2/3 compatible source: the package still imports `six` and `cloudinary/compat.py`. Do not introduce f-strings, walrus operators, or type-annotation syntax into `cloudinary/`. +- A module named after a standard-library module (e.g. `cloudinary/collections.py`) + shadows it on Python 2. Every file in `cloudinary/` that imports that stdlib name + needs `from __future__ import absolute_import`. +- Every API error raises a `cloudinary.exceptions.Error` subclass. Map new HTTP + statuses in `EXCEPTION_CODES` (`cloudinary/api_client/execute_request.py`). - 4-space indent, `snake_case`, module-level functions for API surface (not classes). - Public API convention: positional arguments first, then `**options` passed through to the API call. @@ -83,6 +89,9 @@ offline or mocked tier. CI allocates a throwaway cloud per job via ## Git workflow - Branch from `master`; one topic per pull request. +- Commit messages are public and user-facing: a short summary of the change and a + pointer to the task doc. No usage examples, internal mechanics, or infrastructure + detail; the docs are the reference. - Run `python -m pytest test` before opening a PR, and the Django suite when touching `models.py`, `forms.py`, or `templatetags/`. - Never rewrite published `CHANGELOG.md` entries; new entries go at the top and are @@ -95,6 +104,11 @@ offline or mocked tier. CI allocates a throwaway cloud per job via **Always** - Keep `cloudinary/docs/` and `examples/` consistent with the code they document. +- A new API module needs a task doc in `cloudinary/docs/`, a runnable example in + `examples/`, and links from `platform-capabilities.md`, `import-and-call.md`, + `cloudinary/docs/README.md`, and `README.md`. +- Document only behaviour verified against the live API. Do not document server-side + bugs as expected behaviour. - Update tests when public behavior changes. - Keep API secrets out of examples, docs, tests, and fixtures. diff --git a/README.md b/README.md index d71697a..3beb5c2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Save as `quickstart.py` and run `python quickstart.py`. [Create a free account]( - [Transform and deliver an image](cloudinary/docs/transform-and-deliver-image.md) - [Transform and deliver a video](cloudinary/docs/transform-and-deliver-video.md) - [Search and manage assets](cloudinary/docs/search-and-manage-assets.md) +- [Organize and share collections](cloudinary/docs/organize-and-share-collections.md) - [Moderate an upload](cloudinary/docs/moderate-upload.md) - [Use structured metadata](cloudinary/docs/use-structured-metadata.md) - [Use with Django](cloudinary/docs/use-with-django.md) diff --git a/cloudinary/api_client/execute_request.py b/cloudinary/api_client/execute_request.py index ca29919..3ca0e69 100644 --- a/cloudinary/api_client/execute_request.py +++ b/cloudinary/api_client/execute_request.py @@ -7,11 +7,14 @@ import cloudinary from cloudinary.exceptions import ( + Error, BadRequest, AuthorizationRequired, NotAllowed, NotFound, + NotAcceptable, AlreadyExists, + Locked, RateLimited, GeneralError ) @@ -23,8 +26,10 @@ 401: AuthorizationRequired, 403: NotAllowed, 404: NotFound, + 406: NotAcceptable, 409: AlreadyExists, 420: RateLimited, + 423: Locked, 429: RateLimited, 500: GeneralError, 503: GeneralError @@ -88,8 +93,11 @@ def execute_request(http_connector, method, params, headers, auth, api_url, **op # Error is parsing json raise GeneralError("Error parsing server response (%d) - %s. Got - %s" % (response.status, body, e)) + if response.status >= 400 and "error" not in result and "message" in result: + result = {"error": {"message": result["message"]}} + if "error" in result: - exception_class = EXCEPTION_CODES.get(response.status) or Exception + exception_class = EXCEPTION_CODES.get(response.status) or Error raise exception_class("Error {0} - {1}".format(response.status, result["error"]["message"])) return Response(result, response) diff --git a/cloudinary/collections.py b/cloudinary/collections.py new file mode 100644 index 0000000..26266f8 --- /dev/null +++ b/cloudinary/collections.py @@ -0,0 +1,289 @@ +# Copyright Cloudinary + +import json + +from six import string_types + +import cloudinary +from cloudinary.api import only +from cloudinary.api_client.call_api import _call_v2_api +from cloudinary.utils import base64url_decode + +CUSTOMER_ID_HEADER = "X-Cld-Customer-External-Id" + +__COLLECTION_PARAMS = ["name", "description", "configuration"] + +__PUBLIC_PAGE_PARAMS = ["name", "available_from", "expires_at", "show_download_original_option", + "download_transformations", "create_download_transformations"] + +__PUBLIC_PAGE_UPDATE_PARAMS = __PUBLIC_PAGE_PARAMS + ["protection_mode", "allowed_emails", "allowed_domains", + "allow_any", "custom_layout_url"] + + +def collections(**options): + """ + Lists the collections the caller may read, most-recently-updated first by default. + + :param options: Additional options. + :keyword str query: Case-insensitive filter on the collection name. Matches collections with a + word in their name that starts with this text. + :keyword str sort_by: Sort expression, " ", optionally comma-separated. + Field is one of "name", "created_at" or "updated_at"; direction is "asc" + or "desc". Defaults to "updated_at desc". + :keyword int max_results: Maximum number of collections to return. 1-100, default 20. + :keyword str next_cursor: The cursor from a previous response, to fetch the next page. + :return: The collections, as {"data": [...], "next_cursor": ...}. + :rtype: Response + """ + params = only(options, "query", "sort_by", "max_results", "next_cursor") + return __call_collections_api("get", [], params, **options) + + +def create_collection(name, **options): + """ + Creates a collection and makes the caller its owner. + + :param name: The name of the collection. + :type name: str + :param options: Additional options. + :keyword str description: The description of the collection. + :keyword str type: "manual" (the default) holds an explicit asset list, populated with + `add_collection_assets`; "dynamic" is populated by a saved search expression. + :keyword dict configuration: The configuration of a dynamic collection, as + {"expression": ""}. Required for "dynamic", + not allowed for "manual". + :return: The created collection. + :rtype: Response + """ + params = only(options, "description", "type", "configuration") + params["name"] = name + return __call_collections_api("post", [], params, **options) + + +def collection(collection_id, **options): + """ + Returns a single collection. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param options: Additional options. + :return: The collection. + :rtype: Response + """ + return __call_collections_api("get", [collection_id], {}, **options) + + +def update_collection(collection_id, **options): + """ + Updates a collection. Only the fields passed are changed. The type of a collection can't be changed. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param options: Additional options. + :keyword str name: The new name of the collection. + :keyword str description: The new description of the collection. + :keyword dict configuration: The new configuration of a dynamic collection, as + {"expression": ""}. + :return: The updated collection. + :rtype: Response + """ + params = only(options, *__COLLECTION_PARAMS) + return __call_collections_api("put", [collection_id], params, **options) + + +def delete_collection(collection_id, **options): + """ + Deletes a collection. The assets in it are not modified or deleted. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param options: Additional options. + :return: A dictionary with a "message" key. + :rtype: Response + """ + return __call_collections_api("delete", [collection_id], {}, **options) + + +def collection_assets(collection_id, **options): + """ + Lists the assets in a collection that the caller may view, newest first by default. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param options: Additional options. + :keyword str sort_by: Sort expression, " ", optionally comma-separated. + Field is one of "created_at", "uploaded_at", "public_id", "display_name" + or "bytes"; direction is "asc" or "desc". Defaults to "created_at desc". + Send the same sort_by on every page. + :keyword int max_results: Maximum number of assets to return. 1-100, default 20. + :keyword str next_cursor: The cursor from a previous response, to fetch the next page. + :return: The assets, as {"data": [...], "next_cursor": ...}. + :rtype: Response + """ + params = only(options, "sort_by", "max_results", "next_cursor") + return __call_collections_api("get", [collection_id, "assets"], params, **options) + + +def add_collection_assets(collection_id, asset_ids, **options): + """ + Adds assets to a collection. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param asset_ids: The asset IDs of the assets to add. A single ID may be passed as a string. + :type asset_ids: list[str] or str + :param options: Additional options. + :return: The collection. + :rtype: Response + """ + return __call_collections_api("post", [collection_id, "assets"], __asset_ids_list(asset_ids), **options) + + +def remove_collection_assets(collection_id, asset_ids, **options): + """ + Removes assets from a collection. Assets not in the collection are ignored, and the assets + themselves are not modified or deleted. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param asset_ids: The asset IDs of the assets to remove. A single ID may be passed as a string. + :type asset_ids: list[str] or str + :param options: Additional options. + :return: The collection. + :rtype: Response + """ + return __call_collections_api("delete", [collection_id, "assets"], __asset_ids_list(asset_ids), **options) + + +def create_collection_public_page(collection_id, **options): + """ + Publishes a collection as a shareable public page. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param options: Additional options. + :keyword str name: The name of the public page. + :keyword str available_from: The date-time (ISO 8601) the page becomes available. + :keyword str expires_at: The date-time (ISO 8601) the page expires. + :keyword bool show_download_original_option: Whether viewers can download the original assets. + :keyword list download_transformations: Named transformations viewers can apply when downloading, + as [{"name": "", "types": ["image", ...]}, ...]. + At most 200. + :keyword bool create_download_transformations: Whether to create any listed transformation name + that isn't a transformation template yet. + :return: The created public page, including its URL. + :rtype: Response + """ + params = only(options, *__PUBLIC_PAGE_PARAMS) + return __call_collections_api("post", [collection_id, "public_pages"], params, **options) + + +def collection_public_pages(collection_id, **options): + """ + Lists the public pages of a collection. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param options: Additional options. + :return: The public pages, as {"data": [...]}. + :rtype: Response + """ + return __call_collections_api("get", [collection_id, "public_pages"], {}, **options) + + +def collection_public_page(collection_id, public_page_id, **options): + """ + Returns a single public page of a collection. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param public_page_id: The external ID of the public page. + :type public_page_id: str + :param options: Additional options. + :return: The public page. + :rtype: Response + """ + return __call_collections_api("get", [collection_id, "public_pages", public_page_id], {}, **options) + + +def update_collection_public_page(collection_id, public_page_id, **options): + """ + Updates the settings of a public page of a collection. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param public_page_id: The external ID of the public page. + :type public_page_id: str + :param options: Additional options. Accepts the keywords of `create_collection_public_page`, and: + :keyword str protection_mode: "none" or "email_otp". "email_otp" requires the secured-sharing + feature on the plan. + :keyword list allowed_emails: The emails allowed to open a protected page. + :keyword list allowed_domains: The email domains allowed to open a protected page. + :keyword bool allow_any: Whether any email may open a protected page. + :keyword str custom_layout_url: The URL of a custom layout for the page. + :return: The updated public page. + :rtype: Response + """ + params = only(options, *__PUBLIC_PAGE_UPDATE_PARAMS) + return __call_collections_api("put", [collection_id, "public_pages", public_page_id], params, **options) + + +def delete_collection_public_page(collection_id, public_page_id, **options): + """ + Unpublishes a public page of a collection. The collection and its assets are not changed. + + :param collection_id: The external ID of the collection. + :type collection_id: str + :param public_page_id: The external ID of the public page. + :type public_page_id: str + :param options: Additional options. + :return: A dictionary with a "message" key. + :rtype: Response + """ + return __call_collections_api("delete", [collection_id, "public_pages", public_page_id], {}, **options) + + +def __asset_ids_list(asset_ids): + """ + Wraps a single asset ID in a list. + + :internal + """ + return [asset_ids] if isinstance(asset_ids, string_types) else list(asset_ids) + + +def __call_collections_api(method, uri, params, **options): + """ + Private function that assists with performing an API call to the collections endpoints. + + :param method: The HTTP method. Valid methods: get, post, put, delete + :param uri: REST endpoint of the API (without 'collections', the module or the cloud name) + :param params: Query/body parameters passed to the method + :param options: Additional options + :rtype: Response + :internal + """ + oauth_token = options.get("oauth_token", cloudinary.config().oauth_token) + customer_id = __customer_external_id(oauth_token) if oauth_token else None + if customer_id: + extra_headers = {CUSTOMER_ID_HEADER: customer_id} + extra_headers.update(options.get("extra_headers") or {}) + options["extra_headers"] = extra_headers + + return _call_v2_api(method, ["collections"] + uri, params, module="asset_management", **options) + + +def __customer_external_id(oauth_token): + """ + Reads ext.customer_external_id from an unverified JWT payload. + + :param oauth_token: The OAuth access token. + :return: The customer external ID, or None if absent or unreadable. + :internal + """ + try: + value = json.loads(base64url_decode(oauth_token.split(".")[1]))["ext"]["customer_external_id"] + except (IndexError, KeyError, TypeError, ValueError): + return None + + return value if isinstance(value, string_types) and value else None diff --git a/cloudinary/docs/README.md b/cloudinary/docs/README.md index 20ac5a6..27b4e8e 100644 --- a/cloudinary/docs/README.md +++ b/cloudinary/docs/README.md @@ -40,6 +40,7 @@ directory of the repository. - [Transform and deliver an image](transform-and-deliver-image.md) - [Transform and deliver a video](transform-and-deliver-video.md) - [Search and manage assets](search-and-manage-assets.md) +- [Organize and share collections](organize-and-share-collections.md) - [Moderate an upload](moderate-upload.md) - [Use structured metadata](use-structured-metadata.md) - [Use with Django](use-with-django.md) diff --git a/cloudinary/docs/import-and-call.md b/cloudinary/docs/import-and-call.md index 92935d7..2cfd513 100644 --- a/cloudinary/docs/import-and-call.md +++ b/cloudinary/docs/import-and-call.md @@ -14,7 +14,8 @@ Import the submodules you use explicitly. `import cloudinary` on its own gives y `config()`, `CloudinaryImage`, `CloudinaryVideo`, `Search`, and `cloudinary.utils`, but **not** `cloudinary.uploader` or `cloudinary.api` — reaching for those raises `AttributeError: module 'cloudinary' has no attribute 'uploader'` until you import -them. +them. The same applies to `cloudinary.collections` — use +`from cloudinary import collections`. Calls are synchronous and return plain dictionaries. Positional arguments come first, then keyword options that pass through to the API: diff --git a/cloudinary/docs/organize-and-share-collections.md b/cloudinary/docs/organize-and-share-collections.md new file mode 100644 index 0000000..1231947 --- /dev/null +++ b/cloudinary/docs/organize-and-share-collections.md @@ -0,0 +1,222 @@ +# Organize and share collections + +## When to use + +Grouping existing assets into a named set, and sharing that set through a public link. + +A collection references assets; it does not move, copy, or change them. Pick the right +tool: + +- **Collection** (`cloudinary.collections`) — a curated set you name, list, and share as a + public page. An asset can be in many collections. +- **Tag** (`uploader.add_tag`) — a label for filtering and bulk operations. No sharing. +- **Folder** (`api.create_folder`) — where an asset lives. An asset has exactly one. +- **Search** (`cloudinary.Search`) — a one-off query. Nothing is saved. + +There are two types of collection, fixed at creation: + +- **manual** (the default) — holds exactly the assets you add and remove. +- **dynamic** — holds whatever matches a saved search expression, and updates itself as + assets start or stop matching. You cannot add or remove assets by hand. + +## Rules + +- `cloudinary.collections` must be imported explicitly. `import cloudinary` alone does not + load it. +- Every response wraps its payload in `"data"`: `result["data"]["id"]`, not `result["id"]`. +- Add and remove assets by `asset_id`. A `public_id` fails with `NotFound: Error 404 - + Unknown resource`. +- Store the collection `id` and the public page `id`. Every other call takes them. +- Never pass `None` as an ID. The SDK drops it from the URL and calls a different endpoint: + `collections.collection(None)` lists all collections instead of failing. + +## Complete flow + +```python +import cloudinary +import cloudinary.exceptions +import cloudinary.uploader +from cloudinary import collections # must be imported explicitly + +# Configuration is read from CLOUDINARY_URL automatically. + + +def main(): + asset = cloudinary.uploader.upload("https://res.cloudinary.com/demo/image/upload/sample.jpg") + + collection = collections.create_collection( + "Summer campaign", + description="Approved images for the launch", + )["data"] + + collections.add_collection_assets(collection["id"], [asset["asset_id"]]) + + page = collections.create_collection_public_page(collection["id"], name="Launch preview")["data"] + print(page["url"]) # https://collection.cloudinary.com// + + return collection["id"], page["id"] + + +if __name__ == "__main__": + try: + main() + except cloudinary.exceptions.Error as error: + print("Collections call failed: {0}".format(error)) + raise SystemExit(1) +``` + +## Result fields to keep + +| Call | Returns | Keep | +|---|---|---| +| `create_collection`, `update_collection`, `add_collection_assets`, `remove_collection_assets` | `{"data": {collection}}` | `data.id` | +| `collection` | `{"data": {collection}}`, plus `asset_count` and `cover_asset_url` | | +| `collections`, `collection_assets` | `{"data": [...], "total_count": N, "next_cursor": "..."}` | `next_cursor` | +| `create_collection_public_page`, `update_collection_public_page`, `collection_public_page` | `{"data": {page}}` | `data.id`, `data.url` | +| `collection_public_pages` | `{"data": [...]}` | | +| `delete_collection`, `delete_collection_public_page` | `{"message": "..."}` | | + +A collection has `id`, `name`, `description`, `type`, `configuration`, `created_at`, and +`updated_at`. An asset in `collection_assets` has `asset_id`, `public_id`, `display_name`, +`resource_type`, `type`, `format`, `bytes`, `width`, `height`, `secure_url`, and +`created_at`. + +## Managing assets in a manual collection + +```python +collections.add_collection_assets(collection_id, ["asset_id_1", "asset_id_2"]) +collections.add_collection_assets(collection_id, "asset_id_3") # a single ID works too +collections.remove_collection_assets(collection_id, ["asset_id_1"]) +``` + +Adding an asset that is already in the collection succeeds and changes nothing. Removing an +asset does not delete it. + +Changes can take a few seconds to show up in `collection_assets`. For read-after-write, poll +until the asset appears instead of reading once. + +## Dynamic collections + +```python +collection = collections.create_collection( + "Summer images", + type="dynamic", + configuration={"expression": "tags=summer AND resource_type:image"}, +)["data"] + +collections.update_collection(collection["id"], configuration={"expression": "tags=summer"}) +``` + +The expression uses the same syntax as `cloudinary.Search`. A new dynamic collection takes a few seconds to populate. + +## Listing and paging + +```python +cursor = None +while True: + page = collections.collection_assets(collection_id, sort_by="created_at desc", + max_results=100, next_cursor=cursor) + for asset in page["data"]: + print(asset["asset_id"], asset["secure_url"]) + cursor = page.get("next_cursor") + if not cursor: + break +``` + +`next_cursor` is absent on the last page; read it with `.get()`. Send the same `sort_by` on +every page — a cursor is only valid for the sort it was issued with, and the server does not +reject a mismatch. + +| Option | `collections` | `collection_assets` | +|---|---|---| +| `max_results` | 1-100, default 20 | 1-100, default 20 | +| `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` | +| `query` | matches a word in the name that starts with the text: `"sum"` matches "Summer campaign", `"umm"` does not | — | + +Combine sort keys with commas: `sort_by="name asc, created_at desc"`. + +## Sharing with public pages + +```python +page = collections.create_collection_public_page( + collection_id, + name="Press kit", + expires_at="2026-12-31T00:00:00Z", + show_download_original_option=False, + download_transformations=[{"name": "thumbnail_400", "types": ["image"]}], + create_download_transformations=True, +)["data"] + +collections.update_collection_public_page(collection_id, page["id"], name="Press kit (final)") +collections.collection_public_pages(collection_id) +collections.delete_collection_public_page(collection_id, page["id"]) # unpublish +``` + +Anyone with `page["url"]` can open the page until it expires or is unpublished. Unpublishing +does not change the collection or its assets. + +- `download_transformations` must name existing named transformations, or the call fails with + `BadRequest: Error 400 - Unknown named transformation ''`. Create one with + `cloudinary.api.create_transformation("thumbnail_400", "c_thumb,w_400")`. +- The first time a named transformation is offered for download, pass + `create_download_transformations=True`. Without it the call fails with + `BadRequest: Error 400 - Named transformation '' is not available for download yet`. +- `types` (`["image"]`, `["video"]`, or omitted for both) is fixed the first time a name is + offered for download, on any public page. Later requests must omit it or send the same + list. +- `show_download_original_option` defaults to `True`. +- Restricting a page to specific viewers is update-only: + `update_collection_public_page(..., protection_mode="email_otp", allowed_emails=[...], + allowed_domains=[...])`. It needs the secured-sharing feature on your plan, or it fails + with `NotAllowed: Error 403 - Feature is not allowed`. + +## Updating and deleting + +```python +collections.update_collection(collection_id, name="Summer campaign (final)") +collections.delete_collection(collection_id) +``` + +Updates change only the fields you pass: `name`, `description`, and, for dynamic collections, +`configuration`. The type cannot change; `type` is ignored on update. Deleting a collection +does not delete its assets. + +## Authentication and plans + +The methods work with an API key and secret, or with an OAuth token +(`cloudinary.config(oauth_token=...)`, scope `asset_management`). + +Collections are a premium Assets Enterprise feature. On the Assets Free plan you can have at +most 3 collections, with up to 50 assets each. Dynamic collections and protected public pages +must be enabled for your product environment. + +## Troubleshooting + +- `NotAllowed: Error 403 - Cannot have more than 3 classic collections` — the plan's + collection limit. Delete a collection or upgrade. +- `NotAllowed: Error 403 - Feature is not allowed` — dynamic collections or `email_otp` + protection are not enabled for your product environment. +- ``NotFound: Error 404 - We can`t find a collection with this id ...`` — wrong or deleted + collection ID. +- `NotFound: Error 404 - Unknown resource` from add or remove — you passed a `public_id`, or + an `asset_id` that does not exist. +- `NotAcceptable: Error 406 - unsupported operation for dynamic collection` — you tried to add + or remove assets in a dynamic collection. Change its expression instead. +- `BadRequest: Error 400 - dynamic collections require a configuration with a non-empty + expression` — `type="dynamic"` without `configuration`. +- `BadRequest: Error 400 - configuration is only allowed for dynamic collections` — + `configuration` on a manual collection. +- `BadRequest: Error 400 - Unknown named transformation ...` or `... is not available for + download yet` — see [Sharing with public pages](#sharing-with-public-pages). +- `BadRequest: Error 400 - invalid sort_by field ...` or `... max_results ...` — see the + options table above. +- An asset you just added is missing from `collection_assets` — wait a few seconds and read + again. +- A call returned every collection instead of one — the ID was `None`. + +## Related + +- Runnable example: `examples/organize-and-share-collections.py` +- [Search and manage assets](search-and-manage-assets.md) — finding the `asset_id` values to + add, and the expression syntax for dynamic collections. +- [Import and call the SDK](import-and-call.md) diff --git a/cloudinary/docs/platform-capabilities.md b/cloudinary/docs/platform-capabilities.md index 0496bf4..e7842e4 100644 --- a/cloudinary/docs/platform-capabilities.md +++ b/cloudinary/docs/platform-capabilities.md @@ -99,6 +99,7 @@ URL building is local: no network call, no `api_secret`. | 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) | | Attach and query typed metadata fields | `api` + upload options | [Use structured metadata](use-structured-metadata.md) | | Find visually similar assets | `api.visual_search` — needs the feature enabled | [Visual Search](https://cloudinary.com/documentation/visual_search.md) | +| 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) | ## Analyze diff --git a/cloudinary/docs/troubleshoot-errors.md b/cloudinary/docs/troubleshoot-errors.md index cb217b8..d784482 100644 --- a/cloudinary/docs/troubleshoot-errors.md +++ b/cloudinary/docs/troubleshoot-errors.md @@ -10,9 +10,12 @@ Failures raise exceptions from `cloudinary.exceptions`, mapped from the HTTP sta | `AuthorizationRequired` | 401 | Missing or wrong credentials, bad signature | | `NotAllowed` | 403 | Valid credentials without permission for this operation | | `NotFound` | 404 | No such asset, folder, or preset | +| `NotAcceptable` | 406 | The operation is not supported for this resource (for example, adding assets to a dynamic collection) | | `AlreadyExists` | 409 | The resource exists (duplicate `external_id`, folder) | | `RateLimited` | 420 / 429 | Hourly API limit reached, **or** an add-on you are not subscribed to | +| `Locked` | 423 | The asset is still processing | | `GeneralError` | 500 / 503 | A server-side failure | +| `Error` | any other status | | All of them subclass `cloudinary.exceptions.Error`, so one handler catches everything: @@ -126,9 +129,7 @@ uploading a large video, or while an eager or add-on-driven transformation is st running. This is transient: retry with backoff rather than treating it as a failure. For long jobs, prefer `eager_async=True` with a `notification_url` over polling. -Note that 423 is not in this SDK's status-to-exception map, so it surfaces as a plain -`Exception` rather than a `cloudinary.exceptions.Error` subclass — a handler that catches -only `cloudinary.exceptions.Error` will miss it. +It raises `cloudinary.exceptions.Locked`, so you can retry on that class specifically. ### A transformation is missing from the generated URL diff --git a/cloudinary/exceptions.py b/cloudinary/exceptions.py index 6ed033b..9571d76 100644 --- a/cloudinary/exceptions.py +++ b/cloudinary/exceptions.py @@ -25,6 +25,14 @@ class BadRequest(Error): pass +class NotAcceptable(Error): + pass + + +class Locked(Error): + pass + + class GeneralError(Error): pass diff --git a/cloudinary/uploader.py b/cloudinary/uploader.py index 2af572b..be88630 100644 --- a/cloudinary/uploader.py +++ b/cloudinary/uploader.py @@ -1,4 +1,5 @@ # Copyright Cloudinary +from __future__ import absolute_import import json import os diff --git a/cloudinary/utils.py b/cloudinary/utils.py index 80c3c5e..f669549 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -1,4 +1,6 @@ # Copyright Cloudinary +from __future__ import absolute_import + import base64 import copy import hashlib diff --git a/examples/organize-and-share-collections.py b/examples/organize-and-share-collections.py new file mode 100644 index 0000000..b1d774b --- /dev/null +++ b/examples/organize-and-share-collections.py @@ -0,0 +1,76 @@ +"""Group assets into a collection, share it as a public page, then clean up. + +A collection references assets without moving or copying them. A public page is a +shareable link to the collection. + +Prerequisites: set CLOUDINARY_URL in your environment (Console > Settings > API Keys). +Collections must be available on your plan; the Assets Free plan allows 3. + +Related: +- Task doc: cloudinary/docs/organize-and-share-collections.md +- Finding asset IDs to add: examples/search-and-manage-assets.py +""" +import time + +import cloudinary +import cloudinary.exceptions +import cloudinary.uploader +from cloudinary import collections # must be imported explicitly + +# Configuration is read from CLOUDINARY_URL automatically. + +SAMPLE_IMAGE = "https://res.cloudinary.com/demo/image/upload/sample.jpg" + + +def wait_for_assets(collection_id: str, expected: int) -> list: + """Poll until the collection lists the expected number of assets.""" + for _ in range(10): + assets = collections.collection_assets(collection_id)["data"] + if len(assets) == expected: + return assets + time.sleep(2) + raise TimeoutError(f"Collection did not reach {expected} asset(s)") + + +def main() -> None: + asset = cloudinary.uploader.upload(SAMPLE_IMAGE, tags=["collections-example"]) + print(f"Uploaded {asset['public_id']} (asset_id {asset['asset_id']})") + + collection = collections.create_collection( + "Collections example", + description="Created by examples/organize-and-share-collections.py", + )["data"] + print(f"Created {collection['type']} collection {collection['id']}") + + try: + # Assets are added by asset_id, not public_id. + collections.add_collection_assets(collection["id"], [asset["asset_id"]]) + for item in wait_for_assets(collection["id"], 1): + print(f" contains {item['public_id']} {item['secure_url']}") + + page = collections.create_collection_public_page(collection["id"], name="Example page")["data"] + print(f"Public page: {page['url']}") + + collections.update_collection_public_page(collection["id"], page["id"], + show_download_original_option=False) + pages = collections.collection_public_pages(collection["id"])["data"] + print(f"Collection has {len(pages)} public page(s)") + + collections.delete_collection_public_page(collection["id"], page["id"]) + print("Unpublished the page") + finally: + collections.delete_collection(collection["id"]) + cloudinary.uploader.destroy(asset["public_id"]) + print("Deleted the collection and the sample asset") + + +if __name__ == "__main__": + try: + main() + except cloudinary.exceptions.Error as error: + print(f"Collections call failed: {error}") + raise SystemExit(1) + except Exception as error: + # Missing configuration surfaces as a bare Exception, not a ValueError. + print(f"Error: {error}") + raise SystemExit(1) diff --git a/test/test_collections.py b/test/test_collections.py new file mode 100644 index 0000000..90f1437 --- /dev/null +++ b/test/test_collections.py @@ -0,0 +1,351 @@ +import json +import unittest + +import six +from urllib3 import disable_warnings + +import cloudinary +from cloudinary import api, collections, utils +from test.helper_test import ( + UNIQUE_TEST_ID, get_uri, get_params, get_method, get_headers, get_json_body, api_response_mock, + http_response_mock, URLLIB3_REQUEST, patch +) +from cloudinary.exceptions import BadRequest, Error, Locked, NotAcceptable, NotFound + +MOCK_RESPONSE = api_response_mock() + +COLLECTION_ID = "col_{}".format(UNIQUE_TEST_ID) +PUBLIC_PAGE_ID = "pub_{}".format(UNIQUE_TEST_ID) +ASSET_IDS = ["ast_1_{}".format(UNIQUE_TEST_ID), "ast_2_{}".format(UNIQUE_TEST_ID)] + +CUSTOMER_ID_HEADER = "X-Cld-Customer-External-Id" +CUSTOMER_ID = "cust_{}".format(UNIQUE_TEST_ID) + +disable_warnings() + + +def build_jwt(claims): + return ".".join(["e30", utils.base64url_encode(json.dumps(claims)).rstrip("="), "sig"]) + + +OAUTH_TOKEN_WITH_CUSTOMER_ID = build_jwt({"ext": {"customer_external_id": CUSTOMER_ID}}) + + +def collections_uri(*path): + return "/".join(["/v2/asset_management", cloudinary.config().cloud_name, "collections"] + list(path)) + + +@unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") +class CollectionsTest(unittest.TestCase): + def assertRequest(self, mocker, method, *path): + self.assertEqual(get_method(mocker), method) + self.assertEqual(get_uri(mocker), collections_uri(*path)) + + @patch(URLLIB3_REQUEST) + def test01_collections(self, mocker): + """Should list collections with query, sort and paging params""" + mocker.return_value = MOCK_RESPONSE + + collections.collections(query="summer", sort_by="name asc", max_results=10, next_cursor="abc") + + self.assertRequest(mocker, "GET") + self.assertEqual(get_params(mocker), + {"query": "summer", "sort_by": "name asc", "max_results": "10", "next_cursor": "abc"}) + + @patch(URLLIB3_REQUEST) + def test02_create_collection(self, mocker): + """Should create a dynamic collection""" + mocker.return_value = MOCK_RESPONSE + + configuration = {"expression": "tags=summer"} + collections.create_collection("Summer", description="desc", type="dynamic", configuration=configuration) + + self.assertRequest(mocker, "POST") + self.assertEqual(get_json_body(mocker), {"name": "Summer", "description": "desc", "type": "dynamic", + "configuration": configuration}) + + @patch(URLLIB3_REQUEST) + def test03_collection(self, mocker): + """Should get a collection""" + mocker.return_value = MOCK_RESPONSE + + collections.collection(COLLECTION_ID) + + self.assertRequest(mocker, "GET", COLLECTION_ID) + + @patch(URLLIB3_REQUEST) + def test04_update_collection(self, mocker): + """Should send only the passed fields when updating a collection""" + mocker.return_value = MOCK_RESPONSE + + collections.update_collection(COLLECTION_ID, name="Renamed", type="manual") + + self.assertRequest(mocker, "PUT", COLLECTION_ID) + self.assertEqual(get_json_body(mocker), {"name": "Renamed"}) + + @patch(URLLIB3_REQUEST) + def test05_update_collection_explicit_none(self, mocker): + """Should send an explicitly passed None as null""" + mocker.return_value = MOCK_RESPONSE + + collections.update_collection(COLLECTION_ID, description=None) + + self.assertEqual(get_json_body(mocker), {"description": None}) + + @patch(URLLIB3_REQUEST) + def test06_delete_collection(self, mocker): + """Should delete a collection""" + mocker.return_value = MOCK_RESPONSE + + collections.delete_collection(COLLECTION_ID) + + self.assertRequest(mocker, "DELETE", COLLECTION_ID) + + @patch(URLLIB3_REQUEST) + def test07_collection_assets(self, mocker): + """Should list the assets of a collection with sort and paging params""" + mocker.return_value = MOCK_RESPONSE + + collections.collection_assets(COLLECTION_ID, sort_by="bytes desc", max_results=5, next_cursor="abc") + + self.assertRequest(mocker, "GET", COLLECTION_ID, "assets") + self.assertEqual(get_params(mocker), {"sort_by": "bytes desc", "max_results": "5", "next_cursor": "abc"}) + + @patch(URLLIB3_REQUEST) + def test08_add_collection_assets(self, mocker): + """Should add assets to a collection as a bare JSON array""" + mocker.return_value = MOCK_RESPONSE + + collections.add_collection_assets(COLLECTION_ID, ASSET_IDS) + + self.assertRequest(mocker, "POST", COLLECTION_ID, "assets") + self.assertEqual(get_json_body(mocker), ASSET_IDS) + + @patch(URLLIB3_REQUEST) + def test09_add_collection_asset_string(self, mocker): + """Should wrap a single asset ID in a list""" + mocker.return_value = MOCK_RESPONSE + + collections.add_collection_assets(COLLECTION_ID, ASSET_IDS[0]) + + self.assertEqual(get_json_body(mocker), [ASSET_IDS[0]]) + + @patch(URLLIB3_REQUEST) + def test10_remove_collection_assets(self, mocker): + """Should remove assets from a collection as a bare JSON array""" + mocker.return_value = MOCK_RESPONSE + + collections.remove_collection_assets(COLLECTION_ID, ASSET_IDS) + + self.assertRequest(mocker, "DELETE", COLLECTION_ID, "assets") + self.assertEqual(get_json_body(mocker), ASSET_IDS) + + @patch(URLLIB3_REQUEST) + def test11_create_collection_public_page(self, mocker): + """Should publish a collection, dropping update-only fields""" + mocker.return_value = MOCK_RESPONSE + + transformations = [{"name": "thumbnail_400", "types": ["image"]}] + collections.create_collection_public_page(COLLECTION_ID, name="Preview", + available_from="2026-03-20T00:00:00Z", + expires_at="2026-04-20T00:00:00Z", + show_download_original_option=True, + download_transformations=transformations, + create_download_transformations=True, + protection_mode="email_otp") + + self.assertRequest(mocker, "POST", COLLECTION_ID, "public_pages") + self.assertEqual(get_json_body(mocker), {"name": "Preview", + "available_from": "2026-03-20T00:00:00Z", + "expires_at": "2026-04-20T00:00:00Z", + "show_download_original_option": True, + "download_transformations": transformations, + "create_download_transformations": True}) + + @patch(URLLIB3_REQUEST) + def test12_collection_public_pages(self, mocker): + """Should list the public pages of a collection""" + mocker.return_value = MOCK_RESPONSE + + collections.collection_public_pages(COLLECTION_ID) + + self.assertRequest(mocker, "GET", COLLECTION_ID, "public_pages") + + @patch(URLLIB3_REQUEST) + def test13_collection_public_page(self, mocker): + """Should get a public page of a collection""" + mocker.return_value = MOCK_RESPONSE + + collections.collection_public_page(COLLECTION_ID, PUBLIC_PAGE_ID) + + self.assertRequest(mocker, "GET", COLLECTION_ID, "public_pages", PUBLIC_PAGE_ID) + + @patch(URLLIB3_REQUEST) + def test14_update_collection_public_page(self, mocker): + """Should update the protection settings of a public page""" + mocker.return_value = MOCK_RESPONSE + + collections.update_collection_public_page(COLLECTION_ID, PUBLIC_PAGE_ID, name="Press kit", + protection_mode="email_otp", + allowed_emails=["alice@example.com"], + allowed_domains=["example.com"], allow_any=False, + custom_layout_url="https://example.com/layout") + + self.assertRequest(mocker, "PUT", COLLECTION_ID, "public_pages", PUBLIC_PAGE_ID) + self.assertEqual(get_json_body(mocker), {"name": "Press kit", + "protection_mode": "email_otp", + "allowed_emails": ["alice@example.com"], + "allowed_domains": ["example.com"], + "allow_any": False, + "custom_layout_url": "https://example.com/layout"}) + + @patch(URLLIB3_REQUEST) + def test15_delete_collection_public_page(self, mocker): + """Should unpublish a public page of a collection""" + mocker.return_value = MOCK_RESPONSE + + collections.delete_collection_public_page(COLLECTION_ID, PUBLIC_PAGE_ID) + + self.assertRequest(mocker, "DELETE", COLLECTION_ID, "public_pages", PUBLIC_PAGE_ID) + + +class CollectionsCustomerIdTest(unittest.TestCase): + def setUp(self): + self.config = cloudinary.config() + self.config.cloud_name = self.config.cloud_name or "test-cloud" + + def tearDown(self): + self.config.oauth_token = None + + @patch(URLLIB3_REQUEST) + def test01_customer_id_header_from_oauth_option(self, mocker): + """Should send the customer ID from the OAuth token passed as an option""" + mocker.return_value = MOCK_RESPONSE + + collections.collections(oauth_token=OAUTH_TOKEN_WITH_CUSTOMER_ID) + + self.assertEqual(get_headers(mocker)[CUSTOMER_ID_HEADER], CUSTOMER_ID) + self.assertEqual(get_headers(mocker)["authorization"], "Bearer " + OAUTH_TOKEN_WITH_CUSTOMER_ID) + + @patch(URLLIB3_REQUEST) + def test02_customer_id_header_from_oauth_config(self, mocker): + """Should send the customer ID from the OAuth token in the config""" + mocker.return_value = MOCK_RESPONSE + self.config.oauth_token = OAUTH_TOKEN_WITH_CUSTOMER_ID + + collections.add_collection_assets(COLLECTION_ID, ASSET_IDS) + + self.assertEqual(get_headers(mocker)[CUSTOMER_ID_HEADER], CUSTOMER_ID) + + @patch(URLLIB3_REQUEST) + def test03_customer_id_header_not_sent_with_key_secret(self, mocker): + """Should not send the customer ID with API key and secret""" + mocker.return_value = MOCK_RESPONSE + + collections.collections(api_key="key", api_secret="secret") + + self.assertNotIn(CUSTOMER_ID_HEADER, get_headers(mocker)) + + @patch(URLLIB3_REQUEST) + def test04_customer_id_header_not_sent_outside_collections(self, mocker): + """Should not send the customer ID on other APIs""" + mocker.return_value = MOCK_RESPONSE + + api.ping(oauth_token=OAUTH_TOKEN_WITH_CUSTOMER_ID) + + self.assertNotIn(CUSTOMER_ID_HEADER, get_headers(mocker)) + + @patch(URLLIB3_REQUEST) + def test05_customer_id_header_not_sent_without_claim(self, mocker): + """Should not send the customer ID when the token has no usable claim""" + mocker.return_value = MOCK_RESPONSE + + tokens = [build_jwt({}), build_jwt({"ext": {}}), build_jwt({"ext": {"customer_external_id": ""}}), + build_jwt({"ext": {"customer_external_id": 123}}), build_jwt({"ext": "x"}), build_jwt([]), + "not-a-jwt", "a.!!!.c", "a.e30"] + for token in tokens: + collections.collections(oauth_token=token) + + self.assertNotIn(CUSTOMER_ID_HEADER, get_headers(mocker), token) + + @patch(URLLIB3_REQUEST) + def test06_customer_id_header_from_caller_kept(self, mocker): + """Should keep a customer ID header passed by the caller""" + mocker.return_value = MOCK_RESPONSE + + collections.collections(oauth_token=OAUTH_TOKEN_WITH_CUSTOMER_ID, + extra_headers={CUSTOMER_ID_HEADER: "explicit"}) + + self.assertEqual(get_headers(mocker)[CUSTOMER_ID_HEADER], "explicit") + + +class CollectionsErrorsTest(unittest.TestCase): + def setUp(self): + self.config = cloudinary.config() + self.config.cloud_name = self.config.cloud_name or "test-cloud" + + @patch(URLLIB3_REQUEST) + def test01_error_code_and_message_raises(self, mocker): + """Should raise on an error response with error_code and message""" + mocker.return_value = http_response_mock('{"error_code":"not_found","message":"No such collection"}', + status=404) + + with six.assertRaisesRegex(self, NotFound, "Error 404 - No such collection"): + collections.collection(COLLECTION_ID, api_key="key", api_secret="secret") + + @patch(URLLIB3_REQUEST) + def test02_message_only_raises(self, mocker): + """Should raise on an error response with only a message""" + mocker.return_value = http_response_mock('{"message":"invalid type"}', status=400) + + with six.assertRaisesRegex(self, BadRequest, "Error 400 - invalid type"): + collections.create_collection("x", type="bogus", api_key="key", api_secret="secret") + + @patch(URLLIB3_REQUEST) + def test03_success_message_returned(self, mocker): + """Should return a successful response that carries a message""" + mocker.return_value = http_response_mock('{"message":"Collection deleted successfully."}', status=200) + + result = collections.delete_collection(COLLECTION_ID, api_key="key", api_secret="secret") + + self.assertEqual(result["message"], "Collection deleted successfully.") + + @patch(URLLIB3_REQUEST) + def test04_legacy_error_shape_unchanged(self, mocker): + """Should keep raising on the error object shape""" + mocker.return_value = http_response_mock('{"error":{"message":"Resource not found"}}', status=404) + + with six.assertRaisesRegex(self, NotFound, "Error 404 - Resource not found"): + api.ping(api_key="key", api_secret="secret") + + + @patch(URLLIB3_REQUEST) + def test05_not_acceptable_raises(self, mocker): + """Should raise NotAcceptable on 406""" + mocker.return_value = http_response_mock('{"message":"unsupported operation for dynamic collection"}', + status=406) + + with six.assertRaisesRegex(self, NotAcceptable, "Error 406 - unsupported operation"): + collections.add_collection_assets(COLLECTION_ID, ASSET_IDS, api_key="key", api_secret="secret") + + @patch(URLLIB3_REQUEST) + def test06_locked_raises(self, mocker): + """Should raise Locked on 423""" + mocker.return_value = http_response_mock('{"error":{"message":"Resource is being processed"}}', status=423) + + with six.assertRaisesRegex(self, Locked, "Error 423 - Resource is being processed"): + api.ping(api_key="key", api_secret="secret") + + @patch(URLLIB3_REQUEST) + def test07_unmapped_status_raises_error(self, mocker): + """Should raise the base Error on an unmapped status""" + mocker.return_value = http_response_mock('{"message":"I\'m a teapot"}', status=418) + + with self.assertRaises(Error) as context: + collections.collections(api_key="key", api_secret="secret") + + self.assertIs(type(context.exception), Error) + + +if __name__ == '__main__': + unittest.main()