├── 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 |
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 |
8 | {% if image_url %}
9 | Upload API Result
10 | {{ image_url }}
11 | Thumbnails:
12 |
13 |
14 |
15 |
16 | Original:
17 |
18 | {% endif %}
--------------------------------------------------------------------------------
/samples/basic_flask/templates/upload_form.html:
--------------------------------------------------------------------------------
1 |
2 | Upload new Image
3 | Upload new Image
4 |
8 | {% if upload_result %}
9 | Upload API Result
10 | {{ upload_result }}
11 | Thumbnails:
12 |
13 |
14 |
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 | 
8 |
9 | ### Transformed
10 | 
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 | [](https://github.com/cloudinary/pycloudinary/actions/workflows/test.yml)
2 | [](https://pypi.python.org/pypi/cloudinary/)
3 | [](https://pypi.python.org/pypi/cloudinary/)
4 | [](https://pypi.python.org/pypi/cloudinary/)
5 | [](https://pypi.python.org/pypi/cloudinary/)
6 | [](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 |
--------------------------------------------------------------------------------