├── test ├── __init__.py ├── cache │ ├── __init__.py │ ├── adapter │ │ ├── __init__.py │ │ └── test_key_value_cache_adapter.py │ ├── storage │ │ ├── __init__.py │ │ ├── dummy_cache_storage.py │ │ └── test_file_system_key_value_storage.py │ └── test_responsive_breakpoints_cache.py ├── helper_test.py ├── resources │ ├── docx.docx │ ├── logo.png │ ├── favicon.ico │ └── üni_näme_lögö.png ├── utils │ └── test_unique.py ├── addon_types.py ├── test_http_client.py ├── test_streaming_profiles.py ├── test_api_authorization.py ├── test_cloudinary_resource.py ├── test_expression_normalization.py ├── test_archive.py ├── test_config.py ├── test_auth_token.py └── test_metadata_rules.py ├── django_tests ├── admin.py ├── tests.py ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_remove_poll_pub_date.py │ ├── 0003_add_poll_width_and_height.py │ └── 0001_initial.py ├── apps.py ├── views.py ├── urls.py ├── test_user_agent.py ├── helper_test.py ├── forms.py ├── settings.py ├── models.py ├── test_cloudinary_file_field.py └── test_cloudinaryField.py ├── cloudinary ├── cache │ ├── __init__.py │ ├── adapter │ │ ├── __init__.py │ │ ├── cache_adapter.py │ │ └── key_value_cache_adapter.py │ ├── storage │ │ ├── __init__.py │ │ ├── key_value_storage.py │ │ └── file_system_key_value_storage.py │ └── responsive_breakpoints_cache.py ├── .gitignore ├── api_client │ ├── __init__.py │ ├── call_account_api.py │ ├── execute_request.py │ ├── call_api.py │ └── tcp_keep_alive_manager.py ├── templatetags │ ├── __init__.py │ └── cloudinary.py ├── templates │ ├── cloudinary_js_config.html │ ├── cloudinary_direct_upload.html │ └── cloudinary_includes.html ├── search_folders.py ├── exceptions.py ├── provisioning │ ├── __init__.py │ └── account_config.py ├── compat.py ├── poster │ ├── __init__.py │ └── streaminghttp.py ├── http_client.py ├── auth_token.py ├── search.py ├── forms.py └── models.py ├── samples ├── gae │ ├── .gitignore │ ├── appengine_config.py │ ├── cloudinary.yaml.sample │ ├── favicon.ico │ ├── requirements.txt │ ├── app.yaml │ ├── index.yaml │ ├── index.html │ ├── README.md │ └── main.py ├── basic │ ├── lake.jpg │ ├── pizza.jpg │ ├── README.md │ └── basic.py ├── spookyshots │ ├── .env.example │ ├── requirements.txt │ ├── README.md │ └── main.py ├── basic_flask │ ├── templates │ │ └── upload_form.html │ ├── README.md │ └── app.py └── README.md ├── LICENSE.txt ├── MANIFEST.in ├── tools ├── get_test_cloud.sh ├── allocate_test_cloud.sh └── update_version.sh ├── prepare.sh ├── scripts └── version.sh ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── test.yml ├── tox.ini ├── .gitignore ├── pyproject.toml ├── setup.py ├── CONTRIBUTING.md └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_tests/admin.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_tests/tests.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/cache/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cloudinary/cache/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cloudinary/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /cloudinary/api_client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/cache/adapter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/cache/storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cloudinary/cache/adapter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cloudinary/cache/storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cloudinary/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /samples/gae/.gitignore: -------------------------------------------------------------------------------- 1 | gaenv_lib 2 | cloudinary.yaml -------------------------------------------------------------------------------- /samples/gae/appengine_config.py: -------------------------------------------------------------------------------- 1 | import gaenv_lib 2 | -------------------------------------------------------------------------------- /test/helper_test.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/test/helper_test.py -------------------------------------------------------------------------------- /samples/basic/lake.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/samples/basic/lake.jpg -------------------------------------------------------------------------------- /samples/gae/cloudinary.yaml.sample: -------------------------------------------------------------------------------- 1 | env_variables: 2 | CLOUDINARY_URL: cloudinary://xxxxx:yyyyyy@zzzzz -------------------------------------------------------------------------------- /samples/basic/pizza.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/samples/basic/pizza.jpg -------------------------------------------------------------------------------- /samples/gae/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/samples/gae/favicon.ico -------------------------------------------------------------------------------- /test/resources/docx.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/test/resources/docx.docx -------------------------------------------------------------------------------- /test/resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/test/resources/logo.png -------------------------------------------------------------------------------- /test/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/test/resources/favicon.ico -------------------------------------------------------------------------------- /test/resources/üni_näme_lögö.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary/pycloudinary/HEAD/test/resources/üni_näme_lögö.png -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Released under the MIT license. 2 | 3 | Contains MIT licensed code from https://bitbucket.org/chrisatlee/poster 4 | 5 | 6 | -------------------------------------------------------------------------------- /django_tests/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoTestsConfig(AppConfig): 5 | name = 'django_tests' 6 | -------------------------------------------------------------------------------- /cloudinary/templates/cloudinary_js_config.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | recursive-include cloudinary/templates *.html 3 | recursive-include cloudinary/static *.html *.js 4 | prune django_tests 5 | -------------------------------------------------------------------------------- /django_tests/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | # Create your views here. 5 | def index(request): 6 | return render(request=request) 7 | -------------------------------------------------------------------------------- /samples/spookyshots/.env.example: -------------------------------------------------------------------------------- 1 | CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name 2 | CLOUDINARY_API_KEY=your_cloudinary_api_key 3 | CLOUDINARY_API_SECRET=your_cloudinary_api_secret -------------------------------------------------------------------------------- /samples/gae/requirements.txt: -------------------------------------------------------------------------------- 1 | git+git://github.com/cloudinary/pycloudinary.git 2 | docopt==0.6.2 3 | gaenv==0.1.10.post0 4 | six==1.10.0 5 | urllib3==1.* 6 | webapp2==2.5.2 7 | setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability 8 | -------------------------------------------------------------------------------- /cloudinary/search_folders.py: -------------------------------------------------------------------------------- 1 | from cloudinary import Search 2 | 3 | 4 | class SearchFolders(Search): 5 | FOLDERS = 'folders' 6 | 7 | def __init__(self): 8 | super(SearchFolders, self).__init__() 9 | 10 | self.endpoint(self.FOLDERS) 11 | -------------------------------------------------------------------------------- /samples/spookyshots/requirements.txt: -------------------------------------------------------------------------------- 1 | streamlit 2 | cloudinary 3 | python-dotenv 4 | streamlit_option_menu 5 | tornado>=6.4.2 # not directly required, pinned by Snyk to avoid a vulnerability 6 | urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability 7 | -------------------------------------------------------------------------------- /django_tests/urls.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if int(django.__version__[0]) > 3: 4 | from django.urls import re_path as url 5 | else: 6 | from django.conf.urls import url 7 | 8 | from .views import index 9 | 10 | urlpatterns = [ 11 | url(r'^polls$', view=index, name='index'), 12 | ] 13 | -------------------------------------------------------------------------------- /tools/get_test_cloud.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 4 | 5 | PY_VER=$(python -V 2>&1 | head -n 1 | cut -d ' ' -f 2); 6 | SDK_VER=$(grep -oiP '(?<=version \= \")([a-zA-Z0-9\-.]+)(?=")' setup.py) 7 | 8 | 9 | bash ${DIR}/allocate_test_cloud.sh "Python ${PY_VER} SDK ${SDK_VER}" 10 | -------------------------------------------------------------------------------- /django_tests/test_user_agent.py: -------------------------------------------------------------------------------- 1 | import six 2 | from django.test import TestCase 3 | 4 | import cloudinary 5 | 6 | 7 | class TestUserAgent(TestCase): 8 | def test_django_user_agent(self): 9 | agent = cloudinary.get_user_agent() 10 | 11 | six.assertRegex(self, agent, 12 | r'^Django\/\d\.\d+\.?\d* CloudinaryPython\/\d\.\d+\.\d+ \(.*; Python \d\.\d+\.\d+\)$') 13 | -------------------------------------------------------------------------------- /samples/gae/app.yaml: -------------------------------------------------------------------------------- 1 | application: gae 2 | version: 1 3 | runtime: python27 4 | api_version: 1 5 | threadsafe: yes 6 | 7 | handlers: 8 | - url: /favicon\.ico 9 | static_files: favicon.ico 10 | upload: favicon\.ico 11 | 12 | - url: .* 13 | script: main.app 14 | 15 | libraries: 16 | - name: webapp2 17 | version: "2.5.2" 18 | - name: ssl 19 | version: 2.7.11 20 | 21 | includes: 22 | - cloudinary.yaml 23 | -------------------------------------------------------------------------------- /tools/allocate_test_cloud.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | API_ENDPOINT="https://sub-account-testing.cloudinary.com/create_sub_account" 4 | 5 | SDK_NAME="${1}" 6 | 7 | CLOUD_DETAILS=$(curl -sS -d "{\"prefix\" : \"${SDK_NAME}\"}" "${API_ENDPOINT}") 8 | 9 | echo ${CLOUD_DETAILS} | python -c 'import json,sys;c=json.load(sys.stdin)["payload"];print("cloudinary://%s:%s@%s" % (c["cloudApiKey"], c["cloudApiSecret"], c["cloudName"]))' 10 | -------------------------------------------------------------------------------- /cloudinary/templates/cloudinary_direct_upload.html: -------------------------------------------------------------------------------- 1 |
2 | {% for name, value in params.items %} 3 | 4 | {% endfor %} 5 | {% block extra %} {% endblock %} 6 | {% block file %} 7 | 8 | {% endblock %} 9 | {% block submit %} 10 | 11 | {% endblock %} 12 |
13 | -------------------------------------------------------------------------------- /django_tests/helper_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | 4 | SUFFIX = os.environ.get('TRAVIS_JOB_ID') or random.randint(10000, 99999) 5 | 6 | RESOURCES_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "test", "resources") 7 | TEST_IMAGE = os.path.join(RESOURCES_PATH, "logo.png") 8 | TEST_TAG = "pycloudinary_test" 9 | UNIQUE_TAG = "{0}_{1}".format(TEST_TAG, SUFFIX) 10 | UNIQUE_TEST_ID = UNIQUE_TAG 11 | 12 | TEST_IMAGE_W = 241 13 | TEST_IMAGE_H = 51 14 | -------------------------------------------------------------------------------- /cloudinary/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright Cloudinary 2 | 3 | 4 | class Error(Exception): 5 | pass 6 | 7 | 8 | class NotFound(Error): 9 | pass 10 | 11 | 12 | class NotAllowed(Error): 13 | pass 14 | 15 | 16 | class AlreadyExists(Error): 17 | pass 18 | 19 | 20 | class RateLimited(Error): 21 | pass 22 | 23 | 24 | class BadRequest(Error): 25 | pass 26 | 27 | 28 | class GeneralError(Error): 29 | pass 30 | 31 | 32 | class AuthorizationRequired(Error): 33 | pass 34 | -------------------------------------------------------------------------------- /django_tests/migrations/0002_remove_poll_pub_date.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.3 on 2016-11-23 08:17 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('django_tests', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='poll', 17 | name='pub_date', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /django_tests/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from cloudinary.forms import CloudinaryJsFileField 4 | 5 | 6 | class CloudinaryJsTestFileForm(forms.Form): 7 | js_file_field = CloudinaryJsFileField( 8 | attrs={ 9 | 'style': "margin-top: 30px" 10 | }, 11 | options={ 12 | 'tags': "directly_uploaded", 13 | 'crop': 'limit', 'width': 1000, 'height': 1000, 14 | 'eager': [{'crop': 'fill', 'width': 150, 'height': 100}] 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /samples/gae/index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | 3 | # AUTOGENERATED 4 | 5 | # This index.yaml is automatically updated whenever the dev_appserver 6 | # detects that a new type of query is run. If you want to manage the 7 | # index.yaml file manually, remove the above marker line (the line 8 | # saying "# AUTOGENERATED"). If you want to manage some indexes 9 | # manually, move them above the marker line. The index.yaml file is 10 | # automatically uploaded to the admin console when you next deploy 11 | # your application using appcfg.py. 12 | 13 | -------------------------------------------------------------------------------- /prepare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /bin/rm -rf cloudinary/static/cloudinary 4 | mkdir -p cloudinary/static/cloudinary 5 | cd cloudinary/static/cloudinary 6 | 7 | OPTIONS= 8 | 9 | # GNU tar does not support wildcards by default, it needs explicit --wildcards option, 10 | # while BSD tar does not recognize this option 11 | if tar --version | grep -q 'gnu'; then 12 | OPTIONS='--wildcards' 13 | fi 14 | 15 | curl -L https://github.com/cloudinary/cloudinary_js/tarball/master | tar zxvf - --strip=1 --exclude test $OPTIONS '*/html' '*/js' 16 | 17 | -------------------------------------------------------------------------------- /cloudinary/provisioning/__init__.py: -------------------------------------------------------------------------------- 1 | from .account_config import AccountConfig, account_config, reset_config 2 | from .account import (sub_accounts, create_sub_account, delete_sub_account, sub_account, update_sub_account, 3 | user_groups, create_user_group, update_user_group, delete_user_group, user_group, 4 | add_user_to_group, remove_user_from_group, user_group_users, user_in_user_groups, 5 | users, create_user, delete_user, user, update_user, access_keys, generate_access_key, 6 | update_access_key, delete_access_key, Role) 7 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | GIT_ROOT=$(git rev-parse --show-toplevel) 3 | CURRENT_DIR=$PWD 4 | cd $GIT_ROOT 5 | echo Updating version to $1 6 | sed -E -i.bak "s/version = \'[0-9]\.[0-9]\.[0-9]+\'/\version = \'$1\'/" setup.py 7 | grep -HEo "version = '[0-9]\.[0-9]\.[0-9]+'" setup.py 8 | sed -E -i.bak "s/VERSION = \"[0-9]\.[0-9]\.[0-9]+\"/\VERSION = \"$1\"/" cloudinary/__init__.py 9 | grep -HEo "VERSION = \"[0-9]\.[0-9]\.[0-9]+\"" cloudinary/__init__.py 10 | #git add setup.py cloudinary/__init__.py CHANGELOG.md 11 | #git commit -m "Version $1" 12 | #git tag -a $1 -m "Version $1" 13 | cd $CURRENT_DIR -------------------------------------------------------------------------------- /test/cache/storage/dummy_cache_storage.py: -------------------------------------------------------------------------------- 1 | from cloudinary.cache.storage.key_value_storage import KeyValueStorage 2 | 3 | 4 | class DummyCacheStorage(KeyValueStorage): 5 | def __init__(self): 6 | self._dummy_cache = dict() 7 | 8 | def get(self, key): 9 | return self._dummy_cache.get(key, None) 10 | 11 | def set(self, key, value): 12 | self._dummy_cache[key] = value 13 | 14 | return True 15 | 16 | def delete(self, key): 17 | self._dummy_cache.pop(key, None) 18 | 19 | return True 20 | 21 | def clear(self): 22 | self._dummy_cache = dict() -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Feature request for Cloudinary Python SDK 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Feature request for Cloudinary Python SDK 11 | …(If your feature is for other SDKs, please request them there) 12 | 13 | 14 | ## Explain your use case 15 | … (A high level explanation of why you need this feature) 16 | 17 | ## Describe the problem you’re trying to solve 18 | … (A more technical view of what you’d like to accomplish, and how this feature will help you achieve it) 19 | 20 | ## Do you have a proposed solution? 21 | … (yes, no? Please elaborate if needed) 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,39,310,311,312,313}-core 4 | py{27}-django{111} 5 | py{39,310,311,312,313}-django{32,42,50,51} 6 | 7 | [testenv] 8 | usedevelop = True 9 | commands = 10 | core: python -m pytest test 11 | django{111,32}: django-admin.py test -v2 django_tests {env:D_ARGS:} 12 | django{42,50,51}: django-admin test -v2 django_tests {env:D_ARGS:} 13 | passenv = * 14 | deps = 15 | pytest 16 | py27: mock 17 | django111: Django>=1.11,<1.12 18 | django32: Django>=3.2,<3.3 19 | django42: Django>=4.2,<4.3 20 | django50: Django>=5.0,<5.1 21 | django51: Django>=5.1,<5.2 22 | setenv = 23 | DJANGO_SETTINGS_MODULE=django_tests.settings 24 | -------------------------------------------------------------------------------- /samples/gae/index.html: -------------------------------------------------------------------------------- 1 | 2 | Upload new Image 3 |

Upload new Image

4 |
5 |

6 | 7 |

8 | {% if image_url %} 9 |

Upload API Result

10 |
{{ image_url }}
11 |

Thumbnails:

12 |
{{thumbnail_url1}}
13 | 14 |
{{thumbnail_url2}}
15 | 16 |

Original:

17 | 18 | {% endif %} -------------------------------------------------------------------------------- /samples/basic_flask/templates/upload_form.html: -------------------------------------------------------------------------------- 1 | 2 | Upload new Image 3 |

Upload new Image

4 |
5 |

6 | 7 |

8 | {% if upload_result %} 9 |

Upload API Result

10 |
{{ upload_result }}
11 |

Thumbnails:

12 |
{{thumbnail_url1}}
13 | 14 |
{{thumbnail_url2}}
15 | 16 |

Original:

17 | 18 | {% endif %} -------------------------------------------------------------------------------- /django_tests/settings.py: -------------------------------------------------------------------------------- 1 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 2 | import os 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 5 | 6 | SECRET_KEY = "secret_key_for_testing" 7 | 8 | DEBUG = True 9 | 10 | TEMPLATE_DEBUG = True 11 | 12 | INSTALLED_APPS = ( 13 | 'cloudinary', 14 | 'django_tests' 15 | ) 16 | 17 | DATABASES = { 18 | 'default': { 19 | 'ENGINE': 'django.db.backends.sqlite3', 20 | 'NAME': ":memory:", 21 | # 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 22 | } 23 | } 24 | 25 | CACHES = { 26 | 'default': { 27 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 28 | } 29 | } 30 | 31 | 32 | ROOT_URLCONF = 'django_tests.urls' 33 | -------------------------------------------------------------------------------- /samples/basic_flask/README.md: -------------------------------------------------------------------------------- 1 | Cloudinary Flask sample project 2 | =============================== 3 | 4 | A simple Flask application that performs image upload and generates on the transformations of the uploaded image. 5 | 6 | ## Installing and running 7 | 8 | 1. Install [Python](http://www.python.org/getit/) 9 | 1. Install [Cloudinary python egg](https://github.com/cloudinary/pycloudinary#setup) 10 | 1. Get [a cloudinary account](https://cloudinary.com/users/register/free) 11 | 1. Copy the `CLOUDINARY_URL` environment variable from the [Management Console](https://cloudinary.com/console): 12 | 1. Run the server: 13 | 14 | $ CLOUDINARY_URL=cloudinary://API-Key:API-Secret@Cloud-name python app.py 15 | 1. Browse to http://127.0.0.1:5000/ 16 | 17 | Good luck! 18 | -------------------------------------------------------------------------------- /django_tests/migrations/0003_add_poll_width_and_height.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.9 on 2018-02-01 09:17 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('django_tests', '0002_remove_poll_pub_date'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='poll', 17 | name='image_height', 18 | field=models.PositiveIntegerField(null=True), 19 | ), 20 | migrations.AddField( 21 | model_name='poll', 22 | name='image_width', 23 | field=models.PositiveIntegerField(null=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /samples/gae/README.md: -------------------------------------------------------------------------------- 1 | Cloudinary Google App Engine sample project 2 | =========================================== 3 | 4 | A simple GAE application that performs image upload and generates on the transformations of the uploaded image. 5 | 6 | ## Installing and running 7 | 8 | 1. Install [Python](http://www.python.org/getit/) 9 | 1. Install [pip](https://pip.pypa.io/en/latest/installing.html) 10 | 1. Install [gaenv](https://pypi.python.org/pypi/gaenv) 11 | 1. Install the [Google App Engine SDK](https://developers.google.com/appengine/downloads) 12 | 1. Get [a cloudinary account](https://cloudinary.com/users/register/free) 13 | 1. Copy the `CLOUDINARY_URL` environment variable from the [Management Console](https://cloudinary.com/console) into cloudinary.yaml (see `cloudinary.yaml.sample`) 14 | 1. Run `pip install -r requirements.txt` 15 | 1. Run `gaenv` 16 | 1. Launch your application in development mode with GoogleAppEngineLauncher 17 | 18 | Good luck! 19 | -------------------------------------------------------------------------------- /django_tests/models.py: -------------------------------------------------------------------------------- 1 | from six import python_2_unicode_compatible 2 | 3 | from cloudinary.models import CloudinaryField 4 | from django.db import models 5 | 6 | 7 | @python_2_unicode_compatible 8 | class Poll(models.Model): 9 | id = models.AutoField(primary_key=True) 10 | question = models.CharField(max_length=200) 11 | image_width = models.PositiveIntegerField(null=True) 12 | image_height = models.PositiveIntegerField(null=True) 13 | image = CloudinaryField('image', null=True, width_field='image_width', height_field='image_height') 14 | 15 | def __str__(self): 16 | return self.question 17 | 18 | 19 | @python_2_unicode_compatible 20 | class Choice(models.Model): 21 | id = models.AutoField(primary_key=True) 22 | poll = models.ForeignKey(Poll, on_delete=models.CASCADE) 23 | choice = models.CharField(max_length=200) 24 | votes = models.IntegerField() 25 | 26 | def __str__(self): 27 | return self.choice.encode() 28 | -------------------------------------------------------------------------------- /cloudinary/templates/cloudinary_includes.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if processing %} 9 | 10 | 11 | 12 | 13 | 14 | {% endif %} 15 | -------------------------------------------------------------------------------- /test/utils/test_unique.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from cloudinary.utils import unique 4 | 5 | 6 | class UniqueTest(unittest.TestCase): 7 | def test_when_collection_is_array_with_no_key_function(self): 8 | self.assertEqual( 9 | unique(["image", "picture", "banana", "image", "picture"]), 10 | ["image", "picture", "banana"] 11 | ) 12 | 13 | def test_when_collection_is_array_with_key_function(self): 14 | self.assertEqual( 15 | unique(["image", "word1", "picture", "banana", "image", "picture"], key=len), 16 | ["image", "picture", "banana"] 17 | ) 18 | 19 | def test_handles_hashes_with_correct_key_function(self): 20 | self.assertEqual( 21 | unique( 22 | [{"image": "up"}, {"picture": 1}, {"banana": "left"}, {"image": "down"}, {"picture": 0}], 23 | key=lambda x: next(iter(x))), 24 | [{"image": "down"}, {"picture": 0}, {"banana": "left"}] 25 | ) 26 | 27 | 28 | if __name__ == '__main__': 29 | unittest.main() 30 | -------------------------------------------------------------------------------- /cloudinary/compat.py: -------------------------------------------------------------------------------- 1 | # Copyright Cloudinary 2 | import six.moves.urllib.parse 3 | from six import PY3, string_types, StringIO, BytesIO 4 | 5 | urlencode = six.moves.urllib.parse.urlencode 6 | unquote = six.moves.urllib.parse.unquote 7 | urlparse = six.moves.urllib.parse.urlparse 8 | parse_qs = six.moves.urllib.parse.parse_qs 9 | parse_qsl = six.moves.urllib.parse.parse_qsl 10 | quote_plus = six.moves.urllib.parse.quote_plus 11 | httplib = six.moves.http_client 12 | urllib2 = six.moves.urllib.request 13 | NotConnected = six.moves.http_client.NotConnected 14 | 15 | if PY3: 16 | to_bytes = lambda s: s.encode('utf8') 17 | to_bytearray = lambda s: bytearray(s, 'utf8') 18 | to_string = lambda b: b.decode('utf8') 19 | 20 | else: 21 | to_bytes = str 22 | to_bytearray = str 23 | to_string = str 24 | 25 | try: 26 | cldrange = xrange 27 | except NameError: 28 | def cldrange(*args, **kwargs): 29 | return iter(range(*args, **kwargs)) 30 | 31 | try: 32 | advance_iterator = next 33 | except NameError: 34 | def advance_iterator(it): 35 | return it.next() 36 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Brief Summary of Changes 2 | 5 | 6 | #### What does this PR address? 7 | - [ ] GitHub issue (Add reference - #XX) 8 | - [ ] Refactoring 9 | - [ ] New feature 10 | - [ ] Bug fix 11 | - [ ] Adds more tests 12 | 13 | #### Are tests included? 14 | - [ ] Yes 15 | - [ ] No 16 | 17 | #### Reviewer, please note: 18 | 25 | 26 | #### Checklist: 27 | 28 | 29 | - [ ] My code follows the code style of this project. 30 | - [ ] My change requires a change to the documentation. 31 | - [ ] I ran the full test suite before pushing the changes and all the tests pass. 32 | -------------------------------------------------------------------------------- /samples/basic_flask/app.py: -------------------------------------------------------------------------------- 1 | from cloudinary.uploader import upload 2 | from cloudinary.utils import cloudinary_url 3 | from flask import Flask, render_template, request 4 | 5 | app = Flask(__name__) 6 | 7 | 8 | @app.route('/', methods=['GET', 'POST']) 9 | def upload_file(): 10 | upload_result = None 11 | thumbnail_url1 = None 12 | thumbnail_url2 = None 13 | if request.method == 'POST': 14 | file_to_upload = request.files['file'] 15 | if file_to_upload: 16 | upload_result = upload(file_to_upload) 17 | thumbnail_url1, options = cloudinary_url(upload_result['public_id'], format="jpg", crop="fill", width=100, 18 | height=100) 19 | thumbnail_url2, options = cloudinary_url(upload_result['public_id'], format="jpg", crop="fill", width=200, 20 | height=100, radius=20, effect="sepia") 21 | return render_template('upload_form.html', upload_result=upload_result, thumbnail_url1=thumbnail_url1, 22 | thumbnail_url2=thumbnail_url2) 23 | 24 | 25 | if __name__ == "__main__": 26 | app.debug = True 27 | app.run() 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Bug report for Cloudinary Python SDK 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Bug report for Cloudinary Python SDK 11 | Before proceeding, please update to the latest version and test if the issue persists 12 | 13 | ## Describe the bug in a sentence or two. 14 | … 15 | 16 | ## Issue Type (Can be multiple) 17 | - [ ] Build - Can’t install or import the SDK 18 | - [ ] Performance - Performance issues 19 | - [ ] Behaviour - Functions are not working as expected (such as generate URL) 20 | - [ ] Documentation - Inconsistency between the docs and behaviour 21 | - [ ] Other (Specify) 22 | 23 | ## Steps to reproduce 24 | … if applicable 25 | 26 | ## Error screenshots or Stack Trace (if applicable) 27 | … 28 | 29 | ## Operating System 30 | - [ ] Linux 31 | - [ ] Windows 32 | - [ ] macOS 33 | - [ ] All 34 | 35 | ## Environment and Frameworks (fill in the version numbers) 36 | 37 | - Cloudinary Python SDK version - 0.0.0 38 | - Python Version - 0.0.0 39 | - Framework (Django, Flask, etc) - 0.0.0 40 | 41 | ## Repository 42 | 43 | If possible, please provide a link to a reproducible repository that showcases the problem 44 | -------------------------------------------------------------------------------- /samples/basic/README.md: -------------------------------------------------------------------------------- 1 | Cloudinary Python sample project 2 | ================================ 3 | 4 | This sample is a synchronous script that shows the upload process from local file, remote URL, with different transformations and options. 5 | 6 | ## Installing and running in 5 simple steps 7 | 8 | 1. Install [Python](http://www.python.org/getit/) 9 | 1. Install [Cloudinary python egg](https://github.com/cloudinary/pycloudinary#setup) 10 | 1. Get [a cloudinary account](https://cloudinary.com/users/register/free) 11 | 1. Setup the `CLOUDINARY_URL` environment variable by copying it from the [Management Console](https://cloudinary.com/console): 12 | 13 | Using zsh/bash/sh 14 | 15 | $ export CLOUDINARY_URL=cloudinary://API-Key:API-Secret@Cloud-name 16 | 17 | Using tcsh/csh 18 | 19 | $ setenv CLOUDINARY_URL cloudinary://API-Key:API-Secret@Cloud-name 20 | 21 | Using Windows command prompt/PowerShell 22 | 23 | > set CLOUDINARY_URL=cloudinary://API-Key:API-Secret@Cloud-name 24 | 25 | 1. Run the script: 26 | 27 | $ python basic.py 28 | 29 | In order to delete the uploaded images using Cloudinary's Admin API, run the script: 30 | 31 | $ python basic.py cleanup 32 | 33 | 34 | Good luck! 35 | -------------------------------------------------------------------------------- /cloudinary/provisioning/account_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | 5 | from cloudinary import BaseConfig, import_django_settings 6 | 7 | ACCOUNT_URI_SCHEME = "account" 8 | 9 | 10 | class AccountConfig(BaseConfig): 11 | def __init__(self): 12 | self._uri_scheme = ACCOUNT_URI_SCHEME 13 | 14 | super(AccountConfig, self).__init__() 15 | 16 | def _config_from_parsed_url(self, parsed_url): 17 | if not self._is_url_scheme_valid(parsed_url): 18 | raise ValueError("Invalid CLOUDINARY_ACCOUNT_URL scheme. URL should begin with 'account://'") 19 | 20 | return { 21 | "account_id": parsed_url.hostname, 22 | "provisioning_api_key": parsed_url.username, 23 | "provisioning_api_secret": parsed_url.password, 24 | } 25 | 26 | def _load_config_from_env(self): 27 | if os.environ.get("CLOUDINARY_ACCOUNT_URL"): 28 | self._load_from_url(os.environ.get("CLOUDINARY_ACCOUNT_URL")) 29 | 30 | 31 | def account_config(**keywords): 32 | global _account_config 33 | _account_config.update(**keywords) 34 | return _account_config 35 | 36 | 37 | def reset_config(): 38 | global _account_config 39 | _account_config = AccountConfig() 40 | 41 | 42 | _account_config = AccountConfig() 43 | -------------------------------------------------------------------------------- /cloudinary/cache/storage/key_value_storage.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class KeyValueStorage: 5 | """ 6 | A simple key-value storage abstract base class 7 | """ 8 | __metaclass__ = ABCMeta 9 | 10 | @abstractmethod 11 | def get(self, key): 12 | """ 13 | Get a value identified by the given key 14 | 15 | :param key: The unique identifier 16 | 17 | :return: The value identified by key or None if no value was found 18 | """ 19 | raise NotImplementedError 20 | 21 | @abstractmethod 22 | def set(self, key, value): 23 | """ 24 | Store the value identified by the key 25 | 26 | :param key: The unique identifier 27 | :param value: Value to store 28 | 29 | :return: bool True on success or False on failure 30 | """ 31 | raise NotImplementedError 32 | 33 | @abstractmethod 34 | def delete(self, key): 35 | """ 36 | Deletes item by key 37 | 38 | :param key: The unique identifier 39 | 40 | :return: bool True on success or False on failure 41 | """ 42 | raise NotImplementedError 43 | 44 | @abstractmethod 45 | def clear(self): 46 | """ 47 | Clears all entries 48 | 49 | :return: bool True on success or False on failure 50 | """ 51 | raise NotImplementedError 52 | -------------------------------------------------------------------------------- /test/addon_types.py: -------------------------------------------------------------------------------- 1 | ADDON_ALL = 'all' # Test all addons. 2 | ADDON_ASPOSE = 'aspose' # Aspose Document Conversion. 3 | ADDON_AZURE = 'azure' # Microsoft Azure Video Indexer. 4 | ADDON_BG_REMOVAL = 'bgremoval' # Cloudinary AI Background Removal. 5 | ADDON_FACIAL_ATTRIBUTES_DETECTION = 'facialattributesdetection' # Advanced Facial Attributes Detection. 6 | # Google AI Video Moderation, Google AI, Video Transcription, Google Auto Tagging, Google Automatic Video Tagging, 7 | # Google Translation. 8 | ADDON_GOOGLE = 'google' 9 | ADDON_IMAGGA = 'imagga' # Imagga Auto Tagging, Imagga Crop and Scale. 10 | ADDON_JPEGMINI = 'jpegmini' # JPEGmini Image Optimization. 11 | ADDON_LIGHTROOM = 'lightroom' # Adobe Photoshop Lightroom (BETA). 12 | ADDON_METADEFENDER = 'metadefender' # MetaDefender Anti-Malware Protection. 13 | ADDON_NEURAL_ARTWORK = 'neuralartwork' # Neural Artwork Style Transfer. 14 | ADDON_OBJECT_AWARE_CROPPING = 'objectawarecropping' # Cloudinary Object-Aware Cropping. 15 | ADDON_OCR = 'ocr' # OCR Text Detection and Extraction. 16 | ADDON_PIXELZ = 'pixelz' # Remove the Background. 17 | # Amazon Rekognition AI Moderation, Amazon Rekognition Auto Tagging, Amazon Rekognition Celebrity Detection. 18 | ADDON_REKOGNITION = 'rekognition' 19 | ADDON_URL2PNG = 'url2png' # URL2PNG Website Screenshots. 20 | ADDON_VIESUS = 'viesus' # VIESUS Automatic Image Enhancement. 21 | ADDON_WEBPURIFY = 'webpurify' # WebPurify Image Moderation. 22 | -------------------------------------------------------------------------------- /django_tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-10-31 13:48 3 | from __future__ import unicode_literals 4 | 5 | import cloudinary.models 6 | import django.db.models.deletion 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Choice', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('choice', models.CharField(max_length=200)), 23 | ('votes', models.IntegerField()), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Poll', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('question', models.CharField(max_length=200)), 31 | ('pub_date', models.DateTimeField(verbose_name='date published')), 32 | ('image', cloudinary.models.CloudinaryField(max_length=255, null=True, verbose_name='image')), 33 | ], 34 | ), 35 | migrations.AddField( 36 | model_name='choice', 37 | name='poll', 38 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_tests.Poll'), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /cloudinary/poster/__init__.py: -------------------------------------------------------------------------------- 1 | # MIT licensed code copied from https://bitbucket.org/chrisatlee/poster 2 | # 3 | # Copyright (c) 2011 Chris AtLee 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | """poster module 23 | 24 | Support for streaming HTTP uploads, and multipart/form-data encoding 25 | 26 | ```poster.version``` is a 3-tuple of integers representing the version number. 27 | New releases of poster will always have a version number that compares greater 28 | than an older version of poster. 29 | New in version 0.6.""" 30 | 31 | version = (0, 8, 2) # Thanks JP! 32 | -------------------------------------------------------------------------------- /samples/spookyshots/README.md: -------------------------------------------------------------------------------- 1 | # Spooky Pet Image App 2 | 3 | **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! 4 | 5 | ## Example Image Generated 6 | ### Original 7 | ![alexander-london-mJaD10XeD7w-unsplash](https://github.com/user-attachments/assets/98afa889-364a-4337-98ff-347f2a3a94e2) 8 | 9 | ### Transformed 10 | ![user_uploaded_alexander-london-mJaD10XeD7w-unsplash-min](https://github.com/user-attachments/assets/e3e1dde3-4252-499b-80a5-4b67942b2751) 11 | 12 | 13 | ## Installation 14 | 15 | ### Steps 16 | 17 | 1. **Clone the repository**: 18 | ```bash 19 | git clone https://github.com/cloudinary/pycloudinary.git 20 | cd pycloudinary/samples/spookyshots 21 | ``` 22 | 23 | 2. **Install dependencies**: 24 | ```bash 25 | pip install -r requirements.txt 26 | ``` 27 | 28 | 3. **Set up Cloudinary credentials**: 29 | - Inside the root directory of the project, rename `.env.example` to `.env`. 30 | - Open the `.env` file and fill in your Cloudinary credentials: 31 | ``` 32 | CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name 33 | CLOUDINARY_API_KEY=your_cloudinary_api_key 34 | CLOUDINARY_API_SECRET=your_cloudinary_api_secret 35 | ``` 36 | 37 | 4. **Run the app**: 38 | ```bash 39 | streamlit run main.py 40 | ``` 41 | 42 | 5. **Open the app**: 43 | After running the command, the app should automatically open in your browser. If not, open the browser and go to: 44 | ``` 45 | http://localhost:8501 46 | ``` 47 | 48 | Enjoy transforming your pets for Halloween! 49 | -------------------------------------------------------------------------------- /cloudinary/http_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import socket 3 | 4 | import certifi 5 | from urllib3 import PoolManager 6 | from urllib3.exceptions import HTTPError 7 | 8 | from cloudinary.exceptions import GeneralError 9 | 10 | 11 | class HttpClient: 12 | DEFAULT_HTTP_TIMEOUT = 60 13 | 14 | def __init__(self, **options): 15 | # Lazy initialization of the client, to improve performance when HttpClient is initialized but not used 16 | self._http_client_instance = None 17 | self.timeout = options.get("timeout", self.DEFAULT_HTTP_TIMEOUT) 18 | 19 | @property 20 | def _http_client(self): 21 | if self._http_client_instance is None: 22 | self._http_client_instance = PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where()) 23 | return self._http_client_instance 24 | 25 | def get_json(self, url): 26 | try: 27 | response = self._http_client.request(method="GET", url=url, timeout=self.timeout) 28 | body = response.data 29 | except HTTPError as e: 30 | raise GeneralError("Unexpected error %s" % str(e)) 31 | except socket.error as e: 32 | raise GeneralError("Socket Error: %s" % str(e)) 33 | 34 | if response.status != 200: 35 | raise GeneralError("Server returned unexpected status code - {} - {}".format(response.status, 36 | response.data)) 37 | try: 38 | result = json.loads(body.decode('utf-8')) 39 | except Exception as e: 40 | # Error is parsing json 41 | raise GeneralError("Error parsing server response (%d) - %s. Got - %s" % (response.status, body, e)) 42 | 43 | return result 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-24.04 # noble equivalent 8 | 9 | strategy: 10 | matrix: 11 | include: 12 | - python-version: "3.9" 13 | toxenv: py39-core 14 | - python-version: "3.10" 15 | toxenv: py310-core 16 | - python-version: "3.11" 17 | toxenv: py311-core 18 | - python-version: "3.12" 19 | toxenv: py312-core 20 | - python-version: "3.13" 21 | toxenv: py313-core 22 | - python-version: "3.9" 23 | toxenv: py39-django32 24 | - python-version: "3.10" 25 | toxenv: py310-django42 26 | - python-version: "3.11" 27 | toxenv: py311-django42 28 | - python-version: "3.12" 29 | toxenv: py312-django50 30 | - python-version: "3.13" 31 | toxenv: py313-django51 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Setup Python 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | 41 | - name: Install dependencies 42 | run: | 43 | python -m pip install --upgrade pip 44 | pip install tox pytest 45 | 46 | - name: Set CLOUDINARY_URL 47 | run: | 48 | export CLOUDINARY_URL=$(bash tools/get_test_cloud.sh) 49 | echo "cloud_name: $(echo $CLOUDINARY_URL | cut -d'@' -f2)" 50 | echo "CLOUDINARY_URL=$CLOUDINARY_URL" >> $GITHUB_ENV 51 | 52 | - name: Run tests 53 | env: 54 | TOXENV: ${{ matrix.toxenv }} 55 | PYTHONPATH: ${{ github.workspace }} 56 | run: tox -e $TOXENV 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | .static_storage/ 56 | .media/ 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # virtualenv 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # Other 96 | .DS_Store 97 | *.swp 98 | *.sqlite3 99 | .project 100 | .pydevproject 101 | cloudinary/static/ 102 | log/ 103 | output.html 104 | .idea 105 | .vscode 106 | -------------------------------------------------------------------------------- /cloudinary/api_client/call_account_api.py: -------------------------------------------------------------------------------- 1 | import cloudinary 2 | from cloudinary.api_client.execute_request import execute_request 3 | from cloudinary.provisioning.account_config import account_config 4 | from cloudinary.utils import get_http_connector, normalize_params 5 | 6 | PROVISIONING_SUB_PATH = "provisioning" 7 | ACCOUNT_SUB_PATH = "accounts" 8 | _http = get_http_connector(account_config(), cloudinary.CERT_KWARGS) 9 | 10 | 11 | def _call_account_api(method, uri, params=None, headers=None, **options): 12 | prefix = options.pop("upload_prefix", 13 | cloudinary.config().upload_prefix) or "https://api.cloudinary.com" 14 | account_id = options.pop("account_id", account_config().account_id) 15 | if not account_id: 16 | raise Exception("Must supply account_id") 17 | provisioning_api_key = options.pop("provisioning_api_key", account_config().provisioning_api_key) 18 | if not provisioning_api_key: 19 | raise Exception("Must supply provisioning_api_key") 20 | provisioning_api_secret = options.pop("provisioning_api_secret", 21 | account_config().provisioning_api_secret) 22 | if not provisioning_api_secret: 23 | raise Exception("Must supply provisioning_api_secret") 24 | provisioning_api_url = "/".join( 25 | [prefix, cloudinary.API_VERSION, PROVISIONING_SUB_PATH, ACCOUNT_SUB_PATH, account_id] + uri) 26 | auth = {"key": provisioning_api_key, "secret": provisioning_api_secret} 27 | 28 | return execute_request(http_connector=_http, 29 | method=method, 30 | params=normalize_params(params), 31 | headers=headers, 32 | auth=auth, 33 | api_url=provisioning_api_url, 34 | **options) 35 | -------------------------------------------------------------------------------- /test/test_http_client.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import six 4 | 5 | from cloudinary import uploader, HttpClient, GeneralError 6 | 7 | import cloudinary 8 | from cloudinary.utils import cloudinary_url 9 | 10 | from test.helper_test import UNIQUE_TAG, SUFFIX, TEST_IMAGE, cleanup_test_resources_by_tag 11 | 12 | HTTP_CLIENT_UNIQUE_TEST_TAG = 'http_client_{}'.format(UNIQUE_TAG) 13 | HTTP_CLIENT_TEST_ID = "http_client_{}".format(SUFFIX) 14 | 15 | 16 | class HttpClientTest(TestCase): 17 | @classmethod 18 | def setUpClass(cls): 19 | cloudinary.reset_config() 20 | if not cloudinary.config().api_secret: 21 | return 22 | uploader.upload(TEST_IMAGE, public_id=HTTP_CLIENT_TEST_ID, tags=[HTTP_CLIENT_UNIQUE_TEST_TAG, ]) 23 | 24 | @classmethod 25 | def tearDownClass(cls): 26 | cleanup_test_resources_by_tag([(HTTP_CLIENT_UNIQUE_TEST_TAG,)]) 27 | 28 | def setUp(self): 29 | cloudinary.reset_config() 30 | self.http_client = HttpClient() 31 | 32 | def test_http_client_get_json(self): 33 | json_url = cloudinary_url(HTTP_CLIENT_TEST_ID, width="auto:breakpoints:json")[0] 34 | json_resp = self.http_client.get_json(json_url) 35 | 36 | self.assertIn("breakpoints", json_resp) 37 | self.assertIsInstance(json_resp["breakpoints"], list) 38 | 39 | def test_http_client_get_json_non_json(self): 40 | non_json_url = cloudinary_url(HTTP_CLIENT_TEST_ID)[0] 41 | 42 | with six.assertRaisesRegex(self, GeneralError, "Error parsing server response*"): 43 | self.http_client.get_json(non_json_url) 44 | 45 | def test_http_client_get_json_invalid_url(self): 46 | non_existing_url = cloudinary_url(HTTP_CLIENT_TEST_ID + "_non_existing")[0] 47 | 48 | with six.assertRaisesRegex(self, GeneralError, "Server returned unexpected status code*"): 49 | self.http_client.get_json(non_existing_url) 50 | 51 | -------------------------------------------------------------------------------- /samples/README.md: -------------------------------------------------------------------------------- 1 | Cloudinary Python & Django Sample Projects 2 | ========================================= 3 | 4 | ## Basic sample 5 | 6 | This sample is a synchronous script that shows the upload process from local file, remote URL, with different transformations and options. 7 | 8 | The source code and more details are available here: 9 | 10 | [https://github.com/cloudinary/pycloudinary/tree/master/samples/basic](https://github.com/cloudinary/pycloudinary/tree/master/samples/basic) 11 | 12 | 13 | ## Photo Album 14 | 15 | A simple web application that allows you to uploads photos, maintain a database with references to them, list them with their metadata, and display them using various cloud-based transformations. 16 | 17 | The source code and more details are available here: 18 | 19 | [https://github.com/cloudinary/cloudinary-django-sample](https://github.com/cloudinary/cloudinary-django-sample) 20 | 21 | 22 | ## Basic Flask sample 23 | 24 | A simple Flask application that performs image upload and generates on the transformations of the uploaded image. 25 | 26 | The source code and more details are available here: 27 | 28 | [https://github.com/cloudinary/pycloudinary/tree/master/samples/basic_flask](https://github.com/cloudinary/pycloudinary/tree/master/samples/basic_flask) 29 | 30 | ## Basic Google App Engine sample 31 | 32 | A simple GAE application that performs image upload and generates on the transformations of the uploaded image. Note: It requires the pycloudinary > 1.0.18 to work. 33 | 34 | The source code and more details are available here: 35 | 36 | [https://github.com/cloudinary/pycloudinary/tree/master/samples/gae](https://github.com/cloudinary/pycloudinary/tree/master/samples/gae) 37 | 38 | ## SpookyShots 39 | 40 | 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! 41 | 42 | The source code and more details are available here: 43 | 44 | [https://github.com/cloudinary/pycloudinary/tree/master/samples/spookyshots](https://github.com/cloudinary/pycloudinary/tree/master/samples/spookyshots) -------------------------------------------------------------------------------- /django_tests/test_cloudinary_file_field.py: -------------------------------------------------------------------------------- 1 | from django.core.files.uploadedfile import SimpleUploadedFile 2 | from django.test import TestCase 3 | from test.helper_test import mock 4 | 5 | import cloudinary 6 | from cloudinary import CloudinaryResource 7 | from cloudinary.forms import CloudinaryFileField 8 | from django_tests.forms import CloudinaryJsTestFileForm 9 | from django_tests.helper_test import SUFFIX, TEST_IMAGE, TEST_IMAGE_W, TEST_IMAGE_H 10 | 11 | API_TEST_ID = "dj_test_{}".format(SUFFIX) 12 | 13 | 14 | class TestCloudinaryFileField(TestCase): 15 | def setUp(self): 16 | self.test_file = SimpleUploadedFile(TEST_IMAGE, b'content') 17 | 18 | def test_file_field(self): 19 | cff_no_auto_save = CloudinaryFileField(autosave=False) 20 | res = cff_no_auto_save.to_python(None) 21 | self.assertIsNone(res) 22 | # without auto_save File is untouched 23 | res = cff_no_auto_save.to_python(self.test_file) 24 | self.assertIsInstance(res, SimpleUploadedFile) 25 | 26 | # when auto_save is used, resource is uploaded to Cloudinary and CloudinaryResource is returned 27 | cff_auto_save = CloudinaryFileField(autosave=True, options={"public_id": API_TEST_ID}) 28 | mocked_resource = cloudinary.CloudinaryResource(metadata={"width": TEST_IMAGE_W, "height": TEST_IMAGE_H}, 29 | type="upload", public_id=API_TEST_ID, resource_type="image") 30 | 31 | with mock.patch('cloudinary.uploader.upload_image', return_value=mocked_resource) as upload_mock: 32 | res = cff_auto_save.to_python(self.test_file) 33 | 34 | self.assertTrue(upload_mock.called) 35 | self.assertIsInstance(res, CloudinaryResource) 36 | self.assertEqual(API_TEST_ID, res.public_id) 37 | 38 | def test_js_file_field(self): 39 | js_file_form = CloudinaryJsTestFileForm() 40 | 41 | rendered_form = js_file_form.as_p() 42 | 43 | self.assertIn("margin-top: 30px", rendered_form) 44 | self.assertIn("directly_uploaded", rendered_form) 45 | self.assertIn("c_fill,h_100,w_150", rendered_form) 46 | self.assertIn("c_limit,h_1000,w_1000", rendered_form) 47 | 48 | def tearDown(self): 49 | pass 50 | -------------------------------------------------------------------------------- /cloudinary/cache/adapter/cache_adapter.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class CacheAdapter: 5 | """ 6 | CacheAdapter Abstract Base Class 7 | """ 8 | __metaclass__ = ABCMeta 9 | 10 | @abstractmethod 11 | def get(self, public_id, type, resource_type, transformation, format): 12 | """ 13 | Gets value specified by parameters 14 | 15 | :param public_id: The public ID of the resource 16 | :param type: The storage type 17 | :param resource_type: The type of the resource 18 | :param transformation: The transformation string 19 | :param format: The format of the resource 20 | 21 | :return: None|mixed value, None if not found 22 | """ 23 | raise NotImplementedError 24 | 25 | @abstractmethod 26 | def set(self, public_id, type, resource_type, transformation, format, value): 27 | """ 28 | Sets value specified by parameters 29 | 30 | :param public_id: The public ID of the resource 31 | :param type: The storage type 32 | :param resource_type: The type of the resource 33 | :param transformation: The transformation string 34 | :param format: The format of the resource 35 | :param value: The value to set 36 | 37 | :return: bool True on success or False on failure 38 | """ 39 | raise NotImplementedError 40 | 41 | @abstractmethod 42 | def delete(self, public_id, type, resource_type, transformation, format): 43 | """ 44 | Deletes entry specified by parameters 45 | 46 | :param public_id: The public ID of the resource 47 | :param type: The storage type 48 | :param resource_type: The type of the resource 49 | :param transformation: The transformation string 50 | :param format: The format of the resource 51 | 52 | :return: bool True on success or False on failure 53 | """ 54 | raise NotImplementedError 55 | 56 | @abstractmethod 57 | def flush_all(self): 58 | """ 59 | Flushes all entries from cache 60 | 61 | :return: bool True on success or False on failure 62 | """ 63 | raise NotImplementedError 64 | -------------------------------------------------------------------------------- /cloudinary/cache/storage/file_system_key_value_storage.py: -------------------------------------------------------------------------------- 1 | import glob 2 | from tempfile import gettempdir 3 | 4 | import os 5 | 6 | import errno 7 | 8 | from cloudinary.cache.storage.key_value_storage import KeyValueStorage 9 | 10 | 11 | class FileSystemKeyValueStorage(KeyValueStorage): 12 | """File-based key-value storage""" 13 | _item_ext = ".cldci" 14 | 15 | def __init__(self, root_path): 16 | """ 17 | Create a new Storage object. 18 | 19 | All files will be stored under the root_path location 20 | 21 | :param root_path: The base folder for all storage files 22 | """ 23 | if root_path is None: 24 | root_path = gettempdir() 25 | 26 | if not os.path.isdir(root_path): 27 | os.makedirs(root_path) 28 | 29 | self._root_path = root_path 30 | 31 | def get(self, key): 32 | if not self._exists(key): 33 | return None 34 | 35 | with open(self._get_key_full_path(key), 'r') as f: 36 | value = f.read() 37 | 38 | return value 39 | 40 | def set(self, key, value): 41 | with open(self._get_key_full_path(key), 'w') as f: 42 | f.write(value) 43 | 44 | return True 45 | 46 | def delete(self, key): 47 | try: 48 | os.remove(self._get_key_full_path(key)) 49 | except OSError as e: 50 | if e.errno != errno.ENOENT: # errno.ENOENT - no such file or directory 51 | raise # re-raise exception if a different error occurred 52 | 53 | return True 54 | 55 | def clear(self): 56 | for cache_item_path in glob.iglob(os.path.join(self._root_path, '*' + self._item_ext)): 57 | os.remove(cache_item_path) 58 | 59 | return True 60 | 61 | def _get_key_full_path(self, key): 62 | """ 63 | Generate the file path for the key 64 | 65 | :param key: The key 66 | 67 | :return: The absolute path of the value file associated with the key 68 | """ 69 | return os.path.join(self._root_path, key + self._item_ext) 70 | 71 | def _exists(self, key): 72 | """ 73 | Indicate whether key exists 74 | 75 | :param key: The key 76 | 77 | :return: bool True if the file for the given key exists 78 | """ 79 | return os.path.isfile(self._get_key_full_path(key)) 80 | -------------------------------------------------------------------------------- /samples/gae/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2007 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | import os 18 | 19 | import webapp2 20 | from cloudinary.compat import StringIO 21 | from cloudinary.uploader import upload 22 | from cloudinary.utils import cloudinary_url 23 | from google.appengine.ext.webapp import template 24 | 25 | 26 | class MainHandler(webapp2.RequestHandler): 27 | def get(self): 28 | path = os.path.join(os.path.dirname(__file__), 'index.html') 29 | template_values = { 30 | 'image_url': None, 31 | 'thumbnail_url1': None, 32 | 'thumbnail_url2': None 33 | } 34 | self.response.write(template.render(path, template_values)) 35 | 36 | def post(self): 37 | image_url = None 38 | thumbnail_url1 = None 39 | thumbnail_url2 = None 40 | file_to_upload = self.request.get('file') 41 | if file_to_upload: 42 | str_file = StringIO(file_to_upload) 43 | str_file.name = 'file' 44 | upload_result = upload(str_file) 45 | image_url = upload_result['url'] 46 | thumbnail_url1, options = cloudinary_url(upload_result['public_id'], format="jpg", crop="fill", width=100, 47 | height=100) 48 | thumbnail_url2, options = cloudinary_url(upload_result['public_id'], format="jpg", crop="fill", width=200, 49 | height=100, radius=20, effect="sepia") 50 | template_values = { 51 | 'image_url': image_url, 52 | 'thumbnail_url1': thumbnail_url1, 53 | 'thumbnail_url2': thumbnail_url2 54 | } 55 | path = os.path.join(os.path.dirname(__file__), 'index.html') 56 | self.response.write(template.render(path, template_values)) 57 | 58 | 59 | app = webapp2.WSGIApplication([ 60 | ('/', MainHandler) 61 | ], debug=True) 62 | -------------------------------------------------------------------------------- /test/cache/test_responsive_breakpoints_cache.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from cloudinary.cache import responsive_breakpoints_cache 4 | from cloudinary.cache.adapter.key_value_cache_adapter import KeyValueCacheAdapter 5 | from cloudinary.cache.storage.file_system_key_value_storage import FileSystemKeyValueStorage 6 | from test.cache.storage.dummy_cache_storage import DummyCacheStorage 7 | from test.helper_test import UNIQUE_TEST_ID 8 | 9 | 10 | class ResponsiveBreakpointsCacheTest(unittest.TestCase): 11 | public_id = UNIQUE_TEST_ID 12 | breakpoints = [100, 200, 300, 399] 13 | 14 | def setUp(self): 15 | self.cache = responsive_breakpoints_cache.instance 16 | self.cache.set_cache_adapter(KeyValueCacheAdapter(DummyCacheStorage())) 17 | 18 | def test_rb_cache_set_get(self): 19 | self.cache.set(self.public_id, self.breakpoints) 20 | 21 | res = self.cache.get(self.public_id) 22 | 23 | self.assertEqual(self.breakpoints, res) 24 | 25 | def test_rb_cache_set_invalid_breakpoints(self): 26 | with self.assertRaises(ValueError): 27 | self.cache.set(self.public_id, "Not breakpoints at all") 28 | 29 | def test_rb_cache_delete(self): 30 | self.cache.set(self.public_id, self.breakpoints) 31 | 32 | self.cache.delete(self.public_id) 33 | 34 | res = self.cache.get(self.public_id) 35 | 36 | self.assertIsNone(res) 37 | 38 | def test_rb_cache_flush_all(self): 39 | self.cache.set(self.public_id, self.breakpoints) 40 | 41 | self.cache.flush_all() 42 | 43 | res = self.cache.get(self.public_id) 44 | 45 | self.assertIsNone(res) 46 | 47 | def test_rb_cache_disabled(self): 48 | self.cache._cache_adapter = None 49 | 50 | self.assertFalse(self.cache.enabled) 51 | 52 | self.assertFalse(self.cache.set(self.public_id, self.breakpoints)) 53 | self.assertIsNone(self.cache.get(self.public_id)) 54 | self.assertFalse(self.cache.delete(self.public_id)) 55 | self.assertFalse(self.cache.flush_all()) 56 | 57 | def test_rb_cache_filesystem_storage(self): 58 | self.cache.set_cache_adapter(KeyValueCacheAdapter(FileSystemKeyValueStorage(None))) 59 | 60 | res = None 61 | try: 62 | self.cache.set(self.public_id, self.breakpoints) 63 | res = self.cache.get(self.public_id) 64 | finally: 65 | self.cache.delete(self.public_id) 66 | 67 | self.assertEqual(self.breakpoints, res) 68 | 69 | 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cloudinary" 3 | description = "Python and Django SDK for Cloudinary" 4 | version = "1.44.1" 5 | 6 | authors = [{ name = "Cloudinary", email = "info@cloudinary.com" }] 7 | license = { file = "LICENSE.txt" } 8 | keywords = ["cloudinary", "image", "video", "upload", "crop", "resize", "filter", "transformation", "manipulation", "cdn"] 9 | readme = "README.md" 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Environment :: Web Environment", 13 | "Framework :: Django", 14 | "Framework :: Django :: 1.11", 15 | "Framework :: Django :: 2.2", 16 | "Framework :: Django :: 3.2", 17 | "Framework :: Django :: 4.2", 18 | "Framework :: Django :: 5.0", 19 | "Framework :: Django :: 5.1", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 2", 24 | "Programming Language :: Python :: 2.7", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Topic :: Internet :: WWW/HTTP", 32 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 33 | "Topic :: Multimedia :: Graphics", 34 | "Topic :: Multimedia :: Graphics :: Graphics Conversion", 35 | "Topic :: Multimedia :: Sound/Audio", 36 | "Topic :: Multimedia :: Sound/Audio :: Conversion", 37 | "Topic :: Multimedia :: Video", 38 | "Topic :: Multimedia :: Video :: Conversion", 39 | "Topic :: Software Development :: Libraries :: Python Modules" 40 | ] 41 | dependencies = [ 42 | "six", 43 | "urllib3>=1.26.5", 44 | "certifi" 45 | ] 46 | 47 | [project.optional-dependencies] 48 | dev = [ 49 | "tox", 50 | "pytest==4.6; python_version < '3.7'", 51 | "pytest; python_version >= '3.7'" 52 | ] 53 | 54 | [project.urls] 55 | Homepage = "https://cloudinary.com" 56 | Source = "https://github.com/cloudinary/pycloudinary" 57 | Changelog = "https://raw.githubusercontent.com/cloudinary/pycloudinary/master/CHANGELOG.md" 58 | 59 | [tool.setuptools] 60 | include-package-data = true 61 | zip-safe = false 62 | 63 | [tool.setuptools.packages.find] 64 | exclude = ["samples", "tools", "test*", "django_tests*", "venv*"] 65 | namespaces = false 66 | 67 | 68 | [build-system] 69 | requires = ["setuptools"] 70 | build-backend = "setuptools.build_meta" 71 | -------------------------------------------------------------------------------- /cloudinary/cache/adapter/key_value_cache_adapter.py: -------------------------------------------------------------------------------- 1 | import json 2 | from hashlib import sha1 3 | 4 | from cloudinary.cache.adapter.cache_adapter import CacheAdapter 5 | from cloudinary.cache.storage.key_value_storage import KeyValueStorage 6 | from cloudinary.utils import check_property_enabled 7 | 8 | 9 | class KeyValueCacheAdapter(CacheAdapter): 10 | """ 11 | A cache adapter for a key-value storage type 12 | """ 13 | def __init__(self, storage): 14 | """Create a new adapter for the provided storage interface""" 15 | if not isinstance(storage, KeyValueStorage): 16 | raise ValueError("An instance of valid KeyValueStorage must be provided") 17 | 18 | self._key_value_storage = storage 19 | 20 | @property 21 | def enabled(self): 22 | return self._key_value_storage is not None 23 | 24 | @check_property_enabled 25 | def get(self, public_id, type, resource_type, transformation, format): 26 | key = self.generate_cache_key(public_id, type, resource_type, transformation, format) 27 | value_str = self._key_value_storage.get(key) 28 | return json.loads(value_str) if value_str else value_str 29 | 30 | @check_property_enabled 31 | def set(self, public_id, type, resource_type, transformation, format, value): 32 | key = self.generate_cache_key(public_id, type, resource_type, transformation, format) 33 | return self._key_value_storage.set(key, json.dumps(value)) 34 | 35 | @check_property_enabled 36 | def delete(self, public_id, type, resource_type, transformation, format): 37 | return self._key_value_storage.delete( 38 | self.generate_cache_key(public_id, type, resource_type, transformation, format) 39 | ) 40 | 41 | @check_property_enabled 42 | def flush_all(self): 43 | return self._key_value_storage.clear() 44 | 45 | @staticmethod 46 | def generate_cache_key(public_id, type, resource_type, transformation, format): 47 | """ 48 | Generates key-value storage key from parameters 49 | 50 | :param public_id: The public ID of the resource 51 | :param type: The storage type 52 | :param resource_type: The type of the resource 53 | :param transformation: The transformation string 54 | :param format: The format of the resource 55 | 56 | :return: Resulting cache key 57 | """ 58 | 59 | valid_params = [p for p in [public_id, type, resource_type, transformation, format] if p] 60 | 61 | return sha1("/".join(valid_params).encode("utf-8")).hexdigest() 62 | -------------------------------------------------------------------------------- /cloudinary/auth_token.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import re 4 | import time 5 | from binascii import a2b_hex 6 | 7 | 8 | AUTH_TOKEN_NAME = "__cld_token__" 9 | AUTH_TOKEN_SEPARATOR = "~" 10 | AUTH_TOKEN_UNSAFE_RE = r'([ "#%&\'\/:;<=>?@\[\\\]^`{\|}~]+)' 11 | 12 | 13 | def generate(url=None, acl=None, start_time=None, duration=None, 14 | expiration=None, ip=None, key=None, token_name=AUTH_TOKEN_NAME, **_): 15 | start_time = _ensure_int(start_time) 16 | duration = _ensure_int(duration) 17 | expiration = _ensure_int(expiration) 18 | 19 | if expiration is None: 20 | if duration is not None: 21 | start = start_time if start_time is not None else int(time.time()) 22 | expiration = start + duration 23 | else: 24 | raise Exception("Must provide either expiration or duration") 25 | 26 | if url is None and acl is None: 27 | raise Exception("Must provide either acl or url") 28 | 29 | token_parts = [] 30 | if ip is not None: 31 | token_parts.append("ip=" + ip) 32 | if start_time is not None: 33 | token_parts.append("st=%d" % start_time) 34 | token_parts.append("exp=%d" % expiration) 35 | if acl is not None: 36 | acl_list = acl if type(acl) is list else [acl] 37 | acl_list = [_escape_to_lower(a) for a in acl_list] 38 | token_parts.append("acl=%s" % "!".join(acl_list)) 39 | to_sign = list(token_parts) 40 | if url is not None and acl is None: 41 | to_sign.append("url=%s" % _escape_to_lower(url)) 42 | auth = _digest(AUTH_TOKEN_SEPARATOR.join(to_sign), key) 43 | token_parts.append("hmac=%s" % auth) 44 | return "%(token_name)s=%(token)s" % {"token_name": token_name, "token": AUTH_TOKEN_SEPARATOR.join(token_parts)} 45 | 46 | 47 | def _digest(message, key): 48 | bin_key = a2b_hex(key) 49 | return hmac.new(bin_key, message.encode('utf-8'), hashlib.sha256).hexdigest() 50 | 51 | 52 | def _escape_to_lower(url): 53 | # There is a circular import issue in this file, need to resolve it in the next major release 54 | from cloudinary.utils import smart_escape 55 | escaped_url = smart_escape(url, unsafe=AUTH_TOKEN_UNSAFE_RE) 56 | escaped_url = re.sub(r"%[0-9A-F]{2}", lambda x: x.group(0).lower(), escaped_url) 57 | return escaped_url 58 | 59 | def _ensure_int(value): 60 | """ 61 | Ensures the input value is an integer. 62 | Attempts to cast it to an integer if it is not already. 63 | 64 | :param value: The value to ensure as an integer. 65 | :type value: Any 66 | :return: The integer value. 67 | :rtype: int 68 | :raises ValueError: If the value cannot be converted to an integer. 69 | """ 70 | if isinstance(value, int) or not value: 71 | return value 72 | 73 | try: 74 | return int(value) 75 | except (ValueError, TypeError): 76 | raise ValueError("Value '" + value + "' must be an integer.") 77 | -------------------------------------------------------------------------------- /cloudinary/templatetags/cloudinary.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import json 4 | 5 | import cloudinary 6 | from cloudinary import CloudinaryResource, utils 7 | from cloudinary.compat import PY3 8 | from cloudinary.forms import CloudinaryJsFileField, cl_init_js_callbacks 9 | from django import template 10 | from django.forms import Form 11 | from django.utils.safestring import mark_safe 12 | 13 | register = template.Library() 14 | 15 | 16 | @register.simple_tag(takes_context=True) 17 | def cloudinary_url(context, source, options_dict=None, **options): 18 | if options_dict is None: 19 | options = dict(**options) 20 | else: 21 | options = dict(options_dict, **options) 22 | try: 23 | if context['request'].is_secure() and 'secure' not in options: 24 | options['secure'] = True 25 | except KeyError: 26 | pass 27 | if not isinstance(source, CloudinaryResource): 28 | source = CloudinaryResource(source) 29 | return source.build_url(**options) 30 | 31 | 32 | @register.simple_tag(name='cloudinary', takes_context=True) 33 | def cloudinary_tag(context, image, options_dict=None, **options): 34 | if options_dict is None: 35 | options = dict(**options) 36 | else: 37 | options = dict(options_dict, **options) 38 | try: 39 | if context['request'].is_secure() and 'secure' not in options: 40 | options['secure'] = True 41 | except KeyError: 42 | pass 43 | if not isinstance(image, CloudinaryResource): 44 | image = CloudinaryResource(image) 45 | return mark_safe(image.image(**options)) 46 | 47 | 48 | @register.simple_tag 49 | def cloudinary_direct_upload_field(field_name="image", request=None): 50 | form = type("OnTheFlyForm", (Form,), {field_name: CloudinaryJsFileField()})() 51 | if request: 52 | cl_init_js_callbacks(form, request) 53 | value = form[field_name] 54 | if not PY3: 55 | value = unicode(value) 56 | return value 57 | 58 | 59 | @register.inclusion_tag('cloudinary_direct_upload.html') 60 | def cloudinary_direct_upload(callback_url, **options): 61 | """Deprecated - please use cloudinary_direct_upload_field, or a proper form""" 62 | params = utils.build_upload_params(callback=callback_url, **options) 63 | params = utils.sign_request(params, options) 64 | 65 | api_url = utils.cloudinary_api_url("upload", resource_type=options.get("resource_type", "image"), 66 | upload_prefix=options.get("upload_prefix")) 67 | 68 | return {"params": params, "url": api_url} 69 | 70 | 71 | @register.inclusion_tag('cloudinary_includes.html') 72 | def cloudinary_includes(processing=False): 73 | return {"processing": processing} 74 | 75 | 76 | CLOUDINARY_JS_CONFIG_PARAMS = ("api_key", "cloud_name", "private_cdn", "secure_distribution", "cdn_subdomain") 77 | 78 | 79 | @register.inclusion_tag('cloudinary_js_config.html') 80 | def cloudinary_js_config(): 81 | config = cloudinary.config() 82 | return dict( 83 | params=json.dumps(dict( 84 | (param, getattr(config, param)) for param in CLOUDINARY_JS_CONFIG_PARAMS if getattr(config, param, None) 85 | )) 86 | ) 87 | -------------------------------------------------------------------------------- /cloudinary/api_client/execute_request.py: -------------------------------------------------------------------------------- 1 | import email.utils 2 | import json 3 | import socket 4 | 5 | import urllib3 6 | from urllib3.exceptions import HTTPError 7 | 8 | import cloudinary 9 | from cloudinary.exceptions import ( 10 | BadRequest, 11 | AuthorizationRequired, 12 | NotAllowed, 13 | NotFound, 14 | AlreadyExists, 15 | RateLimited, 16 | GeneralError 17 | ) 18 | from cloudinary.utils import process_params, safe_cast, smart_escape, unquote, normalize_params, urlencode, \ 19 | bracketize_seq 20 | 21 | EXCEPTION_CODES = { 22 | 400: BadRequest, 23 | 401: AuthorizationRequired, 24 | 403: NotAllowed, 25 | 404: NotFound, 26 | 409: AlreadyExists, 27 | 420: RateLimited, 28 | 429: RateLimited, 29 | 500: GeneralError 30 | } 31 | 32 | 33 | class Response(dict): 34 | def __init__(self, result, response, **kwargs): 35 | super(Response, self).__init__(**kwargs) 36 | self.update(result) 37 | 38 | self.rate_limit_allowed = safe_cast(response.headers.get("x-featureratelimit-limit"), int) 39 | self.rate_limit_reset_at = safe_cast(response.headers.get("x-featureratelimit-reset"), email.utils.parsedate) 40 | self.rate_limit_remaining = safe_cast(response.headers.get("x-featureratelimit-remaining"), int) 41 | 42 | 43 | def execute_request(http_connector, method, params, headers, auth, api_url, **options): 44 | # authentication 45 | key = auth.get("key") 46 | secret = auth.get("secret") 47 | oauth_token = auth.get("oauth_token") 48 | req_headers = urllib3.make_headers( 49 | user_agent=cloudinary.get_user_agent() 50 | ) 51 | if oauth_token: 52 | req_headers["authorization"] = "Bearer {}".format(oauth_token) 53 | else: 54 | req_headers.update(urllib3.make_headers(basic_auth="{0}:{1}".format(key, secret))) 55 | 56 | if headers is not None: 57 | req_headers.update(headers) 58 | 59 | api_url = smart_escape(unquote(api_url)) 60 | kw = {} 61 | if "timeout" in options: 62 | kw["timeout"] = options["timeout"] 63 | if "body" in options: 64 | kw["body"] = options["body"] 65 | 66 | if method.upper() == "GET": 67 | query_string = urlencode(bracketize_seq(params), True) 68 | if query_string: 69 | api_url += "?" + query_string 70 | processed_params = None 71 | else: 72 | processed_params = process_params(params) 73 | 74 | try: 75 | response = http_connector.request(method=method.upper(), url=api_url, fields=processed_params, headers=req_headers, **kw) 76 | body = response.data 77 | except HTTPError as e: 78 | raise GeneralError("Unexpected error %s" % str(e)) 79 | except socket.error as e: 80 | raise GeneralError("Socket Error: %s" % str(e)) 81 | 82 | try: 83 | result = json.loads(body.decode('utf-8')) 84 | except Exception as e: 85 | # Error is parsing json 86 | raise GeneralError("Error parsing server response (%d) - %s. Got - %s" % (response.status, body, e)) 87 | 88 | if "error" in result: 89 | exception_class = EXCEPTION_CODES.get(response.status) or Exception 90 | raise exception_class("Error {0} - {1}".format(response.status, result["error"]["message"])) 91 | 92 | return Response(result, response) 93 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from sys import version_info 2 | 3 | from setuptools import find_packages, setup 4 | 5 | if version_info[0] >= 3: 6 | setup() 7 | else: 8 | # Following code is legacy (Python 2.7 compatibility) and will be removed in the future! 9 | # TODO: Remove in next major update (when dropping Python 2.7 compatibility) 10 | version = "1.44.1" 11 | 12 | with open('README.md') as file: 13 | long_description = file.read() 14 | 15 | setup(name='cloudinary', 16 | version=version, 17 | description="Python and Django SDK for Cloudinary", 18 | long_description=long_description, 19 | long_description_content_type='text/markdown', 20 | keywords='cloudinary image video upload crop resize filter transformation manipulation cdn ', 21 | author='Cloudinary', 22 | author_email='info@cloudinary.com', 23 | url='https://cloudinary.com', 24 | project_urls={ 25 | 'Source': 'https://github.com/cloudinary/pycloudinary', 26 | 'Changelog ': 'https://raw.githubusercontent.com/cloudinary/pycloudinary/master/CHANGELOG.md' 27 | }, 28 | license='MIT', 29 | packages=find_packages(exclude=['ez_setup', 'examples', 'test', 'django_tests', 'django_tests.*']), 30 | classifiers=[ 31 | "Development Status :: 5 - Production/Stable", 32 | "Environment :: Web Environment", 33 | "Framework :: Django", 34 | "Framework :: Django :: 1.11", 35 | "Framework :: Django :: 2.2", 36 | "Framework :: Django :: 3.2", 37 | "Framework :: Django :: 4.2", 38 | "Framework :: Django :: 5.0", 39 | "Framework :: Django :: 5.1", 40 | "Intended Audience :: Developers", 41 | "License :: OSI Approved :: MIT License", 42 | "Programming Language :: Python", 43 | "Programming Language :: Python :: 2", 44 | "Programming Language :: Python :: 2.7", 45 | "Programming Language :: Python :: 3", 46 | "Programming Language :: Python :: 3.9", 47 | "Programming Language :: Python :: 3.10", 48 | "Programming Language :: Python :: 3.11", 49 | "Programming Language :: Python :: 3.12", 50 | "Programming Language :: Python :: 3.13", 51 | "Topic :: Internet :: WWW/HTTP", 52 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 53 | "Topic :: Multimedia :: Graphics", 54 | "Topic :: Multimedia :: Graphics :: Graphics Conversion", 55 | "Topic :: Multimedia :: Sound/Audio", 56 | "Topic :: Multimedia :: Sound/Audio :: Conversion", 57 | "Topic :: Multimedia :: Video", 58 | "Topic :: Multimedia :: Video :: Conversion", 59 | "Topic :: Software Development :: Libraries :: Python Modules" 60 | ], 61 | include_package_data=True, 62 | zip_safe=False, 63 | test_suite="test", 64 | install_requires=[ 65 | "six", 66 | "urllib3>=1.26.5", 67 | "certifi" 68 | ], 69 | tests_require=[ 70 | "mock<4", 71 | "pytest" 72 | ], 73 | ) 74 | -------------------------------------------------------------------------------- /test/cache/storage/test_file_system_key_value_storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | import unittest 5 | 6 | from cloudinary.cache.storage.file_system_key_value_storage import FileSystemKeyValueStorage 7 | from test.helper_test import UNIQUE_TEST_ID, ignore_exception 8 | 9 | 10 | class FileSystemKeyValueStorageTest(unittest.TestCase): 11 | key = "test_key" 12 | value = "test_value" 13 | 14 | key2 = "test_key_2" 15 | value2 = "test_value_2" 16 | 17 | def setUp(self): 18 | self.root_path = tempfile.mkdtemp(prefix=UNIQUE_TEST_ID) 19 | self.storage = FileSystemKeyValueStorage(self.root_path) 20 | 21 | def tearDown(self): 22 | with ignore_exception(): 23 | shutil.rmtree(self.root_path, True) 24 | 25 | def set_test_value(self, key, value): 26 | """Helper method for setting value for the key""" 27 | with open(self.storage._get_key_full_path(key), "w") as f: 28 | return f.write(value) 29 | 30 | def get_test_value(self, key): 31 | """Helper method for getting value of the key""" 32 | with open(self.storage._get_key_full_path(key), "r") as f: 33 | return f.read() 34 | 35 | def test_init_with_non_existing_path(self): 36 | non_existing_root_path = self.root_path + "_" 37 | 38 | try: 39 | FileSystemKeyValueStorage(non_existing_root_path) 40 | 41 | self.assertTrue(os.path.exists(non_existing_root_path)) 42 | except Exception as e: 43 | self.fail(str(e)) 44 | finally: 45 | shutil.rmtree(non_existing_root_path, True) 46 | 47 | def test_set(self): 48 | self.storage.set(self.key, self.value) 49 | 50 | self.assertEqual(self.value, self.get_test_value(self.key)) 51 | 52 | # Should set empty value 53 | self.storage.set(self.key, "") 54 | 55 | self.assertEqual("", self.get_test_value(self.key)) 56 | 57 | def test_get(self): 58 | self.set_test_value(self.key, self.value) 59 | 60 | self.assertEqual(self.value, self.storage.get(self.key)) 61 | 62 | self.assertIsNone(self.storage.get("non-existing-key")) 63 | 64 | self.set_test_value(self.key, "") 65 | 66 | self.assertEqual("", self.storage.get(self.key)) 67 | 68 | def test_delete(self): 69 | self.storage.set(self.key, self.value) 70 | self.storage.set(self.key2, self.value2) 71 | 72 | self.assertEqual(self.value, self.storage.get(self.key)) 73 | self.assertEqual(self.value2, self.storage.get(self.key2)) 74 | 75 | self.assertTrue(self.storage.delete(self.key)) 76 | self.assertIsNone(self.storage.get(self.key)) 77 | 78 | # Should delete only one value (opposed to clear) 79 | self.assertEqual(self.value2, self.storage.get(self.key2)) 80 | 81 | # Should not crash on non-existing keys 82 | self.assertTrue(self.storage.delete(self.key)) 83 | 84 | def test_clear(self): 85 | self.storage.set(self.key, self.value) 86 | self.storage.set(self.key2, self.value2) 87 | 88 | self.assertEqual(self.value, self.storage.get(self.key)) 89 | self.assertEqual(self.value2, self.storage.get(self.key2)) 90 | 91 | self.assertTrue(self.storage.clear()) 92 | 93 | self.assertIsNone(self.storage.get(self.key)) 94 | self.assertIsNone(self.storage.get(self.key2)) 95 | 96 | # Should clear empty cache 97 | self.assertTrue(self.storage.clear()) 98 | -------------------------------------------------------------------------------- /cloudinary/api_client/call_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import cloudinary 4 | from cloudinary.api_client.execute_request import execute_request 5 | from cloudinary.utils import get_http_connector, normalize_params 6 | 7 | logger = cloudinary.logger 8 | _http = get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS) 9 | 10 | 11 | def call_metadata_api(method, uri, params, **options): 12 | """Private function that assists with performing an API call to the 13 | metadata_fields part of the Admin API 14 | :param method: The HTTP method. Valid methods: get, post, put, delete 15 | :param uri: REST endpoint of the API (without 'metadata_fields') 16 | :param params: Query/body parameters passed to the method 17 | :param options: Additional options 18 | :rtype: Response 19 | """ 20 | uri = ["metadata_fields"] + (uri or []) 21 | return call_json_api(method, uri, params, **options) 22 | 23 | 24 | def call_metadata_rules_api(method, uri, params, **options): 25 | """Private function that assists with performing an API call to the 26 | metadata_rules part of the Admin API 27 | :param method: The HTTP method. Valid methods: get, post, put, delete 28 | :param uri: REST endpoint of the API (without 'metadata_rules') 29 | :param params: Query/body parameters passed to the method 30 | :param options: Additional options 31 | :rtype: Response 32 | """ 33 | uri = ["metadata_rules"] + (uri or []) 34 | return call_json_api(method, uri, params, **options) 35 | 36 | 37 | def call_json_api(method, uri, params, **options): 38 | data=None 39 | if method.upper() != 'GET': 40 | data = json.dumps(params).encode('utf-8') 41 | params = None 42 | 43 | return _call_api(method, uri, params=params, body=data, headers={'Content-Type': 'application/json'}, **options) 44 | 45 | 46 | def _call_v2_api(method, uri, params, **options): 47 | return call_json_api(method, uri, params=params, api_version='v2', **options) 48 | 49 | 50 | def call_api(method, uri, params, **options): 51 | return _call_api(method, uri, params=params, **options) 52 | 53 | 54 | def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=None, **options): 55 | prefix = options.pop("upload_prefix", 56 | cloudinary.config().upload_prefix) or "https://api.cloudinary.com" 57 | cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name) 58 | if not cloud_name: 59 | raise Exception("Must supply cloud_name") 60 | 61 | api_key = options.pop("api_key", cloudinary.config().api_key) 62 | api_secret = options.pop("api_secret", cloudinary.config().api_secret) 63 | oauth_token = options.pop("oauth_token", cloudinary.config().oauth_token) 64 | 65 | _validate_authorization(api_key, api_secret, oauth_token) 66 | auth = {"key": api_key, "secret": api_secret, "oauth_token": oauth_token} 67 | 68 | api_version = options.pop("api_version", cloudinary.API_VERSION) 69 | api_url = "/".join([prefix, api_version, cloud_name] + uri) 70 | 71 | if body is not None: 72 | options["body"] = body 73 | 74 | if extra_headers is not None: 75 | headers.update(extra_headers) 76 | 77 | return execute_request(http_connector=_http, 78 | method=method, 79 | params=normalize_params(params), 80 | headers=headers, 81 | auth=auth, 82 | api_url=api_url, 83 | **options) 84 | 85 | 86 | def _validate_authorization(api_key, api_secret, oauth_token): 87 | if oauth_token: 88 | return 89 | 90 | if not api_key: 91 | raise Exception("Must supply api_key") 92 | 93 | if not api_secret: 94 | raise Exception("Must supply api_secret") 95 | -------------------------------------------------------------------------------- /test/cache/adapter/test_key_value_cache_adapter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from cloudinary.cache.adapter.key_value_cache_adapter import KeyValueCacheAdapter 4 | from test.cache.storage.dummy_cache_storage import DummyCacheStorage 5 | 6 | 7 | class KeyValueCacheAdapterTest(unittest.TestCase): 8 | parameters = {"public_id": "public_id", 9 | "type": "upload", 10 | "resource_type": "image", 11 | "transformation": "w_100", 12 | "format": "jpg"} 13 | value = [100, 200, 300, 399] 14 | parameters2 = {"public_id": "public_id2", 15 | "type": "fetch", 16 | "resource_type": "image", 17 | "transformation": "w_300", 18 | "format": "png"} 19 | value2 = [101, 201, 301, 398] 20 | 21 | def setUp(self): 22 | self.storage = DummyCacheStorage() 23 | self.adapter = KeyValueCacheAdapter(self.storage) 24 | 25 | def test_initialization(self): 26 | """Should be successfully initialized with a valid storage""" 27 | valid_storage = DummyCacheStorage() 28 | valid_adapter = KeyValueCacheAdapter(valid_storage) 29 | 30 | self.assertEqual(valid_storage, valid_adapter._key_value_storage) 31 | 32 | def test_invalid_initialization(self): 33 | invalid_storage_providers = [ 34 | None, 35 | 'notAStorage', 36 | '', 37 | 5375, 38 | [], 39 | True, 40 | object 41 | ] 42 | 43 | for invalid_storage_provider in invalid_storage_providers: 44 | with self.assertRaises(ValueError): 45 | KeyValueCacheAdapter(invalid_storage_provider) 46 | 47 | def test_generate_cache_key(self): 48 | values = [ 49 | ("467d06e5a695b15468f9362e5a58d44de523026b", 50 | self.parameters), 51 | ("1576396c59fc50ac8dc37b75e1184268882c9bc2", 52 | dict(self.parameters, transformation="", format=None)), 53 | ("d8d824ca4e9ac735544ff3c45c1df67749cc1520", 54 | dict(self.parameters, type="", resource_type=None)) 55 | ] 56 | 57 | for value in values: 58 | self.assertEqual(value[0], self.adapter.generate_cache_key(**value[1])) 59 | 60 | def test_get_set(self): 61 | self.adapter.set(value=self.value, **self.parameters) 62 | actual_value = self.adapter.get(**self.parameters) 63 | 64 | self.assertEqual(self.value, actual_value) 65 | 66 | def test_delete(self): 67 | self.adapter.set(value=self.value, **self.parameters) 68 | actual_value = self.adapter.get(**self.parameters) 69 | 70 | self.assertEqual(self.value, actual_value) 71 | 72 | self.adapter.delete(**self.parameters) 73 | deleted_value = self.adapter.get(**self.parameters) 74 | 75 | self.assertIsNone(deleted_value) 76 | 77 | # Delete non-existing key 78 | result = self.adapter.delete(**self.parameters) 79 | 80 | self.assertTrue(result) 81 | 82 | def test_flush_all(self): 83 | 84 | self.adapter.set(value=self.value, **self.parameters) 85 | self.adapter.set(value=self.value2, **self.parameters2) 86 | 87 | actual_value = self.adapter.get(**self.parameters) 88 | actual_value2 = self.adapter.get(**self.parameters2) 89 | 90 | self.assertEqual(self.value, actual_value) 91 | self.assertEqual(self.value2, actual_value2) 92 | 93 | self.adapter.flush_all() 94 | 95 | deleted_value = self.adapter.get(**self.parameters) 96 | deleted_value2 = self.adapter.get(**self.parameters2) 97 | 98 | self.assertIsNone(deleted_value) 99 | self.assertIsNone(deleted_value2) 100 | -------------------------------------------------------------------------------- /test/test_streaming_profiles.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import cloudinary 3 | from cloudinary import api 4 | from urllib3 import disable_warnings 5 | 6 | from test.helper_test import SUFFIX 7 | 8 | disable_warnings() 9 | 10 | 11 | class StreamingProfilesTest(unittest.TestCase): 12 | initialized = False 13 | test_id = "streaming_profiles_test_{}".format(SUFFIX) 14 | 15 | def setUp(self): 16 | if self.initialized: 17 | return 18 | self.initialized = True 19 | cloudinary.reset_config() 20 | if not cloudinary.config().api_secret: 21 | return 22 | 23 | __predefined_sp = ["4k", "full_hd", "hd", "sd", "full_hd_wifi", "full_hd_lean", "hd_lean"] 24 | 25 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 26 | def test_create_streaming_profile(self): 27 | """should create a streaming profile with representations""" 28 | name = self.test_id + "_streaming_profile" 29 | result = api.create_streaming_profile( 30 | name, 31 | representations=[{"transformation": { 32 | "bit_rate": "5m", "height": 1200, "width": 1200, "crop": "limit" 33 | }}]) 34 | self.assertIn("representations", result["data"]) 35 | reps = result["data"]["representations"] 36 | self.assertIsInstance(reps, list) 37 | 38 | # should return transformation as an array 39 | self.assertIsInstance(reps[0]["transformation"], list) 40 | 41 | tr = reps[0]["transformation"][0] 42 | expected = {"bit_rate": "5m", "height": 1200, "width": 1200, "crop": "limit"} 43 | self.assertDictEqual(expected, tr) 44 | 45 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 46 | def test_list_streaming_profiles(self): 47 | """should list streaming profile""" 48 | result = api.list_streaming_profiles() 49 | names = [sp["name"] for sp in result["data"]] 50 | self.assertTrue(len(names) >= len(self.__predefined_sp)) 51 | # streaming profiles should include the predefined profiles 52 | for name in self.__predefined_sp: 53 | self.assertIn(name, names) 54 | 55 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 56 | def test_get_streaming_profile(self): 57 | """should get a specific streaming profile""" 58 | result = api.get_streaming_profile(self.__predefined_sp[0]) 59 | self.assertIn("representations", result["data"]) 60 | reps = result["data"]["representations"] 61 | self.assertIsInstance(reps, list) 62 | self.assertIsInstance(reps[0]["transformation"], list) 63 | 64 | tr = reps[0]["transformation"][0] 65 | self.assertIn("bit_rate", tr) 66 | self.assertIn("height", tr) 67 | self.assertIn("width", tr) 68 | self.assertIn("crop", tr) 69 | 70 | def test_update_delete_streaming_profile(self): 71 | name = self.test_id + "_streaming_profile_delete" 72 | api.create_streaming_profile( 73 | name, 74 | representations=[{"transformation": { 75 | "bit_rate": "5m", "height": 1200, "width": 1200, "crop": "limit" 76 | }}]) 77 | result = api.update_streaming_profile( 78 | name, 79 | representations=[{"transformation": { 80 | "bit_rate": "5m", "height": 1000, "width": 1000, "crop": "scale" 81 | }}]) 82 | self.assertIn("representations", result["data"]) 83 | reps = result["data"]["representations"] 84 | self.assertIsInstance(reps, list) 85 | # transformation is returned as an array 86 | self.assertIsInstance(reps[0]["transformation"], list) 87 | 88 | tr = reps[0]["transformation"][0] 89 | expected = {"bit_rate": "5m", "height": 1000, "width": 1000, "crop": "scale"} 90 | self.assertDictEqual(expected, tr) 91 | 92 | api.delete_streaming_profile(name) 93 | result = api.list_streaming_profiles() 94 | self.assertNotIn(name, [p["name"] for p in result["data"]]) 95 | -------------------------------------------------------------------------------- /samples/basic/basic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from cloudinary.api import delete_resources_by_tag, resources_by_tag 6 | from cloudinary.uploader import upload 7 | from cloudinary.utils import cloudinary_url 8 | 9 | # config 10 | os.chdir(os.path.join(os.path.dirname(sys.argv[0]), '.')) 11 | if os.path.exists('settings.py'): 12 | exec(open('settings.py').read()) 13 | 14 | DEFAULT_TAG = "python_sample_basic" 15 | 16 | 17 | def dump_response(response): 18 | print("Upload response:") 19 | for key in sorted(response.keys()): 20 | print(" %s: %s" % (key, response[key])) 21 | 22 | 23 | def upload_files(): 24 | print("--- Upload a local file") 25 | response = upload("pizza.jpg", tags=DEFAULT_TAG) 26 | dump_response(response) 27 | url, options = cloudinary_url( 28 | response['public_id'], 29 | format=response['format'], 30 | width=200, 31 | height=150, 32 | crop="fill" 33 | ) 34 | print("Fill 200x150 url: " + url) 35 | print("") 36 | 37 | print("--- Upload a local file with custom public ID") 38 | response = upload( 39 | "pizza.jpg", 40 | tags=DEFAULT_TAG, 41 | public_id="custom_name", 42 | ) 43 | dump_response(response) 44 | url, options = cloudinary_url( 45 | response['public_id'], 46 | format=response['format'], 47 | width=200, 48 | height=150, 49 | crop="fit" 50 | ) 51 | print("Fit into 200x150 url: " + url) 52 | print("") 53 | 54 | print("--- Upload a local file with eager transformation of scaling to 200x150") 55 | response = upload( 56 | "lake.jpg", 57 | tags=DEFAULT_TAG, 58 | public_id="eager_custom_name", 59 | eager=dict( 60 | width=200, 61 | height=150, 62 | crop="scale" 63 | ), 64 | ) 65 | dump_response(response) 66 | url, options = cloudinary_url( 67 | response['public_id'], 68 | format=response['format'], 69 | width=200, 70 | height=150, 71 | crop="scale", 72 | ) 73 | print("scaling to 200x150 url: " + url) 74 | print("") 75 | 76 | print("--- Upload by fetching a remote image") 77 | response = upload( 78 | "http://res.cloudinary.com/demo/image/upload/couple.jpg", 79 | tags=DEFAULT_TAG 80 | ) 81 | dump_response(response) 82 | url, options = cloudinary_url( 83 | response['public_id'], 84 | format=response['format'], 85 | width=200, 86 | height=150, 87 | crop="thumb", 88 | gravity="faces", 89 | ) 90 | print("Face detection based 200x150 thumbnail url: " + url) 91 | print("") 92 | 93 | print("--- Fetch an uploaded remote image, fitting it into 500x500 and reducing saturation") 94 | response = upload( 95 | "http://res.cloudinary.com/demo/image/upload/couple.jpg", 96 | tags=DEFAULT_TAG, 97 | width=500, 98 | height=500, 99 | crop="fit", 100 | effect="saturation:-70", 101 | ) 102 | dump_response(response) 103 | url, options = cloudinary_url( 104 | response['public_id'], 105 | format=response['format'], 106 | width=200, 107 | height=150, 108 | crop="fill", 109 | gravity="faces", 110 | radius=10, 111 | effect="sepia", 112 | ) 113 | print("Fill 200x150, round corners, apply the sepia effect, url: " + url) 114 | print("") 115 | 116 | 117 | def cleanup(): 118 | response = resources_by_tag(DEFAULT_TAG) 119 | resources = response.get('resources', []) 120 | if not resources: 121 | print("No images found") 122 | return 123 | print("Deleting {0:d} images...".format(len(resources))) 124 | delete_resources_by_tag(DEFAULT_TAG) 125 | print("Done!") 126 | 127 | 128 | if len(sys.argv) > 1: 129 | if sys.argv[1] == 'upload': 130 | upload_files() 131 | if sys.argv[1] == 'cleanup': 132 | cleanup() 133 | else: 134 | print("--- Uploading files and then cleaning up") 135 | print(" you can only choose one instead by passing 'upload' or 'cleanup' as an argument") 136 | print("") 137 | upload_files() 138 | -------------------------------------------------------------------------------- /cloudinary/cache/responsive_breakpoints_cache.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import collections 4 | 5 | import cloudinary 6 | from cloudinary.cache.adapter.cache_adapter import CacheAdapter 7 | from cloudinary.utils import check_property_enabled 8 | 9 | 10 | class ResponsiveBreakpointsCache: 11 | """ 12 | Caches breakpoint values for image resources 13 | """ 14 | def __init__(self, **cache_options): 15 | """ 16 | Initialize the cache 17 | 18 | :param cache_options: Cache configuration options 19 | """ 20 | 21 | self._cache_adapter = None 22 | 23 | cache_adapter = cache_options.get("cache_adapter") 24 | 25 | self.set_cache_adapter(cache_adapter) 26 | 27 | def set_cache_adapter(self, cache_adapter): 28 | """ 29 | Assigns cache adapter 30 | 31 | :param cache_adapter: The cache adapter used to store and retrieve values 32 | 33 | :return: Returns True if the cache_adapter is valid 34 | """ 35 | if cache_adapter is None or not isinstance(cache_adapter, CacheAdapter): 36 | return False 37 | 38 | self._cache_adapter = cache_adapter 39 | 40 | return True 41 | 42 | @property 43 | def enabled(self): 44 | """ 45 | Indicates whether cache is enabled or not 46 | 47 | :return: Rrue if a _cache_adapter has been set 48 | """ 49 | return self._cache_adapter is not None 50 | 51 | @staticmethod 52 | def _options_to_parameters(**options): 53 | """ 54 | Extract the parameters required in order to calculate the key of the cache. 55 | 56 | :param options: Input options 57 | 58 | :return: A list of values used to calculate the cache key 59 | """ 60 | options_copy = copy.deepcopy(options) 61 | transformation, _ = cloudinary.utils.generate_transformation_string(**options_copy) 62 | file_format = options.get("format", "") 63 | storage_type = options.get("type", "upload") 64 | resource_type = options.get("resource_type", "image") 65 | 66 | return storage_type, resource_type, transformation, file_format 67 | 68 | @check_property_enabled 69 | def get(self, public_id, **options): 70 | """ 71 | Retrieve the breakpoints of a particular derived resource identified by the public_id and options 72 | 73 | :param public_id: The public ID of the resource 74 | :param options: The public ID of the resource 75 | 76 | :return: Array of responsive breakpoints, None if not found 77 | """ 78 | params = self._options_to_parameters(**options) 79 | 80 | return self._cache_adapter.get(public_id, *params) 81 | 82 | @check_property_enabled 83 | def set(self, public_id, value, **options): 84 | """ 85 | Set responsive breakpoints identified by public ID and options 86 | 87 | :param public_id: The public ID of the resource 88 | :param value: Array of responsive breakpoints to set 89 | :param options: Additional options 90 | 91 | :return: True on success or False on failure 92 | """ 93 | if not (isinstance(value, (list, tuple))): 94 | raise ValueError("A list of breakpoints is expected") 95 | 96 | storage_type, resource_type, transformation, file_format = self._options_to_parameters(**options) 97 | 98 | return self._cache_adapter.set(public_id, storage_type, resource_type, transformation, file_format, value) 99 | 100 | @check_property_enabled 101 | def delete(self, public_id, **options): 102 | """ 103 | Delete responsive breakpoints identified by public ID and options 104 | 105 | :param public_id: The public ID of the resource 106 | :param options: Additional options 107 | 108 | :return: True on success or False on failure 109 | """ 110 | params = self._options_to_parameters(**options) 111 | 112 | return self._cache_adapter.delete(public_id, *params) 113 | 114 | @check_property_enabled 115 | def flush_all(self): 116 | """ 117 | Flush all entries from cache 118 | 119 | :return: True on success or False on failure 120 | """ 121 | return self._cache_adapter.flush_all() 122 | 123 | 124 | instance = ResponsiveBreakpointsCache() 125 | -------------------------------------------------------------------------------- /test/test_api_authorization.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import six 4 | 5 | import cloudinary 6 | from cloudinary import api 7 | from cloudinary import uploader 8 | from test.helper_test import TEST_IMAGE, get_headers, get_params, URLLIB3_REQUEST, patch 9 | from test.test_api import MOCK_RESPONSE 10 | from test.test_config import OAUTH_TOKEN, CLOUD_NAME, API_KEY, API_SECRET 11 | from test.test_uploader import API_TEST_PRESET 12 | 13 | 14 | class ApiAuthorizationTest(unittest.TestCase): 15 | def setUp(self): 16 | self.config = cloudinary.config(cloud_name=CLOUD_NAME, api_key=API_KEY, api_secret=API_SECRET) 17 | 18 | @patch(URLLIB3_REQUEST) 19 | def test_oauth_token_admin_api(self, mocker): 20 | self.config.oauth_token = OAUTH_TOKEN 21 | mocker.return_value = MOCK_RESPONSE 22 | 23 | api.ping() 24 | 25 | headers = get_headers(mocker) 26 | 27 | self.assertTrue("authorization" in headers) 28 | self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) 29 | 30 | @patch(URLLIB3_REQUEST) 31 | def test_oauth_token_as_an_option_admin_api(self, mocker): 32 | mocker.return_value = MOCK_RESPONSE 33 | 34 | api.ping(oauth_token=OAUTH_TOKEN) 35 | 36 | headers = get_headers(mocker) 37 | 38 | self.assertTrue("authorization" in headers) 39 | self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) 40 | 41 | @patch(URLLIB3_REQUEST) 42 | def test_key_and_secret_admin_api(self, mocker): 43 | self.config.oauth_token = None 44 | mocker.return_value = MOCK_RESPONSE 45 | 46 | api.ping() 47 | 48 | headers = get_headers(mocker) 49 | 50 | self.assertTrue("authorization" in headers) 51 | self.assertEqual("Basic a2V5OnNlY3JldA==", headers["authorization"]) 52 | 53 | @patch(URLLIB3_REQUEST) 54 | def test_missing_credentials_admin_api(self, mocker): 55 | self.config.oauth_token = None 56 | self.config.api_key = None 57 | self.config.api_secret = None 58 | 59 | mocker.return_value = MOCK_RESPONSE 60 | 61 | with six.assertRaisesRegex(self, Exception, "Must supply api_key"): 62 | api.ping() 63 | 64 | @patch(URLLIB3_REQUEST) 65 | def test_oauth_token_upload_api(self, mocker): 66 | self.config.oauth_token = OAUTH_TOKEN 67 | mocker.return_value = MOCK_RESPONSE 68 | 69 | uploader.upload(TEST_IMAGE) 70 | 71 | headers = get_headers(mocker) 72 | 73 | self.assertTrue("authorization" in headers) 74 | self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) 75 | 76 | params = get_params(mocker) 77 | self.assertNotIn("signature", params) 78 | 79 | @patch(URLLIB3_REQUEST) 80 | def test_oauth_token_as_an_option_upload_api(self, mocker): 81 | mocker.return_value = MOCK_RESPONSE 82 | 83 | uploader.upload(TEST_IMAGE, oauth_token=OAUTH_TOKEN) 84 | 85 | headers = get_headers(mocker) 86 | 87 | self.assertTrue("authorization" in headers) 88 | self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) 89 | 90 | @patch(URLLIB3_REQUEST) 91 | def test_key_and_secret_upload_api(self, mocker): 92 | self.config.oauth_token = None 93 | mocker.return_value = MOCK_RESPONSE 94 | 95 | uploader.upload(TEST_IMAGE) 96 | 97 | headers = get_headers(mocker) 98 | self.assertNotIn("authorization", headers) 99 | 100 | params = get_params(mocker) 101 | self.assertIn("signature", params) 102 | self.assertIn("api_key", params) 103 | 104 | @patch(URLLIB3_REQUEST) 105 | def test_missing_credentials_upload_api(self, mocker): 106 | self.config.oauth_token = None 107 | self.config.api_key = None 108 | self.config.api_secret = None 109 | 110 | mocker.return_value = MOCK_RESPONSE 111 | 112 | with six.assertRaisesRegex(self, Exception, "Must supply api_key"): 113 | uploader.upload(TEST_IMAGE) 114 | 115 | # no credentials required for unsigned upload 116 | uploader.unsigned_upload(TEST_IMAGE, upload_preset=API_TEST_PRESET) 117 | 118 | args, _ = mocker.call_args 119 | params = get_params(mocker) 120 | self.assertTrue("upload_preset" in params) 121 | 122 | 123 | if __name__ == '__main__': 124 | unittest.main() 125 | -------------------------------------------------------------------------------- /test/test_cloudinary_resource.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from urllib3 import disable_warnings 4 | 5 | import cloudinary 6 | from cloudinary import CloudinaryResource 7 | from cloudinary import uploader 8 | from test.helper_test import SUFFIX, TEST_IMAGE, http_response_mock, get_uri, cleanup_test_resources_by_tag, \ 9 | URLLIB3_REQUEST, mock, retry_assertion 10 | 11 | disable_warnings() 12 | 13 | TEST_TAG = "pycloudinary_resource_test_{0}".format(SUFFIX) 14 | TEST_ID = TEST_TAG 15 | 16 | 17 | class TestCloudinaryResource(TestCase): 18 | mocked_response = http_response_mock('{"breakpoints": [50, 500, 1000]}') 19 | mocked_breakpoints = [50, 500, 1000] 20 | expected_transformation = "c_scale,w_auto:breakpoints_50_1000_20_20:json" 21 | 22 | crop_transformation = {'crop': 'crop', 'width': 100} 23 | crop_transformation_str = 'c_crop,w_100' 24 | 25 | @classmethod 26 | def setUpClass(cls): 27 | cloudinary.reset_config() 28 | cls.uploaded = uploader.upload(TEST_IMAGE, public_id=TEST_ID, tags=TEST_TAG) 29 | 30 | @classmethod 31 | def tearDownClass(cls): 32 | cleanup_test_resources_by_tag([(TEST_TAG,)]) 33 | 34 | def setUp(self): 35 | self.res = CloudinaryResource(metadata=self.uploaded) 36 | 37 | def test_empty_class(self): 38 | """An empty CloudinaryResource""" 39 | 40 | self.shortDescription() 41 | res = CloudinaryResource() 42 | self.assertFalse(res, "should be 'False'") 43 | self.assertEqual(len(res), 0, "should have zero len()") 44 | self.assertIsNone(res.url, "should have None url") 45 | 46 | def test_validate(self): 47 | self.assertTrue(self.res.validate()) 48 | self.assertTrue(self.res) 49 | self.assertGreater(len(self.res), 0) 50 | 51 | def test_get_prep_value(self): 52 | value = "image/upload/v{version}/{id}.{format}".format( 53 | version=self.uploaded["version"], 54 | id=self.uploaded["public_id"], 55 | format=self.uploaded["format"]) 56 | 57 | self.assertEqual(value, self.res.get_prep_value()) 58 | 59 | def test_get_presigned(self): 60 | value = "image/upload/v{version}/{id}.{format}#{signature}".format( 61 | version=self.uploaded["version"], 62 | id=self.uploaded["public_id"], 63 | format=self.uploaded["format"], 64 | signature=self.uploaded["signature"]) 65 | 66 | self.assertEqual(value, self.res.get_presigned()) 67 | 68 | def test_url(self): 69 | self.assertEqual(self.res.url, self.uploaded["url"]) 70 | 71 | def test_image(self): 72 | image = self.res.image() 73 | self.assertIn(' src="{url}'.format(url=self.res.url), image) 74 | self.assertNotIn('data-src="{url}'.format(url=self.res.url), image) 75 | image = self.res.image(responsive=True, width="auto", crop="scale") 76 | self.assertNotIn(' src="{url}'.format(url=self.res.build_url(width="auto", crop="scale")), image) 77 | self.assertIn('data-src="{url}'.format(url=self.res.build_url(width="auto", crop="scale")), image) 78 | 79 | @mock.patch(URLLIB3_REQUEST, return_value=mocked_response) 80 | def test_fetch_breakpoints(self, mocked_request): 81 | """Should retrieve responsive breakpoints from cloudinary resource (mocked)""" 82 | actual_breakpoints = self.res._fetch_breakpoints() 83 | 84 | self.assertEqual(self.mocked_breakpoints, actual_breakpoints) 85 | 86 | self.assertIn(self.expected_transformation, get_uri(mocked_request)) 87 | 88 | @mock.patch(URLLIB3_REQUEST, return_value=mocked_response) 89 | def test_fetch_breakpoints_with_transformation(self, mocked_request): 90 | """Should retrieve responsive breakpoints from cloudinary resource with custom transformation (mocked)""" 91 | srcset = {"transformation": self.crop_transformation} 92 | actual_breakpoints = self.res._fetch_breakpoints(srcset) 93 | 94 | self.assertEqual(self.mocked_breakpoints, actual_breakpoints) 95 | 96 | self.assertIn(self.crop_transformation_str + "/" + self.expected_transformation, 97 | get_uri(mocked_request)) 98 | 99 | @retry_assertion() 100 | def test_fetch_breakpoints_real(self): 101 | """Should retrieve responsive breakpoints from cloudinary resource (real request)""" 102 | actual_breakpoints = self.res._fetch_breakpoints() 103 | 104 | self.assertIsInstance(actual_breakpoints, list) 105 | 106 | self.assertGreater(len(actual_breakpoints), 0) 107 | -------------------------------------------------------------------------------- /cloudinary/search.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import cloudinary 4 | from cloudinary.api_client.call_api import call_json_api 5 | from cloudinary.utils import (unique, build_distribution_domain, base64url_encode, json_encode, compute_hex_hash, 6 | SIGNATURE_SHA256, build_array) 7 | 8 | 9 | class Search(object): 10 | ASSETS = 'resources' 11 | 12 | _endpoint = ASSETS 13 | 14 | _KEYS_WITH_UNIQUE_VALUES = { 15 | 'sort_by': lambda x: next(iter(x)), 16 | 'aggregate': lambda agg: agg["type"] if isinstance(agg, dict) and "type" in agg else agg, 17 | 'with_field': None, 18 | 'fields': None, 19 | } 20 | 21 | _ttl = 300 # Used for search URLs 22 | 23 | """Build and execute a search query.""" 24 | 25 | def __init__(self): 26 | self.query = {} 27 | 28 | def expression(self, value): 29 | """Specify the search query expression.""" 30 | self.query["expression"] = value 31 | return self 32 | 33 | def max_results(self, value): 34 | """Set the max results to return""" 35 | self.query["max_results"] = value 36 | return self 37 | 38 | def next_cursor(self, value): 39 | """Get next page in the query using the ``next_cursor`` value from a previous invocation.""" 40 | self.query["next_cursor"] = value 41 | return self 42 | 43 | def sort_by(self, field_name, direction=None): 44 | """Add a field to sort results by. If not provided, direction is ``desc``.""" 45 | if direction is None: 46 | direction = 'desc' 47 | self._add("sort_by", {field_name: direction}) 48 | return self 49 | 50 | def aggregate(self, value): 51 | """Aggregate field.""" 52 | self._add("aggregate", value) 53 | return self 54 | 55 | def with_field(self, value): 56 | """Request an additional field in the result set.""" 57 | self._add("with_field", value) 58 | return self 59 | 60 | def fields(self, value): 61 | """Request which fields to return in the result set.""" 62 | self._add("fields", value) 63 | return self 64 | 65 | def ttl(self, ttl): 66 | """ 67 | Sets the time to live of the search URL. 68 | 69 | :param ttl: The time to live in seconds. 70 | :return: self 71 | """ 72 | self._ttl = ttl 73 | return self 74 | 75 | def to_json(self): 76 | return json.dumps(self.as_dict()) 77 | 78 | def execute(self, **options): 79 | """Execute the search and return results.""" 80 | options["content_type"] = 'application/json' 81 | uri = [self._endpoint, 'search'] 82 | return call_json_api('post', uri, self.as_dict(), **options) 83 | 84 | def as_dict(self): 85 | to_return = {} 86 | 87 | for key, value in self.query.items(): 88 | if key in self._KEYS_WITH_UNIQUE_VALUES: 89 | value = unique(value, self._KEYS_WITH_UNIQUE_VALUES[key]) 90 | 91 | to_return[key] = value 92 | 93 | return to_return 94 | 95 | def to_url(self, ttl=None, next_cursor=None, **options): 96 | """ 97 | Creates a signed Search URL that can be used on the client side. 98 | 99 | :param ttl: The time to live in seconds. 100 | :param next_cursor: Starting position. 101 | :param options: Additional url delivery options. 102 | :return: The resulting search URL. 103 | """ 104 | api_secret = options.get("api_secret", cloudinary.config().api_secret or None) 105 | if not api_secret: 106 | raise ValueError("Must supply api_secret") 107 | 108 | if ttl is None: 109 | ttl = self._ttl 110 | 111 | query = self.as_dict() 112 | 113 | _next_cursor = query.pop("next_cursor", None) 114 | if next_cursor is None: 115 | next_cursor = _next_cursor 116 | 117 | b64query = base64url_encode(json_encode(query, sort_keys=True)) 118 | 119 | prefix = build_distribution_domain(options) 120 | 121 | signature = compute_hex_hash("{ttl}{b64query}{api_secret}".format( 122 | ttl=ttl, 123 | b64query=b64query, 124 | api_secret=api_secret 125 | ), algorithm=SIGNATURE_SHA256) 126 | 127 | return "{prefix}/search/{signature}/{ttl}/{b64query}{next_cursor}".format( 128 | prefix=prefix, 129 | signature=signature, 130 | ttl=ttl, 131 | b64query=b64query, 132 | next_cursor="/{}".format(next_cursor) if next_cursor else "") 133 | 134 | def endpoint(self, endpoint): 135 | self._endpoint = endpoint 136 | return self 137 | 138 | def _add(self, name, value): 139 | if name not in self.query: 140 | self.query[name] = [] 141 | self.query[name].extend(build_array(value)) 142 | return self 143 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Pycloudinary 2 | 3 | Contributions are welcome and greatly appreciated! 4 | 5 | ## Reporting a bug 6 | 7 | - Ensure that the bug was not already reported by searching in GitHub under [Issues](https://github.com/cloudinary/pycloudinary) and the Cloudinary [Support forms](https://support.cloudinary.com). 8 | - If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/cloudinary/pycloudinary/issues/new). 9 | Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. 10 | - If you require assistance in the implementation of pycloudinary please [submit a request](https://support.cloudinary.com/hc/en-us/requests/new) in the Cloudinary web site. 11 | 12 | ## Requesting a feature 13 | 14 | We would love to hear your requests! 15 | Please be aware that the package is used in a wide variety of environments and that some features may not be applicable to all users. 16 | 17 | - Open a GitHub [issue](https://github.com/cloudinary/pycloudinary) describing the benefits (and possible drawbacks) of the requested feature 18 | 19 | ## Fixing a bug / Implementing a new feature 20 | 21 | - Follow the instructions detailed in [Code contribution](#code-contribution) 22 | - Open a new GitHub pull request 23 | - Ensure the PR description clearly describes the bug / feature. Include the relevant issue number if applicable. 24 | - Provide test code that covers the new code 25 | - Make sure that your code works both with and without Django 26 | - The code should support: 27 | - Python >= 2.7 28 | - Django >= 1.8 29 | 30 | ## Code contribution 31 | 32 | When contributing code, either to fix a bug or to implement a new feature, please follow these guidelines: 33 | 34 | #### Fork the Project 35 | 36 | Fork [project on Github](https://github.com/cloudinary/pycloudinary) and check out your copy. 37 | 38 | ``` 39 | git clone https://github.com/contributor/pycloudinary.git 40 | cd pycloudinary 41 | git remote add upstream https://github.com/cloudinary/pycloudinary.git 42 | ``` 43 | 44 | #### Create a Topic Branch 45 | 46 | Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. 47 | 48 | ``` 49 | git checkout master 50 | git pull upstream master 51 | git checkout -b my-feature-branch 52 | ``` 53 | #### Rebase 54 | 55 | If you've been working on a change for a while, rebase with upstream/master. 56 | 57 | ``` 58 | git fetch upstream 59 | git rebase upstream/master 60 | git push origin my-feature-branch -f 61 | ``` 62 | 63 | 64 | #### Write Tests 65 | 66 | Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. Add to [test](test). 67 | 68 | We definitely appreciate pull requests that highlight or reproduce a problem, even without a fix. 69 | 70 | #### Write Code 71 | 72 | Implement your feature or bug fix. 73 | Try to follow [PEP8](https://pep8.org/). 74 | Make sure that your code works both with and without Django 75 | The code should support: 76 | 77 | - Python >= 2.7 78 | - Django >= 1.8 79 | 80 | Make sure that tests completes without errors. 81 | 82 | #### Write Documentation 83 | 84 | Document any external behavior in the [README](README.md). 85 | 86 | #### Running the tests 87 | 88 | Run the basic test suite with your `CLOUDINARY_URL`: 89 | 90 | CLOUDINARY_URL=cloudinary://apikey:apisecret@cloudname python setup.py test 91 | 92 | This only runs the tests for the current environment. 93 | Travis-CI will run the full suite when you submit your pull request. 94 | 95 | The full test suite takes a long time to run because it tests multiple combinations of Python and Django. 96 | You need to have Python 2.7, 3.4, 3.5, 3.6, 3.7 installed to run all environments. Then run: 97 | 98 | CLOUDINARY_URL=cloudinary://apikey:apisecret@cloudname tox 99 | 100 | #### Commit Changes 101 | 102 | Make sure git knows your name and email address: 103 | 104 | ``` 105 | git config --global user.name "Your Name" 106 | git config --global user.email "contributor@example.com" 107 | ``` 108 | 109 | Writing good commit logs is important. A commit log should describe what changed and why. 110 | 111 | ``` 112 | git add ... 113 | git commit 114 | ``` 115 | 116 | 117 | > Please squash your commits into a single commit when appropriate. This simplifies future cherry picks and keeps the git log clean. 118 | 119 | #### Push 120 | 121 | ``` 122 | git push origin my-feature-branch 123 | ``` 124 | 125 | #### Make a Pull Request 126 | 127 | Go to https://github.com/contributor/pycloudinary and select your feature branch. Click the 'Pull Request' button and fill out the form. Pull requests are usually reviewed within a few days. 128 | Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 129 | 130 | #### Rebase 131 | 132 | If you've been working on a change for a while, rebase with upstream/master. 133 | 134 | ``` 135 | git fetch upstream 136 | git rebase upstream/master 137 | git push origin my-feature-branch -f 138 | ``` 139 | 140 | #### Check on Your Pull Request 141 | 142 | Go back to your pull request after a few minutes and see whether it passed muster with Travis-CI. Everything should look green, otherwise fix issues and amend your commit as described above. 143 | 144 | #### Be Patient 145 | 146 | It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang on there! 147 | 148 | #### Thank You 149 | 150 | Please do know that we really appreciate and value your time and work. We love you, really. 151 | -------------------------------------------------------------------------------- /cloudinary/forms.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import cloudinary.uploader 5 | import cloudinary.utils 6 | from cloudinary import CloudinaryResource 7 | from django import forms 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | 11 | def cl_init_js_callbacks(form, request): 12 | for field in form.fields.values(): 13 | if isinstance(field, CloudinaryJsFileField): 14 | field.enable_callback(request) 15 | 16 | 17 | class CloudinaryInput(forms.TextInput): 18 | input_type = 'file' 19 | 20 | def render(self, name, value, attrs=None, renderer=None): 21 | attrs = dict(self.attrs, **attrs) 22 | options = attrs.get('options', {}) 23 | attrs["options"] = '' 24 | 25 | params = cloudinary.utils.build_upload_params(**options) 26 | if options.get("unsigned"): 27 | params = cloudinary.utils.cleanup_params(params) 28 | else: 29 | params = cloudinary.utils.sign_request(params, options) 30 | 31 | if 'resource_type' not in options: 32 | options['resource_type'] = 'auto' 33 | cloudinary_upload_url = cloudinary.utils.cloudinary_api_url("upload", **options) 34 | 35 | attrs["data-url"] = cloudinary_upload_url 36 | attrs["data-form-data"] = json.dumps(params) 37 | attrs["data-cloudinary-field"] = name 38 | chunk_size = options.get("chunk_size", None) 39 | if chunk_size: 40 | attrs["data-max-chunk-size"] = chunk_size 41 | attrs["class"] = " ".join(["cloudinary-fileupload", attrs.get("class", "")]) 42 | 43 | widget = super(CloudinaryInput, self).render("file", None, attrs=attrs) 44 | if value: 45 | if isinstance(value, CloudinaryResource): 46 | value_string = value.get_presigned() 47 | else: 48 | value_string = value 49 | widget += forms.HiddenInput().render(name, value_string) 50 | return widget 51 | 52 | 53 | class CloudinaryJsFileField(forms.Field): 54 | default_error_messages = { 55 | "required": _("No file selected!") 56 | } 57 | 58 | def __init__(self, attrs=None, options=None, autosave=True, *args, **kwargs): 59 | if attrs is None: 60 | attrs = {} 61 | if options is None: 62 | options = {} 63 | self.autosave = autosave 64 | attrs = attrs.copy() 65 | attrs["options"] = options.copy() 66 | 67 | field_options = {'widget': CloudinaryInput(attrs=attrs)} 68 | field_options.update(kwargs) 69 | super(CloudinaryJsFileField, self).__init__(*args, **field_options) 70 | 71 | def enable_callback(self, request): 72 | from django.contrib.staticfiles.storage import staticfiles_storage 73 | self.widget.attrs["options"]["callback"] = request.build_absolute_uri( 74 | staticfiles_storage.url("html/cloudinary_cors.html")) 75 | 76 | def to_python(self, value): 77 | """Convert to CloudinaryResource""" 78 | if not value: 79 | return None 80 | m = re.search(r'^([^/]+)/([^/]+)/v(\d+)/([^#]+)#([^/]+)$', value) 81 | if not m: 82 | raise forms.ValidationError("Invalid format") 83 | resource_type = m.group(1) 84 | upload_type = m.group(2) 85 | version = m.group(3) 86 | filename = m.group(4) 87 | signature = m.group(5) 88 | m = re.search(r'(.*)\.(.*)', filename) 89 | if not m: 90 | raise forms.ValidationError("Invalid file name") 91 | public_id = m.group(1) 92 | image_format = m.group(2) 93 | return CloudinaryResource(public_id, 94 | format=image_format, 95 | version=version, 96 | signature=signature, 97 | type=upload_type, 98 | resource_type=resource_type) 99 | 100 | def validate(self, value): 101 | """Validate the signature""" 102 | # Use the parent's handling of required fields, etc. 103 | super(CloudinaryJsFileField, self).validate(value) 104 | if not value: 105 | return 106 | if not value.validate(): 107 | raise forms.ValidationError("Signature mismatch") 108 | 109 | 110 | class CloudinaryUnsignedJsFileField(CloudinaryJsFileField): 111 | def __init__(self, upload_preset, attrs=None, options=None, autosave=True, *args, **kwargs): 112 | if attrs is None: 113 | attrs = {} 114 | if options is None: 115 | options = {} 116 | options = options.copy() 117 | options.update({"unsigned": True, "upload_preset": upload_preset}) 118 | super(CloudinaryUnsignedJsFileField, self).__init__( 119 | attrs, options, autosave, *args, **kwargs) 120 | 121 | 122 | class CloudinaryFileField(forms.FileField): 123 | my_default_error_messages = { 124 | "required": _("No file selected!") 125 | } 126 | default_error_messages = forms.FileField.default_error_messages.copy() 127 | default_error_messages.update(my_default_error_messages) 128 | 129 | def __init__(self, options=None, autosave=True, *args, **kwargs): 130 | self.autosave = autosave 131 | self.options = options or {} 132 | super(CloudinaryFileField, self).__init__(*args, **kwargs) 133 | 134 | def to_python(self, value): 135 | """Upload and convert to CloudinaryResource""" 136 | value = super(CloudinaryFileField, self).to_python(value) 137 | if not value: 138 | return None 139 | if self.autosave: 140 | return cloudinary.uploader.upload_image(value, **self.options) 141 | else: 142 | return value 143 | -------------------------------------------------------------------------------- /test/test_expression_normalization.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import unittest 3 | 4 | from cloudinary.utils import normalize_expression, generate_transformation_string, _SIMPLE_TRANSFORMATION_PARAMS 5 | 6 | NORMALIZATION_EXAMPLES = { 7 | 'None is not affected': [None, None], 8 | 'number replaced with a string value': [10, '10'], 9 | 'empty string is not affected': ['', ''], 10 | 'single space is replaced with a single underscore': [' ', '_'], 11 | 'blank string is replaced with a single underscore': [' ', '_'], 12 | 'underscore is not affected': ['_', '_'], 13 | 'sequence of underscores and spaces is replaced with a single underscore': [' _ __ _', '_'], 14 | 'arbitrary text is not affected': ['foobar', 'foobar'], 15 | 'double ampersand replaced with and operator': ['foo && bar', 'foo_and_bar'], 16 | 'double ampersand with no space at the end is not affected': ['foo&&bar', 'foo&&bar'], 17 | 'width recognized as variable and replaced with w': ['width', 'w'], 18 | 'initial aspect ratio recognized as variable and replaced with iar': ['initial_aspect_ratio', 'iar'], 19 | 'duration is recognized as a variable and replaced with du': ['duration', 'du'], 20 | 'duration after : is not a variable and is not affected': ['preview:duration_2', 'preview:duration_2'], 21 | '$width recognized as user variable and not affected': ['$width', '$width'], 22 | '$initial_aspect_ratio recognized as user variable followed by aspect_ratio variable': [ 23 | '$initial_aspect_ratio', 24 | '$initial_ar', 25 | ], 26 | '$mywidth recognized as user variable and not affected': ['$mywidth', '$mywidth'], 27 | '$widthwidth recognized as user variable and not affected': ['$widthwidth', '$widthwidth'], 28 | '$_width recognized as user variable and not affected': ['$_width', '$_width'], 29 | '$__width recognized as user variable and not affected': ['$__width', '$_width'], 30 | '$$width recognized as user variable and not affected': ['$$width', '$$width'], 31 | '$height recognized as user variable and not affected': ['$height_100', '$height_100'], 32 | '$heightt_100 recognized as user variable and not affected': ['$heightt_100', '$heightt_100'], 33 | '$$height_100 recognized as user variable and not affected': ['$$height_100', '$$height_100'], 34 | '$heightmy_100 recognized as user variable and not affected': ['$heightmy_100', '$heightmy_100'], 35 | '$myheight_100 recognized as user variable and not affected': ['$myheight_100', '$myheight_100'], 36 | '$heightheight_100 recognized as user variable and not affected': [ 37 | '$heightheight_100', 38 | '$heightheight_100', 39 | ], 40 | '$theheight_100 recognized as user variable and not affected': ['$theheight_100', '$theheight_100'], 41 | '$__height_100 recognized as user variable and not affected': ['$__height_100', '$_height_100'] 42 | } 43 | 44 | 45 | class ExpressionNormalizationTest(unittest.TestCase): 46 | 47 | def test_expression_normalization(self): 48 | for description, (input_expression, expected_expression) in NORMALIZATION_EXAMPLES.items(): 49 | with self.subTest(description, input_expression=input_expression): 50 | self.assertEqual(expected_expression, normalize_expression(input_expression)) 51 | 52 | def test_predefined_parameters_normalization(self): 53 | normalized_params = ( 54 | 'angle', 55 | 'aspect_ratio', 56 | 'dpr', 57 | 'effect', 58 | 'height', 59 | 'opacity', 60 | 'quality', 61 | 'width', 62 | 'x', 63 | 'y', 64 | 'start_offset', 65 | 'end_offset', 66 | 'zoom' 67 | ) 68 | 69 | value = 'width * 2' 70 | normalized_value = 'w_mul_2' 71 | 72 | for param in normalized_params: 73 | with self.subTest('should normalize value in {}'.format(param), param=param): 74 | 75 | options = {param: value} 76 | 77 | # Set no_html_sizes 78 | if param in ['height', 'width']: 79 | options['crop'] = 'fit' 80 | 81 | result = generate_transformation_string(**options) 82 | 83 | self.assertEqual(result[1], {}) 84 | self.assertFalse(value in result[0]) 85 | self.assertTrue(normalized_value in result[0]) 86 | 87 | def test_simple_parameters_normalization(self): 88 | value = 'width * 2' 89 | normalized_value = 'w_mul_2' 90 | not_normalized_params = list(_SIMPLE_TRANSFORMATION_PARAMS.values()) 91 | not_normalized_params.extend(['overlay', 'underlay']) 92 | 93 | for param in not_normalized_params: 94 | with self.subTest('should not normalize value in {}'.format(param), param=param): 95 | options = {param: value} 96 | 97 | result = generate_transformation_string(**options) 98 | 99 | self.assertTrue(value in result[0]) 100 | self.assertFalse(normalized_value in result[0]) 101 | 102 | def test_support_start_offset(self): 103 | result = generate_transformation_string(**{"width": "100", "start_offset": "idu - 5"}) 104 | self.assertIn("so_idu_sub_5", result[0]) 105 | 106 | result = generate_transformation_string(**{"width": "100", "start_offset": "$logotime"}) 107 | self.assertIn("so_$logotime", result[0]) 108 | 109 | def test_support_end_offset(self): 110 | result = generate_transformation_string(**{"width": "100", "end_offset": "idu - 5"}) 111 | self.assertIn("eo_idu_sub_5", result[0]) 112 | 113 | result = generate_transformation_string(**{"width": "100", "end_offset": "$logotime"}) 114 | self.assertIn("eo_$logotime", result[0]) 115 | 116 | if not hasattr(unittest.TestCase, "subTest"): 117 | # Support Python before version 3.4 118 | @contextlib.contextmanager 119 | def subTest(self, msg="", **params): 120 | yield 121 | -------------------------------------------------------------------------------- /cloudinary/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from cloudinary import CloudinaryResource, forms, uploader 4 | from django.core.files.uploadedfile import UploadedFile 5 | from django.db import models 6 | from cloudinary.uploader import upload_options 7 | from cloudinary.utils import upload_params 8 | 9 | # Add introspection rules for South, if it's installed. 10 | try: 11 | from south.modelsinspector import add_introspection_rules 12 | add_introspection_rules([], ["^cloudinary.models.CloudinaryField"]) 13 | except ImportError: 14 | pass 15 | 16 | CLOUDINARY_FIELD_DB_RE = r'(?:(?Pimage|raw|video)/' \ 17 | r'(?Pupload|private|authenticated)/)?' \ 18 | r'(?:v(?P\d+)/)?' \ 19 | r'(?P.*?)' \ 20 | r'(\.(?P[^.]+))?$' 21 | 22 | 23 | def with_metaclass(meta, *bases): 24 | """ 25 | Create a base class with a metaclass. 26 | 27 | This requires a bit of explanation: the basic idea is to make a dummy 28 | metaclass for one level of class instantiation that replaces itself with 29 | the actual metaclass. 30 | 31 | Taken from six - https://pythonhosted.org/six/ 32 | """ 33 | class metaclass(meta): 34 | def __new__(cls, name, this_bases, d): 35 | return meta(name, bases, d) 36 | return type.__new__(metaclass, 'temporary_class', (), {}) 37 | 38 | 39 | class CloudinaryField(models.Field): 40 | description = "A resource stored in Cloudinary" 41 | 42 | def __init__(self, *args, **kwargs): 43 | self.default_form_class = kwargs.pop("default_form_class", forms.CloudinaryFileField) 44 | self.type = kwargs.pop("type", "upload") 45 | self.resource_type = kwargs.pop("resource_type", "image") 46 | self.width_field = kwargs.pop("width_field", None) 47 | self.height_field = kwargs.pop("height_field", None) 48 | # Collect all options related to Cloudinary upload 49 | self.options = {key: kwargs.pop(key) for key in set(kwargs.keys()) if key in upload_params + upload_options} 50 | 51 | field_options = kwargs 52 | field_options['max_length'] = 255 53 | super(CloudinaryField, self).__init__(*args, **field_options) 54 | 55 | def get_internal_type(self): 56 | return 'CharField' 57 | 58 | def value_to_string(self, obj): 59 | """ 60 | We need to support both legacy `_get_val_from_obj` and new `value_from_object` models.Field methods. 61 | It would be better to wrap it with try -> except AttributeError -> fallback to legacy. 62 | Unfortunately, we can catch AttributeError exception from `value_from_object` function itself. 63 | Parsing exception string is an overkill here, that's why we check for attribute existence 64 | 65 | :param obj: Value to serialize 66 | 67 | :return: Serialized value 68 | """ 69 | 70 | if hasattr(self, 'value_from_object'): 71 | value = self.value_from_object(obj) 72 | else: # fallback for legacy django versions 73 | value = self._get_val_from_obj(obj) 74 | 75 | return self.get_prep_value(value) 76 | 77 | def parse_cloudinary_resource(self, value): 78 | m = re.match(CLOUDINARY_FIELD_DB_RE, value) 79 | resource_type = m.group('resource_type') or self.resource_type 80 | upload_type = m.group('type') or self.type 81 | return CloudinaryResource( 82 | type=upload_type, 83 | resource_type=resource_type, 84 | version=m.group('version'), 85 | public_id=m.group('public_id'), 86 | format=m.group('format') 87 | ) 88 | 89 | def from_db_value(self, value, expression, connection, *args, **kwargs): 90 | # TODO: when dropping support for versions prior to 2.0, you may return 91 | # the signature to from_db_value(value, expression, connection) 92 | if value is not None: 93 | return self.parse_cloudinary_resource(value) 94 | 95 | def to_python(self, value): 96 | if isinstance(value, CloudinaryResource): 97 | return value 98 | elif isinstance(value, UploadedFile): 99 | return value 100 | elif value is None or value is False: 101 | return value 102 | else: 103 | return self.parse_cloudinary_resource(value) 104 | 105 | def pre_save(self, model_instance, add): 106 | value = super(CloudinaryField, self).pre_save(model_instance, add) 107 | if isinstance(value, UploadedFile): 108 | options = {"type": self.type, "resource_type": self.resource_type} 109 | options.update({key: val(model_instance) if callable(val) else val for key, val in self.options.items()}) 110 | if hasattr(value, 'seekable') and value.seekable(): 111 | value.seek(0) 112 | instance_value = uploader.upload_resource(value, **options) 113 | setattr(model_instance, self.attname, instance_value) 114 | if self.width_field: 115 | setattr(model_instance, self.width_field, instance_value.metadata.get('width')) 116 | if self.height_field: 117 | setattr(model_instance, self.height_field, instance_value.metadata.get('height')) 118 | return self.get_prep_value(instance_value) 119 | else: 120 | return value 121 | 122 | def get_prep_value(self, value): 123 | if not value: 124 | return self.get_default() 125 | if isinstance(value, CloudinaryResource): 126 | return value.get_prep_value() 127 | else: 128 | return value 129 | 130 | def formfield(self, **kwargs): 131 | options = {"type": self.type, "resource_type": self.resource_type} 132 | options.update(kwargs.pop('options', {})) 133 | defaults = {'form_class': self.default_form_class, 'options': options, 'autosave': False} 134 | defaults.update(kwargs) 135 | return super(CloudinaryField, self).formfield(**defaults) 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Tests](https://github.com/cloudinary/pycloudinary/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/cloudinary/pycloudinary/actions/workflows/test.yml) 2 | [![PyPI Version](https://img.shields.io/pypi/v/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) 3 | [![PyPI PyVersions](https://img.shields.io/pypi/pyversions/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) 4 | [![PyPI DjangoVersions](https://img.shields.io/pypi/djversions/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) 5 | [![PyPI Version](https://img.shields.io/pypi/dm/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) 6 | [![PyPI License](https://img.shields.io/pypi/l/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) 7 | 8 | 9 | Cloudinary Python SDK 10 | ================== 11 | 12 | ## About 13 | The Cloudinary Python SDK allows you to quickly and easily integrate your application with Cloudinary. 14 | Effortlessly optimize, transform, upload and manage your cloud's assets. 15 | 16 | 17 | #### Note 18 | This Readme provides basic installation and usage information. 19 | For the complete documentation, see the [Python SDK Guide](https://cloudinary.com/documentation/django_integration). 20 | 21 | ## Table of Contents 22 | - [Key Features](#key-features) 23 | - [Version Support](#Version-Support) 24 | - [Installation](#installation) 25 | - [Usage](#usage) 26 | - [Setup](#Setup) 27 | - [Transform and Optimize Assets](#Transform-and-Optimize-Assets) 28 | - [Django](#Django) 29 | 30 | 31 | ## Key Features 32 | - [Transform](https://cloudinary.com/documentation/django_video_manipulation#video_transformation_examples) and 33 | [optimize](https://cloudinary.com/documentation/django_image_manipulation#image_optimizations) assets. 34 | - Generate [image](https://cloudinary.com/documentation/django_image_manipulation#deliver_and_transform_images) and 35 | [video](https://cloudinary.com/documentation/django_video_manipulation#django_video_transformation_code_examples) tags. 36 | - [Asset Management](https://cloudinary.com/documentation/django_asset_administration). 37 | - [Secure URLs](https://cloudinary.com/documentation/video_manipulation_and_delivery#generating_secure_https_urls_using_sdks). 38 | 39 | 40 | 41 | ## Version Support 42 | 43 | | SDK Version | Python 2.7 | Python 3.x | 44 | |-------------|------------|------------| 45 | | 1.x | ✔ | ✔ | 46 | 47 | | SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | 48 | |-------------|-------------|------------|------------|------------|------------| 49 | | 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | 50 | 51 | 52 | ## Installation 53 | ```bash 54 | pip install cloudinary 55 | ``` 56 | 57 | # Usage 58 | 59 | ### Setup 60 | ```python 61 | import cloudinary 62 | ``` 63 | 64 | ### Transform and Optimize Assets 65 | - [See full documentation](https://cloudinary.com/documentation/django_image_manipulation). 66 | 67 | ```python 68 | cloudinary.utils.cloudinary_url("sample.jpg", width=100, height=150, crop="fill") 69 | ``` 70 | 71 | ### Upload 72 | - [See full documentation](https://cloudinary.com/documentation/django_image_and_video_upload). 73 | - [Learn more about configuring your uploads with upload presets](https://cloudinary.com/documentation/upload_presets). 74 | ```python 75 | cloudinary.uploader.upload("my_picture.jpg") 76 | ``` 77 | 78 | ### Django 79 | - [See full documentation](https://cloudinary.com/documentation/django_image_and_video_upload#django_forms_and_models). 80 | 81 | ### Security options 82 | - [See full documentation](https://cloudinary.com/documentation/solution_overview#security). 83 | 84 | ### Sample projects 85 | - [Sample projects](https://github.com/cloudinary/pycloudinary/tree/master/samples). 86 | - [Django Photo Album](https://github.com/cloudinary/cloudinary-django-sample). 87 | 88 | 89 | ## Contributions 90 | - Ensure tests run locally. 91 | - Open a PR and ensure Travis tests pass. 92 | - See [CONTRIBUTING](CONTRIBUTING.md). 93 | 94 | ## Get Help 95 | If you run into an issue or have a question, you can either: 96 | - Issues related to the SDK: [Open a GitHub issue](https://github.com/cloudinary/pycloudinary/issues). 97 | - Issues related to your account: [Open a support ticket](https://cloudinary.com/contact). 98 | 99 | 100 | ## About Cloudinary 101 | Cloudinary is a powerful media API for websites and mobile apps alike, Cloudinary enables developers to efficiently 102 | manage, transform, optimize, and deliver images and videos through multiple CDNs. Ultimately, viewers enjoy responsive 103 | and personalized visual-media experiences—irrespective of the viewing device. 104 | 105 | 106 | ## Additional Resources 107 | - [Cloudinary Transformation and REST API References](https://cloudinary.com/documentation/cloudinary_references): Comprehensive references, including syntax and examples for all SDKs. 108 | - [MediaJams.dev](https://mediajams.dev/): Bite-size use-case tutorials written by and for Cloudinary Developers 109 | - [DevJams](https://www.youtube.com/playlist?list=PL8dVGjLA2oMr09amgERARsZyrOz_sPvqw): Cloudinary developer podcasts on YouTube. 110 | - [Cloudinary Academy](https://training.cloudinary.com/): Free self-paced courses, instructor-led virtual courses, and on-site courses. 111 | - [Code Explorers and Feature Demos](https://cloudinary.com/documentation/code_explorers_demos_index): A one-stop shop for all code explorers, Postman collections, and feature demos found in the docs. 112 | - [Cloudinary Roadmap](https://cloudinary.com/roadmap): Your chance to follow, vote, or suggest what Cloudinary should develop next. 113 | - [Cloudinary Facebook Community](https://www.facebook.com/groups/CloudinaryCommunity): Learn from and offer help to other Cloudinary developers. 114 | - [Cloudinary Account Registration](https://cloudinary.com/users/register/free): Free Cloudinary account registration. 115 | - [Cloudinary Website](https://cloudinary.com): Learn about Cloudinary's products, partners, customers, pricing, and more. 116 | 117 | 118 | ## Licence 119 | Released under the MIT license. 120 | -------------------------------------------------------------------------------- /cloudinary/api_client/tcp_keep_alive_manager.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import sys 3 | 4 | from urllib3 import HTTPSConnectionPool, HTTPConnectionPool, PoolManager, ProxyManager 5 | 6 | # Inspired by: 7 | # https://github.com/finbourne/lusid-sdk-python/blob/b813882e4f1777ea78670a03a7596486639e6f40/sdk/lusid/tcp/tcp_keep_alive_probes.py 8 | 9 | # The content to send on Mac OS in the TCP Keep Alive probe 10 | TCP_KEEPALIVE = 0x10 11 | # The maximum time to keep the connection idle before sending probes 12 | TCP_KEEP_IDLE = 60 13 | # The interval between probes 14 | TCP_KEEPALIVE_INTERVAL = 60 15 | # The maximum number of failed probes before terminating the connection 16 | TCP_KEEP_CNT = 3 17 | 18 | 19 | class TCPKeepAliveValidationMethods: 20 | """ 21 | This class contains a single method whose sole purpose is to set up TCP Keep Alive probes on the socket for a 22 | connection. This is necessary for long-running requests which will be silently terminated by the AWS Network Load 23 | Balancer which kills a connection if it is idle for more than 350 seconds. 24 | """ 25 | 26 | @staticmethod 27 | def adjust_connection_socket(conn, protocol="https"): 28 | """ 29 | Adjusts the socket settings so that the client sends a TCP keep alive probe over the connection. This is only 30 | applied where possible, if the ability to set the socket options is not available, for example using Anaconda, 31 | then the settings will be left as is. 32 | :param conn: The connection to update the socket settings for 33 | :param str protocol: The protocol of the connection 34 | :return: None 35 | """ 36 | 37 | if protocol == "http": 38 | # It isn't clear how to set this up over HTTP, it seems to differ from HTTPs 39 | return 40 | 41 | # TCP Keep Alive Probes for different platforms 42 | platform = sys.platform 43 | # TCP Keep Alive Probes for Linux 44 | if (platform == 'linux' and hasattr(conn.sock, "setsockopt") and hasattr(socket, "SO_KEEPALIVE") and 45 | hasattr(socket, "TCP_KEEPIDLE") and hasattr(socket, "TCP_KEEPINTVL") and hasattr(socket, 46 | "TCP_KEEPCNT")): 47 | conn.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 48 | conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, TCP_KEEP_IDLE) 49 | conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, TCP_KEEPALIVE_INTERVAL) 50 | conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, TCP_KEEP_CNT) 51 | 52 | # TCP Keep Alive Probes for Windows OS 53 | elif platform == 'win32' and hasattr(socket, "SIO_KEEPALIVE_VALS") and getattr(conn.sock, "ioctl", 54 | None) is not None: 55 | conn.sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, TCP_KEEP_IDLE * 1000, TCP_KEEPALIVE_INTERVAL * 1000)) 56 | 57 | # TCP Keep Alive Probes for Mac OS 58 | elif platform == 'darwin' and getattr(conn.sock, "setsockopt", None) is not None: 59 | conn.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 60 | conn.sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPALIVE, TCP_KEEPALIVE_INTERVAL) 61 | 62 | 63 | class TCPKeepAliveHTTPSConnectionPool(HTTPSConnectionPool): 64 | """ 65 | This class overrides the _validate_conn method in the HTTPSConnectionPool class. This is the entry point to use 66 | for modifying the socket as it is called after the socket is created and before the request is made. 67 | """ 68 | 69 | def _validate_conn(self, conn): 70 | """ 71 | Called right before a request is made, after the socket is created. 72 | """ 73 | # Call the method on the base class 74 | super(TCPKeepAliveHTTPSConnectionPool, self)._validate_conn(conn) 75 | 76 | # Set up TCP Keep Alive probes, this is the only line added to this function 77 | TCPKeepAliveValidationMethods.adjust_connection_socket(conn, "https") 78 | 79 | 80 | class TCPKeepAliveHTTPConnectionPool(HTTPConnectionPool): 81 | """ 82 | This class overrides the _validate_conn method in the HTTPSConnectionPool class. This is the entry point to use 83 | for modifying the socket as it is called after the socket is created and before the request is made. 84 | In the base class this method is passed completely. 85 | """ 86 | 87 | def _validate_conn(self, conn): 88 | """ 89 | Called right before a request is made, after the socket is created. 90 | """ 91 | # Call the method on the base class 92 | super(TCPKeepAliveHTTPConnectionPool, self)._validate_conn(conn) 93 | 94 | # Set up TCP Keep Alive probes, this is the only line added to this function 95 | TCPKeepAliveValidationMethods.adjust_connection_socket(conn, "http") 96 | 97 | 98 | class TCPKeepAlivePoolManager(PoolManager): 99 | """ 100 | This Pool Manager has only had the pool_classes_by_scheme variable changed. This now points at the TCPKeepAlive 101 | connection pools rather than the default connection pools. 102 | """ 103 | 104 | def __init__(self, num_pools=10, headers=None, **connection_pool_kw): 105 | super(TCPKeepAlivePoolManager, self).__init__(num_pools=num_pools, headers=headers, **connection_pool_kw) 106 | self.pool_classes_by_scheme = {"http": TCPKeepAliveHTTPConnectionPool, "https": TCPKeepAliveHTTPSConnectionPool} 107 | 108 | 109 | class TCPKeepAliveProxyManager(ProxyManager): 110 | """ 111 | This Proxy Manager has only had the pool_classes_by_scheme variable changed. This now points at the TCPKeepAlive 112 | connection pools rather than the default connection pools. 113 | """ 114 | 115 | def __init__(self, proxy_url, num_pools=10, headers=None, proxy_headers=None, **connection_pool_kw): 116 | super(TCPKeepAliveProxyManager, self).__init__(proxy_url=proxy_url, num_pools=num_pools, headers=headers, 117 | proxy_headers=proxy_headers, 118 | **connection_pool_kw) 119 | self.pool_classes_by_scheme = {"http": TCPKeepAliveHTTPConnectionPool, "https": TCPKeepAliveHTTPSConnectionPool} 120 | -------------------------------------------------------------------------------- /test/test_archive.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import time 3 | import unittest 4 | import zipfile 5 | 6 | import certifi 7 | 8 | import cloudinary 9 | import cloudinary.poster.streaminghttp 10 | from cloudinary import uploader, utils 11 | 12 | import six 13 | import urllib3 14 | from urllib3 import disable_warnings 15 | 16 | from test.helper_test import SUFFIX, TEST_IMAGE, api_response_mock, cleanup_test_resources_by_tag, UNIQUE_TEST_ID, \ 17 | get_uri, get_list_param, get_params, URLLIB3_REQUEST, patch 18 | 19 | MOCK_RESPONSE = api_response_mock() 20 | 21 | TEST_TAG = "arch_pycloudinary_test_{}".format(SUFFIX) 22 | TEST_TAG_RAW = "arch_pycloudinary_test_raw_{}".format(SUFFIX) 23 | 24 | disable_warnings() 25 | 26 | 27 | class ArchiveTest(unittest.TestCase): 28 | @classmethod 29 | def setUpClass(cls): 30 | cloudinary.reset_config() 31 | uploader.upload(TEST_IMAGE, tags=[TEST_TAG]) 32 | uploader.upload(TEST_IMAGE, tags=[TEST_TAG], transformation=dict(width=10)) 33 | 34 | @classmethod 35 | def tearDownClass(cls): 36 | cleanup_test_resources_by_tag([ 37 | (TEST_TAG,), 38 | (TEST_TAG_RAW, {'resource_type': 'raw'}), 39 | ]) 40 | 41 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 42 | def test_create_archive(self): 43 | """should successfully generate an archive""" 44 | result = uploader.create_archive(tags=[TEST_TAG], target_tags=[TEST_TAG_RAW]) 45 | self.assertEqual(2, result.get("file_count")) 46 | result2 = uploader.create_zip( 47 | tags=[TEST_TAG], transformations=[{"width": 0.5}, {"width": 2.0}], target_tags=[TEST_TAG_RAW]) 48 | self.assertEqual(4, result2.get("file_count")) 49 | 50 | @patch(URLLIB3_REQUEST) 51 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 52 | def test_optional_parameters(self, mocker): 53 | """should allow optional parameters""" 54 | mocker.return_value = MOCK_RESPONSE 55 | expires_at = int(time.time()+3600) 56 | uploader.create_zip( 57 | tags=[TEST_TAG], 58 | expires_at=expires_at, 59 | allow_missing=True, 60 | skip_transformation_name=True, 61 | ) 62 | params = get_params(mocker) 63 | self.assertEqual(params['expires_at'], expires_at) 64 | self.assertTrue(params['allow_missing']) 65 | self.assertTrue(params['skip_transformation_name']) 66 | 67 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 68 | def test_archive_url(self): 69 | result = utils.download_zip_url(tags=[TEST_TAG], transformations=[{"width": 0.5}, {"width": 2.0}]) 70 | http = urllib3.PoolManager( 71 | cert_reqs='CERT_REQUIRED', 72 | ca_certs=certifi.where() 73 | ) 74 | response = http.request('get', result) 75 | with tempfile.NamedTemporaryFile() as temp_file: 76 | temp_file_name = temp_file.name 77 | temp_file.write(response.data) 78 | temp_file.flush() 79 | with zipfile.ZipFile(temp_file_name, 'r') as zip_file: 80 | infos = zip_file.infolist() 81 | self.assertEqual(4, len(infos)) 82 | http.clear() 83 | 84 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 85 | def test_download_zip_url_options(self): 86 | result = utils.download_zip_url(tags=[TEST_TAG], transformations=[{"width": 0.5}, {"width": 2.0}], 87 | cloud_name="demo") 88 | upload_prefix = cloudinary.config().upload_prefix or "https://api.cloudinary.com" 89 | six.assertRegex(self, result, r'^{0}/v1_1/demo/.*$'.format(upload_prefix)) 90 | 91 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 92 | def test_download_folder(self): 93 | """Should generate and return a url for downloading a folder""" 94 | # Should return url with resource_type image 95 | download_folder_url = utils.download_folder(folder_path="samples/", resource_type="image") 96 | self.assertIn("image", download_folder_url) 97 | 98 | # Should return valid url 99 | download_folder_url = utils.download_folder(folder_path="folder/") 100 | self.assertTrue(download_folder_url) 101 | self.assertIn("generate_archive", download_folder_url) 102 | 103 | # Should flatten folder 104 | download_folder_url = utils.download_folder(folder_path="folder/", flatten_folders=True) 105 | self.assertIn("flatten_folders", download_folder_url) 106 | 107 | # Should expire_at folder 108 | expiration_time = int(time.time() + 60) 109 | download_folder_url = utils.download_folder(folder_path="folder/", expires_at=expiration_time) 110 | self.assertIn("expires_at", download_folder_url) 111 | 112 | # Should use original file_name of folder 113 | download_folder_url = utils.download_folder(folder_path="folder/", use_original_filename=True) 114 | self.assertIn("use_original_filename", download_folder_url) 115 | 116 | @patch(URLLIB3_REQUEST) 117 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 118 | def test_create_archive_multiple_resource_types(self, mocker): 119 | """should allow fully_qualified_public_ids""" 120 | 121 | mocker.return_value = MOCK_RESPONSE 122 | 123 | test_ids = [ 124 | "image/upload/" + UNIQUE_TEST_ID, 125 | "video/upload/" + UNIQUE_TEST_ID, 126 | "raw/upload/" + UNIQUE_TEST_ID, 127 | ] 128 | uploader.create_zip( 129 | resource_type='auto', 130 | fully_qualified_public_ids=test_ids 131 | ) 132 | 133 | self.assertTrue(get_uri(mocker).endswith('/auto/generate_archive')) 134 | self.assertEqual(test_ids, get_list_param(mocker, 'fully_qualified_public_ids')) 135 | 136 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 137 | def test_download_backedup_asset(self): 138 | download_backedup_asset_url = utils.download_backedup_asset('b71b23d9c89a81a254b88a91a9dad8cd', 139 | '0e493356d8a40b856c4863c026891a4e') 140 | 141 | self.assertIn("asset_id", download_backedup_asset_url) 142 | self.assertIn("version_id", download_backedup_asset_url) 143 | 144 | 145 | if __name__ == '__main__': 146 | unittest.main() 147 | -------------------------------------------------------------------------------- /django_tests/test_cloudinaryField.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from test.helper_test import mock 5 | from urllib3.util import parse_url 6 | 7 | import cloudinary 8 | from cloudinary import CloudinaryImage, CloudinaryResource, uploader 9 | from cloudinary.forms import CloudinaryFileField 10 | from cloudinary.models import CloudinaryField 11 | from django.test import TestCase 12 | from django.core.files.uploadedfile import SimpleUploadedFile 13 | 14 | from .models import Poll 15 | from django_tests.helper_test import SUFFIX, TEST_IMAGE, TEST_IMAGE_W, TEST_IMAGE_H, UNIQUE_TEST_ID 16 | 17 | API_TEST_ID = "dj_test_{}".format(SUFFIX) 18 | 19 | 20 | class TestCloudinaryField(TestCase): 21 | @classmethod 22 | def setUpTestData(cls): 23 | Poll.objects.create(question="with image", image="image/upload/v1234/{}.jpg".format(API_TEST_ID)) 24 | Poll.objects.create(question="empty") 25 | 26 | def setUp(self): 27 | self.p = Poll() 28 | self.p.image = SimpleUploadedFile(TEST_IMAGE, b'') 29 | 30 | def test_get_internal_type(self): 31 | c = CloudinaryField('image', null=True) 32 | self.assertEqual(c.get_internal_type(), "CharField") 33 | 34 | def test_to_python(self): 35 | c = CloudinaryField('image') 36 | res = CloudinaryResource(public_id=API_TEST_ID, format='jpg') 37 | # Can't compare the objects, so compare url instead 38 | self.assertEqual(c.to_python('{}.jpg'.format(API_TEST_ID)).build_url(), res.build_url()) 39 | 40 | def test_upload_options_with_filename(self): 41 | c = CloudinaryField('image', filename=UNIQUE_TEST_ID) 42 | c.set_attributes_from_name('image') 43 | mocked_resource = cloudinary.CloudinaryResource(type="upload", public_id=TEST_IMAGE, resource_type="image") 44 | 45 | with mock.patch('cloudinary.uploader.upload_resource', return_value=mocked_resource) as upload_mock: 46 | c.pre_save(self.p, None) 47 | 48 | self.assertTrue(upload_mock.called) 49 | self.assertEqual(upload_mock.call_args[1]['filename'], UNIQUE_TEST_ID) 50 | 51 | def test_upload_options(self): 52 | c = CloudinaryField('image', width_field="image_width", height_field="image_height", unique_filename='true', 53 | use_filename='true', phash='true') 54 | c.set_attributes_from_name('image') 55 | mocked_resource = cloudinary.CloudinaryResource(metadata={"width": TEST_IMAGE_W, "height": TEST_IMAGE_H}, 56 | type="upload", public_id=TEST_IMAGE, resource_type="image") 57 | 58 | with mock.patch('cloudinary.uploader.upload_resource', return_value=mocked_resource) as upload_mock: 59 | c.pre_save(self.p, None) 60 | 61 | self.assertTrue(upload_mock.called) 62 | self.assertEqual(upload_mock.call_args[1]['unique_filename'], 'true') 63 | self.assertEqual(upload_mock.call_args[1]['use_filename'], 'true') 64 | self.assertEqual(upload_mock.call_args[1]['phash'], 'true') 65 | 66 | def test_callable_upload_options(self): 67 | 68 | def get_image_name(instance): 69 | return instance.question 70 | 71 | c = CloudinaryField('image', public_id=get_image_name) 72 | c.set_attributes_from_name('image') 73 | poll = Poll(question="question", image=SimpleUploadedFile(TEST_IMAGE, b'')) 74 | 75 | mocked_resource = cloudinary.CloudinaryResource(type="upload", public_id=TEST_IMAGE, resource_type="image") 76 | with mock.patch('cloudinary.uploader.upload_resource', return_value=mocked_resource) as upload_mock: 77 | c.pre_save(poll, None) 78 | 79 | self.assertTrue(upload_mock.called) 80 | self.assertEqual(upload_mock.call_args[1]['public_id'], 'question') 81 | 82 | def test_pre_save(self): 83 | c = CloudinaryField('image', width_field="image_width", height_field="image_height") 84 | c.set_attributes_from_name('image') 85 | mocked_resource = cloudinary.CloudinaryResource(metadata={"width": TEST_IMAGE_W, "height": TEST_IMAGE_H}, 86 | type="upload", public_id=TEST_IMAGE, resource_type="image") 87 | 88 | with mock.patch('cloudinary.uploader.upload_resource', return_value=mocked_resource) as upload_mock: 89 | prep_value = c.pre_save(self.p, None) 90 | 91 | self.assertTrue(upload_mock.called) 92 | self.assertEqual(".png", os.path.splitext(prep_value)[1]) 93 | self.assertEqual(TEST_IMAGE_W, self.p.image_width) 94 | self.assertEqual(TEST_IMAGE_H, self.p.image_height) 95 | 96 | 97 | # check empty values handling 98 | self.p.image = SimpleUploadedFile(TEST_IMAGE, b'') 99 | mocked_resource_empty = cloudinary.CloudinaryResource(metadata={}) 100 | with mock.patch('cloudinary.uploader.upload_resource', return_value=mocked_resource_empty) as upload_mock: 101 | c.pre_save(self.p, None) 102 | 103 | self.assertTrue(upload_mock.called) 104 | self.assertIsNone(self.p.image_width) 105 | self.assertIsNone(self.p.image_height) 106 | 107 | def test_get_prep_value(self): 108 | c = CloudinaryField('image') 109 | res = CloudinaryImage(public_id=API_TEST_ID, format='jpg') 110 | self.assertEqual(c.get_prep_value(res), "image/upload/{}.jpg".format(API_TEST_ID)) 111 | 112 | def test_value_to_string(self): 113 | c = CloudinaryField('image') 114 | c.set_attributes_from_name('image') 115 | image_field = Poll.objects.get(question="with image") 116 | value_string = c.value_to_string(image_field) 117 | self.assertEqual("image/upload/v1234/{name}.jpg".format(name=API_TEST_ID), value_string) 118 | 119 | def test_formfield(self): 120 | c = CloudinaryField('image') 121 | form_field = c.formfield() 122 | self.assertTrue(isinstance(form_field, CloudinaryFileField)) 123 | 124 | def test_empty_field(self): 125 | emptyField = Poll.objects.get(question="empty") 126 | self.assertIsNotNone(emptyField) 127 | self.assertIsNone(emptyField.image) 128 | self.assertFalse(True and emptyField.image) 129 | 130 | def test_image_field(self): 131 | field = Poll.objects.get(question="with image") 132 | self.assertIsNotNone(field) 133 | self.assertEqual(field.image.public_id, API_TEST_ID) 134 | self.assertEqual( 135 | parse_url(field.image.url).path, 136 | "/{cloud}/image/upload/v1234/{name}.jpg".format(cloud=cloudinary.config().cloud_name, name=API_TEST_ID) 137 | ) 138 | self.assertTrue(False or field.image) 139 | -------------------------------------------------------------------------------- /test/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | 4 | import cloudinary 5 | from cloudinary.provisioning import account_config 6 | from test.helper_test import mock 7 | 8 | CLOUD_NAME = 'test123' 9 | API_KEY = 'key' 10 | API_SECRET = 'secret' 11 | OAUTH_TOKEN = 'NTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZj17' 12 | URL_WITH_OAUTH_TOKEN = 'cloudinary://{}?oauth_token={}'.format(CLOUD_NAME, OAUTH_TOKEN) 13 | 14 | 15 | MOCKED_SETTINGS = { 16 | 'api_secret': 'secret_from_settings' 17 | } 18 | 19 | 20 | def _clean_env(): 21 | for key in os.environ.keys(): 22 | if key.startswith("CLOUDINARY_"): 23 | if key != "CLOUDINARY_URL": 24 | del os.environ[key] 25 | 26 | 27 | class TestConfig(TestCase): 28 | def setUp(self): 29 | self._environ_backup = os.environ.copy() 30 | _clean_env() 31 | 32 | def tearDown(self): 33 | _clean_env() 34 | 35 | for key, val in self._environ_backup.items(): 36 | os.environ[key] = val 37 | 38 | def test_parse_cloudinary_url(self): 39 | config = cloudinary.config() 40 | parsed_url = config._parse_cloudinary_url('cloudinary://key:secret@test123?foo[bar]=value') 41 | config._setup_from_parsed_url(parsed_url) 42 | foo = config.__dict__.get('foo') 43 | self.assertIsNotNone(foo) 44 | self.assertEqual(foo.get('bar'), 'value') 45 | 46 | def test_cloudinary_url_valid_scheme(self): 47 | config = cloudinary.config() 48 | cloudinary_urls = [ 49 | 'cloudinary://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test' 50 | 'CLouDiNaRY://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test' 51 | ] 52 | for cloudinary_url in cloudinary_urls: 53 | parsed_url = config._parse_cloudinary_url(cloudinary_url) 54 | config._setup_from_parsed_url(parsed_url) 55 | 56 | def test_cloudinary_url_invalid_scheme(self): 57 | config = cloudinary.config() 58 | cloudinary_urls = [ 59 | 'CLOUDINARY_URL=cloudinary://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test', 60 | 'https://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test', 61 | '://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test', 62 | 'https://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test?cloudinary=foo', 63 | ' ' 64 | ] 65 | for cloudinary_url in cloudinary_urls: 66 | with self.assertRaises(ValueError): 67 | parsed_url = config._parse_cloudinary_url(cloudinary_url) 68 | config._setup_from_parsed_url(parsed_url) 69 | 70 | def test_parse_cloudinary_account_url(self): 71 | config = account_config() 72 | parsed_url = config._parse_cloudinary_url('account://key:secret@test123?foo[bar]=value') 73 | config._setup_from_parsed_url(parsed_url) 74 | foo = config.__dict__.get('foo') 75 | self.assertIsNotNone(foo) 76 | self.assertEqual(foo.get('bar'), 'value') 77 | 78 | def test_cloudinary_account_url_valid_scheme(self): 79 | config = account_config() 80 | cloudinary_account_urls = [ 81 | 'account://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test' 82 | 'aCCouNT://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test' 83 | ] 84 | for cloudinary_account_url in cloudinary_account_urls: 85 | parsed_url = config._parse_cloudinary_url(cloudinary_account_url) 86 | config._setup_from_parsed_url(parsed_url) 87 | 88 | def test_cloudinary_account_url_invalid_scheme(self): 89 | config = account_config() 90 | cloudinary_account_urls = [ 91 | 'CLOUDINARY__ACCOUNT_URL=account://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test', 92 | 'https://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test', 93 | '://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test', 94 | 'https://123456789012345:ALKJdjklLJAjhkKJ45hBK92baj3@test?account=foo' 95 | ' ' 96 | ] 97 | for cloudinary_account_url in cloudinary_account_urls: 98 | with self.assertRaises(ValueError): 99 | parsed_url = config._parse_cloudinary_url(cloudinary_account_url) 100 | config._setup_from_parsed_url(parsed_url) 101 | 102 | def test_support_CLOUDINARY_prefixed_environment_variables(self): 103 | os.environ["CLOUDINARY_CLOUD_NAME"] = "c" 104 | os.environ["CLOUDINARY_API_KEY"] = "k" 105 | os.environ["CLOUDINARY_API_SECRET"] = "s" 106 | os.environ["CLOUDINARY_SECURE_DISTRIBUTION"] = "sd" 107 | os.environ["CLOUDINARY_PRIVATE_CDN"] = "false" 108 | os.environ["CLOUDINARY_SECURE"] = "true" 109 | 110 | config = cloudinary.Config() 111 | 112 | self.assertEqual(config.cloud_name, "c") 113 | self.assertEqual(config.api_key, "k") 114 | self.assertEqual(config.api_secret, "s") 115 | self.assertEqual(config.secure_distribution, "sd") 116 | self.assertFalse(config.private_cdn) 117 | self.assertTrue(config.secure) 118 | 119 | def test_overwrites_only_existing_keys_from_environment(self): 120 | os.environ["CLOUDINARY_CLOUD_NAME"] = "c" 121 | os.environ["CLOUDINARY_API_KEY"] = "key_from_env" 122 | 123 | with mock.patch('cloudinary.import_django_settings', return_value=MOCKED_SETTINGS): 124 | config = cloudinary.Config() 125 | 126 | self.assertEqual(config.cloud_name, "c") 127 | self.assertEqual(config.api_key, "key_from_env") 128 | self.assertEqual(config.api_secret, "secret_from_settings") 129 | 130 | def test_config_from_url_without_key_and_secret_but_with_oauth_token(self): 131 | config = cloudinary.config() 132 | parsed_url = config._parse_cloudinary_url(URL_WITH_OAUTH_TOKEN) 133 | config._setup_from_parsed_url(parsed_url) 134 | 135 | self.assertEqual(config.cloud_name, CLOUD_NAME) 136 | self.assertEqual(config.oauth_token, OAUTH_TOKEN) 137 | self.assertIsNone(config.api_key) 138 | self.assertIsNone(config.api_secret) 139 | 140 | def test_config_from_url_with_key_and_secret_and_oauth_token(self): 141 | config = cloudinary.config() 142 | parsed_url = config._parse_cloudinary_url( 143 | 'cloudinary://{}:{}@test123?oauth_token={}'.format(API_KEY, API_SECRET, OAUTH_TOKEN) 144 | ) 145 | config._setup_from_parsed_url(parsed_url) 146 | 147 | self.assertEqual(config.cloud_name, CLOUD_NAME) 148 | self.assertEqual(config.oauth_token, OAUTH_TOKEN) 149 | self.assertEqual(config.api_key, API_KEY) 150 | self.assertEqual(config.api_secret, API_SECRET) 151 | -------------------------------------------------------------------------------- /tools/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Update version number and prepare for publishing the new version 4 | 5 | set -e 6 | 7 | # Empty to run the rest of the line and "echo" for a dry run 8 | CMD_PREFIX= 9 | 10 | # Add a quote if this is a dry run 11 | QUOTE= 12 | 13 | NEW_VERSION= 14 | 15 | UPDATE_ONLY=false 16 | 17 | function echo_err 18 | { 19 | echo "$@" 1>&2; 20 | } 21 | 22 | function usage 23 | { 24 | echo "Usage: $0 [parameters]" 25 | echo " -v | --version " 26 | echo " -d | --dry-run print the commands without executing them" 27 | echo " -u | --update-only only update the version" 28 | echo " -h | --help print this information and exit" 29 | echo 30 | echo "For example: $0 -v 1.2.3" 31 | } 32 | 33 | function process_arguments 34 | { 35 | while [[ "$1" != "" ]]; do 36 | case $1 in 37 | -v | --version ) 38 | shift 39 | NEW_VERSION=${1:-} 40 | if ! [[ "${NEW_VERSION}" =~ [0-9]+\.[0-9]+\.[0-9]+(\-.+)? ]]; then 41 | echo_err "You must supply a new version after -v or --version" 42 | echo_err "For example:" 43 | echo_err " 1.2.3" 44 | echo_err " 1.2.3-rc1" 45 | echo_err "" 46 | usage; return 1 47 | fi 48 | ;; 49 | -d | --dry-run ) 50 | CMD_PREFIX=echo 51 | echo "Dry Run" 52 | echo "" 53 | ;; 54 | -u | --update-only ) 55 | UPDATE_ONLY=true 56 | echo "Only update version" 57 | echo "" 58 | ;; 59 | -h | --help ) 60 | usage; return 0 61 | ;; 62 | * ) 63 | usage; return 1 64 | esac 65 | shift || true 66 | done 67 | } 68 | 69 | # Intentionally make pushd silent 70 | function pushd 71 | { 72 | command pushd "$@" > /dev/null 73 | } 74 | 75 | # Intentionally make popd silent 76 | function popd 77 | { 78 | command popd > /dev/null 79 | } 80 | 81 | # Check if one version is less than or equal than other 82 | # Example: 83 | # ver_lte 1.2.3 1.2.3 && echo "yes" || echo "no" # yes 84 | # ver_lte 1.2.3 1.2.4 && echo "yes" || echo "no" # yes 85 | # ver_lte 1.2.4 1.2.3 && echo "yes" || echo "no" # no 86 | function ver_lte 87 | { 88 | [[ "$1" = "`echo -e "$1\n$2" | sort -V | head -n1`" ]] 89 | } 90 | 91 | # Extract the last entry or entry for a given version 92 | # The function is not currently used in this file. 93 | # Examples: 94 | # changelog_last_entry 95 | # changelog_last_entry 1.10.0 96 | # 97 | function changelog_last_entry 98 | { 99 | sed -e "1,/^${1}/d" -e '/^=/d' -e '/^$/d' -e '/^[0-9]/,$d' CHANGELOG.md 100 | } 101 | 102 | function verify_dependencies 103 | { 104 | # Test if the gnu grep is installed 105 | if ! grep --version | grep -q GNU 106 | then 107 | echo_err "GNU grep is required for this script" 108 | echo_err "You can install it using the following command:" 109 | echo_err "" 110 | echo_err "brew install grep --with-default-names" 111 | return 1 112 | fi 113 | 114 | if [[ "${UPDATE_ONLY}" = true ]]; then 115 | return 0; 116 | fi 117 | 118 | if [[ -z "$(type -t git-changelog)" ]] 119 | then 120 | echo_err "git-extras packages is not installed." 121 | echo_err "You can install it using the following command:" 122 | echo_err "" 123 | echo_err "brew install git-extras" 124 | return 1 125 | fi 126 | } 127 | 128 | # Replace old string only if it is present in the file, otherwise return 1 129 | function safe_replace 130 | { 131 | local old=$1 132 | local new=$2 133 | local file=$3 134 | 135 | grep -q "${old}" "${file}" || { echo_err "${old} was not found in ${file}"; return 1; } 136 | 137 | ${CMD_PREFIX} sed -i.bak -e "${QUOTE}s/${old}/${new}/${QUOTE}" -- "${file}" && rm -- "${file}.bak" 138 | } 139 | 140 | function update_version 141 | { 142 | if [[ -z "${NEW_VERSION}" ]]; then 143 | usage; return 1 144 | fi 145 | 146 | # Enter git root 147 | pushd $(git rev-parse --show-toplevel) 148 | 149 | local current_version=`grep -oiP '(?<=version \= \")([a-zA-Z0-9\-.]+)(?=")' setup.py` 150 | 151 | if [[ -z "${current_version}" ]]; then 152 | echo_err "Failed getting current version, please check directory structure and/or contact developer" 153 | return 1 154 | fi 155 | 156 | # Use literal dot character in regular expression 157 | local current_version_re=${current_version//./\\.} 158 | 159 | echo "# Current version is: ${current_version}" 160 | echo "# New version is: ${NEW_VERSION}" 161 | 162 | ver_lte "${NEW_VERSION}" "${current_version}" && { echo_err "New version is not greater than current version"; return 1; } 163 | 164 | # Add a quote if this is a dry run 165 | QUOTE=${CMD_PREFIX:+"'"} 166 | 167 | safe_replace "version = \"${current_version_re}\""\ 168 | "version = \"${NEW_VERSION}\""\ 169 | setup.py\ 170 | || return 1 171 | safe_replace "VERSION = \"${current_version_re}\""\ 172 | "VERSION = \"${NEW_VERSION}\""\ 173 | cloudinary/__init__.py\ 174 | || return 1 175 | safe_replace "version = \"${current_version_re}\""\ 176 | "version = \"${NEW_VERSION}\""\ 177 | pyproject.toml\ 178 | || return 1 179 | 180 | if [[ "${UPDATE_ONLY}" = true ]]; then 181 | popd; 182 | return 0; 183 | fi 184 | 185 | ${CMD_PREFIX} git changelog -t ${NEW_VERSION} || true 186 | 187 | echo "" 188 | echo "# After editing CHANGELOG.md, optionally review changes and issue these commands:" 189 | echo git add setup.py cloudinary/__init__.py pyproject.toml CHANGELOG.md 190 | echo git commit -m "\"Version ${NEW_VERSION}\"" 191 | echo sed -e "'1,/^${NEW_VERSION//./\\.}/d'" \ 192 | -e "'/^=/d'" \ 193 | -e "'/^$/d'" \ 194 | -e "'/^[0-9]/,\$d'" \ 195 | CHANGELOG.md \ 196 | \| git tag -a "'${NEW_VERSION}'" --file=- 197 | 198 | # Don't run those commands on dry run 199 | [[ -n "${CMD_PREFIX}" ]] && { popd; return 0; } 200 | 201 | echo "" 202 | read -p "Run the above commands automatically? (y/N): " confirm && [[ ${confirm} == [yY] || ${confirm} == [yY][eE][sS] ]] || { popd; return 0; } 203 | 204 | git git add setup.py cloudinary/__init__.py pyproject.toml CHANGELOG.md 205 | git commit -m "Version ${NEW_VERSION}" 206 | sed -e "1,/^${NEW_VERSION//./\\.}/d" \ 207 | -e "/^=/d" \ 208 | -e "/^$/d" \ 209 | -e "/^[0-9]/,\$d" \ 210 | CHANGELOG.md \ 211 | | git tag -a "${NEW_VERSION}" --file=- 212 | 213 | popd 214 | } 215 | 216 | process_arguments "$@" 217 | verify_dependencies 218 | update_version 219 | -------------------------------------------------------------------------------- /test/test_auth_token.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | import cloudinary 5 | from test.helper_test import ignore_exception 6 | 7 | KEY = '00112233FF99' 8 | ALT_KEY = "CCBB2233FF00" 9 | 10 | 11 | class AuthTokenTest(unittest.TestCase): 12 | def setUp(self): 13 | self.url_backup = os.environ.get("CLOUDINARY_URL") 14 | os.environ["CLOUDINARY_URL"] = ("cloudinary://a:b@test123?" 15 | "auth_token[duration]=300" 16 | "&auth_token[start_time]=11111111" 17 | "&auth_token[key]=" + KEY) 18 | cloudinary.reset_config() 19 | 20 | def tearDown(self): 21 | with ignore_exception(): 22 | os.environ["CLOUDINARY_URL"] = self.url_backup 23 | cloudinary.reset_config() 24 | 25 | def test_generate_with_start_time_and_duration(self): 26 | token = cloudinary.utils.generate_auth_token(acl='/image/*', start_time=1111111111, duration=300) 27 | self.assertEqual('__cld_token__=st=1111111111~exp=1111111411~acl=%2fimage%2f*~hmac' 28 | '=1751370bcc6cfe9e03f30dd1a9722ba0f2cdca283fa3e6df3342a00a7528cc51', token) 29 | 30 | def test_should_add_token_if_authToken_is_globally_set_and_signed_is_True(self): 31 | cloudinary.config(private_cdn=True) 32 | url, _ = cloudinary.utils.cloudinary_url("sample.jpg", sign_url=True, resource_type="image", 33 | type="authenticated", version="1486020273") 34 | self.assertEqual(url, "http://test123-res.cloudinary.com/image/authenticated/v1486020273/sample.jpg" 35 | "?__cld_token__=st=11111111~exp=11111411~hmac" 36 | "=8db0d753ee7bbb9e2eaf8698ca3797436ba4c20e31f44527e43b6a6e995cfdb3") 37 | 38 | def test_should_add_token_for_public_resource(self): 39 | cloudinary.config(private_cdn=True) 40 | url, _ = cloudinary.utils.cloudinary_url("sample.jpg", sign_url=True, resource_type="image", type="public", 41 | version="1486020273") 42 | self.assertEqual(url, "http://test123-res.cloudinary.com/image/public/v1486020273/sample.jpg?__cld_token__=st" 43 | "=11111111~exp=11111411~hmac=" 44 | "c2b77d9f81be6d89b5d0ebc67b671557e88a40bcf03dd4a6997ff4b994ceb80e") 45 | 46 | def test_should_not_add_token_if_signed_is_false(self): 47 | cloudinary.config(private_cdn=True) 48 | url, _ = cloudinary.utils.cloudinary_url("sample.jpg", type="authenticated", version="1486020273") 49 | self.assertEqual(url, "http://test123-res.cloudinary.com/image/authenticated/v1486020273/sample.jpg") 50 | 51 | def test_null_token(self): 52 | cloudinary.config(private_cdn=True) 53 | url, _ = cloudinary.utils.cloudinary_url("sample.jpg", auth_token=False, sign_url=True, type="authenticated", 54 | version="1486020273") 55 | self.assertEqual( 56 | url, 57 | "http://test123-res.cloudinary.com/image/authenticated/s--v2fTPYTu--/v1486020273/sample.jpg" 58 | ) 59 | 60 | def test_explicit_authToken_should_override_global_setting(self): 61 | cloudinary.config(private_cdn=True) 62 | url, _ = cloudinary.utils.cloudinary_url("sample.jpg", sign_url=True, 63 | auth_token={"key": ALT_KEY, "start_time": 222222222, "duration": 100}, 64 | type="authenticated", transformation={"crop": "scale", "width": 300}) 65 | self.assertEqual(url, "http://test123-res.cloudinary.com/image/authenticated/c_scale," 66 | "w_300/sample.jpg?__cld_token__=st=222222222~exp=222222322~hmac" 67 | "=55cfe516530461213fe3b3606014533b1eca8ff60aeab79d1bb84c9322eebc1f") 68 | 69 | def test_should_set_url_signature(self): 70 | cloudinary.config(private_cdn=True) 71 | url, _ = cloudinary.utils.cloudinary_url("sample.jpg", sign_url=True, 72 | auth_token={"key": ALT_KEY, "start_time": 222222222, "duration": 100, 73 | "set_url_signature": True}, 74 | type="authenticated", transformation={"crop": "scale", "width": 300}) 75 | self.assertEqual("http://test123-res.cloudinary.com/image/authenticated/s--Ok4O32K7--/" 76 | "c_scale,w_300/sample.jpg?__cld_token__=st=222222222~exp=222222322~hmac" 77 | "=92a55aaed531b2dab074074bbd1430120119f9cb1b901656925dda2e514a63cc", url) 78 | 79 | def test_should_compute_expiration_as_start_time_plus_duration(self): 80 | cloudinary.config(private_cdn=True) 81 | token = {"key": KEY, "start_time": 11111111, "duration": 300} 82 | url, _ = cloudinary.utils.cloudinary_url("sample.jpg", sign_url=True, auth_token=token, resource_type="image", 83 | type="authenticated", version="1486020273") 84 | self.assertEqual(url, "http://test123-res.cloudinary.com/image/authenticated/v1486020273/sample.jpg" 85 | "?__cld_token__=st=11111111~exp=11111411~hmac" 86 | "=8db0d753ee7bbb9e2eaf8698ca3797436ba4c20e31f44527e43b6a6e995cfdb3") 87 | 88 | def test_generate_token_string(self): 89 | user = "foobar" # we can't rely on the default "now" value in tests 90 | token_options = {"key": KEY, "duration": 300, "acl": "/*/t_%s" % user, "start_time": 222222222} 91 | cookie_token = cloudinary.utils.generate_auth_token(**token_options) 92 | self.assertEqual( 93 | cookie_token, 94 | "__cld_token__=st=222222222~exp=222222522~acl=%2f*%2ft_foobar~hmac=" 95 | "8e39600cc18cec339b21fe2b05fcb64b98de373355f8ce732c35710d8b10259f" 96 | ) 97 | 98 | def test_generate_token_string_from_string_values(self): 99 | user = "foobar" 100 | token_options = {"key": KEY, "duration": "300", "acl": "/*/t_%s" % user, "start_time": "222222222"} 101 | cookie_token = cloudinary.utils.generate_auth_token(**token_options) 102 | self.assertEqual( 103 | cookie_token, 104 | "__cld_token__=st=222222222~exp=222222522~acl=%2f*%2ft_foobar~hmac=" 105 | "8e39600cc18cec339b21fe2b05fcb64b98de373355f8ce732c35710d8b10259f" 106 | ) 107 | 108 | def test_should_ignore_url_if_acl_is_provided(self): 109 | token_options = {"key": KEY, "duration": 300, "acl": '/image/*', "start_time": 222222222} 110 | acl_token = cloudinary.utils.generate_auth_token(**token_options) 111 | 112 | token_options["url"] = "sample.jpg" 113 | acl_token_url_ignored = cloudinary.utils.generate_auth_token(**token_options) 114 | 115 | self.assertEqual( 116 | acl_token, 117 | acl_token_url_ignored 118 | ) 119 | 120 | def test_should_support_multiple_acls(self): 121 | token_options = {"key": KEY, "duration": 3600, 122 | "acl": ["/i/a/*", "/i/a/*", "/i/a/*"], 123 | "start_time": 222222222} 124 | 125 | cookie_token = cloudinary.utils.generate_auth_token(**token_options) 126 | self.assertEqual( 127 | cookie_token, 128 | "__cld_token__=st=222222222~exp=222225822~acl=%2fi%2fa%2f*!%2fi%2fa%2f*!" 129 | "%2fi%2fa%2f*~hmac=10d9ad42d6ed66dce2386c4b564b2aa25a6ac668e5b90d070363a03c9842965f" 130 | ) 131 | 132 | def test_must_provide_expiration_or_duration(self): 133 | self.assertRaises(Exception, cloudinary.utils.generate_auth_token, acl="*", expiration=None, duration=None) 134 | 135 | def test_must_provide_acl_or_url(self): 136 | self.assertRaises(Exception, cloudinary.utils.generate_auth_token, start_time=1111111111, duration=300) 137 | 138 | def test_should_support_url_without_acl(self): 139 | url_token = cloudinary.utils.generate_auth_token( 140 | start_time=1111111111, 141 | duration=300, 142 | url="http://res.cloudinary.com/test123/image/upload/v1486020273/sample.jpg" 143 | ) 144 | 145 | self.assertEqual( 146 | url_token, 147 | "__cld_token__=st=1111111111~exp=1111111411~hmac=" 148 | "639406f8c07fc6a1613e1f6192baba631f9d5719185a32049281e94e15c5619b" 149 | ) 150 | 151 | 152 | if __name__ == '__main__': 153 | unittest.main() 154 | -------------------------------------------------------------------------------- /cloudinary/poster/streaminghttp.py: -------------------------------------------------------------------------------- 1 | # MIT licensed code copied from https://bitbucket.org/chrisatlee/poster 2 | """Streaming HTTP uploads module. 3 | 4 | This module extends the standard httplib and urllib2 objects so that 5 | iterable objects can be used in the body of HTTP requests. 6 | 7 | In most cases all one should have to do is call :func:`register_openers()` 8 | to register the new streaming http handlers which will take priority over 9 | the default handlers, and then you can use iterable objects in the body 10 | of HTTP requests. 11 | 12 | **N.B.** You must specify a Content-Length header if using an iterable object 13 | since there is no way to determine in advance the total size that will be 14 | yielded, and there is no way to reset an interator. 15 | 16 | Example usage: 17 | 18 | >>> from StringIO import StringIO 19 | >>> import urllib2, poster.streaminghttp 20 | 21 | >>> opener = poster.streaminghttp.register_openers() 22 | 23 | >>> s = "Test file data" 24 | >>> f = StringIO(s) 25 | 26 | >>> req = urllib2.Request("http://localhost:5000", f, 27 | ... {'Content-Length': str(len(s))}) 28 | """ 29 | 30 | import socket 31 | import sys 32 | 33 | from cloudinary.compat import NotConnected, httplib, urllib2 34 | 35 | __all__ = ['StreamingHTTPConnection', 'StreamingHTTPRedirectHandler', 36 | 'StreamingHTTPHandler', 'register_openers'] 37 | 38 | if hasattr(httplib, 'HTTPS'): 39 | __all__.extend(['StreamingHTTPSHandler', 'StreamingHTTPSConnection']) 40 | 41 | 42 | class _StreamingHTTPMixin: 43 | """Mixin class for HTTP and HTTPS connections that implements a streaming 44 | send method.""" 45 | def send(self, value): 46 | """Send ``value`` to the server. 47 | 48 | ``value`` can be a string object, a file-like object that supports 49 | a .read() method, or an iterable object that supports a .next() 50 | method. 51 | """ 52 | # Based on python 2.6's httplib.HTTPConnection.send() 53 | if self.sock is None: 54 | if self.auto_open: 55 | self.connect() 56 | else: 57 | raise NotConnected() 58 | 59 | # send the data to the server. if we get a broken pipe, then close 60 | # the socket. we want to reconnect when somebody tries to send again. 61 | # 62 | # NOTE: we DO propagate the error, though, because we cannot simply 63 | # ignore the error... the caller will know if they can retry. 64 | if self.debuglevel > 0: 65 | print("send:", repr(value)) 66 | try: 67 | blocksize = 8192 68 | if hasattr(value, 'read'): 69 | if hasattr(value, 'seek'): 70 | value.seek(0) 71 | if self.debuglevel > 0: 72 | print("sendIng a read()able") 73 | data = value.read(blocksize) 74 | while data: 75 | self.sock.sendall(data) 76 | data = value.read(blocksize) 77 | elif hasattr(value, 'next'): 78 | if hasattr(value, 'reset'): 79 | value.reset() 80 | if self.debuglevel > 0: 81 | print("sendIng an iterable") 82 | for data in value: 83 | self.sock.sendall(data) 84 | else: 85 | self.sock.sendall(value) 86 | except socket.error: 87 | e = sys.exc_info()[1] 88 | if e[0] == 32: # Broken pipe 89 | self.close() 90 | raise 91 | 92 | 93 | class StreamingHTTPConnection(_StreamingHTTPMixin, httplib.HTTPConnection): 94 | """Subclass of `httplib.HTTPConnection` that overrides the `send()` method 95 | to support iterable body objects""" 96 | 97 | 98 | class StreamingHTTPRedirectHandler(urllib2.HTTPRedirectHandler): 99 | """Subclass of `urllib2.HTTPRedirectHandler` that overrides the 100 | `redirect_request` method to properly handle redirected POST requests 101 | 102 | This class is required because python 2.5's HTTPRedirectHandler does 103 | not remove the Content-Type or Content-Length headers when requesting 104 | the new resource, but the body of the original request is not preserved. 105 | """ 106 | 107 | handler_order = urllib2.HTTPRedirectHandler.handler_order - 1 108 | 109 | # From python2.6 urllib2's HTTPRedirectHandler 110 | def redirect_request(self, req, fp, code, msg, headers, newurl): 111 | """Return a Request or None in response to a redirect. 112 | 113 | This is called by the http_error_30x methods when a 114 | redirection response is received. If a redirection should 115 | take place, return a new Request to allow http_error_30x to 116 | perform the redirect. Otherwise, raise HTTPError if no-one 117 | else should try to handle this url. Return None if you can't 118 | but another Handler might. 119 | """ 120 | m = req.get_method() 121 | if (code in (301, 302, 303, 307) and m in ("GET", "HEAD") 122 | or code in (301, 302, 303) and m == "POST"): 123 | # Strictly (according to RFC 2616), 301 or 302 in response 124 | # to a POST MUST NOT cause a redirection without confirmation 125 | # from the user (of urllib2, in this case). In practice, 126 | # essentially all clients do redirect in this case, so we 127 | # do the same. 128 | # be conciliant with URIs containing a space 129 | newurl = newurl.replace(' ', '%20') 130 | newheaders = dict((k, v) for k, v in req.headers.items() 131 | if k.lower() not in ( 132 | "content-length", "content-type") 133 | ) 134 | return urllib2.Request( 135 | newurl, 136 | headers=newheaders, 137 | origin_req_host=req.get_origin_req_host(), 138 | unverifiable=True) 139 | else: 140 | raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) 141 | 142 | 143 | class StreamingHTTPHandler(urllib2.HTTPHandler): 144 | """Subclass of `urllib2.HTTPHandler` that uses 145 | StreamingHTTPConnection as its http connection class.""" 146 | 147 | handler_order = urllib2.HTTPHandler.handler_order - 1 148 | 149 | def http_open(self, req): 150 | """Open a StreamingHTTPConnection for the given request""" 151 | return self.do_open(StreamingHTTPConnection, req) 152 | 153 | def http_request(self, req): 154 | """Handle a HTTP request. Make sure that Content-Length is specified 155 | if we're using an interable value""" 156 | # Make sure that if we're using an iterable object as the request 157 | # body, that we've also specified Content-Length 158 | if req.has_data(): 159 | data = req.get_data() 160 | if hasattr(data, 'read') or hasattr(data, 'next'): 161 | if not req.has_header('Content-length'): 162 | raise ValueError( 163 | "No Content-Length specified for iterable body") 164 | return urllib2.HTTPHandler.do_request_(self, req) 165 | 166 | 167 | if hasattr(httplib, 'HTTPS'): 168 | class StreamingHTTPSConnection(_StreamingHTTPMixin, httplib.HTTPSConnection): 169 | """Subclass of `httplib.HTTSConnection` that overrides the `send()` 170 | method to support iterable body objects""" 171 | 172 | class StreamingHTTPSHandler(urllib2.HTTPSHandler): 173 | """Subclass of `urllib2.HTTPSHandler` that uses 174 | StreamingHTTPSConnection as its http connection class.""" 175 | 176 | handler_order = urllib2.HTTPSHandler.handler_order - 1 177 | 178 | def https_open(self, req): 179 | return self.do_open(StreamingHTTPSConnection, req) 180 | 181 | def https_request(self, req): 182 | # Make sure that if we're using an iterable object as the request 183 | # body, that we've also specified Content-Length 184 | if req.has_data(): 185 | data = req.get_data() 186 | if hasattr(data, 'read') or hasattr(data, 'next'): 187 | if not req.has_header('Content-length'): 188 | raise ValueError( 189 | "No Content-Length specified for iterable body") 190 | return urllib2.HTTPSHandler.do_request_(self, req) 191 | 192 | 193 | def get_handlers(): 194 | handlers = [StreamingHTTPHandler, StreamingHTTPRedirectHandler] 195 | if hasattr(httplib, "HTTPS"): 196 | handlers.append(StreamingHTTPSHandler) 197 | return handlers 198 | 199 | 200 | def register_openers(): 201 | """Register the streaming http handlers in the global urllib2 default 202 | opener object. 203 | 204 | Returns the created OpenerDirector object.""" 205 | opener = urllib2.build_opener(*get_handlers()) 206 | 207 | urllib2.install_opener(opener) 208 | 209 | return opener 210 | -------------------------------------------------------------------------------- /samples/spookyshots/main.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | from streamlit_option_menu import option_menu 3 | import cloudinary 4 | from cloudinary import CloudinaryImage 5 | import cloudinary.uploader 6 | import cloudinary.api 7 | from dotenv import load_dotenv 8 | import os 9 | import time 10 | 11 | load_dotenv() 12 | cloudinary.reset_config() 13 | 14 | MAX_FILE_SIZE_MB = 5 15 | MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 16 | 17 | with st.sidebar: 18 | selected = option_menu( 19 | menu_title="Navigation", 20 | options=["Home", "Spooky Pet Background Generator", "Spooky Cat Face Transformer"], 21 | icons=["house", "image", "skull"], 22 | menu_icon="cast", 23 | default_index=0, 24 | ) 25 | 26 | if selected == "Home": 27 | st.title("Welcome to the Spooky Pet Image App! ~Powered by Cloudinary") 28 | 29 | st.write(""" 30 | **Spooky Pet Image App** is a fun and creative platform that transforms ordinary pet images into spooky, Halloween-themed masterpieces. 31 | 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! 32 | 33 | ### Features: 34 | - **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. 35 | - **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. 36 | 37 | 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! 38 | """) 39 | 40 | if selected == "Spooky Pet Background Generator": 41 | st.title("Spooky Halloween Pet Image Transformer") 42 | 43 | upload_option = st.radio("Select image source:", ("Upload a file", "Enter an image URL")) 44 | 45 | if upload_option == "Upload a file": 46 | uploaded_file = st.file_uploader("Upload an image (jpg, jpeg, png)", type=["jpg", "jpeg", "png"]) 47 | else: 48 | image_url = st.text_input("Enter the direct URL of the image (jpg, jpeg, png)") 49 | 50 | 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" 51 | 52 | modify_prompt = st.checkbox("Do you want to modify the generative Halloween background prompt?", value=False) 53 | 54 | custom_prompt = st.text_input( 55 | "Optional: Modify the generative Halloween background prompt", 56 | value=default_prompt, 57 | disabled=not modify_prompt 58 | ) 59 | 60 | if st.button("Submit"): 61 | if upload_option == "Upload a file" and uploaded_file: 62 | if uploaded_file.size > MAX_FILE_SIZE_BYTES: 63 | st.warning(f"File size exceeds the 5 MB limit. Please upload a smaller file.") 64 | else: 65 | with st.spinner("Generating image... Please have patience while the image is being processed by Cloudinary."): 66 | upload_result = cloudinary.uploader.upload( 67 | uploaded_file, 68 | public_id=f"user_uploaded_{uploaded_file.name[:6]}", 69 | unique_filename=True, 70 | overwrite=False 71 | ) 72 | public_id = upload_result['public_id'] 73 | halloween_bg_image_url = CloudinaryImage(public_id).image( 74 | effect=f"gen_background_replace:prompt_{custom_prompt}" 75 | ) 76 | 77 | start_index = halloween_bg_image_url.find('src="') + len('src="') 78 | end_index = halloween_bg_image_url.find('"', start_index) 79 | generated_image_url = halloween_bg_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None 80 | 81 | if generated_image_url: 82 | st.image(generated_image_url) 83 | else: 84 | st.write("Failed to apply the background. Please try again.") 85 | 86 | elif upload_option == "Enter an image URL" and image_url: 87 | with st.spinner("Generating image... Please have patience while the image is being processed by Cloudinary."): 88 | unique_id = f"user_uploaded_url_{int(time.time())}" 89 | upload_result = cloudinary.uploader.upload( 90 | image_url, 91 | public_id=unique_id, 92 | unique_filename=True, 93 | overwrite=False 94 | ) 95 | public_id = upload_result['public_id'] 96 | halloween_bg_image_url = CloudinaryImage(public_id).image( 97 | effect=f"gen_background_replace:prompt_{custom_prompt}" 98 | ) 99 | 100 | start_index = halloween_bg_image_url.find('src="') + len('src="') 101 | end_index = halloween_bg_image_url.find('"', start_index) 102 | generated_image_url = halloween_bg_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None 103 | 104 | if generated_image_url: 105 | st.image(generated_image_url) 106 | else: 107 | st.write("Failed to apply the background. Please try again.") 108 | else: 109 | st.write("Please upload an image or provide a URL to proceed.") 110 | 111 | if selected == "Spooky Cat Face Transformer": 112 | st.title("Spooky Cat Face Transformer") 113 | 114 | upload_option = st.radio("Select image source:", ("Upload a file", "Enter an image URL")) 115 | 116 | if upload_option == "Upload a file": 117 | uploaded_cat_pic = st.file_uploader("Upload a cat image to give it a spooky transformation! (jpg, jpeg, png)", type=["jpg", "jpeg", "png"]) 118 | else: 119 | cat_image_url = st.text_input("Enter the direct URL of the cat image (jpg, jpeg, png)") 120 | 121 | default_cat_prompt = "A demonic cat with glowing red eyes sharp fangs and dark mist swirling around it under a blood moon" 122 | 123 | modify_cat_prompt = st.checkbox("Do you want to modify the spooky cat transformation prompt?", value=False) 124 | 125 | custom_cat_prompt = st.text_input( 126 | "Optional: Modify the spooky cat transformation prompt", 127 | value=default_cat_prompt, 128 | disabled=not modify_cat_prompt 129 | ) 130 | 131 | if st.button("Transform to Spooky"): 132 | if upload_option == "Upload a file" and uploaded_cat_pic: 133 | if uploaded_cat_pic.size > MAX_FILE_SIZE_BYTES: 134 | st.warning(f"File size exceeds the 5 MB limit. Please upload a smaller file.") 135 | else: 136 | with st.spinner("Generating your cat's spooky transformation... Please wait while Cloudinary processes the image."): 137 | upload_result = cloudinary.uploader.upload( 138 | uploaded_cat_pic, 139 | public_id=f"user_spooky_cat_{uploaded_cat_pic.name[:6]}", 140 | unique_filename=True, 141 | overwrite=False 142 | ) 143 | public_id = upload_result['public_id'] 144 | spooky_image_url = CloudinaryImage(public_id).image( 145 | effect=f"gen_replace:from_cat;to_{custom_cat_prompt}" 146 | ) 147 | 148 | start_index = spooky_image_url.find('src="') + len('src="') 149 | end_index = spooky_image_url.find('"', start_index) 150 | generated_image_url = spooky_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None 151 | 152 | if generated_image_url: 153 | st.image(generated_image_url) 154 | else: 155 | st.write("Failed to generate the spooky transformation. Please try again.") 156 | 157 | elif upload_option == "Enter an image URL" and cat_image_url: 158 | with st.spinner("Generating your cat's spooky transformation... Please wait while Cloudinary processes the image."): 159 | unique_id = f"user_uploaded_url_{int(time.time())}" 160 | upload_result = cloudinary.uploader.upload( 161 | cat_image_url, 162 | public_id=unique_id, 163 | unique_filename=True, 164 | overwrite=False 165 | ) 166 | public_id = upload_result['public_id'] 167 | spooky_image_url = CloudinaryImage(public_id).image( 168 | effect=f"gen_replace:from_cat;to_{custom_cat_prompt}" 169 | ) 170 | 171 | start_index = spooky_image_url.find('src="') + len('src="') 172 | end_index = spooky_image_url.find('"', start_index) 173 | generated_image_url = spooky_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None 174 | 175 | if generated_image_url: 176 | st.image(generated_image_url) 177 | else: 178 | st.write("Failed to generate the spooky transformation. Please try again.") 179 | else: 180 | st.write("Please upload an image or provide a URL to proceed.") 181 | -------------------------------------------------------------------------------- /test/test_metadata_rules.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from six import text_type 3 | from urllib3 import disable_warnings 4 | 5 | import cloudinary 6 | from cloudinary import api 7 | from cloudinary.exceptions import BadRequest, NotFound 8 | from test.helper_test import ( 9 | UNIQUE_TEST_ID, get_uri, get_params, get_method, api_response_mock, ignore_exception, get_json_body, 10 | URLLIB3_REQUEST, patch 11 | ) 12 | 13 | MOCK_RESPONSE = api_response_mock() 14 | 15 | # External IDs for metadata fields and metadata_rules that should be created and later deleted 16 | EXTERNAL_ID_ENUM = "metadata_external_id_enum_{}".format(UNIQUE_TEST_ID) 17 | EXTERNAL_ID_SET = "metadata_external_id_set_{}".format(UNIQUE_TEST_ID) 18 | EXTERNAL_ID_METADATA_RULE_GENERAL = "metadata_rule_id_general_{}".format(UNIQUE_TEST_ID) 19 | EXTERNAL_ID_METADATA_RULE_DELETE = "metadata_rule_id_deletion_{}".format(UNIQUE_TEST_ID) 20 | 21 | # Sample datasource data 22 | DATASOURCE_ENTRY_EXTERNAL_ID = "metadata_datasource_entry_external_id{}".format(UNIQUE_TEST_ID) 23 | 24 | disable_warnings() 25 | 26 | class MetadataRulesTest(unittest.TestCase): 27 | @patch(URLLIB3_REQUEST) 28 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 29 | def test01_list_metadata_rules(self, mocker): 30 | """Test getting a list of all metadata rules""" 31 | 32 | mocker.return_value = MOCK_RESPONSE 33 | api.list_metadata_rules() 34 | 35 | self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) 36 | self.assertEqual(get_method(mocker), "GET") 37 | 38 | 39 | @patch(URLLIB3_REQUEST) 40 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 41 | def test02_create_metadata_rule(self, mocker): 42 | """Test creating an and metadata rule""" 43 | 44 | mocker.return_value = MOCK_RESPONSE 45 | api.add_metadata_rule({ 46 | "metadata_field_id": EXTERNAL_ID_ENUM, 47 | "condition": { "metadata_field_id": EXTERNAL_ID_SET, "equals": DATASOURCE_ENTRY_EXTERNAL_ID }, 48 | "result": { "enable": True, "activate_values": "all" }, 49 | "name": EXTERNAL_ID_ENUM 50 | }) 51 | 52 | self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) 53 | self.assertEqual(get_method(mocker), "POST") 54 | self.assertEqual(get_json_body(mocker), { 55 | "metadata_field_id": EXTERNAL_ID_ENUM, 56 | "name": EXTERNAL_ID_ENUM, 57 | "condition": { "metadata_field_id": EXTERNAL_ID_SET, "equals": DATASOURCE_ENTRY_EXTERNAL_ID }, 58 | "result": { "enable": True, "activate_values": "all" }, 59 | }) 60 | 61 | 62 | @patch(URLLIB3_REQUEST) 63 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 64 | def test03_create_and_metadata_rule(self, mocker): 65 | """Test creating an and metadata rule""" 66 | 67 | mocker.return_value = MOCK_RESPONSE 68 | api.add_metadata_rule({ 69 | "metadata_field_id": EXTERNAL_ID_ENUM, 70 | "condition": {"and": [ 71 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 72 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 73 | ]}, 74 | "result": { "enable": True, "apply_value": {"value": "value1_and_value2","mode": "default"}}, 75 | "name": EXTERNAL_ID_ENUM + "_AND" 76 | }) 77 | 78 | self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) 79 | self.assertEqual(get_method(mocker), "POST") 80 | self.assertEqual(get_json_body(mocker), { 81 | "metadata_field_id": EXTERNAL_ID_ENUM, 82 | "name": EXTERNAL_ID_ENUM + "_AND" , 83 | "condition": {"and": [ 84 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 85 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 86 | ]}, 87 | "result": { "enable": True, "apply_value": {"value": "value1_and_value2","mode": "default"}}, 88 | }) 89 | 90 | 91 | @patch(URLLIB3_REQUEST) 92 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 93 | def test04_create_or_metadata_rule(self,mocker): 94 | """Test creating an or metadata rule""" 95 | 96 | mocker.return_value = MOCK_RESPONSE 97 | api.add_metadata_rule({ 98 | "metadata_field_id": EXTERNAL_ID_ENUM, 99 | "condition": {"or": [ 100 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 101 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 102 | ]}, 103 | "result": { "enable": True, "apply_value": {"value": "value1_or_value2","mode": "default"}}, 104 | "name": EXTERNAL_ID_ENUM + "_OR" 105 | }) 106 | 107 | self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) 108 | self.assertEqual(get_method(mocker), "POST") 109 | self.assertEqual(get_json_body(mocker), { 110 | "metadata_field_id": EXTERNAL_ID_ENUM, 111 | "name": EXTERNAL_ID_ENUM + "_OR", 112 | "condition": {"or": [ 113 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 114 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 115 | ]}, 116 | "result": { "enable": True, "apply_value": {"value": "value1_or_value2","mode": "default"}}, 117 | }) 118 | 119 | 120 | @patch(URLLIB3_REQUEST) 121 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 122 | def test05_create_and_or_metadata_rule(self, mocker): 123 | """Test creating an and+or metadata rule""" 124 | 125 | mocker.return_value = MOCK_RESPONSE 126 | api.add_metadata_rule({ 127 | "metadata_field_id": EXTERNAL_ID_ENUM, 128 | "condition": {"and": [ 129 | { "metadata_field_id": EXTERNAL_ID_SET, "populated": True }, 130 | {"or": [ 131 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 132 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 133 | ]} 134 | ]}, 135 | "result": { "enable": True, "activate_values": "all"}, 136 | "name": EXTERNAL_ID_ENUM + "_AND_OR" 137 | }) 138 | 139 | self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) 140 | self.assertEqual(get_method(mocker), "POST") 141 | self.assertEqual(get_json_body(mocker), { 142 | "metadata_field_id": EXTERNAL_ID_ENUM, 143 | "name": EXTERNAL_ID_ENUM + "_AND_OR", 144 | "condition": {"and": [ 145 | { "metadata_field_id": EXTERNAL_ID_SET, "populated": True }, 146 | {"or": [ 147 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 148 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 149 | ]} 150 | ]}, 151 | "result": { "enable": True, "activate_values": "all"}, 152 | }) 153 | 154 | 155 | @patch(URLLIB3_REQUEST) 156 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 157 | def test06_create_or_and_metadata_rule(self,mocker): 158 | """Test creating an or+and metadata rule""" 159 | 160 | mocker.return_value = MOCK_RESPONSE 161 | api.add_metadata_rule({ 162 | "metadata_field_id": EXTERNAL_ID_ENUM, 163 | "condition": {"or": [ 164 | {"metadata_field_id": EXTERNAL_ID_SET, "populated": False }, 165 | {"and": [ 166 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 167 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 168 | ]} 169 | ]}, 170 | "result": { "enable": True, "activate_values": {"external_ids": ["value1","value2"]}}, 171 | "name": EXTERNAL_ID_ENUM + "_OR_AND" 172 | }) 173 | 174 | self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) 175 | self.assertEqual(get_method(mocker), "POST") 176 | self.assertEqual(get_json_body(mocker), { 177 | "metadata_field_id": EXTERNAL_ID_ENUM, 178 | "name": EXTERNAL_ID_ENUM + "_OR_AND", 179 | "condition": {"or": [ 180 | {"metadata_field_id": EXTERNAL_ID_SET, "populated": False }, 181 | {"and": [ 182 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, 183 | {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} 184 | ]} 185 | ]}, 186 | "result": { "enable": True, "activate_values": {"external_ids": ["value1","value2"]}}, 187 | }) 188 | 189 | @patch(URLLIB3_REQUEST) 190 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 191 | def test07_update_metadata_rule(self,mocker): 192 | """Update a metadata rule by external id""" 193 | mocker.return_value = MOCK_RESPONSE 194 | 195 | new_name = "update_metadata_rule_new_name{}".format(EXTERNAL_ID_METADATA_RULE_GENERAL) 196 | 197 | api.update_metadata_rule(EXTERNAL_ID_METADATA_RULE_GENERAL, { 198 | "metadata_field_id": EXTERNAL_ID_ENUM, 199 | "name": new_name + "_inactive", 200 | "condition": {}, 201 | "result": {}, 202 | "state": "inactive" 203 | }) 204 | 205 | target_uri = "/metadata_rules/{}".format(EXTERNAL_ID_METADATA_RULE_GENERAL) 206 | self.assertTrue(get_uri(mocker).endswith(target_uri)) 207 | self.assertEqual(get_method(mocker), "PUT") 208 | self.assertEqual(get_params(mocker).get("state"), "inactive") 209 | self.assertEqual(get_params(mocker).get("name"), new_name + "_inactive") 210 | 211 | @patch(URLLIB3_REQUEST) 212 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") 213 | def test08_delete_metadata_rule(self, mocker): 214 | """Test deleting a metadata rule definition by its external id.""" 215 | 216 | mocker.return_value = MOCK_RESPONSE 217 | api.delete_metadata_rule(EXTERNAL_ID_METADATA_RULE_DELETE) 218 | 219 | target_uri = "/metadata_rules/{}".format(EXTERNAL_ID_METADATA_RULE_DELETE) 220 | self.assertTrue(get_uri(mocker).endswith(target_uri)) 221 | self.assertEqual(get_method(mocker), "DELETE") 222 | 223 | self.assertEqual(get_json_body(mocker), {}) 224 | 225 | 226 | if __name__ == "__main__": 227 | unittest.main() 228 | --------------------------------------------------------------------------------