diff --git a/lib/cloudinary/__init__.py b/lib/cloudinary/__init__.py index 7746e365..2cc24dc4 100644 --- a/lib/cloudinary/__init__.py +++ b/lib/cloudinary/__init__.py @@ -38,7 +38,7 @@ CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAA URI_SCHEME = "cloudinary" API_VERSION = "v1_1" -VERSION = "1.32.0" +VERSION = "1.34.0" _USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version()))) diff --git a/lib/cloudinary/api.py b/lib/cloudinary/api.py index dd34686e..8f07ee9e 100644 --- a/lib/cloudinary/api.py +++ b/lib/cloudinary/api.py @@ -155,6 +155,26 @@ def resources_by_context(key, value=None, **options): return call_api("get", uri, params, **options) +def visual_search(image_url=None, image_asset_id=None, text=None, **options): + """ + Find images based on their visual content. + + :param image_url: The URL of an image. + :type image_url: str + :param image_asset_id: The asset_id of an image in your account. + :type image_asset_id: str + :param text: A textual description, e.g., "cat" + :type text: str + :param options: Additional options + :type options: dict, optional + :return: Resources (assets) that were found + :rtype: Response + """ + uri = ["resources", "visual_search"] + params = {"image_url": image_url, "image_asset_id": image_asset_id, "text": text} + return call_api("get", uri, params, **options) + + def resource(public_id, **options): resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") @@ -317,6 +337,24 @@ def add_related_assets(public_id, assets_to_relate, resource_type="image", type= return call_json_api("post", uri, params, **options) +def add_related_assets_by_asset_ids(asset_id, assets_to_relate, **options): + """ + Relates an asset to other assets by asset IDs. + + :param asset_id: The asset ID of the asset to update. + :type asset_id: str + :param assets_to_relate: The array of up to 10 asset IDs. + :type assets_to_relate: list[str] + :param options: Additional options. + :type options: dict, optional + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "related_assets", asset_id] + params = {"assets_to_relate": utils.build_array(assets_to_relate)} + return call_json_api("post", uri, params, **options) + + def delete_related_assets(public_id, assets_to_unrelate, resource_type="image", type="upload", **options): """ Unrelates an asset from other assets by public IDs. @@ -339,6 +377,24 @@ def delete_related_assets(public_id, assets_to_unrelate, resource_type="image", return call_json_api("delete", uri, params, **options) +def delete_related_assets_by_asset_ids(asset_id, assets_to_unrelate, **options): + """ + Unrelates an asset from other assets by asset IDs. + + :param asset_id: The asset ID of the asset to update. + :type asset_id: str + :param assets_to_unrelate: The array of up to 10 asset IDs. + :type assets_to_unrelate: list[str] + :param options: Additional options. + :type options: dict, optional + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "related_assets", asset_id] + params = {"assets_to_unrelate": utils.build_array(assets_to_unrelate)} + return call_json_api("delete", uri, params, **options) + + def tags(**options): resource_type = options.pop("resource_type", "image") uri = ["tags", resource_type] diff --git a/lib/cloudinary/api_client/call_api.py b/lib/cloudinary/api_client/call_api.py index 5b5eb4f5..916a396a 100644 --- a/lib/cloudinary/api_client/call_api.py +++ b/lib/cloudinary/api_client/call_api.py @@ -31,7 +31,7 @@ def call_api(method, uri, params, **options): return _call_api(method, uri, params=params, **options) -def _call_api(method, uri, params=None, body=None, headers=None, **options): +def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=None, **options): prefix = options.pop("upload_prefix", cloudinary.config().upload_prefix) or "https://api.cloudinary.com" cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name) @@ -50,6 +50,9 @@ def _call_api(method, uri, params=None, body=None, headers=None, **options): if body is not None: options["body"] = body + if extra_headers is not None: + headers.update(extra_headers) + return execute_request(http_connector=_http, method=method, params=params, diff --git a/lib/cloudinary/search.py b/lib/cloudinary/search.py index 0e4b0ee0..7af1773c 100644 --- a/lib/cloudinary/search.py +++ b/lib/cloudinary/search.py @@ -1,7 +1,10 @@ +import base64 import json +import cloudinary from cloudinary.api_client.call_api import call_json_api -from cloudinary.utils import unique +from cloudinary.utils import unique, unsigned_download_url_prefix, build_distribution_domain, base64url_encode, \ + json_encode, compute_hex_hash, SIGNATURE_SHA256 class Search(object): @@ -15,7 +18,10 @@ class Search(object): 'with_field': None, } + _ttl = 300 # Used for search URLs + """Build and execute a search query.""" + def __init__(self): self.query = {} @@ -51,6 +57,16 @@ class Search(object): self._add("with_field", value) return self + def ttl(self, ttl): + """ + Sets the time to live of the search URL. + + :param ttl: The time to live in seconds. + :return: self + """ + self._ttl = ttl + return self + def to_json(self): return json.dumps(self.as_dict()) @@ -60,12 +76,6 @@ class Search(object): uri = [self._endpoint, 'search'] return call_json_api('post', uri, self.as_dict(), **options) - def _add(self, name, value): - if name not in self.query: - self.query[name] = [] - self.query[name].append(value) - return self - def as_dict(self): to_return = {} @@ -77,6 +87,51 @@ class Search(object): return to_return + def to_url(self, ttl=None, next_cursor=None, **options): + """ + Creates a signed Search URL that can be used on the client side. + + :param ttl: The time to live in seconds. + :param next_cursor: Starting position. + :param options: Additional url delivery options. + :return: The resulting search URL. + """ + api_secret = options.get("api_secret", cloudinary.config().api_secret or None) + if not api_secret: + raise ValueError("Must supply api_secret") + + if ttl is None: + ttl = self._ttl + + query = self.as_dict() + + _next_cursor = query.pop("next_cursor", None) + if next_cursor is None: + next_cursor = _next_cursor + + b64query = base64url_encode(json_encode(query, sort_keys=True)) + + prefix = build_distribution_domain(options) + + signature = compute_hex_hash("{ttl}{b64query}{api_secret}".format( + ttl=ttl, + b64query=b64query, + api_secret=api_secret + ), algorithm=SIGNATURE_SHA256) + + return "{prefix}/search/{signature}/{ttl}/{b64query}{next_cursor}".format( + prefix=prefix, + signature=signature, + ttl=ttl, + b64query=b64query, + next_cursor="/{}".format(next_cursor) if next_cursor else "") + def endpoint(self, endpoint): self._endpoint = endpoint return self + + def _add(self, name, value): + if name not in self.query: + self.query[name] = [] + self.query[name].append(value) + return self diff --git a/lib/cloudinary/uploader.py b/lib/cloudinary/uploader.py index 188c06e0..d4039ccc 100644 --- a/lib/cloudinary/uploader.py +++ b/lib/cloudinary/uploader.py @@ -472,7 +472,8 @@ def call_cacheable_api(action, params, http_headers=None, return_error=False, un return result -def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, **options): +def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, + extra_headers=None, **options): params = utils.cleanup_params(params) headers = {"User-Agent": cloudinary.get_user_agent()} @@ -480,6 +481,9 @@ def call_api(action, params, http_headers=None, return_error=False, unsigned=Fal if http_headers is not None: headers.update(http_headers) + if extra_headers is not None: + headers.update(extra_headers) + oauth_token = options.get("oauth_token", cloudinary.config().oauth_token) if oauth_token: diff --git a/lib/cloudinary/utils.py b/lib/cloudinary/utils.py index 7aac086b..680c175e 100644 --- a/lib/cloudinary/utils.py +++ b/lib/cloudinary/utils.py @@ -92,6 +92,7 @@ __SIMPLE_UPLOAD_PARAMS = [ "eager_notification_url", "eager_async", "eval", + "on_success", "proxy", "folder", "asset_folder", @@ -106,6 +107,7 @@ __SIMPLE_UPLOAD_PARAMS = [ "categorization", "detection", "similarity_search", + "visual_search", "background_removal", "upload_preset", "phash", @@ -281,7 +283,7 @@ def encode_context(context): return "|".join(("{}={}".format(k, normalize_context_value(v))) for k, v in iteritems(context)) -def json_encode(value): +def json_encode(value, sort_keys=False): """ Converts value to a json encoded string @@ -289,7 +291,7 @@ def json_encode(value): :return: JSON encoded string """ - return json.dumps(value, default=__json_serializer, separators=(',', ':')) + return json.dumps(value, default=__json_serializer, separators=(',', ':'), sort_keys=sort_keys) def encode_date_to_usage_api_format(date_obj): @@ -373,8 +375,17 @@ def generate_transformation_string(**options): flags = ".".join(build_array(options.pop("flags", None))) dpr = options.pop("dpr", cloudinary.config().dpr) duration = norm_range_value(options.pop("duration", None)) - start_offset = norm_auto_range_value(options.pop("start_offset", None)) - end_offset = norm_range_value(options.pop("end_offset", None)) + + so_raw = options.pop("start_offset", None) + start_offset = norm_auto_range_value(so_raw) + if start_offset == None: + start_offset = so_raw + + eo_raw = options.pop("end_offset", None) + end_offset = norm_range_value(eo_raw) + if end_offset == None: + end_offset = eo_raw + offset = split_range(options.pop("offset", None)) if offset: start_offset = norm_auto_range_value(offset[0]) @@ -700,6 +711,25 @@ def unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, return prefix +def build_distribution_domain(options): + source = options.pop('source', '') + cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name or None) + if cloud_name is None: + raise ValueError("Must supply cloud_name in tag or in configuration") + secure = options.pop("secure", cloudinary.config().secure) + private_cdn = options.pop("private_cdn", cloudinary.config().private_cdn) + cname = options.pop("cname", cloudinary.config().cname) + secure_distribution = options.pop("secure_distribution", + cloudinary.config().secure_distribution) + cdn_subdomain = options.pop("cdn_subdomain", cloudinary.config().cdn_subdomain) + secure_cdn_subdomain = options.pop("secure_cdn_subdomain", + cloudinary.config().secure_cdn_subdomain) + + return unsigned_download_url_prefix( + source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, + cname, secure, secure_distribution) + + def merge(*dict_args): result = None for dictionary in dict_args: @@ -728,19 +758,8 @@ def cloudinary_url(source, **options): version = options.pop("version", None) format = options.pop("format", None) - cdn_subdomain = options.pop("cdn_subdomain", cloudinary.config().cdn_subdomain) - secure_cdn_subdomain = options.pop("secure_cdn_subdomain", - cloudinary.config().secure_cdn_subdomain) - cname = options.pop("cname", cloudinary.config().cname) shorten = options.pop("shorten", cloudinary.config().shorten) - cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name or None) - if cloud_name is None: - raise ValueError("Must supply cloud_name in tag or in configuration") - secure = options.pop("secure", cloudinary.config().secure) - private_cdn = options.pop("private_cdn", cloudinary.config().private_cdn) - secure_distribution = options.pop("secure_distribution", - cloudinary.config().secure_distribution) sign_url = options.pop("sign_url", cloudinary.config().sign_url) api_secret = options.pop("api_secret", cloudinary.config().api_secret) url_suffix = options.pop("url_suffix", None) @@ -786,9 +805,9 @@ def cloudinary_url(source, **options): base64.urlsafe_b64encode( hash_fn(to_bytes(to_sign + api_secret)).digest())[0:chars_length]) + "--" - prefix = unsigned_download_url_prefix( - source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, - cname, secure, secure_distribution) + options["source"] = source + prefix = build_distribution_domain(options) + source = "/".join(__compact( [prefix, resource_type, type, signature, transformation, version, source])) if sign_url and auth_token: @@ -999,6 +1018,7 @@ def archive_params(**options): "skip_transformation_name": options.get("skip_transformation_name"), "tags": options.get("tags") and build_array(options.get("tags")), "target_format": options.get("target_format"), + "target_asset_folder": options.get("target_asset_folder"), "target_public_id": options.get("target_public_id"), "target_tags": options.get("target_tags") and build_array(options.get("target_tags")), "timestamp": timestamp, diff --git a/requirements.txt b/requirements.txt index 8aeaadb0..03b49577 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ bleach==6.0.0 certifi==2023.7.22 cheroot==9.0.0 cherrypy==18.8.0 -cloudinary==1.32.0 +cloudinary==1.34.0 distro==1.8.0 dnspython==2.3.0 facebook-sdk==3.1.0