From 428fc5df5485efc060f81026137f399c27ecdcc5 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Thu, 16 May 2024 14:59:14 +0300 Subject: [PATCH 01/15] Add support for `rename_folder` Admin API --- cloudinary/api.py | 22 ++++++++++++++++++---- test/test_api.py | 12 ++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/cloudinary/api.py b/cloudinary/api.py index 0ae0fb33..8e4a365c 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -543,10 +543,6 @@ def create_upload_preset(**options): return call_api("post", uri, params, **options) -def create_folder(path, **options): - return call_api("post", ["folders", path], {}, **options) - - def root_folders(**options): return call_api("get", ["folders"], only(options, "next_cursor", "max_results"), **options) @@ -555,6 +551,24 @@ def subfolders(of_folder_path, **options): return call_api("get", ["folders", of_folder_path], only(options, "next_cursor", "max_results"), **options) +def create_folder(path, **options): + return call_api("post", ["folders", path], {}, **options) + + +def rename_folder(from_path, to_path, **options): + """ + Renames folder + + :param from_path: The full path of an existing asset folder. + :param to_path: The full path of the new asset folder. + :param options: Additional options + + :rtype: Response + """ + params = {"to_folder": to_path} + return call_api("put", ["folders", from_path], params, **options) + + def delete_folder(path, **options): """Deletes folder diff --git a/test/test_api.py b/test/test_api.py index 0a8318a6..418b1561 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -983,6 +983,18 @@ def test_create_folder(self, mocker): self.assertEqual("POST", get_method(mocker)) self.assertTrue(get_uri(mocker).endswith('/folders/' + UNIQUE_TEST_FOLDER)) + @patch(URLLIB3_REQUEST) + def test_rename_folder(self, mocker): + """ should rename folder """ + mocker.return_value = MOCK_RESPONSE + + api.rename_folder(UNIQUE_TEST_FOLDER, UNIQUE_TEST_FOLDER + "_new") + + self.assertEqual("PUT", get_method(mocker)) + self.assertTrue(get_uri(mocker).endswith('/folders/' + UNIQUE_TEST_FOLDER)) + self.assertTrue("to_folder" in get_params(mocker)) + self.assertEqual(UNIQUE_TEST_FOLDER + "_new", get_params(mocker)["to_folder"]) + @patch(URLLIB3_REQUEST) def test_delete_folder(self, mocker): """ should delete folder """ From e71c77355d6e469488995a241d354846077db94e Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Tue, 23 Jul 2024 08:25:17 +0300 Subject: [PATCH 02/15] Add support for `default_disabled` parameter in `MetadataField` --- cloudinary/api.py | 2 +- test/test_metadata.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cloudinary/api.py b/cloudinary/api.py index 8e4a365c..95543d32 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -741,7 +741,7 @@ def update_metadata_field(field_external_id, field, **options): def __metadata_field_params(field): return only(field, "type", "external_id", "label", "mandatory", "restrictions", - "default_value", "validation", "datasource") + "default_value", "default_disabled", "validation", "datasource") def delete_metadata_field(field_external_id, **options): diff --git a/test/test_metadata.py b/test/test_metadata.py index aebfc130..5f4a6633 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -199,15 +199,19 @@ def test03_create_string_metadata_field(self, mocker): "external_id": EXTERNAL_ID_STRING, "label": EXTERNAL_ID_STRING, "type": "string", - "restrictions": {"readonly_ui": True} + "restrictions": {"readonly_ui": True}, + "mandatory": False, + "default_disabled": True }) self.assertTrue(get_uri(mocker).endswith("/metadata_fields")) self.assertEqual(get_method(mocker), "POST") self.assertEqual(get_json_body(mocker), { + 'default_disabled': True, "type": "string", "external_id": EXTERNAL_ID_STRING, "label": EXTERNAL_ID_STRING, + 'mandatory': False, "restrictions": {"readonly_ui": True} }) From 2db58e3ee3b228e9d3dd6311efb5c52a06af3994 Mon Sep 17 00:00:00 2001 From: cld-sec <121499964+cld-sec@users.noreply.github.com> Date: Wed, 31 Jul 2024 16:55:06 +0300 Subject: [PATCH 03/15] Bump sample project vulnerable dependencies The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-SETUPTOOLS-7448482 Co-authored-by: snyk-bot --- samples/gae/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/gae/requirements.txt b/samples/gae/requirements.txt index f0705891..5fd060cf 100644 --- a/samples/gae/requirements.txt +++ b/samples/gae/requirements.txt @@ -4,4 +4,4 @@ gaenv==0.1.10.post0 six==1.10.0 urllib3==1.* webapp2==2.5.2 -setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability +setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability From b037310313e54df437861aeabb3716b3e81bdb33 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Fri, 2 Aug 2024 00:10:18 +0300 Subject: [PATCH 04/15] Add support for `set_url_signature` in `AuthToken` --- cloudinary/auth_token.py | 2 +- cloudinary/utils.py | 2 +- test/test_auth_token.py | 18 +++++++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cloudinary/auth_token.py b/cloudinary/auth_token.py index 4f6c1fe1..8ddeacdf 100644 --- a/cloudinary/auth_token.py +++ b/cloudinary/auth_token.py @@ -11,7 +11,7 @@ def generate(url=None, acl=None, start_time=None, duration=None, - expiration=None, ip=None, key=None, token_name=AUTH_TOKEN_NAME): + expiration=None, ip=None, key=None, token_name=AUTH_TOKEN_NAME, **_): if expiration is None: if duration is not None: diff --git a/cloudinary/utils.py b/cloudinary/utils.py index 1b7b7215..297bdb7a 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -820,7 +820,7 @@ def cloudinary_url(source, **options): transformation = re.sub(r'([^:])/+', r'\1/', transformation) signature = None - if sign_url and not auth_token: + if sign_url and (not auth_token or auth_token.pop('set_url_signature', False)): to_sign = "/".join(__compact([transformation, source_to_sign])) if long_url_signature: # Long signature forces SHA256 diff --git a/test/test_auth_token.py b/test/test_auth_token.py index b18970c5..9160731e 100644 --- a/test/test_auth_token.py +++ b/test/test_auth_token.py @@ -64,6 +64,16 @@ def test_explicit_authToken_should_override_global_setting(self): "w_300/sample.jpg?__cld_token__=st=222222222~exp=222222322~hmac" "=55cfe516530461213fe3b3606014533b1eca8ff60aeab79d1bb84c9322eebc1f") + def test_should_set_url_signature(self): + cloudinary.config(private_cdn=True) + url, _ = cloudinary.utils.cloudinary_url("sample.jpg", sign_url=True, + auth_token={"key": ALT_KEY, "start_time": 222222222, "duration": 100, + "set_url_signature": True}, + type="authenticated", transformation={"crop": "scale", "width": 300}) + self.assertEqual("http://test123-res.cloudinary.com/image/authenticated/s--Ok4O32K7--/" + "c_scale,w_300/sample.jpg?__cld_token__=st=222222222~exp=222222322~hmac" + "=92a55aaed531b2dab074074bbd1430120119f9cb1b901656925dda2e514a63cc", url) + def test_should_compute_expiration_as_start_time_plus_duration(self): cloudinary.config(private_cdn=True) token = {"key": KEY, "start_time": 11111111, "duration": 300} @@ -75,8 +85,7 @@ def test_should_compute_expiration_as_start_time_plus_duration(self): def test_generate_token_string(self): user = "foobar" # we can't rely on the default "now" value in tests - token_options = {"key": KEY, "duration": 300, "acl": "/*/t_%s" % user} - token_options["start_time"] = 222222222 # we can't rely on the default "now" value in tests + token_options = {"key": KEY, "duration": 300, "acl": "/*/t_%s" % user, "start_time": 222222222} cookie_token = cloudinary.utils.generate_auth_token(**token_options) self.assertEqual( cookie_token, @@ -97,8 +106,8 @@ def test_should_ignore_url_if_acl_is_provided(self): ) def test_should_support_multiple_acls(self): - token_options = {"key": KEY, "duration": 3600, - "acl": ["/i/a/*", "/i/a/*", "/i/a/*"], + token_options = {"key": KEY, "duration": 3600, + "acl": ["/i/a/*", "/i/a/*", "/i/a/*"], "start_time": 222222222} cookie_token = cloudinary.utils.generate_auth_token(**token_options) @@ -128,6 +137,5 @@ def test_should_support_url_without_acl(self): ) - if __name__ == '__main__': unittest.main() From 002c705d01d31c3c1ecd6ac50fb2a0aa65f8c989 Mon Sep 17 00:00:00 2001 From: cloudinary-bot Date: Thu, 1 Aug 2024 21:15:39 +0000 Subject: [PATCH 05/15] Version 1.41.0 --- CHANGELOG.md | 10 ++++++++++ cloudinary/__init__.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 595072fb..2d7992dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +1.41.0 / 2024-08-01 +================== + +New functionality and features +------------------------------ + + * Add support for `set_url_signature` in `AuthToken` + * Add support for `default_disabled` parameter in `MetadataField` + * Add support for `rename_folder` Admin API + 1.40.0 / 2024-04-18 ================== diff --git a/cloudinary/__init__.py b/cloudinary/__init__.py index f9b79955..877340c9 100644 --- a/cloudinary/__init__.py +++ b/cloudinary/__init__.py @@ -38,7 +38,7 @@ URI_SCHEME = "cloudinary" API_VERSION = "v1_1" -VERSION = "1.40.0" +VERSION = "1.41.0" _USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version()))) diff --git a/pyproject.toml b/pyproject.toml index 1bacb96b..12a12b0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "cloudinary" description = "Python and Django SDK for Cloudinary" -version = "1.40.0" +version = "1.41.0" authors = [{ name = "Cloudinary", email = "info@cloudinary.com" }] license = { file = "LICENSE.txt" } diff --git a/setup.py b/setup.py index c0cfcc3f..ca10aa4e 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ else: # Following code is legacy (Python 2.7 compatibiltiy) and will be removed in the future! # TODO: Remove in next major update (when dropping Python 2.7 compatibility) - version = "1.40.0" + version = "1.41.0" with open('README.md') as file: long_description = file.read() From b3b7054439e3fc3e96ed2b92ad640db36ae83f7f Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:10:32 +0300 Subject: [PATCH 06/15] Switch to `pytest` --- .travis.yml | 2 +- django_tests/test_cloudinaryField.py | 4 ++-- django_tests/test_cloudinary_file_field.py | 2 +- pyproject.toml | 4 +++- setup.py | 13 ++++--------- test/helper_test.py | 8 ++++++++ test/test_api.py | 4 ++-- test/test_api_authorization.py | 3 +-- test/test_archive.py | 3 +-- test/test_cloudinary_resource.py | 3 +-- test/test_config.py | 4 +--- test/test_image.py | 2 +- test/test_metadata.py | 5 ++--- test/test_search.py | 3 +-- test/test_uploader.py | 5 ++--- test/test_utils.py | 5 ++--- tox.ini | 5 +++-- 17 files changed, 36 insertions(+), 39 deletions(-) diff --git a/.travis.yml b/.travis.yml index abc300c0..abde8122 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,7 +27,7 @@ matrix: - python: 3.11 env: TOXENV=py311-django41 install: -- pip install tox +- pip install tox pytest before_script: > export CLOUDINARY_URL=$(bash tools/get_test_cloud.sh); diff --git a/django_tests/test_cloudinaryField.py b/django_tests/test_cloudinaryField.py index 11b95c95..32483016 100644 --- a/django_tests/test_cloudinaryField.py +++ b/django_tests/test_cloudinaryField.py @@ -1,7 +1,7 @@ import os import unittest -from mock import mock +from test.helper_test import mock from urllib3.util import parse_url import cloudinary @@ -77,7 +77,7 @@ def get_image_name(instance): c.pre_save(poll, None) self.assertTrue(upload_mock.called) - self.assertEqual(upload_mock.call_args.kwargs['public_id'], 'question') + self.assertEqual(upload_mock.call_args[1]['public_id'], 'question') def test_pre_save(self): c = CloudinaryField('image', width_field="image_width", height_field="image_height") diff --git a/django_tests/test_cloudinary_file_field.py b/django_tests/test_cloudinary_file_field.py index 2533ac81..9303c045 100644 --- a/django_tests/test_cloudinary_file_field.py +++ b/django_tests/test_cloudinary_file_field.py @@ -1,6 +1,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase -from mock import mock +from test.helper_test import mock import cloudinary from cloudinary import CloudinaryResource diff --git a/pyproject.toml b/pyproject.toml index 12a12b0a..af3a2b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,9 @@ dependencies = [ [project.optional-dependencies] dev = [ - "tox" + "tox", + "pytest==4.6; python_version < '3.7'", + "pytest; python_version >= '3.7'" ] [project.urls] diff --git a/setup.py b/setup.py index ca10aa4e..8fc932c9 100644 --- a/setup.py +++ b/setup.py @@ -3,15 +3,9 @@ from setuptools import find_packages, setup if version_info[0] >= 3: - - setup(test_suite="test", - tests_require=[ - "mock" + ("<4" if version_info < (3, 6) else "") - ], - ) - + setup() else: - # Following code is legacy (Python 2.7 compatibiltiy) and will be removed in the future! + # Following code is legacy (Python 2.7 compatibility) and will be removed in the future! # TODO: Remove in next major update (when dropping Python 2.7 compatibility) version = "1.41.0" @@ -76,6 +70,7 @@ "certifi" ], tests_require=[ - "mock<4" + "mock<4", + "pytest" ], ) diff --git a/test/helper_test.py b/test/helper_test.py index d8d721c7..d90155ea 100644 --- a/test/helper_test.py +++ b/test/helper_test.py @@ -18,6 +18,14 @@ from cloudinary.exceptions import NotFound from test.addon_types import ADDON_ALL +try: + from unittest import mock +except ImportError: + # Python 2.7 + import mock + +patch = mock.patch + SUFFIX = os.environ.get('TRAVIS_JOB_ID') or random.randint(10000, 99999) REMOTE_TEST_IMAGE = "http://cloudinary.com/images/old_logo.png" diff --git a/test/test_api.py b/test/test_api.py index 418b1561..c93b07d1 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -4,7 +4,7 @@ from collections import OrderedDict import six -from mock import patch + from urllib3 import disable_warnings, ProxyManager, PoolManager import cloudinary @@ -13,7 +13,7 @@ from test.helper_test import SUFFIX, TEST_IMAGE, get_uri, get_headers, get_params, get_list_param, get_param, \ TEST_DOC, get_method, UNIQUE_TAG, api_response_mock, ignore_exception, cleanup_test_resources_by_tag, \ cleanup_test_transformation, cleanup_test_resources, UNIQUE_TEST_FOLDER, EVAL_STR, get_json_body, REMOTE_TEST_IMAGE, \ - TEST_IMAGE_SIZE, URLLIB3_REQUEST + TEST_IMAGE_SIZE, URLLIB3_REQUEST, patch from cloudinary.exceptions import BadRequest, NotFound MOCK_RESPONSE = api_response_mock() diff --git a/test/test_api_authorization.py b/test/test_api_authorization.py index 6bd1603c..982d6544 100644 --- a/test/test_api_authorization.py +++ b/test/test_api_authorization.py @@ -1,12 +1,11 @@ import unittest import six -from mock import patch import cloudinary from cloudinary import api from cloudinary import uploader -from test.helper_test import TEST_IMAGE, get_headers, get_params, URLLIB3_REQUEST +from test.helper_test import TEST_IMAGE, get_headers, get_params, URLLIB3_REQUEST, patch from test.test_api import MOCK_RESPONSE from test.test_config import OAUTH_TOKEN, CLOUD_NAME, API_KEY, API_SECRET from test.test_uploader import API_TEST_PRESET diff --git a/test/test_archive.py b/test/test_archive.py index 87225c9c..acc62dce 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -9,13 +9,12 @@ import cloudinary.poster.streaminghttp from cloudinary import uploader, utils -from mock import patch import six import urllib3 from urllib3 import disable_warnings from test.helper_test import SUFFIX, TEST_IMAGE, api_response_mock, cleanup_test_resources_by_tag, UNIQUE_TEST_ID, \ - get_uri, get_list_param, get_params, URLLIB3_REQUEST + get_uri, get_list_param, get_params, URLLIB3_REQUEST, patch MOCK_RESPONSE = api_response_mock() diff --git a/test/test_cloudinary_resource.py b/test/test_cloudinary_resource.py index 6086d435..9c94abe9 100644 --- a/test/test_cloudinary_resource.py +++ b/test/test_cloudinary_resource.py @@ -1,13 +1,12 @@ from unittest import TestCase -import mock from urllib3 import disable_warnings import cloudinary from cloudinary import CloudinaryResource from cloudinary import uploader from test.helper_test import SUFFIX, TEST_IMAGE, http_response_mock, get_uri, cleanup_test_resources_by_tag, \ - URLLIB3_REQUEST + URLLIB3_REQUEST, mock disable_warnings() diff --git a/test/test_config.py b/test/test_config.py index 6a5db7ec..21cd23cd 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,11 +1,9 @@ import os from unittest import TestCase -from mock import mock - import cloudinary from cloudinary.provisioning import account_config - +from test.helper_test import mock CLOUD_NAME = 'test123' API_KEY = 'key' diff --git a/test/test_image.py b/test/test_image.py index 3b8ca0fe..904b7964 100644 --- a/test/test_image.py +++ b/test/test_image.py @@ -6,10 +6,10 @@ from collections import OrderedDict import six -from mock import mock import cloudinary from cloudinary import CloudinaryImage +from test.helper_test import mock class ImageTest(unittest.TestCase): diff --git a/test/test_metadata.py b/test/test_metadata.py index 5f4a6633..4e275262 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -1,9 +1,7 @@ -import json import time import unittest from datetime import datetime, timedelta -from mock import patch from six import text_type from urllib3 import disable_warnings @@ -11,7 +9,8 @@ from cloudinary import api from cloudinary.exceptions import BadRequest, NotFound from test.helper_test import ( - UNIQUE_TEST_ID, get_uri, get_params, get_method, api_response_mock, ignore_exception, get_json_body, URLLIB3_REQUEST + UNIQUE_TEST_ID, get_uri, get_params, get_method, api_response_mock, ignore_exception, get_json_body, + URLLIB3_REQUEST, patch ) MOCK_RESPONSE = api_response_mock() diff --git a/test/test_search.py b/test/test_search.py index bdb927d1..32a313a4 100644 --- a/test/test_search.py +++ b/test/test_search.py @@ -3,14 +3,13 @@ import time import unittest -from mock.mock import patch from six import iterkeys from urllib3 import disable_warnings import cloudinary from cloudinary import uploader, SearchFolders, Search from test.helper_test import SUFFIX, TEST_IMAGE, TEST_TAG, UNIQUE_TAG, TEST_FOLDER, UNIQUE_TEST_FOLDER, \ - retry_assertion, cleanup_test_resources_by_tag, URLLIB3_REQUEST, get_json_body, get_uri + retry_assertion, cleanup_test_resources_by_tag, URLLIB3_REQUEST, get_json_body, get_uri, patch from test.test_api import MOCK_RESPONSE, NEXT_CURSOR from test.test_config import CLOUD_NAME, API_KEY, API_SECRET diff --git a/test/test_uploader.py b/test/test_uploader.py index 1469d4a3..90a71897 100644 --- a/test/test_uploader.py +++ b/test/test_uploader.py @@ -6,9 +6,7 @@ from datetime import datetime import six -from mock import patch from urllib3 import disable_warnings -from urllib3.util import parse_url import cloudinary from cloudinary import api, uploader, utils, exceptions @@ -19,7 +17,7 @@ from test.helper_test import uploader_response_mock, SUFFIX, TEST_IMAGE, get_params, get_headers, TEST_ICON, TEST_DOC, \ REMOTE_TEST_IMAGE, UTC, populate_large_file, TEST_UNICODE_IMAGE, get_uri, get_method, get_param, \ cleanup_test_resources_by_tag, cleanup_test_transformation, cleanup_test_resources, EVAL_STR, ON_SUCCESS_STR, \ - URLLIB3_REQUEST + URLLIB3_REQUEST, patch, retry_assertion from test.test_utils import TEST_ID, TEST_FOLDER MOCK_RESPONSE = uploader_response_mock() @@ -620,6 +618,7 @@ def test_tags(self): uploader.replace_tag(UNIQUE_TAG, result["public_id"]) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + @retry_assertion() def test_multiple_tags(self): """ Should support adding multiple tags: list ["tag1","tag2"] and comma-separated "tag1,tag2" """ result = uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG]) diff --git a/test/test_utils.py b/test/test_utils.py index 316084ef..0fd596bb 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -4,7 +4,6 @@ import tempfile import unittest import uuid -import json import time from collections import OrderedDict from datetime import datetime, date @@ -12,7 +11,6 @@ from os.path import getsize import six -from mock import patch import cloudinary.utils from cloudinary import CL_BLANK @@ -32,7 +30,7 @@ verify_api_response_signature, ) from cloudinary.compat import to_bytes -from test.helper_test import TEST_IMAGE, REMOTE_TEST_IMAGE +from test.helper_test import TEST_IMAGE, REMOTE_TEST_IMAGE, patch from test.test_api import ( API_TEST_TRANS_SCALE100, API_TEST_TRANS_SCALE100_STR, @@ -58,6 +56,7 @@ MOCKED_NOW = 1549533574 API_SECRET = 'X7qLTrsES31MzxxkxPPA-pAGGfU' + class TestUtils(unittest.TestCase): crop_transformation = {'crop': 'crop', 'width': 100} crop_transformation_str = 'c_crop,w_100' diff --git a/tox.ini b/tox.ini index 14404d1a..14ab54d4 100644 --- a/tox.ini +++ b/tox.ini @@ -6,12 +6,13 @@ envlist = [testenv] usedevelop = True commands = - core: python setup.py test {env:P_ARGS:} + core: python -m pytest test django{111,22,32}: django-admin.py test -v2 django_tests {env:D_ARGS:} django{40,41}: django-admin test -v2 django_tests {env:D_ARGS:} passenv = * deps = - django{111,22,32,40,41}: mock + pytest + py27: mock django111: Django>=1.11,<1.12 django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 From 89006f293051cd403fc97920822446267aba7d0c Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Mon, 21 Oct 2024 23:12:40 +0300 Subject: [PATCH 07/15] Add support for `allow_dynamic_list_values` parameter in `MetadataField` --- cloudinary/api.py | 2 +- test/test_metadata.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cloudinary/api.py b/cloudinary/api.py index 95543d32..5014d249 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -741,7 +741,7 @@ def update_metadata_field(field_external_id, field, **options): def __metadata_field_params(field): return only(field, "type", "external_id", "label", "mandatory", "restrictions", - "default_value", "default_disabled", "validation", "datasource") + "default_value", "default_disabled", "validation", "datasource", "allow_dynamic_list_values") def delete_metadata_field(field_external_id, **options): diff --git a/test/test_metadata.py b/test/test_metadata.py index 4e275262..9870f784 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -285,12 +285,14 @@ def test07_create_set_metadata_field(self): "external_id": EXTERNAL_ID_SET, "label": EXTERNAL_ID_SET, "type": "set", + "allow_dynamic_list_values": True, }) self.assert_metadata_field(result, "set", { "label": EXTERNAL_ID_SET, "external_id": EXTERNAL_ID_SET, "mandatory": False, + "allow_dynamic_list_values": True, }) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") From f05d748bb48a2f29a7e005b9dc16aa8610750664 Mon Sep 17 00:00:00 2001 From: Parth Shah <87560178+codingis4noobs2@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:32:57 +0530 Subject: [PATCH 08/15] Add Generative AI Transformation sample project --- samples/README.md | 8 ++ samples/spookyshots/.env.example | 3 + samples/spookyshots/README.md | 48 +++++++ samples/spookyshots/main.py | 180 +++++++++++++++++++++++++++ samples/spookyshots/requirements.txt | 4 + 5 files changed, 243 insertions(+) create mode 100644 samples/spookyshots/.env.example create mode 100644 samples/spookyshots/README.md create mode 100644 samples/spookyshots/main.py create mode 100644 samples/spookyshots/requirements.txt diff --git a/samples/README.md b/samples/README.md index 9ea0bdb9..d0b9e964 100644 --- a/samples/README.md +++ b/samples/README.md @@ -34,3 +34,11 @@ A simple GAE application that performs image upload and generates on the transfo The source code and more details are available here: [https://github.com/cloudinary/pycloudinary/tree/master/samples/gae](https://github.com/cloudinary/pycloudinary/tree/master/samples/gae) + +## SpookyShots + +Spooky Pet Image App is a fun platform that transforms ordinary pet images into spooky, Halloween-themed creations. Whether you're looking to give your cat a spooky makeover or place any pet in a chilling Halloween setting, this app has everything you need for a spooky transformation! + +The source code and more details are available here: + +[https://github.com/cloudinary/pycloudinary/tree/master/samples/spookyshots](https://github.com/cloudinary/pycloudinary/tree/master/samples/spookyshots) \ No newline at end of file diff --git a/samples/spookyshots/.env.example b/samples/spookyshots/.env.example new file mode 100644 index 00000000..801d240e --- /dev/null +++ b/samples/spookyshots/.env.example @@ -0,0 +1,3 @@ +CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name +CLOUDINARY_API_KEY=your_cloudinary_api_key +CLOUDINARY_API_SECRET=your_cloudinary_api_secret \ No newline at end of file diff --git a/samples/spookyshots/README.md b/samples/spookyshots/README.md new file mode 100644 index 00000000..1e62125f --- /dev/null +++ b/samples/spookyshots/README.md @@ -0,0 +1,48 @@ +# Spooky Pet Image App + +**Spooky Pet Image App** is a fun platform that transforms ordinary pet images into spooky, Halloween-themed creations. Whether you're looking to give your cat a spooky makeover or place any pet in a chilling Halloween setting, this app has everything you need for a spooky transformation! + +## Example Image Generated +### Original +![alexander-london-mJaD10XeD7w-unsplash](https://github.com/user-attachments/assets/98afa889-364a-4337-98ff-347f2a3a94e2) + +### Transformed +![user_uploaded_alexander-london-mJaD10XeD7w-unsplash-min](https://github.com/user-attachments/assets/e3e1dde3-4252-499b-80a5-4b67942b2751) + + +## Installation + +### Steps + +1. **Clone the repository**: + ```bash + git clone https://github.com/cloudinary/pycloudinary.git + cd pycloudinary/samples/spookyshots + ``` + +2. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +3. **Set up Cloudinary credentials**: + - Inside the root directory of the project, rename `.env.example` to `.env`. + - Open the `.env` file and fill in your Cloudinary credentials: + ``` + CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name + CLOUDINARY_API_KEY=your_cloudinary_api_key + CLOUDINARY_API_SECRET=your_cloudinary_api_secret + ``` + +4. **Run the app**: + ```bash + streamlit run main.py + ``` + +5. **Open the app**: + After running the command, the app should automatically open in your browser. If not, open the browser and go to: + ``` + http://localhost:8501 + ``` + +Enjoy transforming your pets for Halloween! diff --git a/samples/spookyshots/main.py b/samples/spookyshots/main.py new file mode 100644 index 00000000..6b50fd11 --- /dev/null +++ b/samples/spookyshots/main.py @@ -0,0 +1,180 @@ +import streamlit as st +from streamlit_option_menu import option_menu +import cloudinary +from cloudinary import CloudinaryImage +import cloudinary.uploader +import cloudinary.api +from dotenv import load_dotenv +import os +import time + +load_dotenv() +cloudinary.reset_config() + +MAX_FILE_SIZE_MB = 5 +MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 + +with st.sidebar: + selected = option_menu( + menu_title="Navigation", + options=["Home", "Spooky Pet Background Generator", "Spooky Cat Face Transformer"], + icons=["house", "image", "skull"], + menu_icon="cast", + default_index=0, + ) + +if selected == "Home": + st.title("Welcome to the Spooky Pet Image App! ~Powered by Cloudinary") + + st.write(""" + **Spooky Pet Image App** is a fun and creative platform that transforms ordinary pet images into spooky, Halloween-themed masterpieces. + Whether you're looking to give your cat a spooky makeover or place your pet in a chilling Halloween setting, this app has you covered! + + ### Features: + - **Spooky Pet Background Generator**: Upload an image of any pet, and the app will replace the background with a dark, foggy Halloween scene featuring eerie trees, glowing pumpkins, a haunted house, and more. + - **Spooky Cat Face Transformer**: Specifically designed for cats, this feature transforms your cat into a demonic version with glowing red eyes, sharp fangs, bat wings, and dark mist under a blood moon. You can also modify the transformation prompt for a more personalized spooky effect. + + This app leverages Cloudinary's powerful Generative AI features to make your pets look extra spooky this Halloween. Try it out, and share the spooky transformations with your friends! + """) + +if selected == "Spooky Pet Background Generator": + st.title("Spooky Halloween Pet Image Transformer") + + upload_option = st.radio("Select image source:", ("Upload a file", "Enter an image URL")) + + if upload_option == "Upload a file": + uploaded_file = st.file_uploader("Upload an image (jpg, jpeg, png)", type=["jpg", "jpeg", "png"]) + else: + image_url = st.text_input("Enter the direct URL of the image (jpg, jpeg, png)") + + default_prompt = "A dark foggy Halloween night with a full moon in the sky surrounded by twisted trees Scattered glowing pumpkins with carved faces placed around an old broken fence in the background a shadowy haunted house with dimly lit windows" + + modify_prompt = st.checkbox("Do you want to modify the generative Halloween background prompt?", value=False) + + custom_prompt = st.text_input( + "Optional: Modify the generative Halloween background prompt", + value=default_prompt, + disabled=not modify_prompt + ) + + if st.button("Submit"): + if upload_option == "Upload a file" and uploaded_file: + if uploaded_file.size > MAX_FILE_SIZE_BYTES: + st.warning(f"File size exceeds the 5 MB limit. Please upload a smaller file.") + else: + with st.spinner("Generating image... Please have patience while the image is being processed by Cloudinary."): + upload_result = cloudinary.uploader.upload( + uploaded_file, + public_id=f"user_uploaded_{uploaded_file.name[:6]}", + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + halloween_bg_image_url = CloudinaryImage(public_id).image( + effect=f"gen_background_replace:prompt_{custom_prompt}" + ) + + start_index = halloween_bg_image_url.find('src="') + len('src="') + end_index = halloween_bg_image_url.find('"', start_index) + generated_image_url = halloween_bg_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to apply the background. Please try again.") + + elif upload_option == "Enter an image URL" and image_url: + with st.spinner("Generating image... Please have patience while the image is being processed by Cloudinary."): + unique_id = f"user_uploaded_url_{int(time.time())}" + upload_result = cloudinary.uploader.upload( + image_url, + public_id=unique_id, + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + halloween_bg_image_url = CloudinaryImage(public_id).image( + effect=f"gen_background_replace:prompt_{custom_prompt}" + ) + + start_index = halloween_bg_image_url.find('src="') + len('src="') + end_index = halloween_bg_image_url.find('"', start_index) + generated_image_url = halloween_bg_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to apply the background. Please try again.") + else: + st.write("Please upload an image or provide a URL to proceed.") + +if selected == "Spooky Cat Face Transformer": + st.title("Spooky Cat Face Transformer") + + upload_option = st.radio("Select image source:", ("Upload a file", "Enter an image URL")) + + if upload_option == "Upload a file": + uploaded_cat_pic = st.file_uploader("Upload a cat image to give it a spooky transformation! (jpg, jpeg, png)", type=["jpg", "jpeg", "png"]) + else: + cat_image_url = st.text_input("Enter the direct URL of the cat image (jpg, jpeg, png)") + + default_cat_prompt = "A demonic cat with glowing red eyes sharp fangs and dark mist swirling around it under a blood moon" + + modify_cat_prompt = st.checkbox("Do you want to modify the spooky cat transformation prompt?", value=False) + + custom_cat_prompt = st.text_input( + "Optional: Modify the spooky cat transformation prompt", + value=default_cat_prompt, + disabled=not modify_cat_prompt + ) + + if st.button("Transform to Spooky"): + if upload_option == "Upload a file" and uploaded_cat_pic: + if uploaded_cat_pic.size > MAX_FILE_SIZE_BYTES: + st.warning(f"File size exceeds the 5 MB limit. Please upload a smaller file.") + else: + with st.spinner("Generating your cat's spooky transformation... Please wait while Cloudinary processes the image."): + upload_result = cloudinary.uploader.upload( + uploaded_cat_pic, + public_id=f"user_spooky_cat_{uploaded_cat_pic.name[:6]}", + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + spooky_image_url = CloudinaryImage(public_id).image( + effect=f"gen_replace:from_cat;to_{custom_cat_prompt}" + ) + + start_index = spooky_image_url.find('src="') + len('src="') + end_index = spooky_image_url.find('"', start_index) + generated_image_url = spooky_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to generate the spooky transformation. Please try again.") + + elif upload_option == "Enter an image URL" and cat_image_url: + with st.spinner("Generating your cat's spooky transformation... Please wait while Cloudinary processes the image."): + unique_id = f"user_uploaded_url_{int(time.time())}" + upload_result = cloudinary.uploader.upload( + cat_image_url, + public_id=unique_id, + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + spooky_image_url = CloudinaryImage(public_id).image( + effect=f"gen_replace:from_cat;to_{custom_cat_prompt}" + ) + + start_index = spooky_image_url.find('src="') + len('src="') + end_index = spooky_image_url.find('"', start_index) + generated_image_url = spooky_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to generate the spooky transformation. Please try again.") + else: + st.write("Please upload an image or provide a URL to proceed.") diff --git a/samples/spookyshots/requirements.txt b/samples/spookyshots/requirements.txt new file mode 100644 index 00000000..3cee483f --- /dev/null +++ b/samples/spookyshots/requirements.txt @@ -0,0 +1,4 @@ +streamlit +cloudinary +python-dotenv +streamlit_option_menu From c9b3f4d1db8915f3418b38b9d5458e793dc9a072 Mon Sep 17 00:00:00 2001 From: cld-sec <121499964+cld-sec@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:01:21 +0200 Subject: [PATCH 09/15] Fix sample project vulnerabilities The following vulnerabilities are fixed by pinning transitive dependencies: - https://snyk.io/vuln/SNYK-PYTHON-TORNADO-8400708 Co-authored-by: snyk-bot --- samples/spookyshots/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/spookyshots/requirements.txt b/samples/spookyshots/requirements.txt index 3cee483f..2eb7e9ce 100644 --- a/samples/spookyshots/requirements.txt +++ b/samples/spookyshots/requirements.txt @@ -2,3 +2,4 @@ streamlit cloudinary python-dotenv streamlit_option_menu +tornado>=6.4.2 # not directly required, pinned by Snyk to avoid a vulnerability From 950936fce863c473c867ed3fec7e277a7cdd624c Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:33:58 +0200 Subject: [PATCH 10/15] Fix failing tests --- test/test_api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/test_api.py b/test/test_api.py index c93b07d1..ec44aaba 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1087,8 +1087,9 @@ def test_upload_mapping(self): result = api.upload_mapping(MAPPING_TEST_ID) self.assertEqual(result["template"], "http://res.cloudinary.com") result = api.upload_mappings() - self.assertIn({"folder": MAPPING_TEST_ID, "template": "http://res.cloudinary.com"}, - result["mappings"]) + self.assertTrue(any( + mapping.get("folder") == MAPPING_TEST_ID and mapping.get("template") == "http://res.cloudinary.com" for + mapping in result["mappings"])) api.delete_upload_mapping(MAPPING_TEST_ID) result = api.upload_mappings() self.assertNotIn(MAPPING_TEST_ID, [mapping.get("folder") for mapping in result["mappings"]]) From 1bb75150c31b11f3b91163b030450348c0dc5e39 Mon Sep 17 00:00:00 2001 From: Thomas Gurung <97883332+tommyg-cld@users.noreply.github.com> Date: Sun, 22 Dec 2024 14:50:39 +0000 Subject: [PATCH 11/15] Add support for `delete_backed_up_assets` Admin API --- cloudinary/api.py | 18 ++++++++++++++++++ test/test_api.py | 15 +++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cloudinary/api.py b/cloudinary/api.py index 5014d249..859ab6b3 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -358,6 +358,24 @@ def delete_derived_by_transformation(public_ids, transformations, return call_api("delete", uri, params, **options) +def delete_backed_up_assets(asset_id, version_ids, **options): + """ + Deletes backed up versions of a resource by asset IDs. + + :param asset_id: The asset ID of the asset to update. + :type asset_id: str + :param version_ids: The array of version IDs. + :type version_ids: list[str] + :param options: Additional options. + :type options: dict, optional + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "backup", asset_id] + params = {"version_ids": utils.build_array(version_ids)} + return call_json_api("delete", uri, params, **options) + + def add_related_assets(public_id, assets_to_relate, resource_type="image", type="upload", **options): """ Relates an asset to other assets by public IDs. diff --git a/test/test_api.py b/test/test_api.py index ec44aaba..e22a23f1 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -31,6 +31,8 @@ API_TEST_ID6 = "api_test_{}6".format(SUFFIX) API_TEST_ID7 = "api_test_{}7".format(SUFFIX) API_TEST_ASSET_ID = "4af5a0d1d4047808528b5425d166c101" +API_TEST_ASSET_ID_VERSION_ID = "ded32c1fa9b710b04574f0676133c00a" +API_TEST_ASSET_ID_VERSION_ID_2 = "aae2bae059d13e1ef0ec1742033bb5f7" API_TEST_ASSET_ID2 = "4af5a0d1d4047808528b5425d166c102" API_TEST_ASSET_ID3 = "4af5a0d1d4047808528b5425d166c103" API_TEST_TRANS = "api_test_transformation_{}".format(SUFFIX) @@ -586,6 +588,19 @@ def test_delete_related_assets_by_asset_ids(self, mocker): self.assertIn(API_TEST_ASSET_ID2, param) self.assertIn(API_TEST_ASSET_ID3, param) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_delete_backed_up_assets(self, mocker): + """ should allow deleting backed up versions of an asset by asset id""" + mocker.return_value = MOCK_RESPONSE + api.delete_backed_up_assets(API_TEST_ASSET_ID, [API_TEST_ASSET_ID_VERSION_ID, API_TEST_ASSET_ID_VERSION_ID_2]) + args, _ = mocker.call_args + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/backup/' + API_TEST_ASSET_ID)) + version_ids = get_json_body(mocker)['version_ids'] + self.assertIn(API_TEST_ASSET_ID_VERSION_ID, version_ids) + self.assertIn(API_TEST_ASSET_ID_VERSION_ID_2, version_ids) + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test10_tags(self, mocker): From deb4ae19cf1a7b7517814d470e4544c587faf9c5 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Sun, 22 Dec 2024 18:04:45 +0200 Subject: [PATCH 12/15] Fix `AuthToken` configuration consumption --- cloudinary/auth_token.py | 22 ++++++++++++++++++++++ test/test_auth_token.py | 16 ++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cloudinary/auth_token.py b/cloudinary/auth_token.py index 8ddeacdf..417c9901 100644 --- a/cloudinary/auth_token.py +++ b/cloudinary/auth_token.py @@ -12,6 +12,9 @@ def generate(url=None, acl=None, start_time=None, duration=None, expiration=None, ip=None, key=None, token_name=AUTH_TOKEN_NAME, **_): + start_time = _ensure_int(start_time) + duration = _ensure_int(duration) + expiration = _ensure_int(expiration) if expiration is None: if duration is not None: @@ -52,3 +55,22 @@ def _escape_to_lower(url): escaped_url = smart_escape(url, unsafe=AUTH_TOKEN_UNSAFE_RE) escaped_url = re.sub(r"%[0-9A-F]{2}", lambda x: x.group(0).lower(), escaped_url) return escaped_url + +def _ensure_int(value): + """ + Ensures the input value is an integer. + Attempts to cast it to an integer if it is not already. + + :param value: The value to ensure as an integer. + :type value: Any + :return: The integer value. + :rtype: int + :raises ValueError: If the value cannot be converted to an integer. + """ + if isinstance(value, int) or not value: + return value + + try: + return int(value) + except (ValueError, TypeError): + raise ValueError("Value '" + value + "' must be an integer.") diff --git a/test/test_auth_token.py b/test/test_auth_token.py index 9160731e..349365f9 100644 --- a/test/test_auth_token.py +++ b/test/test_auth_token.py @@ -11,9 +11,11 @@ class AuthTokenTest(unittest.TestCase): def setUp(self): self.url_backup = os.environ.get("CLOUDINARY_URL") - os.environ["CLOUDINARY_URL"] = "cloudinary://a:b@test123" + os.environ["CLOUDINARY_URL"] = ("cloudinary://a:b@test123?" + "auth_token[duration]=300" + "&auth_token[start_time]=11111111" + "&auth_token[key]=" + KEY) cloudinary.reset_config() - cloudinary.config(auth_token={"key": KEY, "duration": 300, "start_time": 11111111}) def tearDown(self): with ignore_exception(): @@ -93,6 +95,16 @@ def test_generate_token_string(self): "8e39600cc18cec339b21fe2b05fcb64b98de373355f8ce732c35710d8b10259f" ) + def test_generate_token_string_from_string_values(self): + user = "foobar" + token_options = {"key": KEY, "duration": "300", "acl": "/*/t_%s" % user, "start_time": "222222222"} + cookie_token = cloudinary.utils.generate_auth_token(**token_options) + self.assertEqual( + cookie_token, + "__cld_token__=st=222222222~exp=222222522~acl=%2f*%2ft_foobar~hmac=" + "8e39600cc18cec339b21fe2b05fcb64b98de373355f8ce732c35710d8b10259f" + ) + def test_should_ignore_url_if_acl_is_provided(self): token_options = {"key": KEY, "duration": 300, "acl": '/image/*', "start_time": 222222222} acl_token = cloudinary.utils.generate_auth_token(**token_options) From fe916055eb747c4b6c3c122eebc58e84a1d884c5 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Mon, 23 Dec 2024 15:13:00 +0200 Subject: [PATCH 13/15] Add support for `delete_resources_by_asset_ids` Admin API --- cloudinary/api.py | 15 +++++++++++++++ test/test_api.py | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/cloudinary/api.py b/cloudinary/api.py index 859ab6b3..b0d9ef55 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -299,6 +299,21 @@ def delete_resources(public_ids, **options): params = __delete_resource_params(options, public_ids=public_ids) return call_api("delete", uri, params, **options) +def delete_resources_by_asset_ids(asset_ids, **options): + """ + Deletes resources (assets) by asset IDs. + + :param asset_ids: The asset IDs of the assets to delete. + :type asset_ids: list[str] + :param options: Additional options. + :type options: dict, optional + :return: The result of the command. + :rtype: dict + """ + uri = ["resources"] + params = __delete_resource_params(options, asset_ids=asset_ids) + return call_json_api("delete", uri, params, **options) + def delete_resources_by_prefix(prefix, **options): resource_type = options.pop("resource_type", "image") diff --git a/test/test_api.py b/test/test_api.py index e22a23f1..cf82ea27 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -480,6 +480,19 @@ def test09_delete_resources(self, mocker): self.assertIn(API_TEST_ID, param) self.assertIn(API_TEST_ID2, param) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test09_delete_resources_by_asset_ids(self, mocker): + """ should allow deleting resources by asset_ids""" + mocker.return_value = MOCK_RESPONSE + api.delete_resources_by_asset_ids([API_TEST_ASSET_ID, API_TEST_ASSET_ID2]) + + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources')) + param = get_json_body(mocker)['asset_ids'] + self.assertIn(API_TEST_ASSET_ID, param) + self.assertIn(API_TEST_ASSET_ID2, param) + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test09a_delete_resources_by_prefix(self, mocker): From e77322dbd9f397b3766a2fedb0642be036c0bac5 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:11:36 +0200 Subject: [PATCH 14/15] Bump versions --- .travis.yml | 20 ++++++++++---------- README.md | 8 ++++---- pyproject.toml | 13 +++++-------- setup.py | 13 +++++-------- test/test_cloudinary_resource.py | 3 ++- tox.ini | 15 ++++++++------- 6 files changed, 34 insertions(+), 38 deletions(-) diff --git a/.travis.yml b/.travis.yml index abde8122..6490ba68 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,28 +4,28 @@ matrix: include: - python: 2.7 env: TOXENV=py27-core - - python: 3.7 - env: TOXENV=py37-core - - python: 3.8 - env: TOXENV=py38-core - python: 3.9 env: TOXENV=py39-core - python: 3.10 env: TOXENV=py310-core - python: 3.11 env: TOXENV=py311-core + - python: 3.12 + env: TOXENV=py312-core + - python: 3.13 + env: TOXENV=py313-core - python: 2.7 env: TOXENV=py27-django111 - - python: 3.7 - env: TOXENV=py37-django22 - python: 3.9 env: TOXENV=py39-django32 - python: 3.10 - env: TOXENV=py310-django40 - - python: 3.10 - env: TOXENV=py310-django41 + env: TOXENV=py310-django42 - python: 3.11 - env: TOXENV=py311-django41 + env: TOXENV=py311-django42 + - python: 3.12 + env: TOXENV=py312-django50 + - python: 3.13 + env: TOXENV=py313-django51 install: - pip install tox pytest diff --git a/README.md b/README.md index e116208a..93c29daf 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,12 @@ For the complete documentation, see the [Python SDK Guide](https://cloudinary.co ## Version Support | SDK Version | Python 2.7 | Python 3.x | -| ----------- | ---------- | ---------- | +|-------------|------------|------------| | 1.x | ✔ | ✔ | -| SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | -| ----------- | ----------- | ---------- | ---------- | ---------- | -| 1.x | ✔ | ✔ | ✔ | ✔ | +| SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | +|-------------|-------------|------------|------------|------------|------------| +| 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | ## Installation diff --git a/pyproject.toml b/pyproject.toml index af3a2b81..a11685e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,25 +12,22 @@ classifiers = [ "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 1.11", - "Framework :: Django :: 2.0", - "Framework :: Django :: 2.1", "Framework :: Django :: 2.2", - "Framework :: Django :: 3.0", - "Framework :: Django :: 3.1", "Framework :: Django :: 3.2", - "Framework :: Django :: 4.0", - "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Multimedia :: Graphics", diff --git a/setup.py b/setup.py index 8fc932c9..8c56143e 100644 --- a/setup.py +++ b/setup.py @@ -32,25 +32,22 @@ "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 1.11", - "Framework :: Django :: 2.0", - "Framework :: Django :: 2.1", "Framework :: Django :: 2.2", - "Framework :: Django :: 3.0", - "Framework :: Django :: 3.1", "Framework :: Django :: 3.2", - "Framework :: Django :: 4.0", - "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Multimedia :: Graphics", diff --git a/test/test_cloudinary_resource.py b/test/test_cloudinary_resource.py index 9c94abe9..099374d9 100644 --- a/test/test_cloudinary_resource.py +++ b/test/test_cloudinary_resource.py @@ -6,7 +6,7 @@ from cloudinary import CloudinaryResource from cloudinary import uploader from test.helper_test import SUFFIX, TEST_IMAGE, http_response_mock, get_uri, cleanup_test_resources_by_tag, \ - URLLIB3_REQUEST, mock + URLLIB3_REQUEST, mock, retry_assertion disable_warnings() @@ -96,6 +96,7 @@ def test_fetch_breakpoints_with_transformation(self, mocked_request): self.assertIn(self.crop_transformation_str + "/" + self.expected_transformation, get_uri(mocked_request)) + @retry_assertion() def test_fetch_breakpoints_real(self): """Should retrieve responsive breakpoints from cloudinary resource (real request)""" actual_breakpoints = self.res._fetch_breakpoints() diff --git a/tox.ini b/tox.ini index 14ab54d4..dee98c68 100644 --- a/tox.ini +++ b/tox.ini @@ -1,22 +1,23 @@ [tox] envlist = - py{27,37,38,39,310,311}-core + py{27,39,310,311,312,313}-core py{27}-django{111} - py{37,38,39,310,311}-django{22,32,40,41} + py{39,310,311,312,313}-django{32,42,50,51} + [testenv] usedevelop = True commands = core: python -m pytest test - django{111,22,32}: django-admin.py test -v2 django_tests {env:D_ARGS:} - django{40,41}: django-admin test -v2 django_tests {env:D_ARGS:} + django{111,32}: django-admin.py test -v2 django_tests {env:D_ARGS:} + django{42,50,51}: django-admin test -v2 django_tests {env:D_ARGS:} passenv = * deps = pytest py27: mock django111: Django>=1.11,<1.12 - django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 - django40: Django>=4.0,<4.1 - django41: Django>=4.1,<4.2 + django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 setenv = DJANGO_SETTINGS_MODULE=django_tests.settings From 6a4f5186974b5d2ea4dae35a26ea8739bd356d9a Mon Sep 17 00:00:00 2001 From: Constantine Nathanson <35217733+const-cloudinary@users.noreply.github.com> Date: Mon, 23 Dec 2024 16:59:49 +0200 Subject: [PATCH 15/15] Add support for `restore_by_asset_ids` Admin API --- cloudinary/api.py | 14 ++++++++++++++ test/test_api.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/cloudinary/api.py b/cloudinary/api.py index b0d9ef55..cf9dbeb2 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -624,6 +624,20 @@ def restore(public_ids, **options): params = dict(public_ids=public_ids, **only(options, "versions")) return call_json_api("post", uri, params, **options) +def restore_by_asset_ids(asset_ids, **options): + """ + Restores resources (assets) by their asset IDs. + + :param asset_ids: The asset IDs of the assets to restore. + :type asset_ids: list[str] + :param options: Additional options. + :type options: dict, optional + :return: The result of the restore operation. + :rtype: dict + """ + uri = ["resources", "restore"] + params = dict(asset_ids=asset_ids, **only(options, "versions")) + return call_json_api("post", uri, params, **options) def upload_mappings(**options): uri = ["upload_mappings"] diff --git a/test/test_api.py b/test/test_api.py index cf82ea27..24cddf26 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1105,6 +1105,21 @@ def test_restore_versions(self, mocker): self.assertListEqual(public_ids, json_body["public_ids"]) self.assertListEqual(versions, json_body["versions"]) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_restore_by_asset_ids(self, mocker): + mocker.return_value = MOCK_RESPONSE + + asset_ids = [API_TEST_ASSET_ID, API_TEST_ASSET_ID2] + versions = ["ver1", "ver2"] + + api.restore_by_asset_ids(asset_ids, versions=versions) + + json_body = get_json_body(mocker) + + self.assertListEqual(asset_ids, json_body["asset_ids"]) + self.assertListEqual(versions, json_body["versions"]) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_mapping(self):