From a73d4a99bf4e0de18ae6eec1aadf6b6aaaac7d37 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Mon, 29 Jun 2026 15:41:19 +0300 Subject: [PATCH 1/3] Make metadata field tests more permissive - Add CldTestCase.assertObjectContainsSubset for recursive dict-subset assertions, allowing the server to return extra keys. - Use it in metadata field assertions so added restriction defaults (hidden_ui, excluded_from_search) no longer fail the test. - Drop the conflicting mandatory+readonly combination in the update test (server rejects it) while keeping restrictions coverage. - Reuse the helper across metadata reorder and uploader tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/helper_test.py | 12 ++++++++++++ test/test_metadata.py | 23 ++++++++--------------- test/test_uploader.py | 29 +++++++++++++++++------------ 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/test/helper_test.py b/test/helper_test.py index c4579cd9..616a771b 100644 --- a/test/helper_test.py +++ b/test/helper_test.py @@ -351,3 +351,15 @@ def count_elements(lst): if count1 != count2: standard_msg = '%s != %s' % (count1, count2) self.fail(self._formatMessage(msg, standard_msg)) + + def assertObjectContainsSubset(self, actual, expected, msg=None): + """ + Fail unless every key/value pair in expected is present in actual. + Nested dicts are compared recursively, so actual may contain extra keys. + """ + for key, value in expected.items(): + self.assertIn(key, actual, msg) + if isinstance(value, dict): + self.assertObjectContainsSubset(actual[key], value, msg) + else: + self.assertEqual(actual[key], value, msg) diff --git a/test/test_metadata.py b/test/test_metadata.py index 9870f784..8f212162 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -10,7 +10,7 @@ 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, patch + URLLIB3_REQUEST, patch, CldTestCase ) MOCK_RESPONSE = api_response_mock() @@ -105,7 +105,7 @@ disable_warnings() -class MetadataTest(unittest.TestCase): +class MetadataTest(CldTestCase): @classmethod def setUpClass(cls): cloudinary.reset_config() @@ -151,9 +151,7 @@ def assert_metadata_field(self, metadata_field, field_type=None, values=None): if metadata_field["type"] in ["enum", "set"]: self.assert_metadata_field_datasource(metadata_field["datasource"]) - values = values or {} - for key, value in values.items(): - self.assertEqual(metadata_field[key], value) + self.assertObjectContainsSubset(metadata_field, values or {}) def assert_metadata_field_datasource(self, datasource): """Asserts that a given object fits the generic structure of a metadata field datasource @@ -308,17 +306,15 @@ def test08_update_metadata_field(self): "external_id": EXTERNAL_ID_SET, "label": new_label, "type": "integer", - "mandatory": True, "default_value": new_default_value, - "restrictions": {"readonly_ui": True} + "restrictions": {"readonly_ui": True}, }) self.assert_metadata_field(result, "string", { "external_id": EXTERNAL_ID_GENERAL, "label": new_label, "default_value": new_default_value, - "mandatory": True, - "restrictions": {"readonly_ui": True} + "restrictions": {"readonly_ui": True}, }) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -501,8 +497,7 @@ def test_reorder_metadata_fields_by_label(self, mocker): self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order")) self.assertEqual(get_method(mocker), "PUT") - self.assertEqual(get_json_body(mocker)['order_by'], "label") - self.assertEqual(get_json_body(mocker)['direction'], "asc") + self.assertObjectContainsSubset(get_json_body(mocker), {"order_by": "label", "direction": "asc"}) @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -513,8 +508,7 @@ def test_reorder_metadata_fields_by_external_id(self, mocker): self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order")) self.assertEqual(get_method(mocker), "PUT") - self.assertEqual(get_json_body(mocker)['order_by'], "external_id") - self.assertEqual(get_json_body(mocker)['direction'], "desc") + self.assertObjectContainsSubset(get_json_body(mocker), {"order_by": "external_id", "direction": "desc"}) @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -525,8 +519,7 @@ def test_reorder_metadata_fields_by_created_at(self, mocker): self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order")) self.assertEqual(get_method(mocker), "PUT") - self.assertEqual(get_json_body(mocker)['order_by'], "created_at") - self.assertEqual(get_json_body(mocker)['direction'], "asc") + self.assertObjectContainsSubset(get_json_body(mocker), {"order_by": "created_at", "direction": "asc"}) if __name__ == "__main__": diff --git a/test/test_uploader.py b/test/test_uploader.py index b9e1d395..d05d9eec 100644 --- a/test/test_uploader.py +++ b/test/test_uploader.py @@ -196,11 +196,12 @@ def test_upload_unicode_filename(self): result = uploader.upload(TEST_UNICODE_IMAGE, tags=[UNIQUE_TAG], use_filename=True, unique_filename=False) - self.assertEqual(result["width"], TEST_IMAGE_WIDTH) - self.assertEqual(result["height"], TEST_IMAGE_HEIGHT) - - self.assertEqual(expected_name, result["public_id"]) - self.assertEqual(expected_name, result["original_filename"]) + self.assertObjectContainsSubset(result, { + "width": TEST_IMAGE_WIDTH, + "height": TEST_IMAGE_HEIGHT, + "public_id": expected_name, + "original_filename": expected_name, + }) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_file_io_without_filename(self): @@ -211,9 +212,11 @@ def test_upload_file_io_without_filename(self): result = uploader.upload(temp_file, tags=[UNIQUE_TAG]) - self.assertEqual(result["width"], TEST_IMAGE_WIDTH) - self.assertEqual(result["height"], TEST_IMAGE_HEIGHT) - self.assertEqual('stream', result["original_filename"]) + self.assertObjectContainsSubset(result, { + "width": TEST_IMAGE_WIDTH, + "height": TEST_IMAGE_HEIGHT, + "original_filename": "stream", + }) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_custom_filename(self): @@ -794,11 +797,13 @@ def test_upload_large(self): use_filename=True, unique_filename=False, filename=filename) self.assertCountEqual(resource2["tags"], ["upload_large_tag", UNIQUE_TAG]) - self.assertEqual(resource2["resource_type"], "image") - self.assertEqual(resource2["original_filename"], filename) + self.assertObjectContainsSubset(resource2, { + "resource_type": "image", + "original_filename": filename, + "width": LARGE_FILE_WIDTH, + "height": LARGE_FILE_HEIGHT, + }) self.assertEqual(resource2["original_filename"], resource2["public_id"]) - self.assertEqual(resource2["width"], LARGE_FILE_WIDTH) - self.assertEqual(resource2["height"], LARGE_FILE_HEIGHT) resource3 = uploader.upload_large(temp_file_name, chunk_size=LARGE_FILE_SIZE, tags=["upload_large_tag", UNIQUE_TAG]) From 9801e8b3432b36d4d8fabe7723c8557b8e649970 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Mon, 29 Jun 2026 15:50:53 +0300 Subject: [PATCH 2/3] CI: refresh Python and Django version matrix - Add Python 3.14 and Django 5.2 LTS / 6.0. - Drop EOL Python 3.9 and Django 3.2. - Pin django60 to Python 3.12+ (its minimum) in the matrix. - Update classifiers in setup.py and pyproject.toml to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 10 ++++++---- pyproject.toml | 3 ++- setup.py | 5 +++-- tox.ini | 12 +++++++----- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20499990..11aa9b5d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,8 +9,6 @@ jobs: strategy: matrix: include: - - python-version: "3.9" - toxenv: py39-core - python-version: "3.10" toxenv: py310-core - python-version: "3.11" @@ -19,8 +17,8 @@ jobs: toxenv: py312-core - python-version: "3.13" toxenv: py313-core - - python-version: "3.9" - toxenv: py39-django32 + - python-version: "3.14" + toxenv: py314-core - python-version: "3.10" toxenv: py310-django42 - python-version: "3.11" @@ -29,6 +27,10 @@ jobs: toxenv: py312-django50 - python-version: "3.13" toxenv: py313-django51 + - python-version: "3.13" + toxenv: py313-django52 + - python-version: "3.14" + toxenv: py314-django60 steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index a7177738..020604ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,11 @@ classifiers = [ "Framework :: Django", "Framework :: Django :: 1.11", "Framework :: Django :: 2.2", - "Framework :: Django :: 3.2", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", diff --git a/setup.py b/setup.py index 7f794056..a28c9a75 100644 --- a/setup.py +++ b/setup.py @@ -33,21 +33,22 @@ "Framework :: Django", "Framework :: Django :: 1.11", "Framework :: Django :: 2.2", - "Framework :: Django :: 3.2", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", "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.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Multimedia :: Graphics", diff --git a/tox.ini b/tox.ini index dee98c68..42441ce8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,23 +1,25 @@ [tox] envlist = - py{27,39,310,311,312,313}-core + py{27,310,311,312,313,314}-core py{27}-django{111} - py{39,310,311,312,313}-django{32,42,50,51} + py{310,311,312,313,314}-django{42,50,51,52} + py{312,313,314}-django{60} [testenv] usedevelop = True commands = core: python -m pytest test - 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:} + django{111}: django-admin.py test -v2 django_tests {env:D_ARGS:} + django{42,50,51,52,60}: django-admin test -v2 django_tests {env:D_ARGS:} passenv = * deps = pytest py27: mock django111: Django>=1.11,<1.12 - django32: Django>=3.2,<3.3 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 + django52: Django>=5.2,<5.3 + django60: Django>=6.0,<6.1 setenv = DJANGO_SETTINGS_MODULE=django_tests.settings From 2c7375d1805f87b37a0a92c4302ed940963aa1c9 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Mon, 29 Jun 2026 16:10:42 +0300 Subject: [PATCH 3/3] Update python version in CI --- .github/workflows/test.yml | 14 +++++++++----- README.md | 6 +++--- pyproject.toml | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11aa9b5d..214cbefd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,14 +21,18 @@ jobs: toxenv: py314-core - python-version: "3.10" toxenv: py310-django42 - - python-version: "3.11" - toxenv: py311-django42 - - python-version: "3.12" - toxenv: py312-django50 + - python-version: "3.10" + toxenv: py310-django50 + - python-version: "3.10" + toxenv: py310-django51 + - python-version: "3.10" + toxenv: py310-django52 - python-version: "3.13" - toxenv: py313-django51 + toxenv: py313-django42 - python-version: "3.13" toxenv: py313-django52 + - python-version: "3.12" + toxenv: py312-django60 - python-version: "3.14" toxenv: py314-django60 diff --git a/README.md b/README.md index 63b17053..13443b71 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ For the complete documentation, see the [Python SDK Guide](https://cloudinary.co |-------------|------------|------------| | 1.x | ✔ | ✔ | -| SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | -|-------------|-------------|------------|------------|------------|------------| -| 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | +| SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | Django 6.x | +|-------------|-------------|------------|------------|------------|------------|------------| +| 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ## Installation diff --git a/pyproject.toml b/pyproject.toml index 020604ee..4f693008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,11 @@ classifiers = [ "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "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", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Multimedia :: Graphics",