diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 539bdd27..2f2c86b9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,22 +3,22 @@ name: Bug report about: Bug report for Cloudinary Python SDK title: '' labels: '' -assignees: const-cloudinary +assignees: '' --- ## Bug report for Cloudinary Python SDK -Before proceeding, please update to latest version and test if the issue persists +Before proceeding, please update to the latest version and test if the issue persists ## Describe the bug in a sentence or two. … ## Issue Type (Can be multiple) -[ ] Build - Can’t install or import the SDK -[ ] Performance - Performance issues -[ ] Behaviour - Functions aren’t working as expected (Such as generate URL) -[ ] Documentation - Inconsistency between the docs and behaviour -[ ] Other (Specify) +- [ ] Build - Can’t install or import the SDK +- [ ] Performance - Performance issues +- [ ] Behaviour - Functions are not working as expected (such as generate URL) +- [ ] Documentation - Inconsistency between the docs and behaviour +- [ ] Other (Specify) ## Steps to reproduce … if applicable @@ -27,15 +27,17 @@ Before proceeding, please update to latest version and test if the issue persist … ## Operating System -[ ] Linux -[ ] Windows -[ ] OSX -[ ] All +- [ ] Linux +- [ ] Windows +- [ ] macOS +- [ ] All ## Environment and Frameworks (fill in the version numbers) -Cloudinary Python SDK version - 0.0.0 -Python Version - 0.0.0 -Framework (Django, Flask, etc) - 0.0.0 + +- Cloudinary Python SDK version - 0.0.0 +- Python Version - 0.0.0 +- Framework (Django, Flask, etc) - 0.0.0 ## Repository + If possible, please provide a link to a reproducible repository that showcases the problem diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2cbc6677..a8e0f83a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,9 +1,9 @@ --- name: Feature request -about: " Feature request for Cloudinary Python SDK" +about: Feature request for Cloudinary Python SDK title: '' labels: '' -assignees: const-cloudinary +assignees: '' --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..b2001d86 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ +### Brief Summary of Changes + + +#### What does this PR address? +- [ ] GitHub issue (Add reference - #XX) +- [ ] Refactoring +- [ ] New feature +- [ ] Bug fix +- [ ] Adds more tests + +#### Are tests included? +- [ ] Yes +- [ ] No + +#### Reviewer, please note: + + +#### Checklist: + + +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I ran the full test suite before pushing the changes and all the tests pass. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..20499990 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,56 @@ +name: Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-24.04 # noble equivalent + + strategy: + matrix: + include: + - python-version: "3.9" + toxenv: py39-core + - python-version: "3.10" + toxenv: py310-core + - python-version: "3.11" + toxenv: py311-core + - python-version: "3.12" + toxenv: py312-core + - python-version: "3.13" + toxenv: py313-core + - python-version: "3.9" + toxenv: py39-django32 + - python-version: "3.10" + toxenv: py310-django42 + - python-version: "3.11" + toxenv: py311-django42 + - python-version: "3.12" + toxenv: py312-django50 + - python-version: "3.13" + toxenv: py313-django51 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox pytest + + - name: Set CLOUDINARY_URL + run: | + export CLOUDINARY_URL=$(bash tools/get_test_cloud.sh) + echo "cloud_name: $(echo $CLOUDINARY_URL | cut -d'@' -f2)" + echo "CLOUDINARY_URL=$CLOUDINARY_URL" >> $GITHUB_ENV + + - name: Run tests + env: + TOXENV: ${{ matrix.toxenv }} + PYTHONPATH: ${{ github.workspace }} + run: tox -e $TOXENV diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ce0d6de7..00000000 --- a/.travis.yml +++ /dev/null @@ -1,49 +0,0 @@ -language: python -matrix: - include: - - python: 2.7 - env: TOXENV=py27-core - - python: 3.5 - env: TOXENV=py35-core - - python: 3.6 - env: TOXENV=py36-core - - python: 3.7 - env: TOXENV=py37-core - - python: 3.8 - env: TOXENV=py38-core - - python: 2.7 - env: TOXENV=py27-django18 - - python: 3.5 - env: TOXENV=py35-django18 - - python: 2.7 - env: TOXENV=py27-django19 - - python: 3.5 - env: TOXENV=py35-django19 - - python: 2.7 - env: TOXENV=py27-django110 - - python: 3.6 - env: TOXENV=py36-django110 - - python: 2.7 - env: TOXENV=py27-django111 - - python: 3.6 - env: TOXENV=py36-django111 - - python: 3.5 - env: TOXENV=py35-django20 - - python: 3.7 - env: TOXENV=py37-django20 - - python: 3.7 - env: TOXENV=py37-django21 - - python: 3.6 - env: TOXENV=py36-django22 - - python: 3.7 - env: TOXENV=py37-django22 - - python: 3.6 - env: TOXENV=py36-django30 - - python: 3.7 - env: TOXENV=py37-django30 - - python: 3.8 - env: TOXENV=py38-django30 -install: -- pip install tox -script: -- tox -e $TOXENV diff --git a/CHANGELOG.md b/CHANGELOG.md index 741e2a8a..e6170fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,360 @@ +1.44.1 / 2025-06-17 +================== + + * Fix API parameters signature + +1.44.0 / 2025-04-09 +================== + +New Functionality and Features +------------------------------ + + * Add support for custom aggregations in Search API + +Other Changes +------------- + + * Improve error handling in Upload API + +1.43.0 / 2025-03-18 +================== + +New functionality and features +------------------------------ + + * Add support for Conditional Metadata Rules API + +1.42.2 / 2025-02-05 +================== + + * Fix list parameters encoding in Admin API + * Align API doc strings + +1.42.1 / 2025-01-08 +================== + + * Fix signature containing boolean values + +1.42.0 / 2025-01-07 +================== + +New functionality and features +------------------------------ + + * Add support for `restore_by_asset_ids` Admin API + * Add support for `delete_resources_by_asset_ids` Admin API + * Add support for `delete_backed_up_assets` Admin API + * Add support for `allow_dynamic_list_values` parameter in `MetadataField` + +Other Changes +------------- + * Fix `AuthToken` configuration consumption + * Switch to `pytest` + * Add Generative AI Transformation sample project + * Add missing doc strings + +1.41.0 / 2024-08-01 +================== + +New functionality and features +------------------------------ + + * Add support for `set_url_signature` in `AuthToken` + * Add support for `default_disabled` parameter in `MetadataField` + * Add support for `rename_folder` Admin API + +1.40.0 / 2024-04-18 +================== + +New functionality and features +------------------------------ + + * Add support for `skip_backup` in `delete_folder` Admin API + +1.39.1 / 2024-03-19 +================== + +* Fix `analyze` API endpoint + +1.39.0 / 2024-02-27 +================== + +New functionality and features +------------------------------ + + * Add support for `restrictions` metadata field parameter + * Add support for `regions` upload parameter + +1.38.0 / 2024-01-08 +================== + +New functionality and features +------------------------------ + + * Add support for `analyze` API + * Add support for `dedicated_for` parameter in `update_access_key` Provisioning API + * Add support for `delete_access_key` in Provisioning API + * Add support for `use_fetch_format` parameter in video tag + +1.37.0 / 2023-12-03 +================== + +New functionality and features +------------------------------ + + * Add support for `fields` parameter in Search and Admin APIs + +Other Changes +------------- + + * Fix special characters encoding in `fetch` overlays + * Migrate to `pyproject.toml` + +1.36.0 / 2023-10-02 +================== + +New functionality and features +------------------------------ + + * Add support for access keys management in Provisioning API + +Other Changes +------------- + + * Add support for `urllib3` version `2.0` + * Remove unnecessary `else` statements + +1.35.0 / 2023-09-25 +================== + +New functionality and features +------------------------------ + + * Add support for `image_file` in `visual_search` Admin API + * Add support for `last_login` field in `users` Provisioning API + * Add support for `config` Admin API + +Other Changes +------------- + + * Fix fetch video overlay + * Add additional tests for a signed url + + +1.34.0 / 2023-08-14 +=================== + +New functionality and features +------------------------------ + + * Add support for `on_success` upload parameter + * Add support for `visual_search` Admin API + +1.33.0 / 2023-05-15 +================== + +New functionality and features +------------------------------ + + * Add support for Search URL + * Add support for `target_asset_folder` API parameter + * Add support for expressions in `start_offset` and `end_offset` + * Add support for `extra_headers` option in Upload and Admin API + * Add support for `add_related_assets_by_asset_ids` and `delete_related_assets_by_asset_ids` + +1.32.0 / 2023-02-09 +================== + +New functionality and features +------------------------------ + + * Add support for related assets APIs + +1.31.0 / 2023-01-12 +================== + +New functionality and features +------------------------------ + + * Add support for `SearchFolders` API + * Add support for `media_metadata` API parameter + * Add support for `clear_invalid` metadata parameter + * Add support for `upload_preset` in configuration + +Other Changes +------------- + + * Fix API error handling + +1.30.0 / 2022-09-20 +================== + +New functionality and features +------------------------------ + + * Add support for `resources_by_asset_folder` Admin API + * Add support for `unique_display_name` parameter + * Add support for `use_asset_folder_as_public_id_prefix` parameter + * Add support for `metadata` in `update` Admin API + * Add support for multiple ACLs in `AuthToken` + +Other Changes +------------- + + * Move Django static files to `static/cloudinary` + * Allow passing callable parameters to `CloudinaryField` + * Fix incorrect Provisioning API parameter for `base_account` + * Add OS info to `User-Agent` + * Add source URL for PyPi + * Improve tests of API response headers + * Extend search resources by asset_id tests + +1.29.0 / 2022-02-03 +================== + +New functionality and features +------------------------------ + + * Add support for multiple tags in `add_tag`, `remove_tag` and `replace_tag` + +Other Changes +------------- + + * Fix connection reset by peer issue + * Bump `urllib3` to version `1.26.5` + +1.28.1 / 2022-01-13 +================== + + * Add support for Django 4 + * Add tests to verify expression normalization + +1.28.0 / 2021-11-11 +================== + +New functionality and features +------------------------------ + +* Add support for `reorder_metadata_fields` Admin API +* Add support for disabling b-frames in `video_codec` transformation parameter + +Other Changes +------------- + +* Fix regression in `upload_resource` function + +1.27.0 / 2021-11-10 +================== + +New functionality and features +------------------------------ + + * Add support for `resources_by_asset_ids` Admin API + * Add support for `resource_by_asset_id` Admin API + * Add support for `reorder_metadata_field_datasource` Admin API + * Add support for folder decoupling + * Add support for `create_slideshow` Upload API + * Add support for uploading `pathlib.Path` + * Add support for variables in text style + +Other Changes +------------- + + * Remove duplicates in Search API fields + * Fix named parameters normalization issue + * Remove redundant parameter from `update_sub_account` Provisioning API + +1.26.0 / 2021-06-20 +================== + +New functionality and features +------------------------------ + + * Add support for OAuth authorization + * Add support for large file upload in `CloudinaryField` + * Add support for `context` and `metadata` parameters in `rename` API + * Add support for overriding Django settings with env variables + * Add support for `filename_override` upload parameter + * Add support for `metadata` parameter in `resources` APIs + +Other Changes +------------- + + * Add validation to `generate_auth_token` to enforce url or acl + * Fix `normalize_expression` in advanced cases + + +1.25.0 / 2021-03-26 +================== + +New functionality and features +------------------------------ + + * Add support for `download_generated_sprite` and `download_multi` helpers + * Add support for `urls` in `multi` and `sprite` APIs + * Add support for `SHA-256` in auth signatures + +Other Changes +------------- + + * Fix `prepare.sh` script + * Fix `pending` parameter of the `users` method of Provisioning API + * Change test for `eval` upload parameter + * Extract add-on type constants to a separate file + + +1.24.0 / 2020-12-18 +=================== + +New functionality and features +------------------------------ + + * Add support for list values in metadata + * Add `Python 3.9` support + +Other Changes +------------- + + * Improve add-on tests + +1.23.0 / 2020-11-16 +=================== + +New functionality and features +------------------------------ + + * Add support for `date` in `usage` Admin API + * Add `download_folder` helper method + +Other Changes +------------- + + * Fix typo in docstring for `get_param` + * Add test for context metadata as user variables + * Fix Django deprecation warning + * Fix detection integration test + * Add pull request template + +1.22.0 / 2020-07-23 +=================== + +New functionality and features +------------------------------ + + * Add `download_backedup_asset` helper method + * Add support for `accessibility_analysis` parameter + * Add support for `eval` upload parameter + +Other Changes +------------- + + * Detect data URLs with suffix in mime type + * Integrate with sub-account test service + +1.21.1 / 2020-06-11 +=================== + + * Fix static files in Django 3.0 1.21.0 / 2020-06-04 =================== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ec257ef..3815bb11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ Document any external behavior in the [README](README.md). #### Running the tests -Run the basic test suite with:: +Run the basic test suite with your `CLOUDINARY_URL`: CLOUDINARY_URL=cloudinary://apikey:apisecret@cloudname python setup.py test @@ -93,7 +93,7 @@ This only runs the tests for the current environment. Travis-CI will run the full suite when you submit your pull request. The full test suite takes a long time to run because it tests multiple combinations of Python and Django. -You need to have Python 2.7, 3.4, 3.5, 3.6, 3.7 installed to run all of the environments. Then run:: +You need to have Python 2.7, 3.4, 3.5, 3.6, 3.7 installed to run all environments. Then run: CLOUDINARY_URL=cloudinary://apikey:apisecret@cloudname tox diff --git a/README.md b/README.md new file mode 100644 index 00000000..63b17053 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +[![Tests](https://github.com/cloudinary/pycloudinary/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/cloudinary/pycloudinary/actions/workflows/test.yml) +[![PyPI Version](https://img.shields.io/pypi/v/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) +[![PyPI PyVersions](https://img.shields.io/pypi/pyversions/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) +[![PyPI DjangoVersions](https://img.shields.io/pypi/djversions/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) +[![PyPI Version](https://img.shields.io/pypi/dm/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) +[![PyPI License](https://img.shields.io/pypi/l/cloudinary.svg)](https://pypi.python.org/pypi/cloudinary/) + + +Cloudinary Python SDK +================== + +## About +The Cloudinary Python SDK allows you to quickly and easily integrate your application with Cloudinary. +Effortlessly optimize, transform, upload and manage your cloud's assets. + + +#### Note +This Readme provides basic installation and usage information. +For the complete documentation, see the [Python SDK Guide](https://cloudinary.com/documentation/django_integration). + +## Table of Contents +- [Key Features](#key-features) +- [Version Support](#Version-Support) +- [Installation](#installation) +- [Usage](#usage) + - [Setup](#Setup) + - [Transform and Optimize Assets](#Transform-and-Optimize-Assets) + - [Django](#Django) + + +## Key Features +- [Transform](https://cloudinary.com/documentation/django_video_manipulation#video_transformation_examples) and + [optimize](https://cloudinary.com/documentation/django_image_manipulation#image_optimizations) assets. +- Generate [image](https://cloudinary.com/documentation/django_image_manipulation#deliver_and_transform_images) and + [video](https://cloudinary.com/documentation/django_video_manipulation#django_video_transformation_code_examples) tags. +- [Asset Management](https://cloudinary.com/documentation/django_asset_administration). +- [Secure URLs](https://cloudinary.com/documentation/video_manipulation_and_delivery#generating_secure_https_urls_using_sdks). + + + +## Version Support + +| SDK Version | Python 2.7 | Python 3.x | +|-------------|------------|------------| +| 1.x | ✔ | ✔ | + +| SDK Version | Django 1.11 | Django 2.x | Django 3.x | Django 4.x | Django 5.x | +|-------------|-------------|------------|------------|------------|------------| +| 1.x | ✔ | ✔ | ✔ | ✔ | ✔ | + + +## Installation +```bash +pip install cloudinary +``` + +# Usage + +### Setup +```python +import cloudinary +``` + +### Transform and Optimize Assets +- [See full documentation](https://cloudinary.com/documentation/django_image_manipulation). + +```python +cloudinary.utils.cloudinary_url("sample.jpg", width=100, height=150, crop="fill") +``` + +### Upload +- [See full documentation](https://cloudinary.com/documentation/django_image_and_video_upload). +- [Learn more about configuring your uploads with upload presets](https://cloudinary.com/documentation/upload_presets). +```python +cloudinary.uploader.upload("my_picture.jpg") +``` + +### Django +- [See full documentation](https://cloudinary.com/documentation/django_image_and_video_upload#django_forms_and_models). + +### Security options +- [See full documentation](https://cloudinary.com/documentation/solution_overview#security). + +### Sample projects +- [Sample projects](https://github.com/cloudinary/pycloudinary/tree/master/samples). +- [Django Photo Album](https://github.com/cloudinary/cloudinary-django-sample). + + +## Contributions +- Ensure tests run locally. +- Open a PR and ensure Travis tests pass. +- See [CONTRIBUTING](CONTRIBUTING.md). + +## Get Help +If you run into an issue or have a question, you can either: +- Issues related to the SDK: [Open a GitHub issue](https://github.com/cloudinary/pycloudinary/issues). +- Issues related to your account: [Open a support ticket](https://cloudinary.com/contact). + + +## About Cloudinary +Cloudinary is a powerful media API for websites and mobile apps alike, Cloudinary enables developers to efficiently +manage, transform, optimize, and deliver images and videos through multiple CDNs. Ultimately, viewers enjoy responsive +and personalized visual-media experiences—irrespective of the viewing device. + + +## Additional Resources +- [Cloudinary Transformation and REST API References](https://cloudinary.com/documentation/cloudinary_references): Comprehensive references, including syntax and examples for all SDKs. +- [MediaJams.dev](https://mediajams.dev/): Bite-size use-case tutorials written by and for Cloudinary Developers +- [DevJams](https://www.youtube.com/playlist?list=PL8dVGjLA2oMr09amgERARsZyrOz_sPvqw): Cloudinary developer podcasts on YouTube. +- [Cloudinary Academy](https://training.cloudinary.com/): Free self-paced courses, instructor-led virtual courses, and on-site courses. +- [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. +- [Cloudinary Roadmap](https://cloudinary.com/roadmap): Your chance to follow, vote, or suggest what Cloudinary should develop next. +- [Cloudinary Facebook Community](https://www.facebook.com/groups/CloudinaryCommunity): Learn from and offer help to other Cloudinary developers. +- [Cloudinary Account Registration](https://cloudinary.com/users/register/free): Free Cloudinary account registration. +- [Cloudinary Website](https://cloudinary.com): Learn about Cloudinary's products, partners, customers, pricing, and more. + + +## Licence +Released under the MIT license. diff --git a/README.rst b/README.rst deleted file mode 100644 index 6ea483c2..00000000 --- a/README.rst +++ /dev/null @@ -1,438 +0,0 @@ -Cloudinary -========== - -Cloudinary is a cloud service that offers a solution to a web -application's entire image management pipeline. - -Easily upload images to the cloud. Automatically perform smart image -resizing, cropping and conversion without installing any complex -software. Integrate Facebook or Twitter profile image extraction in a -snap, in any dimension and style to match your website's graphics -requirements. Images are seamlessly delivered through a fast CDN, and -much much more. - -Cloudinary offers comprehensive APIs and administration capabilities and -is easy to integrate with any web application, existing or new. - -Cloudinary provides URL and HTTP based APIs that can be easily -integrated with any Web development framework. - -For Python, Cloudinary provides an egg for simplifying the integration -even further. - -Getting started guide ---------------------- - -|image0| Take a look at our `Getting started guide for Python & -Django `__. - -Setup ------ - -You can install Cloudinary's module using either ``easy_install`` or -``pip`` package management tools. For example: - -.. code:: sh - - $ pip install cloudinary - -Try it right away ------------------ - -Sign up for a `free -account `_ so you can try -out image transformations and seamless image delivery through CDN. - -*Note: Replace ``demo`` in all the following examples with your -Cloudinary's ``cloud name``.* - -Accessing an uploaded image with the ``sample`` public ID through a CDN: - -http://res.cloudinary.com/demo/image/upload/sample.jpg - -.. figure:: https://res.cloudinary.com/demo/image/upload/w_0.4/sample.jpg - :alt: Sample - - Sample - -Generating a 150x100 version of the ``sample`` image and downloading it -through a CDN: - -http://res.cloudinary.com/demo/image/upload/w\_150,h\_100,c\_fill/sample.jpg - -.. figure:: https://res.cloudinary.com/demo/image/upload/w_150,h_100,c_fill/sample.jpg - :alt: Sample 150x100 - - Sample 150x100 - -Converting to a 150x100 PNG with rounded corners of 20 pixels: - -http://res.cloudinary.com/demo/image/upload/w\_150,h\_100,c\_fill,r\_20/sample.png - -.. figure:: https://res.cloudinary.com/demo/image/upload/w_150,h_100,c_fill,r_20/sample.png - :alt: Sample 150x150 Rounded PNG - - Sample 150x150 Rounded PNG - -For many more transformation options, see our `image transformations -documentation `__. - -Generating a 120x90 thumbnail based on automatic face detection of the -Facebook profile picture of Bill Clinton: - -http://res.cloudinary.com/demo/image/facebook/c\_thumb,g\_face,h\_90,w\_120/billclinton.jpg - -.. figure:: https://res.cloudinary.com/demo/image/facebook/c_thumb,g_face,h_90,w_120/billclinton.jpg - :alt: Facebook 90x200 - - Facebook 90x120 - -For more details, see our documentation for embedding -`Facebook `__ -and -`Twitter `__ -profile pictures. - -Usage ------ -.. _configuration: - -Configuration -~~~~~~~~~~~~~ - -Each request for building a URL of a remote cloud resource must have the -``cloud_name`` parameter set. Each request to our secure APIs (e.g., -image uploads, eager sprite generation) must have the ``api_key`` and -``api_secret`` parameters set. See `API, URLs and access -identifiers `_ -for more details. - -Setting the ``cloud_name``, ``api_key`` and ``api_secret`` parameters -can be done either directly in each call to a Cloudinary method, by -calling the cloudinary.config(), by using environment variables, or -using the CLOUDINARY django settings. - -You can `download your customized cloudinary python -configuration `__ -using our Management Console. - -Embedding and transforming images -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Any image uploaded to Cloudinary can be transformed and embedded using -powerful view helper methods: - -The following example generates the url for accessing an uploaded -``sample`` image while transforming it to fill a 100x150 rectangle: - -.. code:: python - - cloudinary.utils.cloudinary_url("sample.jpg", - width = 100, - height = 150, - crop = "fill") - -Another example, emedding a smaller version of an uploaded image while -generating a 90x90 face detection based thumbnail: - -.. code:: python - - cloudinary.utils.cloudinary_url("woman.jpg", - width = 90, - height = 90, - crop = "thumb", - gravity = "face") - -You can provide either a Facebook name or a numeric ID of a Facebook -profile or a fan page. - -Embedding a Facebook profile to match your graphic design is very -simple: - -.. code:: python - - cloudinary.utils.cloudinary_url("billclinton.jpg", - width = 90, - height = 130, - type = "facebook", - crop = "fill", - gravity = "north_west") - -Same goes for Twitter: - -.. code:: python - - cloudinary.utils.cloudinary_url("billclinton.jpg", - type = "twitter_name") - -|image1| See `our -documentation `__ -for more information about displaying and transforming images in Python -& Django. - -Upload -~~~~~~ - -Assuming you have your Cloudinary configuration parameters defined -(``cloud_name``, ``api_key``, ``api_secret``), uploading to Cloudinary -is very simple. - -The following example uploads a local JPG to the cloud: - -.. code:: python - - cloudinary.uploader.upload("my_picture.jpg") - -The uploaded image is assigned a randomly generated public ID. The image -is immediately available for download through a CDN: - -.. code:: python - - cloudinary.utils.cloudinary_url("abcfrmo8zul1mafopawefg.jpg") - - # http://res.cloudinary.com/demo/image/upload/abcfrmo8zul1mafopawefg.jpg - -You can also specify your own public ID: - -.. code:: python - - cloudinary.uploader.upload("http://www.example.com/image.jpg", public_id = 'sample_remote') - - cloudinary.utils.cloudinary_url("sample_remote.jpg") - - # http://res.cloudinary.com/demo/image/upload/sample_remote.jpg - -|image2| See `our -documentation `__ -for plenty more options of uploading to the cloud from your Python & -Django code or directly from the browser. - -Django ------- - -Configuration -~~~~~~~~~~~~~ -1. Follow python configuration_ instructions. -2. Add ``cloudinary`` to `INSTALLED_APPS` in your `settings.py` file. - -cloudinary.CloudinaryImage -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Represents an image stored in Cloudinary. - -Usage: - -.. code:: python - - img = cloudinary.CloudinaryImage("sample", format="png") - - img.build_url(width=100, height=100, crop="fill") - # http://res.cloudinary.com/cloud_name/image/upload/c_fill,h_100,w_100/sample.png - - # Note: since v1.0.0 this method was change from 'url' - # to 'build_url' to avoid conflicts with the 'url' property. - - img.image(width=100, height=100, crop="fill") - # - -Models -~~~~~~ - -CloudinaryField -^^^^^^^^^^^^^^^ - -The ``cloudinary.models.CloudinaryField`` defines a field in the model -that represents an image stored in Cloudinary. Allows you to store -references to Cloudinary stored images in your model. The internal type -of the field is ``CharField``. - -Returns an CloudinaryResource object. - -Usage: - -.. code:: python - - class Poll(models.Model): - # ... - image = cloudinary.models.CloudinaryField('image') - -Configuration -^^^^^^^^^^^^^ - -The size of the ``CloudinaryField`` can be set in the Django -``setting.py`` file: - -.. code:: python - - CLOUDINARY = { - 'max_length': 200, - } - -Forms -~~~~~ - -The CloudinaryField model field has -``default_form_class = cloudinary.forms.CloudinaryFileField``. You can -create a simple ModelForm that will let you upload an image to through -the backend to cloudinary. - -.. code:: python - - class PollForm(django.forms.ModelForm): - Meta: - class = Poll - -``cloudinary.forms.CloudinaryFileField`` - simple upload -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Form field that renders to a simple file input html element and allows -you to validate, upload to Cloudinary and convert to CloudinaryImage an -uploaded image file - -``cloudinary.forms.CloudinaryJsFileField`` - direct ajax upload -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -This form field renders to a special input element that interacts with -Cloudinary's jQuery plugin and jQuery-File-Upload. It allows you to -validate and convert to CloudinaryImage a signed Cloudinary image -reference resulting from a successful image upload (see -`here `__) - -Cloudinary template tags -~~~~~~~~~~~~~~~~~~~~~~~~ - -Initialization: -^^^^^^^^^^^^^^^ - -.. code:: htmldjango - - {% load cloudinary %} - -Including the required Javascript files: - -.. code:: htmldjango - - {% cloudinary_includes %} - -Passing configuration parameters to Cloudinary's jQuery plugin - will -create a script tag with configuration initialization: - -.. code:: htmldjango - - {% cloudinary_js_config %} - -Embedding images -^^^^^^^^^^^^^^^^ - -Image tags can be generated from a public\_id or from a CloudinaryImage -object using: - -.. code:: htmldjango - - {% cloudinary image width=100 height=100 crop="fill" %} - - -Uploading images -^^^^^^^^^^^^^^^^ - -The following tag generates an html form field that can be used to -upload the file directly to Cloudinary via ajax using the -jQuery-File-Upload widget. It could be used simply without parameters, -anywhere in the DOM: - -.. code:: django - - {% cloudinary_direct_upload_field request=request %} - -Alternatively, if used within an HTML form, after successful upload, the -jQuery plugin creates a hidden input field that could be used to pass -the uploaded image's metadata to the backend: - -.. code:: htmldjango - -
- {% csrf_token %} - {% cloudinary_direct_upload_field field='fieldname' request=request %} -
- -In both cases, the request object is optional, but is needed for -correctly handling older browsers which don't fully support CORS. - -The following tag generates an html form that can be used to upload the -file directly to Cloudinary. The result is a redirect to the supplied -callback\_url. - -.. code:: htmldjango - - {% cloudinary_direct_upload callback_url %} - -Optional parameters: - -- ``public_id`` - The name of the uploaded file in Cloudinary - -Code samples ------------- - -Basic Python sample -~~~~~~~~~~~~~~~~~~~ - -This sample is a synchronous script that shows the upload process from -local file, remote URL, with different transformations and options. - -The source code and more details are available here: - -https://github.com/cloudinary/pycloudinary/tree/master/samples/basic - -Photo Album - Django Web application -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -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. - -The source code and more details are available here: - -https://github.com/cloudinary/cloudinary-django-sample - -Additional resources --------------------- - -Additional resources are available at: - -- `Website `__ -- `Documentation `__ -- `Knowledge Base `__ -- `Documentation for Django - integration `__ -- `Django image upload - documentation `__ -- `Django image manipulation - documentation `__ -- `Image transformations - documentation `__ - -Support -------- - -You can `open an issue through -GitHub `__. - -Contact us http://cloudinary.com/contact - -Stay tuned for updates, tips and tutorials: -`Blog `__, -`Twitter `__, -`Facebook `__. - -License -------- - -Released under the MIT license. - -Contains MIT licensed code from -`poster `__. - -.. |image0| image:: http://res.cloudinary.com/cloudinary/image/upload/see_more_bullet.png -.. |image1| image:: http://res.cloudinary.com/cloudinary/image/upload/see_more_bullet.png -.. |image2| image:: http://res.cloudinary.com/cloudinary/image/upload/see_more_bullet.png - diff --git a/cloudinary/__init__.py b/cloudinary/__init__.py index ce37c1d6..756e9750 100644 --- a/cloudinary/__init__.py +++ b/cloudinary/__init__.py @@ -1,13 +1,14 @@ from __future__ import absolute_import import abc -from copy import deepcopy -import os -import re import logging import numbers -import certifi +import os +import re +from copy import deepcopy from math import ceil + +import certifi from six import python_2_unicode_compatible, add_metaclass logger = logging.getLogger("Cloudinary") @@ -22,7 +23,7 @@ from cloudinary.http_client import HttpClient from cloudinary.compat import urlparse, parse_qs -from platform import python_version +from platform import python_version, platform CERT_KWARGS = { 'cert_reqs': 'CERT_REQUIRED', @@ -37,15 +38,17 @@ URI_SCHEME = "cloudinary" API_VERSION = "v1_1" -VERSION = "1.21.0" +VERSION = "1.44.1" + +_USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version()))) -USER_AGENT = "CloudinaryPython/{} (Python {})".format(VERSION, python_version()) +USER_AGENT = "CloudinaryPython/{} ({})".format(VERSION, _USER_PLATFORM_DETAILS) """ :const: USER_AGENT """ USER_PLATFORM = "" """ -Additional information to be passed with the USER_AGENT, e.g. "CloudinaryMagento/1.0.1". -This value is set in platform-specific implementations that use cloudinary_php. +Additional information to be passed with the USER_AGENT, e.g. "CloudinaryCLI/1.2.3". +This value is set in platform-specific implementations that use pycloudinary. The format of the value should be /Version[ (comment)]. @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.43 @@ -98,6 +101,13 @@ def import_django_settings(): @add_metaclass(abc.ABCMeta) class BaseConfig(object): + def __init__(self): + django_settings = import_django_settings() + if django_settings: + self.update(**django_settings) + + self._load_config_from_env() + def __getattr__(self, i): return self.__dict__.get(i) @@ -150,6 +160,16 @@ def _setup_from_parsed_url(self, parsed_url): else: self.__dict__[k] = v[0] + def _load_from_url(self, url): + parsed_url = self._parse_cloudinary_url(url) + + return self._setup_from_parsed_url(parsed_url) + + @abc.abstractmethod + def _load_config_from_env(self): + """Load config from environment variables or URL.""" + raise NotImplementedError() + def update(self, **keywords): for k, v in keywords.items(): self.__dict__[k] = v @@ -158,22 +178,13 @@ def update(self, **keywords): class Config(BaseConfig): def __init__(self): self._uri_scheme = URI_SCHEME - django_settings = import_django_settings() - if django_settings: - self.update(**django_settings) - elif os.environ.get("CLOUDINARY_CLOUD_NAME"): - self.update( - cloud_name=os.environ.get("CLOUDINARY_CLOUD_NAME"), - api_key=os.environ.get("CLOUDINARY_API_KEY"), - api_secret=os.environ.get("CLOUDINARY_API_SECRET"), - secure_distribution=os.environ.get("CLOUDINARY_SECURE_DISTRIBUTION"), - private_cdn=os.environ.get("CLOUDINARY_PRIVATE_CDN") == 'true', - api_proxy=os.environ.get("CLOUDINARY_API_PROXY"), - ) - elif os.environ.get("CLOUDINARY_URL"): - cloudinary_url = os.environ.get("CLOUDINARY_URL") - parsed_url = self._parse_cloudinary_url(cloudinary_url) - self._setup_from_parsed_url(parsed_url) + + super(Config, self).__init__() + + if not self.signature_algorithm: + self.signature_algorithm = utils.SIGNATURE_SHA1 + if not self.signature_version: + self.signature_version = 2 def _config_from_parsed_url(self, parsed_url): if not self._is_url_scheme_valid(parsed_url): @@ -191,6 +202,21 @@ def _config_from_parsed_url(self, parsed_url): return result + def _load_config_from_env(self): + if os.environ.get("CLOUDINARY_CLOUD_NAME"): + config_keys = [key for key in os.environ.keys() + if key.startswith("CLOUDINARY_") and key != "CLOUDINARY_URL"] + + for full_key in config_keys: + conf_key = full_key[len("CLOUDINARY_"):].lower() + conf_val = os.environ[full_key] + if conf_val in ["true", "false"]: + conf_val = conf_val == "true" + + self.update(**{conf_key: conf_val}) + elif os.environ.get("CLOUDINARY_URL"): + self._load_from_url(os.environ.get("CLOUDINARY_URL")) + _config = Config() @@ -210,6 +236,7 @@ def reset_config(): # FIXME: circular import issue from cloudinary.search import Search +from cloudinary.search_folders import SearchFolders @python_2_unicode_compatible @@ -255,7 +282,7 @@ def __len__(self): return len(self.public_id) if self.public_id is not None else 0 def validate(self): - return self.signature == self.get_expected_signature() + return utils.verify_api_response_signature(self.public_id, self.version, self.signature) def get_prep_value(self): if None in [self.public_id, @@ -275,7 +302,8 @@ def get_presigned(self): return self.get_prep_value() + '#' + self.get_expected_signature() def get_expected_signature(self): - return utils.api_sign_request({"public_id": self.public_id, "version": self.version}, config().api_secret) + return utils.api_sign_request({"public_id": self.public_id, "version": self.version}, config().api_secret, + config().signature_algorithm, signature_version=1) @property def url(self): @@ -715,7 +743,11 @@ def video(self, **options): :return: Video tag """ public_id = options.get('public_id', self.public_id) - source = re.sub(r"\.({0})$".format("|".join(self.default_source_types())), '', public_id) + use_fetch_format = options.get('use_fetch_format', config().use_fetch_format) + if not use_fetch_format: + source = re.sub(r"\.({0})$".format("|".join(self.default_source_types())), '', public_id) + else: + source = public_id custom_attributes = options.pop("attributes", dict()) diff --git a/cloudinary/api.py b/cloudinary/api.py index 55a69aa0..b905e45b 100644 --- a/cloudinary/api.py +++ b/cloudinary/api.py @@ -1,19 +1,19 @@ # Copyright Cloudinary -import email.utils +import datetime import json -import socket -import urllib3 from six import string_types -from urllib3.exceptions import HTTPError import cloudinary from cloudinary import utils from cloudinary.api_client.call_api import ( - call_api, call_metadata_api, - call_json_api + call_metadata_rules_api, + call_api, + call_json_api, + _call_v2_api, + ) from cloudinary.exceptions import ( BadRequest, @@ -27,88 +27,423 @@ def ping(**options): - return call_api("get", ["ping"], {}, **options) + """ + Tests the reachability of the Cloudinary API. + + See: https://cloudinary.com/documentation/admin_api#ping_cloudinary_servers + + :param options: Additional optional configuration parameters (none currently recognized). + + :return: The result of the API call. + :rtype: Response + """ + return call_json_api("get", ["ping"], {}, **options) def usage(**options): - return call_api("get", ["usage"], {}, **options) + """ + Get account usage details. + + Get a report on the status of your Cloudinary account usage details, including storage, credits, bandwidth, + requests, number of resources, and add-on usage. Note that numbers are updated periodically. + + See: https://cloudinary.com/documentation/admin_api#get_product_environment_usage_details + + :param options: Additional optional parameters. + :keyword date: The date for usage details (string in "YYYY-MM" format or a datetime.date object). + If omitted, returns usage for the current billing period. + :type date: str or datetime.date + + :return: Detailed usage information + :rtype: Response + """ + date = options.pop("date", None) + uri = ["usage"] + if date: + if isinstance(date, datetime.date): + date = utils.encode_date_to_usage_api_format(date) + uri.append(date) + return call_json_api("get", uri, {}, **options) + + +def config(**options): + """ + Get account config details. + + Fetches the account's configuration details with optional settings. + + See: https://cloudinary.com/documentation/admin_api#get_product_environment_config_details + + :param options: The optional parameters for the API request. + :keyword bool settings: When True, returns extended settings in the response (if available). + :return: Detailed config information. + :rtype: Response + """ + params = only(options, "settings") + return call_json_api("get", ["config"], params, **options) def resource_types(**options): - return call_api("get", ["resources"], {}, **options) + """ + Retrieves the types of resources (assets) available. + + See: https://cloudinary.com/documentation/admin_api#get_resources + + :param options: Additional optional configuration parameters (none currently recognized). + :return: The result of the API call. + :rtype: Response + """ + return call_json_api("get", ["resources"], {}, **options) def resources(**options): + """ + Retrieves resources (assets) based on the provided options. + + See: https://cloudinary.com/documentation/admin_api#get_resources + + :param options: Additional options to filter the resources. + :keyword str resource_type: The type of the resources. Defaults to "image". + :keyword str type: The specific asset type. Defaults to None (not added to URI). + :keyword str prefix: Return only resources with a public ID (or folder) that starts with this prefix. + :keyword str start_at: Return resources updated since the specified timestamp (format: "yyyy-mm-dd hh:mm:ss"). + :keyword str direction: Return resources sorted by "asc" or "desc" order of creation. + :keyword str next_cursor: A string that is returned as part of the response when there are more results to retrieve. + :keyword int max_results: Maximum number of resources to return. Default=10. + :return: The result of the API call. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", None) uri = ["resources", resource_type] if upload_type: uri.append(upload_type) - params = only(options, "next_cursor", "max_results", "prefix", "tags", - "context", "moderations", "direction", "start_at") - return call_api("get", uri, params, **options) + params = __list_resources_params(**options) + params.update(only(options, "prefix", "start_at")) + return call_json_api("get", uri, params, **options) def resources_by_tag(tag, **options): + """ + Lists resources (assets) with the specified tag. + + This method does not return matching deleted assets, even if they have been backed up. + + See: https://cloudinary.com/documentation/admin_api#get_resources_by_tag + + :param tag: The tag value. + :type tag: str + :param options: Additional options to filter the resources. + :keyword str resource_type: The type of the resources. Defaults to "image". + :keyword str direction: Return resources in "asc" or "desc" order (by creation). + :keyword int max_results: Maximum number of resources to return. Default=10. + :keyword str next_cursor: A string returned when there are more results to fetch. + :return: The result of the API call. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "tags", tag] - params = only(options, "next_cursor", "max_results", "tags", - "context", "moderations", "direction") - return call_api("get", uri, params, **options) + params = __list_resources_params(**options) + return call_json_api("get", uri, params, **options) def resources_by_moderation(kind, status, **options): + """ + Lists resources (assets) currently in the specified moderation queue and status. + + See: https://cloudinary.com/documentation/admin_api#get_resources_in_moderation + + :param kind: Type of image moderation queue to list (e.g., "manual", "webpurify", "aws_rek", "metascan"). + :type kind: str + :param status: Only assets with this moderation status will be returned. + Valid values: "pending", "approved", "rejected". + :type status: str + :param options: Additional options to filter the resources. + :keyword str resource_type: The type of the resources. Defaults to "image". + :keyword str direction: Return resources in "asc" or "desc" order (by creation). + :keyword int max_results: Maximum number of resources to return. Default=10. + :keyword str next_cursor: A string returned when there are more results to fetch. + :return: The result of the API call. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "moderations", kind, status] - params = only(options, "next_cursor", "max_results", "tags", - "context", "moderations", "direction") - return call_api("get", uri, params, **options) + params = __list_resources_params(**options) + return call_json_api("get", uri, params, **options) def resources_by_ids(public_ids, **options): + """ + Lists resources (assets) with the specified public IDs. + + See: https://cloudinary.com/documentation/admin_api#get_resources + + :param public_ids: The requested public_ids (up to 100). + :type public_ids: list[str] + :param options: The optional parameters. + :keyword str resource_type: The type of the resources. Defaults to "image". + :keyword str type: The specific asset type. Defaults to "upload". + :keyword str direction: Return resources in "asc" or "desc" order. + :keyword int max_results: Maximum number of resources to return. + :keyword str next_cursor: A string returned when there are more results to fetch. + :return: The result of the API call. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type] - params = dict(only(options, "tags", "moderations", "context"), public_ids=public_ids) - return call_api("get", uri, params, **options) + params = dict(__resources_params(**options), public_ids=public_ids) + return call_json_api("get", uri, params, **options) -def resources_by_context(key, value=None, **options): - """Retrieves resources (assets) with a specified context key. +def resources_by_asset_folder(asset_folder, **options): + """ + Returns the details of the resources (assets) under a specified asset_folder. + + See: https://cloudinary.com/documentation/admin_api#get_resources_by_asset_folder + + :param asset_folder: The Asset Folder of the asset. + :type asset_folder: str + :param options: Additional options to filter the resources. + :keyword str direction: Return resources in "asc" or "desc" order. + :keyword int max_results: Maximum number of resources to return. + :keyword str next_cursor: A string returned when there are more results to fetch. + :return: Resources (assets) of a specific asset_folder. + :rtype: Response + """ + uri = ["resources", "by_asset_folder"] + params = __list_resources_params(**options) + params["asset_folder"] = asset_folder + return call_json_api("get", uri, params, **options) + + +def resources_by_asset_ids(asset_ids, **options): + """ + Retrieves the resources (assets) indicated in the asset IDs. This method does not return deleted assets even if they have been backed up. - See: `Get resources by context API reference - `_ + See: https://cloudinary.com/documentation/admin_api#get_resources_by_asset_ids - :param key: Only assets with this context key are returned - :type key: str - :param value: Only assets with this value for the context key are returned - :type value: str, optional - :param options: Additional options - :type options: dict, optional - :return: Resources (assets) with a specified context key - :rtype: Response + :param asset_ids: The requested asset IDs. + :type asset_ids: list[str] + :param options: Additional options to filter the resources. + :keyword str direction: Return resources in "asc" or "desc" order. + :keyword int max_results: Maximum number of resources to return. + :keyword str next_cursor: A string returned when there are more results to fetch. + :return: Resources (assets) as indicated in the asset IDs. + :rtype: Response + """ + uri = ["resources", "by_asset_ids"] + params = dict(__resources_params(**options), asset_ids=asset_ids) + return call_json_api("get", uri, params, **options) + + +def resources_by_context(key, value=None, **options): + """ + Retrieves resources (assets) with a specified context key. + This method does not return deleted assets even if they have been backed up. + + See: https://cloudinary.com/documentation/admin_api#get_resources_by_context + + :param key: Only assets with this context key are returned. + :type key: str + :param value: Only assets with this value for the context key are returned. + :type value: str, optional + :param options: Additional options to filter the resources. + :keyword str resource_type: The type of the resources. Defaults to "image". + :keyword str direction: Return resources in "asc" or "desc" order. + :keyword int max_results: Maximum number of resources to return. + :keyword str next_cursor: A string returned when there are more results to fetch. + :return: Resources (assets) with a specified context key. + :rtype: Response """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "context"] - params = only(options, "next_cursor", "max_results", "tags", - "context", "moderations", "direction") + params = __list_resources_params(**options) params["key"] = key if value is not None: params["value"] = value - return call_api("get", uri, params, **options) + return call_json_api("get", uri, params, **options) + + +def __resources_params(**options): + """ + Prepares optional parameters for resources_* API calls. + + :param options: Additional options + :return: Optional parameters + :rtype: dict + :internal + """ + params = only(options, "tags", "context", "metadata", "moderations") + if options.get("fields"): + params["fields"] = utils.encode_list(utils.build_array(options["fields"])) + + return params + + +def __list_resources_params(**options): + """ + Prepares optional parameters for resources_* API calls. + + :param options: Additional options + :return: Optional parameters + :rtype: dict + :internal + """ + resources_params = __resources_params(**options) + resources_params.update(only(options, "next_cursor", "max_results", "direction")) + return resources_params + + +def visual_search(image_url=None, image_asset_id=None, text=None, image_file=None, **options): + """ + Find images based on their visual content. + + See: https://cloudinary.com/documentation/admin_api#visual_search_for_resources + + :param image_url: The URL of an image. + :type image_url: str, optional + :param image_asset_id: The asset_id of an image in your account. + :type image_asset_id: str, optional + :param text: A textual description (e.g. "cat"). + :type text: str, optional + :param image_file: The image file. (str|callable|Path|bytes) + :type image_file: str or callable or Path or bytes, optional + :param options: Additional optional parameters to pass along. + + :return: Resources (assets) that were found + :rtype: Response + """ + uri = ["resources", "visual_search"] + params = { + "image_url": image_url, + "image_asset_id": image_asset_id, + "text": text, + "image_file": utils.handle_file_parameter(image_file, "file") + } + return call_api("post", uri, params, **options) def resource(public_id, **options): + """ + Returns the details of the specified asset and all its derived assets (by public ID). + + See: https://cloudinary.com/documentation/admin_api#get_details_of_a_single_resource_by_public_id + + :param public_id: The public ID of the resource. + :type public_id: str + :param options: Additional optional parameters for retrieval. + :keyword str resource_type: The resource type (e.g. "image", "raw"). + :keyword str type: The asset's storage type (e.g. "upload"). + :keyword bool exif: Whether to return Exif metadata. + :keyword bool faces: Whether to return face coordinates. + :keyword bool colors: Whether to return color information. + :keyword bool image_metadata: Whether to return image metadata. + :keyword bool media_metadata: Whether to return extended media metadata. + :keyword bool cinemagraph_analysis: Whether to include cinemagraph analysis data. + :keyword bool pages: Whether to include the page count of multi-page files. + :keyword bool phash: Whether to include perceptual hash data. + :keyword bool coordinates: Whether to return custom and face coordinates. + :keyword int max_results: The maximum number of derived resources to return. + :keyword bool quality_analysis: Whether to include quality analysis data. + :keyword str derived_next_cursor: A pagination cursor for derived resources. + :keyword bool accessibility_analysis: Whether to include accessibility analysis data. + :keyword bool versions: Whether to include version information for the asset. + :keyword bool related: Whether to include related assets. + :keyword str related_next_cursor: A pagination cursor for related assets. + :return: The result of the API call. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type, public_id] - params = only(options, "exif", "faces", "colors", "image_metadata", "cinemagraph_analysis", - "pages", "phash", "coordinates", "max_results", "quality_analysis", "derived_next_cursor") - return call_api("get", uri, params, **options) + params = _prepare_asset_details_params(**options) + return call_json_api("get", uri, params, **options) + + +def resource_by_asset_id(asset_id, **options): + """ + Returns the details of the specified asset and all its derived assets (by asset ID). + + See: https://cloudinary.com/documentation/admin_api#get_details_of_a_single_resource_by_asset_id + + :param asset_id: The Asset ID of the asset + :type asset_id: str + :param options: Additional optional parameters for retrieval. + :keyword bool exif: Whether to return Exif metadata. + :keyword bool faces: Whether to return face coordinates. + :keyword bool colors: Whether to return color information. + :keyword bool image_metadata: Whether to return image metadata. + :keyword bool media_metadata: Whether to return extended media metadata. + :keyword bool cinemagraph_analysis: Whether to include cinemagraph analysis data. + :keyword bool pages: Whether to include the page count of multi-page files. + :keyword bool phash: Whether to include perceptual hash data. + :keyword bool coordinates: Whether to return custom and face coordinates. + :keyword int max_results: The maximum number of derived resources to return. + :keyword bool quality_analysis: Whether to include quality analysis data. + :keyword str derived_next_cursor: A pagination cursor for derived resources. + :keyword bool accessibility_analysis: Whether to include accessibility analysis data. + :keyword bool versions: Whether to include version information for the asset. + :keyword bool related: Whether to include related assets. + :keyword str related_next_cursor: A pagination cursor for related assets. + :return: Resource (asset) of a specific asset_id + :rtype: Response + """ + uri = ["resources", asset_id] + params = _prepare_asset_details_params(**options) + return call_json_api("get", uri, params, **options) + + +def _prepare_asset_details_params(**options): + """ + Prepares optional parameters for resource_by_asset_id or resource_by_public_id API calls. + + :param options: Additional options + :return: Optional parameters + :rtype: dict + :internal + """ + return only(options, "exif", "faces", "colors", "image_metadata", "media_metadata", "cinemagraph_analysis", + "pages", "phash", "coordinates", "max_results", "quality_analysis", "derived_next_cursor", + "accessibility_analysis", "versions", "related", "related_next_cursor") def update(public_id, **options): + """ + Updates the details of a specified resource by public ID. + + See: https://cloudinary.com/documentation/admin_api#update_details_of_an_existing_resource + + :param public_id: The public ID of the resource to update. + :type public_id: str + :param options: Additional options for the update operation. + :keyword str resource_type: The resource type (e.g. "image", "raw"). + :keyword str type: The asset's storage type (e.g. "upload"). + :keyword str moderation_status: Sets the moderation status ("approved" / "rejected"). + :keyword str raw_convert: Requests raw file conversion ("aspose", etc.). + :keyword str quality_override: Overrides the quality setting. + :keyword str ocr: Requests OCR extraction ("adv_ocr"). + :keyword str categorization: Sets the categorization mode (e.g. "google_tagging"). + :keyword str detection: Sets the detection mode (e.g. "adv_face"). + :keyword str similarity_search: Reserved for similarity search tasks. + :keyword str background_removal: The background removal setting (e.g. "cloudinary_ai" or "pixelz"). + :keyword str notification_url: A URL for receiving notifications. + :keyword list tags: The tags to assign to the asset. + :keyword list or str face_coordinates: The face coordinates to set. + :keyword list or str custom_coordinates: The custom coordinates to set. + :keyword list regions: Region data for partial image transformations. + :keyword dict context: Contextual (key/value) metadata. + :keyword dict metadata: Structured metadata. + :keyword float auto_tagging: A float from 0.0 to 1.0. If set, automatically tags an image. + :keyword list access_control: An array of access control rules in dictionary form. + :keyword str asset_folder: The folder path in which to place the asset. + :keyword str display_name: A user-friendly name for the asset. + :keyword bool unique_display_name: If True, ensures the display name is unique. + :keyword bool clear_invalid: If True, removes or corrects invalid data (e.g., invalid context). + :return: The result of the API call. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type, public_id] @@ -119,289 +454,900 @@ def update(public_id, **options): if "tags" in options: params["tags"] = ",".join(utils.build_array(options["tags"])) if "face_coordinates" in options: - params["face_coordinates"] = utils.encode_double_array( - options.get("face_coordinates")) + params["face_coordinates"] = utils.encode_double_array(options.get("face_coordinates")) if "custom_coordinates" in options: - params["custom_coordinates"] = utils.encode_double_array( - options.get("custom_coordinates")) + params["custom_coordinates"] = utils.encode_double_array(options.get("custom_coordinates")) + if "regions" in options: + params["regions"] = utils.json_encode(options.get("regions")) if "context" in options: params["context"] = utils.encode_context(options.get("context")) + if "metadata" in options: + params["metadata"] = utils.encode_context(options.get("metadata")) if "auto_tagging" in options: params["auto_tagging"] = str(options.get("auto_tagging")) if "access_control" in options: params["access_control"] = utils.json_encode(utils.build_list_of_dicts(options.get("access_control"))) + if "asset_folder" in options: + params["asset_folder"] = options.get("asset_folder") + if "display_name" in options: + params["display_name"] = options.get("display_name") + if "unique_display_name" in options: + params["unique_display_name"] = options.get("unique_display_name") + if "clear_invalid" in options: + params["clear_invalid"] = options.get("clear_invalid") - return call_api("post", uri, params, **options) + return call_json_api("post", uri, params, **options) def delete_resources(public_ids, **options): + """ + Deletes resources (assets) given their public IDs. + + The resources must belong to the specified resource_type and type. + + See: https://cloudinary.com/documentation/admin_api#delete_resources + + :param public_ids: The public IDs of the resources to delete. + :type public_ids: list[str] + :param options: Additional options. + :keyword str resource_type: Defaults to "image". + :keyword str type: Defaults to "upload". + :keyword list transformations: The derived transformations to delete (if any). + :keyword bool keep_original: When True, keeps the original resource. + :keyword str next_cursor: A string returned when more results are available. + :keyword bool invalidate: When True, invalidates the assets on the CDN. + :return: The result of the command. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type] params = __delete_resource_params(options, public_ids=public_ids) - return call_api("delete", uri, params, **options) + return call_json_api("delete", uri, params, **options) + + +def delete_resources_by_asset_ids(asset_ids, **options): + """ + Deletes resources (assets) by asset IDs. + + See: https://cloudinary.com/documentation/admin_api#delete_resources_by_asset_id + + :param asset_ids: The asset IDs of the assets to delete. + :type asset_ids: list[str] + :param options: Additional options. + :keyword list transformations: The derived transformations to delete (if any). + :keyword bool keep_original: When True, keeps the original resource. + :keyword str next_cursor: A string returned when more results are available. + :keyword bool invalidate: When True, invalidates the assets on the CDN. + :return: The result of the command. + :rtype: dict + """ + uri = ["resources"] + params = __delete_resource_params(options, asset_ids=asset_ids) + return call_json_api("delete", uri, params, **options) def delete_resources_by_prefix(prefix, **options): + """ + Deletes resources (assets) that have a specified prefix for their Public IDs. + + See: https://cloudinary.com/documentation/admin_api#delete_resources + + :param prefix: The prefix of the Public IDs to delete. + :type prefix: str + :param options: Additional options. + :keyword str resource_type: Defaults to "image". + :keyword str type: Defaults to "upload". + :keyword list transformations: The derived transformations to delete (if any). + :keyword bool keep_original: When True, keeps the original resource. + :keyword str next_cursor: A string returned when more results are available. + :keyword bool invalidate: When True, invalidates the assets on the CDN. + :return: The result of the command. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type] params = __delete_resource_params(options, prefix=prefix) - return call_api("delete", uri, params, **options) + return call_json_api("delete", uri, params, **options) def delete_all_resources(**options): + """ + Deletes **all** resources (assets) of a specified resource_type and type. + + Use with caution: This removes all matching resources from your account. + + See: https://cloudinary.com/documentation/admin_api#delete_resources + + :param options: Additional options. + :keyword str resource_type: Defaults to "image". + :keyword str type: Defaults to "upload". + :keyword list transformations: The derived transformations to delete (if any). + :keyword bool keep_original: When True, keeps the original resource. + :keyword str next_cursor: A string returned when more results are available. + :keyword bool invalidate: When True, invalidates the assets on the CDN. + :keyword bool all: (Added internally) If True, indicates all resources are to be deleted. + :return: The result of the command. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type] params = __delete_resource_params(options, all=True) - return call_api("delete", uri, params, **options) + return call_json_api("delete", uri, params, **options) def delete_resources_by_tag(tag, **options): + """ + Deletes resources (assets) that contain a specified tag. + + See: https://cloudinary.com/documentation/admin_api#delete_resources_by_tags + + :param tag: The tag whose associated resources should be deleted. + :type tag: str + :param options: Additional options. + :keyword str resource_type: Defaults to "image". + :keyword list transformations: The derived transformations to delete (if any). + :keyword bool keep_original: When True, keeps the original resource. + :keyword str next_cursor: A string returned when more results are available. + :keyword bool invalidate: When True, invalidates the assets on the CDN. + :return: The result of the command. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "tags", tag] params = __delete_resource_params(options) - return call_api("delete", uri, params, **options) + return call_json_api("delete", uri, params, **options) def delete_derived_resources(derived_resource_ids, **options): + """ + Deletes derived resources by their derived resource IDs. + + See: https://cloudinary.com/documentation/admin_api#delete_derived_resources + + :param derived_resource_ids: A list of derived resource IDs. + :type derived_resource_ids: list[str] + :param options: Additional optional parameters (none currently recognized). + :return: The result of the command. + :rtype: Response + """ uri = ["derived_resources"] params = {"derived_resource_ids": derived_resource_ids} - return call_api("delete", uri, params, **options) + return call_json_api("delete", uri, params, **options) def delete_derived_by_transformation(public_ids, transformations, resource_type='image', type='upload', invalidate=None, **options): - """Delete derived resources of public ids, identified by transformations + """ + Deletes derived resources of public IDs, identified by transformations. - :param public_ids: the base resources - :type public_ids: list of str - :param transformations: the transformation of derived resources, optionally including the format - :type transformations: list of (dict or str) - :param type: The upload type - :type type: str - :param resource_type: The type of the resource: defaults to "image" + See: https://cloudinary.com/documentation/admin_api#delete_derived_resources + + :param public_ids: The base resources (list of public IDs). + :type public_ids: list[str] + :param transformations: The transformations of derived resources, optionally including the format. + :type transformations: list[dict or str] + :param resource_type: The type of the resource. Defaults to "image". :type resource_type: str - :param invalidate: (optional) True to invalidate the resources after deletion - :type invalidate: bool - :return: a list of the public ids for which derived resources were deleted + :param type: The upload type. Defaults to "upload". + :type type: str + :param invalidate: (optional) True to invalidate the resources after deletion. + :type invalidate: bool, optional + :param options: Additional optional parameters (none currently recognized). + :return: The result of the command, including the public IDs for which derived resources were deleted. :rtype: dict """ uri = ["resources", resource_type, type] if not isinstance(public_ids, list): public_ids = [public_ids] - params = {"public_ids": public_ids, - "transformations": utils.build_eager(transformations), - "keep_original": True} + params = { + "public_ids": public_ids, + "transformations": utils.build_eager(transformations), + "keep_original": True + } if invalidate is not None: params['invalidate'] = invalidate - return call_api("delete", uri, params, **options) + return call_json_api("delete", uri, params, **options) + + +def delete_backed_up_assets(asset_id, version_ids, **options): + """ + Deletes backed up versions of a resource by asset IDs. + + See: https://cloudinary.com/documentation/admin_api#delete_backed_up_versions_of_a_resource + + :param asset_id: The asset ID of the asset to update. + :type asset_id: str + :param version_ids: The array of version IDs. + :type version_ids: list[str] + :param options: Additional optional parameters (none currently recognized). + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "backup", asset_id] + params = {"version_ids": utils.build_array(version_ids)} + return call_json_api("delete", uri, params, **options) + + +def add_related_assets(public_id, assets_to_relate, resource_type="image", type="upload", **options): + """ + Relates an asset to other assets by public IDs. + + See: https://cloudinary.com/documentation/admin_api#add_related_assets + + :param public_id: The public ID of the asset to update. + :type public_id: str + :param assets_to_relate: Array of up to 10 fully_qualified_public_ids as resource_type/type/public_id. + :type assets_to_relate: list[str] + :param resource_type: The type of the resource. Defaults to "image". + :type resource_type: str + :param type: The upload type. Defaults to "upload". + :type type: str + :param options: Additional optional parameters (none currently recognized). + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "related_assets", resource_type, type, public_id] + params = {"assets_to_relate": utils.build_array(assets_to_relate)} + return call_json_api("post", uri, params, **options) + + +def add_related_assets_by_asset_ids(asset_id, assets_to_relate, **options): + """ + Relates an asset to other assets by asset IDs. + + See: https://cloudinary.com/documentation/admin_api#add_related_assets_by_asset_id + + :param asset_id: The asset ID of the asset to update. + :type asset_id: str + :param assets_to_relate: The array of up to 10 asset IDs. + :type assets_to_relate: list[str] + :param options: Additional optional parameters (none currently recognized). + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "related_assets", asset_id] + params = {"assets_to_relate": utils.build_array(assets_to_relate)} + return call_json_api("post", uri, params, **options) + + +def delete_related_assets(public_id, assets_to_unrelate, resource_type="image", type="upload", **options): + """ + Unrelates an asset from other assets by public IDs. + + See: https://cloudinary.com/documentation/admin_api#delete_related_assets + + :param public_id: The public ID of the asset to update. + :type public_id: str + :param assets_to_unrelate: Array of up to 10 fully_qualified_public_ids as resource_type/type/public_id. + :type assets_to_unrelate: list[str] + :param resource_type: The type of the resource. Defaults to "image". + :type resource_type: str + :param type: The upload type. Defaults to "upload". + :type type: str + :param options: Additional optional parameters (none currently recognized). + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "related_assets", resource_type, type, public_id] + params = {"assets_to_unrelate": utils.build_array(assets_to_unrelate)} + return call_json_api("delete", uri, params, **options) + + +def delete_related_assets_by_asset_ids(asset_id, assets_to_unrelate, **options): + """ + Unrelates an asset from other assets by asset IDs. + + See: https://cloudinary.com/documentation/admin_api#delete_related_assets_by_asset_id + + :param asset_id: The asset ID of the asset to update. + :type asset_id: str + :param assets_to_unrelate: The array of up to 10 asset IDs. + :type assets_to_unrelate: list[str] + :param options: Additional optional parameters (none currently recognized). + :return: The result of the command. + :rtype: dict + """ + uri = ["resources", "related_assets", asset_id] + params = {"assets_to_unrelate": utils.build_array(assets_to_unrelate)} + return call_json_api("delete", uri, params, **options) def tags(**options): + """ + Lists all the tags currently used for a specified asset type. + + See: https://cloudinary.com/documentation/admin_api#get_tags + + :param options: The optional parameters. + :keyword str resource_type: Defaults to "image". + :keyword str prefix: Return only tags that begin with the specified prefix. + :keyword int max_results: Maximum number of tags to return. + :keyword str next_cursor: A string returned when more results are available. + :return: The result of the API call. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") uri = ["tags", resource_type] - return call_api("get", uri, only(options, "next_cursor", "max_results", "prefix"), **options) + return call_json_api("get", uri, only(options, "next_cursor", "max_results", "prefix"), **options) def transformations(**options): + """ + Lists all transformations. + + See: https://cloudinary.com/documentation/admin_api#get_transformations + + :param options: The optional parameters. + :keyword bool named: When True, return only named transformations. + :keyword str next_cursor: A string returned when more results are available. + :keyword int max_results: Maximum number of transformations to return. + :return: The list of transformations. + :rtype: Response + """ uri = ["transformations"] params = only(options, "named", "next_cursor", "max_results") - - return call_api("get", uri, params, **options) + return call_json_api("get", uri, params, **options) def transformation(transformation, **options): - uri = ["transformations"] + """ + Returns the details of a single transformation. + + See: https://cloudinary.com/documentation/admin_api#get_transformation_details + :param transformation: The transformation to retrieve (string or dict). + :type transformation: str or dict + :param options: The optional parameters. + :keyword str next_cursor: A string returned when more results are available. + :keyword int max_results: Maximum number of derived assets to return. + :return: The transformation details. + :rtype: Response + """ + uri = ["transformations"] params = only(options, "next_cursor", "max_results") params["transformation"] = utils.build_single_eager(transformation) - - return call_api("get", uri, params, **options) + return call_json_api("get", uri, params, **options) def delete_transformation(transformation, **options): - uri = ["transformations"] + """ + Deletes a transformation. - params = {"transformation": utils.build_single_eager(transformation)} + See: https://cloudinary.com/documentation/admin_api#delete_transformation - return call_api("delete", uri, params, **options) + :param transformation: The transformation to delete (string or dict). + :type transformation: str or dict + :param options: Additional options (none currently recognized). + :return: The result of the API call. + :rtype: Response + """ + uri = ["transformations"] + params = {"transformation": utils.build_single_eager(transformation)} + return call_json_api("delete", uri, params, **options) -# updates - currently only supported update is the "allowed_for_strict" -# boolean flag and unsafe_update def update_transformation(transformation, **options): - uri = ["transformations"] + """ + Updates a transformation. - updates = only(options, "allowed_for_strict") + Currently, the only supported update is setting the "allowed_for_strict" flag + and the "unsafe_update" transformation. + See: https://cloudinary.com/documentation/admin_api#update_transformation + + :param transformation: The transformation to update (string or dict). + :type transformation: str or dict + :param options: Additional update options. + :keyword bool allowed_for_strict: Whether the transformation is allowed in strict mode. + :keyword dict or str unsafe_update: The transformation to associate under unsafe_update. + :return: The result of the API call. + :rtype: Response + """ + uri = ["transformations"] + updates = only(options, "allowed_for_strict") if "unsafe_update" in options: updates["unsafe_update"] = transformation_string(options.get("unsafe_update")) - updates["transformation"] = utils.build_single_eager(transformation) - - return call_api("put", uri, updates, **options) + return call_json_api("put", uri, updates, **options) def create_transformation(name, definition, **options): - uri = ["transformations"] + """ + Creates a named transformation based on an existing transformation. - params = {"name": name, "transformation": utils.build_single_eager(definition)} + See: https://cloudinary.com/documentation/admin_api#create_a_named_transformation - return call_api("post", uri, params, **options) + :param name: The name of the transformation to create. + :type name: str + :param definition: The transformation definition (string or dict). + :type definition: str or dict + :param options: Additional options (none currently recognized). + :return: The result of the API call. + :rtype: Response + """ + uri = ["transformations"] + params = {"name": name, "transformation": utils.build_single_eager(definition)} + return call_json_api("post", uri, params, **options) def publish_by_ids(public_ids, **options): + """ + Publishes specific assets by their public IDs. + + :param public_ids: The list of public IDs to publish. + :type public_ids: list[str] + :param options: Additional options. + :keyword str resource_type: The resource type (e.g. "image"). + :keyword str type: The asset type (e.g. "upload"). + :keyword bool overwrite: Whether to overwrite existing published assets. + :keyword bool invalidate: Whether to invalidate the CDN. + :return: The result of the publish operation. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "publish_resources"] params = dict(only(options, "type", "overwrite", "invalidate"), public_ids=public_ids) - return call_api("post", uri, params, **options) + return call_json_api("post", uri, params, **options) def publish_by_prefix(prefix, **options): + """ + Publishes assets that have a specified prefix for their public IDs. + + :param prefix: The prefix of the public IDs to publish. + :type prefix: str + :param options: Additional options. + :keyword str resource_type: The resource type (e.g. "image"). + :keyword str type: The asset type (e.g. "upload"). + :keyword bool overwrite: Whether to overwrite existing published assets. + :keyword bool invalidate: Whether to invalidate the CDN. + :return: The result of the publish operation. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "publish_resources"] params = dict(only(options, "type", "overwrite", "invalidate"), prefix=prefix) - return call_api("post", uri, params, **options) + return call_json_api("post", uri, params, **options) def publish_by_tag(tag, **options): + """ + Publishes assets that contain a specified tag. + + :param tag: The tag whose associated resources should be published. + :type tag: str + :param options: Additional options. + :keyword str resource_type: The resource type (e.g. "image"). + :keyword str type: The asset type (e.g. "upload"). + :keyword bool overwrite: Whether to overwrite existing published assets. + :keyword bool invalidate: Whether to invalidate the CDN. + :return: The result of the publish operation. + :rtype: Response + """ resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "publish_resources"] params = dict(only(options, "type", "overwrite", "invalidate"), tag=tag) - return call_api("post", uri, params, **options) + return call_json_api("post", uri, params, **options) def upload_presets(**options): + """ + Lists all upload presets. + + See: https://cloudinary.com/documentation/admin_api#get_upload_presets + + :param options: Additional options. + :keyword str next_cursor: A string returned when more results are available. + :keyword int max_results: Maximum number of presets to return. + :return: A list of upload presets. + :rtype: Response + """ uri = ["upload_presets"] - return call_api("get", uri, only(options, "next_cursor", "max_results"), **options) + return call_json_api("get", uri, only(options, "next_cursor", "max_results"), **options) def upload_preset(name, **options): + """ + Retrieves the details of a single upload preset. + + See: https://cloudinary.com/documentation/admin_api#get_the_details_of_a_single_upload_preset + + :param name: The name of the upload preset. + :type name: str + :param options: Additional options. + :keyword int max_results: Maximum number of details to return (if relevant). + :return: The upload preset details. + :rtype: Response + """ uri = ["upload_presets", name] - return call_api("get", uri, only(options, "max_results"), **options) + return call_json_api("get", uri, only(options, "max_results"), **options) def delete_upload_preset(name, **options): + """ + Deletes an upload preset by name. + + See: https://cloudinary.com/documentation/admin_api#delete_an_upload_preset + + :param name: The name of the upload preset to delete. + :type name: str + :param options: Additional options (none currently recognized). + :return: The result of the deletion. + :rtype: Response + """ uri = ["upload_presets", name] - return call_api("delete", uri, {}, **options) + return call_json_api("delete", uri, {}, **options) def update_upload_preset(name, **options): + """ + Updates an existing upload preset. + + See: https://cloudinary.com/documentation/admin_api#update_an_upload_preset + + :param name: The name of the upload preset to update. + :type name: str + :param options: The parameters to update for the preset (e.g., folder, tags). + :keyword bool unsigned: Whether this preset is unsigned (public). + :keyword bool disallow_public_id: When True, the public ID cannot be overridden during upload. + :keyword bool live: Whether this preset is for live (video) usage. + :return: The updated upload preset. + :rtype: Response + """ uri = ["upload_presets", name] params = utils.build_upload_params(**options) params = utils.cleanup_params(params) params.update(only(options, "unsigned", "disallow_public_id", "live")) - return call_api("put", uri, params, **options) + return call_json_api("put", uri, params, **options) def create_upload_preset(**options): + """ + Creates a new upload preset. + + See: https://cloudinary.com/documentation/admin_api#create_an_upload_preset + + :param options: The parameters for the new preset (e.g., folder, tags). + :keyword bool unsigned: Whether this preset is unsigned (public). + :keyword bool disallow_public_id: When True, the public ID cannot be overridden during upload. + :keyword str name: The name of the new upload preset. + :keyword bool live: Whether this preset is for live (video) usage. + :return: The created upload preset. + :rtype: Response + """ uri = ["upload_presets"] params = utils.build_upload_params(**options) params = utils.cleanup_params(params) params.update(only(options, "unsigned", "disallow_public_id", "name", "live")) - return call_api("post", uri, params, **options) + return call_json_api("post", uri, params, **options) -def create_folder(path, **options): - return call_api("post", ["folders", path], {}, **options) +def root_folders(**options): + """ + Lists the top-level folders in your Cloudinary account. + See: https://cloudinary.com/documentation/admin_api#get_root_folders -def root_folders(**options): - return call_api("get", ["folders"], only(options, "next_cursor", "max_results"), **options) + :param options: Additional options. + :keyword str next_cursor: A string returned when more results are available. + :keyword int max_results: Maximum number of folders to return. + :return: The list of top-level folders. + :rtype: Response + """ + return call_json_api("get", ["folders"], only(options, "next_cursor", "max_results"), **options) def subfolders(of_folder_path, **options): - return call_api("get", ["folders", of_folder_path], only(options, "next_cursor", "max_results"), **options) + """ + Lists the subfolders of a given folder path. + + See: https://cloudinary.com/documentation/admin_api#get_subfolders + + :param of_folder_path: The path of the parent folder. + :type of_folder_path: str + :param options: Additional options. + :keyword str next_cursor: A string returned when more results are available. + :keyword int max_results: Maximum number of folders to return. + :return: The list of subfolders. + :rtype: Response + """ + return call_json_api("get", ["folders", of_folder_path], only(options, "next_cursor", "max_results"), **options) + + +def create_folder(path, **options): + """ + Creates a folder at the specified path. + + See: https://cloudinary.com/documentation/admin_api#create_folder + + :param path: The path for the new folder. + :type path: str + :param options: Additional options (none currently recognized). + :return: The result of the folder creation. + :rtype: Response + """ + return call_json_api("post", ["folders", path], {}, **options) + + +def rename_folder(from_path, to_path, **options): + """ + Renames a folder. + + See: https://cloudinary.com/documentation/admin_api#update_folder + + :param from_path: The full path of an existing asset folder. + :type from_path: str + :param to_path: The full path of the new asset folder. + :type to_path: str + :param options: Additional options (none currently recognized). + :return: A response indicating the success or failure of the rename operation. + :rtype: Response + """ + params = {"to_folder": to_path} + return call_json_api("put", ["folders", from_path], params, **options) def delete_folder(path, **options): - """Deletes folder + """ + Deletes a folder. - Deleted folder must be empty, but can have descendant empty sub folders + The folder must be empty, but can have descendant empty subfolders. - :param path: The folder to delete - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#delete_folder + :param path: The folder to delete. + :type path: str + :param options: Additional options. + :keyword bool skip_backup: Whether to skip backing up the folder before deletion. + :return: A response indicating the success or failure of the delete operation. :rtype: Response """ - return call_api("delete", ["folders", path], {}, **options) + params = only(options, "skip_backup") + return call_json_api("delete", ["folders", path], params, **options) def restore(public_ids, **options): + """ + Restores deleted resources (assets) by public IDs, if backups are available. + + See: https://cloudinary.com/documentation/admin_api#restore_resources + + :param public_ids: The list of public IDs to restore. + :type public_ids: list[str] + :param options: Additional options, e.g., "versions". + :keyword list[str] versions: A list of specific version IDs to restore. + :keyword str resource_type: Defaults to "image". + :keyword str type: Defaults to "upload". + :return: The result of the restore operation. + :rtype: dict + """ resource_type = options.pop("resource_type", "image") upload_type = options.pop("type", "upload") uri = ["resources", resource_type, upload_type, "restore"] - params = dict(public_ids=public_ids) - return call_api("post", uri, params, **options) + params = dict(public_ids=public_ids, **only(options, "versions")) + return call_json_api("post", uri, params, **options) + + +def restore_by_asset_ids(asset_ids, **options): + """ + Restores deleted resources (assets) by their asset IDs, if backups are available. + + See: https://cloudinary.com/documentation/admin_api#restore_resources_by_asset_id + + :param asset_ids: The asset IDs of the assets to restore. + :type asset_ids: list[str] + :param options: Additional options (e.g., versions). + :keyword list[str] versions: A list of specific version IDs to restore. + :return: The result of the restore operation. + :rtype: dict + """ + uri = ["resources", "restore"] + params = dict(asset_ids=asset_ids, **only(options, "versions")) + return call_json_api("post", uri, params, **options) def upload_mappings(**options): + """ + Lists all upload mappings in your account. + + See: https://cloudinary.com/documentation/admin_api#get_upload_mappings + + :param options: Additional options. + :keyword str next_cursor: A string returned when more results are available. + :keyword int max_results: Maximum number of mappings to return. + :return: A list of upload mappings. + :rtype: Response + """ uri = ["upload_mappings"] - return call_api("get", uri, only(options, "next_cursor", "max_results"), **options) + return call_json_api("get", uri, only(options, "next_cursor", "max_results"), **options) def upload_mapping(name, **options): + """ + Retrieves a single upload mapping by folder name. + + See: https://cloudinary.com/documentation/admin_api#get_the_details_of_a_single_upload_mapping + + :param name: The folder name. + :type name: str + :param options: Additional options (none currently recognized). + :return: Details of the specified upload mapping. + :rtype: Response + """ uri = ["upload_mappings"] params = dict(folder=name) - return call_api("get", uri, params, **options) + return call_json_api("get", uri, params, **options) def delete_upload_mapping(name, **options): + """ + Deletes an upload mapping by folder name. + + See: https://cloudinary.com/documentation/admin_api#delete_an_upload_mapping + + :param name: The folder name. + :type name: str + :param options: Additional options (none currently recognized). + :return: The result of the deletion. + :rtype: Response + """ uri = ["upload_mappings"] params = dict(folder=name) - return call_api("delete", uri, params, **options) + return call_json_api("delete", uri, params, **options) def update_upload_mapping(name, **options): + """ + Updates an upload mapping by folder name. + + See: https://cloudinary.com/documentation/admin_api#update_an_upload_mapping + + :param name: The folder name. + :type name: str + :param options: Additional parameters to update. + :keyword str template: A URL template for the given folder name. + :return: The result of the update operation. + :rtype: Response + """ uri = ["upload_mappings"] params = dict(folder=name) params.update(only(options, "template")) - return call_api("put", uri, params, **options) + return call_json_api("put", uri, params, **options) def create_upload_mapping(name, **options): + """ + Creates a new upload mapping. + + See: https://cloudinary.com/documentation/admin_api#create_an_upload_mapping + + :param name: The folder name. + :type name: str + :param options: Additional parameters. + :keyword str template: A URL template for the given folder name. + :return: The result of the creation. + :rtype: Response + """ uri = ["upload_mappings"] params = dict(folder=name) params.update(only(options, "template")) - return call_api("post", uri, params, **options) + return call_json_api("post", uri, params, **options) def list_streaming_profiles(**options): + """ + Lists all custom and built-in streaming profiles. + + See: https://cloudinary.com/documentation/admin_api#get_adaptive_streaming_profiles + + :param options: Additional optional parameters (none currently recognized). + :return: The list of streaming profiles. + :rtype: Response + """ uri = ["streaming_profiles"] - return call_api('GET', uri, {}, **options) + return call_json_api('GET', uri, {}, **options) def get_streaming_profile(name, **options): + """ + Retrieves details of a specific streaming profile by name. + + See: https://cloudinary.com/documentation/admin_api#get_details_of_a_single_streaming_profile + + :param name: The name of the streaming profile. + :type name: str + :param options: Additional optional parameters (none currently recognized). + :return: The details of the streaming profile. + :rtype: Response + """ uri = ["streaming_profiles", name] - return call_api('GET', uri, {}, **options) + return call_json_api('GET', uri, {}, **options) def delete_streaming_profile(name, **options): + """ + Deletes a specific streaming profile by name (or reverts a built-in). + + See: https://cloudinary.com/documentation/admin_api#delete_or_revert_the_specified_streaming_profile + + :param name: The name of the streaming profile to delete. + :type name: str + :param options: Additional optional parameters (none currently recognized). + :return: The result of the deletion. + :rtype: Response + """ uri = ["streaming_profiles", name] - return call_api('DELETE', uri, {}, **options) + return call_json_api('DELETE', uri, {}, **options) def create_streaming_profile(name, **options): + """ + Creates a new custom streaming profile. + + See: https://cloudinary.com/documentation/admin_api#create_a_streaming_profile + + :param name: The name for the new streaming profile. + :type name: str + :param options: Additional options. + :keyword str display_name: A display name for the streaming profile. + :keyword list representations: A list of transformations (dict or str). + :return: The created streaming profile. + :rtype: Response + """ uri = ["streaming_profiles"] params = __prepare_streaming_profile_params(**options) params["name"] = name - return call_api('POST', uri, params, **options) + return call_json_api('POST', uri, params, **options) def update_streaming_profile(name, **options): + """ + Updates an existing streaming profile. + + See: https://cloudinary.com/documentation/admin_api#update_an_existing_streaming_profile + + :param name: The name of the streaming profile to update. + :type name: str + :param options: Additional options. + :keyword str display_name: A display name for the streaming profile. + :keyword list representations: A list of transformations (dict or str). + :return: The updated streaming profile. + :rtype: Response + """ uri = ["streaming_profiles", name] params = __prepare_streaming_profile_params(**options) - return call_api('PUT', uri, params, **options) + return call_json_api('PUT', uri, params, **options) def only(source, *keys): + """ + Returns a dictionary containing only the specified keys from the source. + + :param source: The source dictionary. + :type source: dict + :param keys: The keys to retain. + :type keys: list or tuple of str or str + :return: A new dictionary with only the specified keys. + :rtype: dict + :internal + """ return {key: source[key] for key in keys if key in source} def transformation_string(transformation): + """ + Converts a transformation (dict or str) into the correct string format. + + :param transformation: The transformation to convert. + :type transformation: dict or str + :return: The transformation as a string. + :rtype: str + :internal + """ if isinstance(transformation, string_types): return transformation else: @@ -409,6 +1355,14 @@ def transformation_string(transformation): def __prepare_streaming_profile_params(**options): + """ + Prepares the parameters for creating or updating a streaming profile. + + :param options: Additional options, typically including "representations" and "display_name". + :return: A dictionary of parameters for the streaming profile API call. + :rtype: dict + :internal + """ params = only(options, "display_name") if "representations" in options: representations = [{"transformation": transformation_string(trans)} @@ -418,6 +1372,17 @@ def __prepare_streaming_profile_params(**options): def __delete_resource_params(options, **params): + """ + Prepares parameters for delete resource methods, including transformations, keep_original, + next_cursor, invalidate, etc. + + :param options: A dict of delete-related options. + :param params: Additional parameters (e.g., prefix, public_ids, asset_ids). + :type params: dict + :return: A combined dictionary of params. + :rtype: dict + :internal + """ p = dict(transformations=utils.build_eager(options.get('transformations')), **only(options, "keep_original", "next_cursor", "invalidate")) p.update(params) @@ -425,26 +1390,28 @@ def __delete_resource_params(options, **params): def list_metadata_fields(**options): - """Returns a list of all metadata field definitions - - See: `Get metadata fields API reference `_ + """ + Returns a list of all metadata field definitions. - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#get_metadata_fields + :param options: Additional optional parameters (none currently recognized). + :return: A list of metadata fields. :rtype: Response """ return call_metadata_api("get", [], {}, **options) def metadata_field_by_field_id(field_external_id, **options): - """Gets a metadata field by external id - - See: `Get metadata field by external ID API reference - `_ + """ + Gets a metadata field by external id. - :param field_external_id: The ID of the metadata field to retrieve - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#get_a_metadata_field_by_external_id + :param field_external_id: The ID of the metadata field to retrieve. + :type field_external_id: str + :param options: Additional optional parameters (none currently recognized). + :return: The metadata field details. :rtype: Response """ uri = [field_external_id] @@ -452,50 +1419,61 @@ def metadata_field_by_field_id(field_external_id, **options): def add_metadata_field(field, **options): - """Creates a new metadata field definition - - See: `Create metadata field API reference `_ + """ + Creates a new metadata field definition. - :param field: The field to add - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#create_a_metadata_field + :param field: The field to add. + :type field: dict + :param options: Additional optional parameters (none currently recognized). + :return: The created metadata field. :rtype: Response """ - params = only(field, "type", "external_id", "label", "mandatory", - "default_value", "validation", "datasource") - return call_metadata_api("post", [], params, **options) + return call_metadata_api("post", [], __metadata_field_params(field), **options) def update_metadata_field(field_external_id, field, **options): - """Updates a metadata field by external id - - Updates a metadata field definition (partially, no need to pass the entire - object) passed as JSON data. - - See `Generic structure of a metadata field - `_ for details. + """ + Updates a metadata field by external id. - :param field_external_id: The id of the metadata field to update - :param field: The field definition - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#update_a_metadata_field_by_external_id + :param field_external_id: The ID of the metadata field to update. + :type field_external_id: str + :param field: The field definition to update. + :type field: dict + :param options: Additional optional parameters (none currently recognized). + :return: The updated metadata field. :rtype: Response """ uri = [field_external_id] - params = only(field, "label", "mandatory", "default_value", "validation") - return call_metadata_api("put", uri, params, **options) + return call_metadata_api("put", uri, __metadata_field_params(field), **options) -def delete_metadata_field(field_external_id, **options): - """Deletes a metadata field definition. - The field should no longer be considered a valid candidate for all other endpoints +def __metadata_field_params(field): + """ + Builds the parameters needed for creating or updating a metadata field. - See: `Delete metadata field API reference - `_ + :param field: The field definition. + :type field: dict + :return: The relevant key-value pairs. + :rtype: dict + :internal + """ + return only(field, "type", "external_id", "label", "mandatory", "restrictions", + "default_value", "default_disabled", "validation", "datasource", "allow_dynamic_list_values") - :param field_external_id: The external id of the field to delete - :param options: Additional options +def delete_metadata_field(field_external_id, **options): + """ + Deletes a metadata field definition. + + See: https://cloudinary.com/documentation/admin_api#delete_a_metadata_field_by_external_id + + :param field_external_id: The external ID of the field to delete. + :type field_external_id: str + :param options: Additional optional parameters (none currently recognized). :return: An array with a "message" key. "ok" value indicates a successful deletion. :rtype: Response """ @@ -504,21 +1482,17 @@ def delete_metadata_field(field_external_id, **options): def delete_datasource_entries(field_external_id, entries_external_id, **options): - """Deletes entries in a metadata field datasource - - Deletes (blocks) the datasource entries for a specified metadata field - definition. Sets the state of the entries to inactive. This is a soft delete, - the entries still exist under the hood and can be activated again with the - restore datasource entries method. - - See: `Delete entries in a metadata field datasource API reference - `_ + """ + Deletes entries in a metadata field datasource. - :param field_external_id: The id of the field to update - :param entries_external_id: The ids of all the entries to delete from the - datasource - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#delete_entries_in_a_metadata_field_datasource + :param field_external_id: The ID of the field to update. + :type field_external_id: str + :param entries_external_id: The IDs of the entries to delete from the datasource. + :type entries_external_id: list[str] + :param options: Additional optional parameters (none currently recognized). + :return: A response indicating the success or failure of the deletion. :rtype: Response """ uri = [field_external_id, "datasource"] @@ -527,20 +1501,17 @@ def delete_datasource_entries(field_external_id, entries_external_id, **options) def update_metadata_field_datasource(field_external_id, entries_external_id, **options): - """Updates a metadata field datasource - - Updates the datasource of a supported field type (currently only enum and set), - passed as JSON data. The update is partial: datasource entries with an - existing external_id will be updated and entries with new external_id's (or - without external_id's) will be appended. + """ + Updates a metadata field datasource. - See: `Update a metadata field datasource API reference - `_ - - :param field_external_id: The external id of the field to update - :param entries_external_id: - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#update_a_metadata_field_datasource + :param field_external_id: The external ID of the field to update. + :type field_external_id: str + :param entries_external_id: The list of entries to update/add. + :type entries_external_id: list[dict] + :param options: Additional optional parameters (none currently recognized). + :return: The updated metadata field. :rtype: Response """ values = [] @@ -555,22 +1526,152 @@ def update_metadata_field_datasource(field_external_id, entries_external_id, **o def restore_metadata_field_datasource(field_external_id, entries_external_ids, **options): - """Restores entries in a metadata field datasource - - Restores (unblocks) any previously deleted datasource entries for a specified - metadata field definition. - Sets the state of the entries to active. - - See: `Restore entries in a metadata field datasource API reference - `_ + """ + Restores entries in a metadata field datasource. - :param field_external_id: The ID of the metadata field - :param entries_external_ids: An array of IDs of datasource entries to restore - (unblock) - :param options: Additional options + See: https://cloudinary.com/documentation/admin_api#restore_entries_in_a_metadata_field_datasource + :param field_external_id: The ID of the metadata field. + :type field_external_id: str + :param entries_external_ids: An array of IDs of datasource entries to restore (unblock). + :type entries_external_ids: list[str] + :param options: Additional optional parameters (none currently recognized). + :return: A response indicating the success or failure of the restore operation. :rtype: Response """ uri = [field_external_id, 'datasource_restore'] params = {"external_ids": entries_external_ids} return call_metadata_api("post", uri, params, **options) + + +def reorder_metadata_field_datasource(field_external_id, order_by, direction=None, **options): + """ + Reorders a metadata field datasource. Currently supports ordering by 'value'. + + See: https://cloudinary.com/documentation/admin_api#order_a_metadata_field_datasource + + :param field_external_id: The ID of the metadata field. + :type field_external_id: str + :param order_by: Criteria for the order. Currently, supports only 'value'. + :type order_by: str + :param direction: Optional direction: 'asc' or 'desc'. + :type direction: str, optional + :param options: Additional optional parameters (none currently recognized). + :return: The result of the ordering operation. + :rtype: Response + """ + uri = [field_external_id, 'datasource', 'order'] + params = {'order_by': order_by, 'direction': direction} + return call_metadata_api('post', uri, params, **options) + + +def reorder_metadata_fields(order_by, direction=None, **options): + """ + Reorders metadata fields. + + :param order_by: Criteria for the order (one of 'label', 'external_id', 'created_at'). + :type order_by: str + :param direction: Optional direction: 'asc' or 'desc'. + :type direction: str, optional + :param options: Additional optional parameters (none currently recognized). + :return: The result of the ordering operation. + :rtype: Response + """ + uri = ['order'] + params = {'order_by': order_by, 'direction': direction} + return call_metadata_api('put', uri, params, **options) + + +def list_metadata_rules(**options): + """ + Returns a list of all metadata rules definitions. + + See: https://cloudinary.com/documentation/admin_api#get_metadata_rules + + :param options: Additional optional parameters (none currently recognized). + :return: A list of metadata rules. + :rtype: Response + """ + return call_metadata_rules_api("get", [], {}, **options) + +def __metadata_rule_params(rule): + """ + Builds the parameters needed for creating or updating a metadata rule. + + :param rule: The rule definition. + :type rule: dict + :return: The relevant key-value pairs. + :rtype: dict + :internal + """ + return only(rule, "external_id", "metadata_field_id", "condition", "result", "name", "state") + + +def add_metadata_rule(rule, **options): + """ + Creates a new metadata rule definition. + + See: https://cloudinary.com/documentation/admin_api#create_a_metadata_rule + + :param rule: The rule to add. + :type rule: dict + :param options: Additional optional parameters (none currently recognized). + :return: The created metadata rule. + :rtype: Response + """ + return call_metadata_rules_api("post", [], __metadata_rule_params(rule), **options) + +def update_metadata_rule(rule_external_id, rule, **options): + """ + Updates a metadata rule by external id. + + See: https://cloudinary.com/documentation/admin_api#update_a_metadata_rule_by_id + + :param rule_external_id: The ID of the metadata rule to update. + :type rule_external_id: str + :param rule: The rule definition to update. + :type rule: dict + :param options: Additional optional parameters (none currently recognized). + :return: The updated metadata rule. + :rtype: Response + """ + uri = [rule_external_id] + return call_metadata_rules_api("put", uri, __metadata_rule_params(rule), **options) + +def delete_metadata_rule(rule_external_id, **options): + """ + Deletes a metadata rule definition. + + See: https://cloudinary.com/documentation/admin_api#delete_a_metadata_rule_by_id + + :param rule_external_id: The external ID of the rule to delete. + :type rule_external_id: str + :param options: Additional optional parameters (none currently recognized). + :return: An array with a "success" key. true value indicates a successful deletion. + :rtype: Response + """ + uri = [rule_external_id] + return call_metadata_rules_api("delete", uri, {}, **options) + + +def analyze(input_type, analysis_type, uri=None, **options): + """ + Analyzes an asset with the requested analysis type. + + :param input_type: The type of input for the asset to analyze (e.g. 'uri'). + :type input_type: str + :param analysis_type: The type of analysis to run (e.g. 'google_tagging', 'captioning', 'fashion'). + :type analysis_type: str + :param uri: The URI of the asset to analyze. + :type uri: str, optional + :param options: Additional optional parameters (none currently recognized). + :return: The analysis result. + :rtype: Response + """ + api_uri = ['analysis', 'analyze', input_type] + params = { + 'analysis_type': analysis_type, + 'uri': uri, + 'parameters': options.get("parameters") + } + return _call_v2_api('post', api_uri, params, **options) diff --git a/cloudinary/api_client/call_account_api.py b/cloudinary/api_client/call_account_api.py index c40aaf3b..5a6cf3ab 100644 --- a/cloudinary/api_client/call_account_api.py +++ b/cloudinary/api_client/call_account_api.py @@ -1,8 +1,7 @@ import cloudinary from cloudinary.api_client.execute_request import execute_request from cloudinary.provisioning.account_config import account_config -from cloudinary.utils import get_http_connector - +from cloudinary.utils import get_http_connector, normalize_params PROVISIONING_SUB_PATH = "provisioning" ACCOUNT_SUB_PATH = "accounts" @@ -28,7 +27,7 @@ def _call_account_api(method, uri, params=None, headers=None, **options): return execute_request(http_connector=_http, method=method, - params=params, + params=normalize_params(params), headers=headers, auth=auth, api_url=provisioning_api_url, diff --git a/cloudinary/api_client/call_api.py b/cloudinary/api_client/call_api.py index 66b105fc..0e93b33c 100644 --- a/cloudinary/api_client/call_api.py +++ b/cloudinary/api_client/call_api.py @@ -2,8 +2,7 @@ import cloudinary from cloudinary.api_client.execute_request import execute_request -from cloudinary.utils import get_http_connector - +from cloudinary.utils import get_http_connector, normalize_params logger = cloudinary.logger _http = get_http_connector(cloudinary.config(), cloudinary.CERT_KWARGS) @@ -22,39 +21,74 @@ def call_metadata_api(method, uri, params, **options): return call_json_api(method, uri, params, **options) -def call_json_api(method, uri, jsonBody, **options): - logger.debug(jsonBody) - data = json.dumps(jsonBody).encode('utf-8') - return _call_api(method, uri, body=data, - headers={'Content-Type': 'application/json'}, **options) +def call_metadata_rules_api(method, uri, params, **options): + """Private function that assists with performing an API call to the + metadata_rules part of the Admin API + :param method: The HTTP method. Valid methods: get, post, put, delete + :param uri: REST endpoint of the API (without 'metadata_rules') + :param params: Query/body parameters passed to the method + :param options: Additional options + :rtype: Response + """ + uri = ["metadata_rules"] + (uri or []) + return call_json_api(method, uri, params, **options) + + +def call_json_api(method, uri, params, **options): + data=None + if method.upper() != 'GET': + data = json.dumps(params).encode('utf-8') + params = None + + return _call_api(method, uri, params=params, body=data, headers={'Content-Type': 'application/json'}, **options) + + +def _call_v2_api(method, uri, params, **options): + return call_json_api(method, uri, params=params, api_version='v2', **options) def call_api(method, uri, params, **options): return _call_api(method, uri, params=params, **options) -def _call_api(method, uri, params=None, body=None, headers=None, **options): +def _call_api(method, uri, params=None, body=None, headers=None, extra_headers=None, **options): prefix = options.pop("upload_prefix", cloudinary.config().upload_prefix) or "https://api.cloudinary.com" cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name) if not cloud_name: raise Exception("Must supply cloud_name") + api_key = options.pop("api_key", cloudinary.config().api_key) - if not api_key: - raise Exception("Must supply api_key") api_secret = options.pop("api_secret", cloudinary.config().api_secret) - if not api_secret: - raise Exception("Must supply api_secret") - api_url = "/".join([prefix, cloudinary.API_VERSION, cloud_name] + uri) - auth = {"key": api_key, "secret": api_secret} + oauth_token = options.pop("oauth_token", cloudinary.config().oauth_token) + + _validate_authorization(api_key, api_secret, oauth_token) + auth = {"key": api_key, "secret": api_secret, "oauth_token": oauth_token} + + api_version = options.pop("api_version", cloudinary.API_VERSION) + api_url = "/".join([prefix, api_version, cloud_name] + uri) if body is not None: options["body"] = body + if extra_headers is not None: + headers.update(extra_headers) + return execute_request(http_connector=_http, method=method, - params=params, + params=normalize_params(params), headers=headers, auth=auth, api_url=api_url, **options) + + +def _validate_authorization(api_key, api_secret, oauth_token): + if oauth_token: + return + + if not api_key: + raise Exception("Must supply api_key") + + if not api_secret: + raise Exception("Must supply api_secret") diff --git a/cloudinary/api_client/execute_request.py b/cloudinary/api_client/execute_request.py index 645d80b2..84c155d8 100644 --- a/cloudinary/api_client/execute_request.py +++ b/cloudinary/api_client/execute_request.py @@ -15,7 +15,8 @@ RateLimited, GeneralError ) -from cloudinary.utils import process_params, safe_cast, smart_escape, unquote +from cloudinary.utils import process_params, safe_cast, smart_escape, unquote, normalize_params, urlencode, \ + bracketize_seq EXCEPTION_CODES = { 400: BadRequest, @@ -24,6 +25,7 @@ 404: NotFound, 409: AlreadyExists, 420: RateLimited, + 429: RateLimited, 500: GeneralError } @@ -42,30 +44,40 @@ def execute_request(http_connector, method, params, headers, auth, api_url, **op # authentication key = auth.get("key") secret = auth.get("secret") + oauth_token = auth.get("oauth_token") req_headers = urllib3.make_headers( - basic_auth="{0}:{1}".format(key, secret), user_agent=cloudinary.get_user_agent() ) + if oauth_token: + req_headers["authorization"] = "Bearer {}".format(oauth_token) + else: + req_headers.update(urllib3.make_headers(basic_auth="{0}:{1}".format(key, secret))) + if headers is not None: req_headers.update(headers) + api_url = smart_escape(unquote(api_url)) kw = {} if "timeout" in options: kw["timeout"] = options["timeout"] if "body" in options: kw["body"] = options["body"] - processed_params = process_params(params) - - api_url = smart_escape(unquote(api_url)) + if method.upper() == "GET": + query_string = urlencode(bracketize_seq(params), True) + if query_string: + api_url += "?" + query_string + processed_params = None + else: + processed_params = process_params(params) try: - response = http_connector.request(method.upper(), api_url, processed_params, req_headers, **kw) + response = http_connector.request(method=method.upper(), url=api_url, fields=processed_params, headers=req_headers, **kw) body = response.data except HTTPError as e: - raise GeneralError("Unexpected error {0}", e.message) + raise GeneralError("Unexpected error %s" % str(e)) except socket.error as e: - raise GeneralError("Socket Error: %s" % (str(e))) + raise GeneralError("Socket Error: %s" % str(e)) try: result = json.loads(body.decode('utf-8')) @@ -75,7 +87,6 @@ def execute_request(http_connector, method, params, headers, auth, api_url, **op if "error" in result: exception_class = EXCEPTION_CODES.get(response.status) or Exception - exception_class = exception_class raise exception_class("Error {0} - {1}".format(response.status, result["error"]["message"])) return Response(result, response) diff --git a/cloudinary/api_client/tcp_keep_alive_manager.py b/cloudinary/api_client/tcp_keep_alive_manager.py new file mode 100644 index 00000000..b2c7b75f --- /dev/null +++ b/cloudinary/api_client/tcp_keep_alive_manager.py @@ -0,0 +1,119 @@ +import socket +import sys + +from urllib3 import HTTPSConnectionPool, HTTPConnectionPool, PoolManager, ProxyManager + +# Inspired by: +# https://github.com/finbourne/lusid-sdk-python/blob/b813882e4f1777ea78670a03a7596486639e6f40/sdk/lusid/tcp/tcp_keep_alive_probes.py + +# The content to send on Mac OS in the TCP Keep Alive probe +TCP_KEEPALIVE = 0x10 +# The maximum time to keep the connection idle before sending probes +TCP_KEEP_IDLE = 60 +# The interval between probes +TCP_KEEPALIVE_INTERVAL = 60 +# The maximum number of failed probes before terminating the connection +TCP_KEEP_CNT = 3 + + +class TCPKeepAliveValidationMethods: + """ + This class contains a single method whose sole purpose is to set up TCP Keep Alive probes on the socket for a + connection. This is necessary for long-running requests which will be silently terminated by the AWS Network Load + Balancer which kills a connection if it is idle for more than 350 seconds. + """ + + @staticmethod + def adjust_connection_socket(conn, protocol="https"): + """ + Adjusts the socket settings so that the client sends a TCP keep alive probe over the connection. This is only + applied where possible, if the ability to set the socket options is not available, for example using Anaconda, + then the settings will be left as is. + :param conn: The connection to update the socket settings for + :param str protocol: The protocol of the connection + :return: None + """ + + if protocol == "http": + # It isn't clear how to set this up over HTTP, it seems to differ from HTTPs + return + + # TCP Keep Alive Probes for different platforms + platform = sys.platform + # TCP Keep Alive Probes for Linux + if (platform == 'linux' and hasattr(conn.sock, "setsockopt") and hasattr(socket, "SO_KEEPALIVE") and + hasattr(socket, "TCP_KEEPIDLE") and hasattr(socket, "TCP_KEEPINTVL") and hasattr(socket, + "TCP_KEEPCNT")): + conn.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, TCP_KEEP_IDLE) + conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, TCP_KEEPALIVE_INTERVAL) + conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, TCP_KEEP_CNT) + + # TCP Keep Alive Probes for Windows OS + elif platform == 'win32' and hasattr(socket, "SIO_KEEPALIVE_VALS") and getattr(conn.sock, "ioctl", + None) is not None: + conn.sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, TCP_KEEP_IDLE * 1000, TCP_KEEPALIVE_INTERVAL * 1000)) + + # TCP Keep Alive Probes for Mac OS + elif platform == 'darwin' and getattr(conn.sock, "setsockopt", None) is not None: + conn.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + conn.sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPALIVE, TCP_KEEPALIVE_INTERVAL) + + +class TCPKeepAliveHTTPSConnectionPool(HTTPSConnectionPool): + """ + This class overrides the _validate_conn method in the HTTPSConnectionPool class. This is the entry point to use + for modifying the socket as it is called after the socket is created and before the request is made. + """ + + def _validate_conn(self, conn): + """ + Called right before a request is made, after the socket is created. + """ + # Call the method on the base class + super(TCPKeepAliveHTTPSConnectionPool, self)._validate_conn(conn) + + # Set up TCP Keep Alive probes, this is the only line added to this function + TCPKeepAliveValidationMethods.adjust_connection_socket(conn, "https") + + +class TCPKeepAliveHTTPConnectionPool(HTTPConnectionPool): + """ + This class overrides the _validate_conn method in the HTTPSConnectionPool class. This is the entry point to use + for modifying the socket as it is called after the socket is created and before the request is made. + In the base class this method is passed completely. + """ + + def _validate_conn(self, conn): + """ + Called right before a request is made, after the socket is created. + """ + # Call the method on the base class + super(TCPKeepAliveHTTPConnectionPool, self)._validate_conn(conn) + + # Set up TCP Keep Alive probes, this is the only line added to this function + TCPKeepAliveValidationMethods.adjust_connection_socket(conn, "http") + + +class TCPKeepAlivePoolManager(PoolManager): + """ + This Pool Manager has only had the pool_classes_by_scheme variable changed. This now points at the TCPKeepAlive + connection pools rather than the default connection pools. + """ + + def __init__(self, num_pools=10, headers=None, **connection_pool_kw): + super(TCPKeepAlivePoolManager, self).__init__(num_pools=num_pools, headers=headers, **connection_pool_kw) + self.pool_classes_by_scheme = {"http": TCPKeepAliveHTTPConnectionPool, "https": TCPKeepAliveHTTPSConnectionPool} + + +class TCPKeepAliveProxyManager(ProxyManager): + """ + This Proxy Manager has only had the pool_classes_by_scheme variable changed. This now points at the TCPKeepAlive + connection pools rather than the default connection pools. + """ + + def __init__(self, proxy_url, num_pools=10, headers=None, proxy_headers=None, **connection_pool_kw): + super(TCPKeepAliveProxyManager, self).__init__(proxy_url=proxy_url, num_pools=num_pools, headers=headers, + proxy_headers=proxy_headers, + **connection_pool_kw) + self.pool_classes_by_scheme = {"http": TCPKeepAliveHTTPConnectionPool, "https": TCPKeepAliveHTTPSConnectionPool} diff --git a/cloudinary/auth_token.py b/cloudinary/auth_token.py index 6ef3874c..417c9901 100644 --- a/cloudinary/auth_token.py +++ b/cloudinary/auth_token.py @@ -11,7 +11,10 @@ def generate(url=None, acl=None, start_time=None, duration=None, - expiration=None, ip=None, key=None, token_name=AUTH_TOKEN_NAME): + expiration=None, ip=None, key=None, token_name=AUTH_TOKEN_NAME, **_): + start_time = _ensure_int(start_time) + duration = _ensure_int(duration) + expiration = _ensure_int(expiration) if expiration is None: if duration is not None: @@ -20,6 +23,9 @@ def generate(url=None, acl=None, start_time=None, duration=None, else: raise Exception("Must provide either expiration or duration") + if url is None and acl is None: + raise Exception("Must provide either acl or url") + token_parts = [] if ip is not None: token_parts.append("ip=" + ip) @@ -27,7 +33,9 @@ def generate(url=None, acl=None, start_time=None, duration=None, token_parts.append("st=%d" % start_time) token_parts.append("exp=%d" % expiration) if acl is not None: - token_parts.append("acl=%s" % _escape_to_lower(acl)) + acl_list = acl if type(acl) is list else [acl] + acl_list = [_escape_to_lower(a) for a in acl_list] + token_parts.append("acl=%s" % "!".join(acl_list)) to_sign = list(token_parts) if url is not None and acl is None: to_sign.append("url=%s" % _escape_to_lower(url)) @@ -47,3 +55,22 @@ def _escape_to_lower(url): escaped_url = smart_escape(url, unsafe=AUTH_TOKEN_UNSAFE_RE) escaped_url = re.sub(r"%[0-9A-F]{2}", lambda x: x.group(0).lower(), escaped_url) return escaped_url + +def _ensure_int(value): + """ + Ensures the input value is an integer. + Attempts to cast it to an integer if it is not already. + + :param value: The value to ensure as an integer. + :type value: Any + :return: The integer value. + :rtype: int + :raises ValueError: If the value cannot be converted to an integer. + """ + if isinstance(value, int) or not value: + return value + + try: + return int(value) + except (ValueError, TypeError): + raise ValueError("Value '" + value + "' must be an integer.") diff --git a/cloudinary/forms.py b/cloudinary/forms.py index ff83a98f..6c0ffaba 100644 --- a/cloudinary/forms.py +++ b/cloudinary/forms.py @@ -5,7 +5,7 @@ import cloudinary.utils from cloudinary import CloudinaryResource from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ def cl_init_js_callbacks(form, request): @@ -52,7 +52,7 @@ def render(self, name, value, attrs=None, renderer=None): class CloudinaryJsFileField(forms.Field): default_error_messages = { - 'required': _(u"No file selected!") + "required": _("No file selected!") } def __init__(self, attrs=None, options=None, autosave=True, *args, **kwargs): @@ -121,7 +121,7 @@ def __init__(self, upload_preset, attrs=None, options=None, autosave=True, *args class CloudinaryFileField(forms.FileField): my_default_error_messages = { - 'required': _(u"No file selected!") + "required": _("No file selected!") } default_error_messages = forms.FileField.default_error_messages.copy() default_error_messages.update(my_default_error_messages) diff --git a/cloudinary/http_client.py b/cloudinary/http_client.py index 4355b017..b5f6e1b3 100644 --- a/cloudinary/http_client.py +++ b/cloudinary/http_client.py @@ -24,7 +24,7 @@ def _http_client(self): def get_json(self, url): try: - response = self._http_client.request("GET", url, timeout=self.timeout) + response = self._http_client.request(method="GET", url=url, timeout=self.timeout) body = response.data except HTTPError as e: raise GeneralError("Unexpected error %s" % str(e)) diff --git a/cloudinary/models.py b/cloudinary/models.py index 5ccb133d..1240150e 100644 --- a/cloudinary/models.py +++ b/cloudinary/models.py @@ -106,7 +106,7 @@ def pre_save(self, model_instance, add): value = super(CloudinaryField, self).pre_save(model_instance, add) if isinstance(value, UploadedFile): options = {"type": self.type, "resource_type": self.resource_type} - options.update(self.options) + options.update({key: val(model_instance) if callable(val) else val for key, val in self.options.items()}) if hasattr(value, 'seekable') and value.seekable(): value.seek(0) instance_value = uploader.upload_resource(value, **options) diff --git a/cloudinary/provisioning/__init__.py b/cloudinary/provisioning/__init__.py index 09afc114..7016343a 100644 --- a/cloudinary/provisioning/__init__.py +++ b/cloudinary/provisioning/__init__.py @@ -2,4 +2,5 @@ from .account import (sub_accounts, create_sub_account, delete_sub_account, sub_account, update_sub_account, user_groups, create_user_group, update_user_group, delete_user_group, user_group, add_user_to_group, remove_user_from_group, user_group_users, user_in_user_groups, - users, create_user, delete_user, user, update_user, Role) + users, create_user, delete_user, user, update_user, access_keys, generate_access_key, + update_access_key, delete_access_key, Role) diff --git a/cloudinary/provisioning/account.py b/cloudinary/provisioning/account.py index dcbba8b8..90b1c385 100644 --- a/cloudinary/provisioning/account.py +++ b/cloudinary/provisioning/account.py @@ -1,10 +1,10 @@ from cloudinary.api_client.call_account_api import _call_account_api from cloudinary.utils import encode_list - SUB_ACCOUNTS_SUB_PATH = "sub_accounts" USERS_SUB_PATH = "users" USER_GROUPS_SUB_PATH = "user_groups" +ACCESS_KEYS = "access_keys" class Role(object): @@ -65,7 +65,7 @@ def create_sub_account(name, cloud_name=None, custom_attributes=None, enabled=No "cloud_name": cloud_name, "custom_attributes": custom_attributes, "enabled": enabled, - "base_account": base_account} + "base_sub_account_id": base_account} return _call_account_api("POST", uri, params=params, **options) @@ -97,9 +97,7 @@ def sub_account(sub_account_id, **options): return _call_account_api("get", uri, {}, **options) -def update_sub_account(sub_account_id, name=None, cloud_name=None, custom_attributes=None, - enabled=None, base_account=None, - **options): +def update_sub_account(sub_account_id, name=None, cloud_name=None, custom_attributes=None, enabled=None, **options): """ Update a sub account :param sub_account_id: The id of the sub account @@ -112,8 +110,6 @@ def update_sub_account(sub_account_id, name=None, cloud_name=None, custom_attrib :type custom_attributes: dict, optional :param enabled: Whether to create the account as enabled (default is enabled). :type enabled: bool, optional - :param base_account: ID of sub-account from which to copy settings - :type base_account: str, optional :param options: Generic advanced options dict, see online documentation :type options: dict, optional :return: Updated sub account @@ -123,22 +119,31 @@ def update_sub_account(sub_account_id, name=None, cloud_name=None, custom_attrib params = {"name": name, "cloud_name": cloud_name, "custom_attributes": custom_attributes, - "enabled": enabled, - "base_account": base_account} + "enabled": enabled} return _call_account_api("put", uri, params=params, **options) -def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **options): +def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, last_login=None, from_date=None, to_date=None, + **options): """ List all users :param user_ids: The ids of the users to fetch :type user_ids: list, optional :param sub_account_id: The id of a sub account :type sub_account_id: str, optional - :param pending: Whether the user is pending + :param pending: Limit results to pending users (True), + users that are not pending (False), + or all users (None, the default). :type pending: bool, optional :param prefix: User prefix :type prefix: str, optional + :param last_login: Return only users that last logged in in the specified range of dates (true), + users that didn't last logged in in that range (false), or all users (None). + :type last_login: bool, optional + :param from_date: Last login start date. + :type from_date: datetime, optional + :param to_date: Last login end date. + :type to_date: datetime, optional :param options: Generic advanced options dict, see online documentation. :type options: dict, optional :return: List of users associated with the account @@ -149,7 +154,10 @@ def users(user_ids=None, sub_account_id=None, pending=None, prefix=None, **optio params = {"ids": user_ids, "sub_account_id": sub_account_id, "pending": pending, - "prefix": prefix} + "prefix": prefix, + "last_login": last_login, + "from": from_date, + "to": to_date} return _call_account_api("get", uri, params=params, **options) @@ -354,7 +362,7 @@ def user_in_user_groups(user_id, **options): """ Get all user groups a user belongs to :param user_id: The id of user - :param user_id: str + :type user_id: str :param options: Generic advanced options dict, see online documentation :type options: dict, optional :return: List of groups user is in @@ -362,3 +370,112 @@ def user_in_user_groups(user_id, **options): """ uri = [USER_GROUPS_SUB_PATH, user_id] return _call_account_api("get", uri, {}, **options) + + +def access_keys(sub_account_id, page_size=None, page=None, sort_by=None, sort_order=None, **options): + """ + Get sub account access keys. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param page_size: How many entries to display on each page. + :type page_size: int + :param page: Which page to return (maximum pages: 100). **Default**: All pages are returned. + :type page: int + :param sort_by: Which response parameter to sort by. + **Possible values**: `api_key`, `created_at`, `name`, `enabled`. + :type sort_by: str + :param sort_order: Control the order of returned keys. **Possible values**: `desc` (default), `asc`. + :type sort_order: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: List of access keys + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS] + params = { + "page_size": page_size, + "page": page, + "sort_by": sort_by, + "sort_order": sort_order, + } + return _call_account_api("get", uri, params, **options) + + +def generate_access_key(sub_account_id, name=None, enabled=None, **options): + """ + Generate a new access key. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param name: The name of the new access key. + :type name: str + :param enabled: Whether the new access key is enabled or disabled. + :type enabled: bool + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Access key details + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS] + params = { + "name": name, + "enabled": enabled, + } + return _call_account_api("post", uri, params, **options) + + +def update_access_key(sub_account_id, api_key, name=None, enabled=None, dedicated_for=None, **options): + """ + Update the name and/or status of an existing access key. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param api_key: The API key of the access key. + :type api_key: str|int + :param name: The updated name of the access key. + :type name: str + :param enabled: Enable or disable the access key. + :type enabled: bool + :param dedicated_for: Designates the access key for a specific purpose while allowing it to be used for + other purposes, as well. This action replaces any previously assigned key. + **Possible values**: `webhooks` + :type dedicated_for: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Access key details + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS, str(api_key)] + params = { + "name": name, + "enabled": enabled, + "dedicated_for": dedicated_for, + } + return _call_account_api("put", uri, params, **options) + + +def delete_access_key(sub_account_id, api_key=None, name=None, **options): + """ + Delete an existing access key by api_key or by name. + + :param sub_account_id: The id of the sub account. + :type sub_account_id: str + :param api_key: The API key of the access key. + :type api_key: str|int + :param name: The name of the access key. + :type name: str + :param options: Generic advanced options dict, see online documentation. + :type options: dict, optional + :return: Operation status. + :rtype: dict + """ + uri = [SUB_ACCOUNTS_SUB_PATH, sub_account_id, ACCESS_KEYS] + + if api_key is not None: + uri.append(str(api_key)) + + params = { + "name": name + } + return _call_account_api("delete", uri, params, **options) diff --git a/cloudinary/provisioning/account_config.py b/cloudinary/provisioning/account_config.py index adb6c25a..0448e60a 100644 --- a/cloudinary/provisioning/account_config.py +++ b/cloudinary/provisioning/account_config.py @@ -10,13 +10,8 @@ class AccountConfig(BaseConfig): def __init__(self): self._uri_scheme = ACCOUNT_URI_SCHEME - django_settings = import_django_settings() - if django_settings: - self.update(**django_settings) - elif os.environ.get("CLOUDINARY_ACCOUNT_URL"): - account_url = os.environ.get("CLOUDINARY_ACCOUNT_URL") - parsed_url = self._parse_cloudinary_url(account_url) - self._setup_from_parsed_url(parsed_url) + + super(AccountConfig, self).__init__() def _config_from_parsed_url(self, parsed_url): if not self._is_url_scheme_valid(parsed_url): @@ -28,6 +23,10 @@ def _config_from_parsed_url(self, parsed_url): "provisioning_api_secret": parsed_url.password, } + def _load_config_from_env(self): + if os.environ.get("CLOUDINARY_ACCOUNT_URL"): + self._load_from_url(os.environ.get("CLOUDINARY_ACCOUNT_URL")) + def account_config(**keywords): global _account_config diff --git a/cloudinary/search.py b/cloudinary/search.py index 0b65d3b9..49aa65b4 100644 --- a/cloudinary/search.py +++ b/cloudinary/search.py @@ -1,11 +1,27 @@ import json -from copy import deepcopy +import cloudinary from cloudinary.api_client.call_api import call_json_api +from cloudinary.utils import (unique, build_distribution_domain, base64url_encode, json_encode, compute_hex_hash, + SIGNATURE_SHA256, build_array) -class Search: +class Search(object): + ASSETS = 'resources' + + _endpoint = ASSETS + + _KEYS_WITH_UNIQUE_VALUES = { + 'sort_by': lambda x: next(iter(x)), + 'aggregate': lambda agg: agg["type"] if isinstance(agg, dict) and "type" in agg else agg, + 'with_field': None, + 'fields': None, + } + + _ttl = 300 # Used for search URLs + """Build and execute a search query.""" + def __init__(self): self.query = {} @@ -41,20 +57,86 @@ def with_field(self, value): self._add("with_field", value) return self + def fields(self, value): + """Request which fields to return in the result set.""" + self._add("fields", value) + return self + + def ttl(self, ttl): + """ + Sets the time to live of the search URL. + + :param ttl: The time to live in seconds. + :return: self + """ + self._ttl = ttl + return self + def to_json(self): - return json.dumps(self.query) + return json.dumps(self.as_dict()) def execute(self, **options): """Execute the search and return results.""" options["content_type"] = 'application/json' - uri = ['resources', 'search'] + uri = [self._endpoint, 'search'] return call_json_api('post', uri, self.as_dict(), **options) + def as_dict(self): + to_return = {} + + for key, value in self.query.items(): + if key in self._KEYS_WITH_UNIQUE_VALUES: + value = unique(value, self._KEYS_WITH_UNIQUE_VALUES[key]) + + to_return[key] = value + + return to_return + + def to_url(self, ttl=None, next_cursor=None, **options): + """ + Creates a signed Search URL that can be used on the client side. + + :param ttl: The time to live in seconds. + :param next_cursor: Starting position. + :param options: Additional url delivery options. + :return: The resulting search URL. + """ + api_secret = options.get("api_secret", cloudinary.config().api_secret or None) + if not api_secret: + raise ValueError("Must supply api_secret") + + if ttl is None: + ttl = self._ttl + + query = self.as_dict() + + _next_cursor = query.pop("next_cursor", None) + if next_cursor is None: + next_cursor = _next_cursor + + b64query = base64url_encode(json_encode(query, sort_keys=True)) + + prefix = build_distribution_domain(options) + + signature = compute_hex_hash("{ttl}{b64query}{api_secret}".format( + ttl=ttl, + b64query=b64query, + api_secret=api_secret + ), algorithm=SIGNATURE_SHA256) + + return "{prefix}/search/{signature}/{ttl}/{b64query}{next_cursor}".format( + prefix=prefix, + signature=signature, + ttl=ttl, + b64query=b64query, + next_cursor="/{}".format(next_cursor) if next_cursor else "") + + def endpoint(self, endpoint): + self._endpoint = endpoint + return self + def _add(self, name, value): if name not in self.query: self.query[name] = [] - self.query[name].append(value) + self.query[name].extend(build_array(value)) return self - - def as_dict(self): - return deepcopy(self.query) diff --git a/cloudinary/search_folders.py b/cloudinary/search_folders.py new file mode 100644 index 00000000..87e2601d --- /dev/null +++ b/cloudinary/search_folders.py @@ -0,0 +1,10 @@ +from cloudinary import Search + + +class SearchFolders(Search): + FOLDERS = 'folders' + + def __init__(self): + super(SearchFolders, self).__init__() + + self.endpoint(self.FOLDERS) diff --git a/cloudinary/templates/cloudinary_includes.html b/cloudinary/templates/cloudinary_includes.html index 6300c037..908c41d2 100644 --- a/cloudinary/templates/cloudinary_includes.html +++ b/cloudinary/templates/cloudinary_includes.html @@ -1,14 +1,14 @@ -{% load staticfiles %} +{% load static %} - - - - + + + + {% if processing %} - - - - - + + + + + {% endif %} diff --git a/cloudinary/uploader.py b/cloudinary/uploader.py index a396645c..57efe5f6 100644 --- a/cloudinary/uploader.py +++ b/cloudinary/uploader.py @@ -1,17 +1,18 @@ # Copyright Cloudinary + import json import os import socket -import certifi from six import string_types -from urllib3 import PoolManager, ProxyManager from urllib3.exceptions import HTTPError import cloudinary from cloudinary import utils -from cloudinary.exceptions import Error +from cloudinary.api_client.execute_request import EXCEPTION_CODES from cloudinary.cache.responsive_breakpoints_cache import instance as responsive_breakpoints_cache_instance +from cloudinary.exceptions import Error +from cloudinary.utils import build_eager try: from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox @@ -42,31 +43,240 @@ def is_appengine_sandbox(): def upload(file, **options): + """ + Uploads a file (image, video, or raw) to your Cloudinary product environment. + + + See: https://cloudinary.com/documentation/image_upload_api_reference#upload + + :param file: + The asset to upload. This can be: + - A local file path (string) + - A file-like object / stream + - A Data URI (Base64 encoded) + - A remote FTP, HTTP, or HTTPS URL + - A private storage bucket (S3 or Google Storage) URL from a whitelisted bucket + :type file: str or file-like object + + :param options: + Additional parameters and configuration for the upload call. + + :keyword str public_id: + Overrides the default public ID for the uploaded file. + :keyword str public_id_prefix: + Prepends a given prefix to the public ID of all uploaded assets. + :keyword str callback: + Callback URL or function for asynchronous notifications. + :keyword str format: + Force a specific asset format (e.g., "jpg", "png"). + :keyword str type: + The storage type (default: "upload"). Possible values: + - "upload" (publicly accessible) + - "private" + - "authenticated" (JWT or token-based access) + :keyword bool backup: + Whether to back up the asset. + :keyword bool faces: + If True, return face coordinates in the response (if faces are detected). + :keyword bool image_metadata: + If True, return image metadata (EXIF, etc.) in the response. + :keyword bool media_metadata: + If True, return media metadata for file types like PDF or AI. + :keyword bool exif: + If True, return EXIF metadata in the response. + :keyword bool colors: + If True, return a list of colors predominant in the image. + :keyword bool use_filename: + If True, derive the public ID from the file name's basename. + :keyword bool unique_filename: + If True, add random characters to the public ID to ensure uniqueness. + :keyword str display_name: + A user-friendly name for the asset, displayed in the Media Library. + :keyword bool use_filename_as_display_name: + If True, sets the display_name to the file's original filename. + :keyword bool discard_original_filename: + If True, do not include the original filename in the public ID. + :keyword str filename_override: + Manually override the final filename (instead of deriving from file). + :keyword bool invalidate: + If True, invalidates the cached copies on the CDN after upload or transformation. + :keyword str notification_url: + A URL to notify when the upload or related process is completed. + :keyword str eager_notification_url: + A URL to notify when eager transformations have completed. + :keyword bool eager_async: + If True, performs eager transformations asynchronously. + :keyword str eval: + Run custom JavaScript (for video or media) at certain processing steps (Advanced). + :keyword str on_success: + A function/URL that triggers upon successful processing (Advanced). + :keyword str proxy: + A proxy server address if needed for the upload request. + :keyword str folder: + The folder path in your Cloudinary product environment to store the asset. + :keyword str asset_folder: + A subfolder path for advanced use-cases. + :keyword bool use_asset_folder_as_public_id_prefix: + If True, treat the asset_folder path as part of the public ID. + :keyword bool unique_display_name: + If True, ensures that the display_name is unique across the product environment. + :keyword bool overwrite: + If True, overwrite an existing asset with the same public ID. + :keyword str moderation: + The moderation type (e.g., "manual", "webpurify", "aws_rek"). + :keyword str raw_convert: + Raw file conversion type (e.g., "aspose"). + :keyword str quality_override: + Overrides the auto-quality or transformation-based quality setting. + :keyword bool quality_analysis: + If True, return the advanced quality analysis results in the response. + :keyword str ocr: + OCR extraction setting (e.g., "adv_ocr"). + :keyword str categorization: + Sets categorization mode (e.g., "google_tagging"). + :keyword str detection: + Sets detection mode (e.g., "adv_face"). + :keyword str similarity_search: + Reserved for advanced similarity search tasks. + :keyword str visual_search: + Reserved for visual search tasks. + :keyword str background_removal: + Background removal feature (e.g., "cloudinary_ai", "pixelz"). + :keyword str upload_preset: + The name of the upload preset to apply. If omitted, defaults to + ``cloudinary.config().upload_preset`` if configured. + :keyword bool phash: + If True, return the perceptual hash (pHash) for the image. + :keyword bool return_delete_token: + If True, returns a token that can be used to delete the asset without authentication. + :keyword float auto_tagging: + (Range 0.0-1.0) If set, automatically tag the image based on content analysis. + :keyword bool async: + If True, requests asynchronous processing (where applicable). + :keyword bool cinemagraph_analysis: + If True, performs analysis for cinemagraph creation. + :keyword bool accessibility_analysis: + If True, performs accessibility (image alt text) analysis. + :keyword int timestamp: + A UNIX timestamp to sign the request. Defaults to now(). + :keyword dict or list transformation: + A transformation or list of transformations to apply. Internally merged into a single string. + :keyword dict headers: + Additional HTTP headers to store with the asset (Advanced usage). + :keyword list eager: + A list of eager transformations to generate during the upload. + :keyword list tags: + A list of tags (or a comma-delimited string) to assign to the uploaded asset. + :keyword list allowed_formats: + A list of file formats allowed for this upload (e.g., ["jpg", "png"]). + :keyword list or tuple face_coordinates: + Face rectangle coordinates, encoded into the upload if required. + :keyword list or tuple custom_coordinates: + Custom rectangle coordinates, stored with the asset. + :keyword dict regions: + Arbitrary region data for advanced transformations or processing. + :keyword dict or str context: + Adds or updates contextual metadata (key-value pairs). + :keyword str responsive_breakpoints: + A JSON string or list describing how to create responsive breakpoints for the image. + :keyword list or dict access_control: + Access control rules (ACL) for the asset, e.g. restricting or allowing access. + :keyword dict metadata: + Key-value pairs for structured metadata fields (by external_id). + :keyword bool use_cache: + (Uploader-specific) If True, store responsive breakpoints in the local cache. + + :return: + The result of the Upload API call, typically including: + - "public_id" + - "version" + - "url", "secure_url" + - etc. + :rtype: dict + """ params = utils.build_upload_params(**options) return call_cacheable_api("upload", params, file=file, **options) def unsigned_upload(file, upload_preset, **options): + """ + Uploads an asset to Cloudinary without requiring authentication. + + See: https://cloudinary.com/documentation/image_upload_api_reference#unsigned_upload_syntax + + :param file: The asset to upload (local path, file-like object, Data URI, remote URL, or bucket URL). + :type file: str or file-like object + :param upload_preset: The unsigned upload preset name to use. + :type upload_preset: str + :param options: Additional options for the upload. + :return: The result of the upload API call. + :rtype: dict + """ return upload(file, upload_preset=upload_preset, unsigned=True, **options) def upload_image(file, **options): + """ + Uploads a file and returns a CloudinaryImage object. + + See: https://cloudinary.com/documentation/image_upload_api_reference#upload + + :param file: The asset to upload. + :type file: Any or str + :param options: Additional parameters for the upload call. + :return: A CloudinaryImage object referencing the uploaded image. + :rtype: cloudinary.CloudinaryImage + """ result = upload(file, **options) return cloudinary.CloudinaryImage( result["public_id"], version=str(result["version"]), - format=result.get("format"), metadata=result) + format=result.get("format"), metadata=result + ) def upload_resource(file, **options): - result = upload(file, **options) + """ + Uploads a file and returns a CloudinaryResource object (image, raw, or video). + + See: https://cloudinary.com/documentation/image_upload_api_reference#upload + + :param file: The asset to upload. + :type file: Any or str + :param options: Additional parameters for the upload call. + :return: A CloudinaryResource object referencing the uploaded asset. + :rtype: cloudinary.CloudinaryResource + """ + upload_func = upload + if hasattr(file, 'size') and file.size > UPLOAD_LARGE_CHUNK_SIZE: + upload_func = upload_large + + result = upload_func(file, **options) + return cloudinary.CloudinaryResource( - result["public_id"], version=str(result["version"]), - format=result.get("format"), type=result["type"], - resource_type=result["resource_type"], metadata=result) + result["public_id"], + version=str(result["version"]), + format=result.get("format"), + type=result["type"], + resource_type=result["resource_type"], + metadata=result + ) def upload_large(file, **options): - """ Upload large files. """ + """ + Uploads a large file (in chunks) to Cloudinary. + + See: https://cloudinary.com/documentation/image_upload_api_reference#upload + + :param file: The file to upload (local path or file-like object). + :type file: str or file-like object + :param options: Additional options for the upload. + :keyword str filename: Override for the file name (for streams). + :keyword int chunk_size: Size of each uploaded chunk (default=20000000). + :keyword bool use_cache: Whether to store responsive breakpoints in cache after upload. + :return: The result of the upload API call. + :rtype: dict + """ if utils.is_remote_url(file): return upload(file, **options) @@ -85,14 +295,18 @@ def upload_large(file, **options): file_name = options.get( "filename", - file_io.name if hasattr(file_io, 'name') and isinstance(file_io.name, str) else "stream") + file_io.name if hasattr(file_io, 'name') and isinstance(file_io.name, str) else "stream" + ) chunk = file_io.read(chunk_size) while chunk: content_range = "bytes {0}-{1}/{2}".format(current_loc, current_loc + len(chunk) - 1, file_size) current_loc += len(chunk) - http_headers = {"Content-Range": content_range, "X-Unique-Upload-Id": upload_id} + http_headers = { + "Content-Range": content_range, + "X-Unique-Upload-Id": upload_id + } upload_result = upload_large_part((file_name, chunk), http_headers=http_headers, **options) @@ -104,7 +318,17 @@ def upload_large(file, **options): def upload_large_part(file, **options): - """ Upload large files. """ + """ + Uploads a large chunk (part) of a file to Cloudinary. + + See: https://cloudinary.com/documentation/image_upload_api_reference#upload + + :param file: A tuple of (filename, chunk_data) for the file part to upload. + :type file: tuple + :param options: Additional parameters for the chunk upload. + :return: The result of the chunk upload API call. + :rtype: dict + """ params = utils.build_upload_params(**options) if 'resource_type' not in options: @@ -114,6 +338,19 @@ def upload_large_part(file, **options): def destroy(public_id, **options): + """ + Deletes a resource (asset) from Cloudinary by public ID. + + See: https://cloudinary.com/documentation/image_upload_api_reference#destroy + + :param public_id: The public ID of the resource to delete. + :type public_id: str + :param options: Additional options for the deletion. + :keyword str type: The storage type (upload, private, authenticated). + :keyword bool invalidate: Invalidate cached copies on the CDN if True. + :return: The result of the API call. + :rtype: dict + """ params = { "timestamp": utils.now(), "type": options.get("type"), @@ -124,6 +361,25 @@ def destroy(public_id, **options): def rename(from_public_id, to_public_id, **options): + """ + Renames a resource (asset) in Cloudinary. + + See: https://cloudinary.com/documentation/image_upload_api_reference#rename_public_id + + :param from_public_id: The current public ID of the resource. + :type from_public_id: str + :param to_public_id: The new public ID for the resource. + :type to_public_id: str + :param options: Additional options for the rename operation. + :keyword str type: The storage type of the original asset. Default=upload. + :keyword bool overwrite: Whether to overwrite if the to_public_id already exists. + :keyword bool invalidate: Invalidate cached copies on the CDN if True. + :keyword str to_type: Change the resource to the specified upload type. + :keyword dict context: Set or update contextual metadata. + :keyword dict metadata: Set or update structured metadata. + :return: The result of the API call. + :rtype: dict + """ params = { "timestamp": utils.now(), "type": options.get("type"), @@ -131,45 +387,68 @@ def rename(from_public_id, to_public_id, **options): "invalidate": options.get("invalidate"), "from_public_id": from_public_id, "to_public_id": to_public_id, - "to_type": options.get("to_type") + "to_type": options.get("to_type"), + "context": options.get("context"), + "metadata": options.get("metadata") } return call_api("rename", params, **options) def update_metadata(metadata, public_ids, **options): """ - Populates metadata fields with the given values. Existing values will be overwritten. - - Any metadata-value pairs given are merged with any existing metadata-value pairs - (an empty value for an existing metadata field clears the value) - - :param metadata: A list of custom metadata fields (by external_id) and the values to assign to each - of them. - :param public_ids: An array of Public IDs of assets uploaded to Cloudinary. - :param options: Options such as - *resource_type* (the type of file. Default: image. Valid values: image, raw, or video) and - *type* (The storage type. Default: upload. Valid values: upload, private, or authenticated.) - - :return: A list of public IDs that were updated - :rtype: mixed + Populates or updates metadata fields with the given values. + + See: https://cloudinary.com/documentation/image_upload_api_reference#metadata + + :param metadata: Key-value pairs for custom metadata fields (by external_id). + :type metadata: dict + :param public_ids: A list of public IDs (assets) to update. + :type public_ids: list[str] + :param options: Additional options such as resource_type or type. + :keyword str resource_type: The resource type (image, raw, video). Default="image". + :keyword str type: The storage type (upload, private, authenticated). + :keyword bool clear_invalid: If True, remove keys that are not valid. + :return: A list of public IDs that were updated. + :rtype: dict """ params = { "timestamp": utils.now(), "metadata": utils.encode_context(metadata), "public_ids": utils.build_array(public_ids), - "type": options.get("type") + "type": options.get("type"), + "clear_invalid": options.get("clear_invalid") } - return call_api("metadata", params, **options) def explicit(public_id, **options): + """ + Applies actions to already uploaded assets (raw, image, or video) via an explicit call. + + See: https://cloudinary.com/documentation/image_upload_api_reference#explicit + + :param public_id: The public ID of the asset to process. + :type public_id: str + :param options: Additional options for the explicit API call. + :return: The result of the API call. + :rtype: dict + """ params = utils.build_upload_params(**options) params["public_id"] = public_id return call_cacheable_api("explicit", params, **options) def create_archive(**options): + """ + Creates an archive of assets in Cloudinary. + + See: https://cloudinary.com/documentation/image_upload_api_reference#generate_archive + + :param options: Additional options for the archive creation (filters, transformations, etc.). + :keyword str target_format: Archive format (zip, tgz, etc.). + :return: The result of the API call. + :rtype: dict + """ params = utils.archive_params(**options) if options.get("target_format") is not None: params["target_format"] = options.get("target_format") @@ -177,34 +456,98 @@ def create_archive(**options): def create_zip(**options): + """ + Creates a ZIP archive of assets in Cloudinary. + + See: https://cloudinary.com/documentation/image_upload_api_reference#create_zip_syntax + + :param options: Additional options for archive creation. + :return: The result of the API call. + :rtype: dict + """ return create_archive(target_format="zip", **options) -def generate_sprite(tag, **options): - params = { - "timestamp": utils.now(), - "tag": tag, - "async": options.get("async"), - "notification_url": options.get("notification_url"), - "transformation": utils.generate_transformation_string( - fetch_format=options.get("format"), **options)[0] - } +def generate_sprite(tag=None, urls=None, **options): + """ + Generates sprites by merging multiple images into a single large image. + + See: https://cloudinary.com/documentation/image_upload_api_reference#sprite + + :param tag: Images with this tag will be used to create the sprite (if set). + :type tag: str + :param urls: List of URLs to create a sprite from (only if tag not set). + :type urls: list[str], optional + :param options: Additional sprite configuration. + :return: Dictionary with metadata and URLs of generated sprite resources. + :rtype: dict + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) return call_api("sprite", params, **options) -def multi(tag, **options): - params = { - "timestamp": utils.now(), - "tag": tag, - "format": options.get("format"), - "async": options.get("async"), - "notification_url": options.get("notification_url"), - "transformation": utils.generate_transformation_string(**options)[0] - } +def download_generated_sprite(tag=None, urls=None, **options): + """ + Generates a downloadable URL for the sprite (with `mode=download`). + + :param tag: Images with this tag will be used to create the sprite (if set). + :type tag: str + :param urls: List of URLs to create a sprite from (only if tag not set). + :type urls: list[str], optional + :param options: Additional sprite configuration. + :return: The signed URL to download the sprite. + :rtype: str + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) + return utils.cloudinary_api_download_url(action="sprite", params=params, **options) + + +def multi(tag=None, urls=None, **options): + """ + Creates an animated image, video, or PDF from a set of images. + + See: https://cloudinary.com/documentation/image_upload_api_reference#multi + + :param tag: Assets with this tag will be used (if set). + :type tag: str + :param urls: A list of image URLs (if no tag is set). + :type urls: list[str], optional + :param options: Additional multi-configuration options. + :return: Dictionary with metadata and URLs of the generated file. + :rtype: dict + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) return call_api("multi", params, **options) +def download_multi(tag=None, urls=None, **options): + """ + Generates a downloadable URL for the multi (with `mode=download`). + + :param tag: Assets with this tag will be used (if set). + :type tag: str + :param urls: A list of image URLs (if no tag is set). + :type urls: list[str], optional + :param options: Additional multi-configuration options. + :return: The signed URL to download the multi. + :rtype: str + """ + params = utils.build_multi_and_sprite_params(tag=tag, urls=urls, **options) + return utils.cloudinary_api_download_url(action="multi", params=params, **options) + + def explode(public_id, **options): + """ + Creates derived images for all the individual pages in a multi-page file (PDF or animated GIF). + + See: https://cloudinary.com/documentation/image_upload_api_reference#explode + + :param public_id: The public ID of the file to explode. + :type public_id: str + :param options: Additional explode options (format, notification_url, transformation). + :return: The result of the API call. + :rtype: dict + """ params = { "timestamp": utils.now(), "public_id": public_id, @@ -215,59 +558,123 @@ def explode(public_id, **options): return call_api("explode", params, **options) -# options may include 'exclusive' (boolean) which causes clearing this tag from all other resources def add_tag(tag, public_ids=None, **options): + """ + Adds one or more tags to the specified assets. + + See: https://cloudinary.com/documentation/image_upload_api_reference#adding_tags_syntax + + :param tag: A single tag or multiple tags (comma-separated string or list). + :type tag: str or list[str] + :param public_ids: A list of public IDs (up to 1000). + :type public_ids: list[str], optional + :param options: Additional options (e.g., exclusive). + :keyword bool exclusive: If True, clears this tag from all other assets in the product environment. + :return: Dictionary with a list of updated public IDs. + :rtype: dict + """ exclusive = options.pop("exclusive", None) command = "set_exclusive" if exclusive else "add" return call_tags_api(tag, command, public_ids, **options) def remove_tag(tag, public_ids=None, **options): + """ + Removes one or more tags from the specified assets. + + See: https://cloudinary.com/documentation/image_upload_api_reference#removing_tags_syntax + + :param tag: A single tag or multiple tags (comma-separated string or list). + :type tag: str or list[str] + :param public_ids: A list of public IDs (up to 1000). + :type public_ids: list[str], optional + :param options: Additional options. + :return: Dictionary with a list of updated public IDs. + :rtype: dict + """ return call_tags_api(tag, "remove", public_ids, **options) def replace_tag(tag, public_ids=None, **options): + """ + Replaces all existing tags on the specified assets with a given tag (or tags). + + See: https://cloudinary.com/documentation/image_upload_api_reference#replacing_tags_syntax + + :param tag: A single tag or multiple tags (comma-separated string or list). + :type tag: str or list[str] + :param public_ids: A list of public IDs (up to 1000). + :type public_ids: list[str], optional + :param options: Additional options. + :return: Dictionary with a list of updated public IDs. + :rtype: dict + """ return call_tags_api(tag, "replace", public_ids, **options) def remove_all_tags(public_ids, **options): """ - Remove all tags from the specified public IDs. + Removes all tags from the specified public IDs. - :param public_ids: the public IDs of the resources to update - :param options: additional options passed to the request + See: https://cloudinary.com/documentation/image_upload_api_reference#removing_all_tags_syntax - :return: dictionary with a list of public IDs that were updated + :param public_ids: The public IDs of the assets. + :type public_ids: list[str] + :param options: Additional options. + :return: Dictionary with a list of updated public IDs. + :rtype: dict """ return call_tags_api(None, "remove_all", public_ids, **options) def add_context(context, public_ids, **options): """ - Add a context keys and values. If a particular key already exists, the value associated with the key is updated. + Adds contextual metadata (key-value pairs) to the specified assets. - :param context: dictionary of context - :param public_ids: the public IDs of the resources to update - :param options: additional options passed to the request + See: https://cloudinary.com/documentation/image_upload_api_reference#adding_context_syntax - :return: dictionary with a list of public IDs that were updated + :param context: A dictionary of context key-value pairs. + :type context: dict + :param public_ids: The public IDs of the assets to update. + :type public_ids: list[str] + :param options: Additional options. + :return: Dictionary with a list of updated public IDs. + :rtype: dict """ return call_context_api(context, "add", public_ids, **options) def remove_all_context(public_ids, **options): """ - Remove all custom context from the specified public IDs. + Removes all custom contextual metadata from the specified public IDs. - :param public_ids: the public IDs of the resources to update - :param options: additional options passed to the request + See: https://cloudinary.com/documentation/image_upload_api_reference#removing_all_context_syntax - :return: dictionary with a list of public IDs that were updated + :param public_ids: The public IDs of the assets to update. + :type public_ids: list[str] + :param options: Additional options. + :return: Dictionary with a list of updated public IDs. + :rtype: dict """ return call_context_api(None, "remove_all", public_ids, **options) def call_tags_api(tag, command, public_ids=None, **options): + """ + Internal helper function for adding/removing/replacing tags on assets. + + See: https://cloudinary.com/documentation/image_upload_api_reference#tags + + :param tag: A single tag or multiple tags. + :type tag: str or list[str], optional + :param command: The command to execute ("add", "remove", "replace", or "remove_all"). + :type command: str + :param public_ids: A list of asset public IDs. + :type public_ids: list[str], optional + :param options: Additional options (e.g., type). + :return: The result of the API call. + :rtype: dict + """ params = { "timestamp": utils.now(), "tag": tag, @@ -279,6 +686,21 @@ def call_tags_api(tag, command, public_ids=None, **options): def call_context_api(context, command, public_ids=None, **options): + """ + Internal helper for adding/removing context on assets. + + See: https://cloudinary.com/documentation/image_upload_api_reference#context + + :param context: A dictionary of context or None. + :type context: dict or None + :param command: The context command ("add", "remove_all"). + :type command: str + :param public_ids: A list of asset public IDs. + :type public_ids: list[str], optional + :param options: Additional options (e.g., type). + :return: The result of the API call. + :rtype: dict + """ params = { "timestamp": utils.now(), "context": utils.encode_context(context), @@ -304,23 +726,77 @@ def call_context_api(context, command, public_ids=None, **options): def text(text, **options): + """ + Dynamically generates an image of a given text string. + + See: https://cloudinary.com/documentation/image_upload_api_reference#text + + :param text: The text string to generate an image for. + :type text: str + :param options: Additional options (e.g., font_family, font_size, etc.). + :return: The result of the text API call. + :rtype: dict + """ params = {"timestamp": utils.now(), "text": text} for key in TEXT_PARAMS: params[key] = options.get(key) return call_api("text", params, **options) +_SLIDESHOW_PARAMS = [ + "notification_url", + "public_id", + "overwrite", + "upload_preset", +] + + +def create_slideshow(**options): + """ + Creates an auto-generated video slideshow from existing assets. + + :param options: Additional parameters for the slideshow creation. + :keyword str resource_type: The resource type, defaults to "video". + :keyword str notification_url: A URL to be notified when the processing is completed. + :keyword str public_id: The public ID to assign to the generated slideshow. + :keyword bool overwrite: Whether to overwrite the slideshow if public_id already exists. + :keyword str upload_preset: An upload preset to apply to the slideshow creation. + :keyword list transformation: A list or dict describing transformations to apply. + :keyword list manifest_transformation: A list or dict transformations for the manifest. + :keyword dict manifest_json: A JSON specification for advanced slideshow creation. + :keyword list tags: A list of tags for the slideshow. + :return: Dictionary with details about the created slideshow. + :rtype: dict + """ + options["resource_type"] = options.get("resource_type", "video") + + params = {param_name: options.get(param_name) for param_name in _SLIDESHOW_PARAMS} + + serialized_params = { + "timestamp": utils.now(), + "transformation": build_eager(options.get("transformation")), + "manifest_transformation": build_eager(options.get("manifest_transformation")), + "manifest_json": options.get("manifest_json") and utils.json_encode(options.get("manifest_json")), + "tags": options.get("tags") and utils.encode_list(utils.build_array(options["tags"])), + } + + params.update(serialized_params) + + return call_api("create_slideshow", params, **options) + + def _save_responsive_breakpoints_to_cache(result): """ - Saves responsive breakpoints parsed from upload result to cache + Saves any responsive breakpoints parsed from an upload result to the local cache. - :param result: Upload result + :param result: The upload result dictionary. + :type result: dict """ if "responsive_breakpoints" not in result: return if "public_id" not in result: - # We have some faulty result, nothing to cache + # Invalid result or missing public_id return options = dict((k, result[k]) for k in ["type", "resource_type"] if k in result) @@ -335,92 +811,113 @@ def _save_responsive_breakpoints_to_cache(result): def call_cacheable_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, **options): """ - Calls Upload API and saves results to cache (if enabled) + Calls the Upload API and caches responsive breakpoints if enabled. + + :param action: The Cloudinary API endpoint to call (e.g., "upload", "explicit"). + :type action: str + :param params: The parameters for the API call (already built and signed if needed). + :type params: dict + :param http_headers: Optional HTTP headers to send. + :type http_headers: dict, optional + :param return_error: If True, returns errors in the response instead of raising them. + :type return_error: bool + :param unsigned: If True, the request is not signed (unsigned upload). + :type unsigned: bool + :param file: A file-like object or path to send to the endpoint. + :type file: Any, optional + :param timeout: Request timeout in seconds. + :type timeout: int, optional + :param options: Additional Cloudinary configuration or parameters. + :return: The parsed JSON response from Cloudinary. + :rtype: dict """ - result = call_api(action, params, http_headers, return_error, unsigned, file, timeout, **options) - if "use_cache" in options or cloudinary.config().use_cache: _save_responsive_breakpoints_to_cache(result) - return result -def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, **options): - if http_headers is None: - http_headers = {} - file_io = None - try: - if unsigned: - params = utils.cleanup_params(params) - else: - params = utils.sign_request(params, options) - - param_list = OrderedDict() - for k, v in params.items(): - if isinstance(v, list): - for i in range(len(v)): - param_list["{0}[{1}]".format(k, i)] = v[i] - elif v: - param_list[k] = v - - api_url = utils.cloudinary_api_url(action, **options) - if file: - filename = options.get("filename") # Custom filename provided by user (relevant only for streams and files) - - if isinstance(file, string_types): - if utils.is_remote_url(file): - # URL - name = None - data = file - else: - # file path - name = filename or file - with open(file, "rb") as opened: - data = opened.read() - elif hasattr(file, 'read') and callable(file.read): - # stream - data = file.read() - name = filename or (file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream") - elif isinstance(file, tuple): - name, data = file - else: - # Not a string, not a stream - name = filename or "file" - data = file - - param_list["file"] = (name, data) if name else data - - headers = {"User-Agent": cloudinary.get_user_agent()} +def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, + extra_headers=None, **options): + """ + A low-level helper to call the Cloudinary Upload API. + + :param action: The specific endpoint to call (e.g. "upload", "destroy"). + :type action: str + :param params: The dictionary of parameters to send to the endpoint. + :type params: dict + :param http_headers: HTTP headers to include in the request. + :type http_headers: dict, optional + :param return_error: If True, returns the error in the response instead of raising an exception. + :type return_error: bool + :param unsigned: If True, this call is not signed (unsigned upload). + :type unsigned: bool + :param file: File data or path to upload if relevant. + :type file: Any, optional + :param timeout: Timeout (in seconds) for the request. + :type timeout: int, optional + :param extra_headers: Additional headers to add/override. + :type extra_headers: dict, optional + :param options: Additional Cloudinary config or advanced parameters. + :return: The parsed JSON response from Cloudinary. + :rtype: dict + + :raises Error: If an HTTP error or a Cloudinary error occurs. + """ + params = utils.cleanup_params(params) + + headers = {"User-Agent": cloudinary.get_user_agent()} + + if http_headers is not None: headers.update(http_headers) - kw = {} - if timeout is not None: - kw['timeout'] = timeout - - code = 200 - try: - response = _http.request("POST", api_url, param_list, headers, **kw) - except HTTPError as e: - raise Error("Unexpected error - {0!r}".format(e)) - except socket.error as e: - raise Error("Socket error: {0!r}".format(e)) - - try: - result = json.loads(response.data.decode('utf-8')) - except Exception as e: - # Error is parsing json - raise Error("Error parsing server response (%d) - %s. Got - %s" % (response.status, response.data, e)) - - if "error" in result: - if response.status not in [200, 400, 401, 403, 404, 500]: - code = response.status - if return_error: - result["error"]["http_code"] = code - else: - raise Error(result["error"]["message"]) - - return result - finally: - if file_io: - file_io.close() + if extra_headers is not None: + headers.update(extra_headers) + + oauth_token = options.get("oauth_token", cloudinary.config().oauth_token) + + if oauth_token: + headers["authorization"] = "Bearer {}".format(oauth_token) + elif not unsigned: + params = utils.sign_request(params, options) + + param_list = [] + for k, v in params.items(): + if isinstance(v, list): + for i in v: + param_list.append(("{0}[]".format(k), i)) + elif v: + param_list.append((k, v)) + + api_url = utils.cloudinary_api_url(action, **options) + + if file: + filename = options.get("filename") # Custom filename for streams + param_list.append(("file", utils.handle_file_parameter(file, filename))) + + kw = {} + if timeout is not None: + kw['timeout'] = timeout + + try: + response = _http.request(method="POST", url=api_url, fields=param_list, headers=headers, **kw) + except HTTPError as e: + raise Error("Unexpected error - {0!r}".format(e)) + except socket.error as e: + raise Error("Socket error: {0!r}".format(e)) + + try: + result = json.loads(response.data.decode('utf-8')) + except Exception as e: + raise Error("Error parsing server response ({0}) - {1}. Got - {2}" + .format(response.status, response.data, e)) + + if "error" in result: + if return_error: + result["error"]["http_code"] = response.status + return result + + exception_class = EXCEPTION_CODES.get(response.status) or Error + raise exception_class(result["error"]["message"]) + + return result diff --git a/cloudinary/utils.py b/cloudinary/utils.py index e4af2434..dd895370 100644 --- a/cloudinary/utils.py +++ b/cloudinary/utils.py @@ -15,15 +15,21 @@ from datetime import datetime, date from fractions import Fraction from numbers import Number -from urllib3 import ProxyManager, PoolManager import six.moves.urllib.parse from six import iteritems +from urllib3 import ProxyManager, PoolManager import cloudinary from cloudinary import auth_token +from cloudinary.api_client.tcp_keep_alive_manager import TCPKeepAlivePoolManager, TCPKeepAliveProxyManager from cloudinary.compat import PY3, to_bytes, to_bytearray, to_string, string_types, urlparse +try: # Python 3.4+ + from pathlib import Path as PathLibPathType +except ImportError: + PathLibPathType = None + VAR_NAME_RE = r'(\$\([a-zA-Z]\w+\))' urlencode = six.moves.urllib.parse.urlencode @@ -37,7 +43,7 @@ RANGE_VALUE_RE = r'^(?P(\d+\.)?\d+)(?P[%pP])?$' RANGE_RE = r'^(\d+\.)?\d+[%pP]?\.\.(\d+\.)?\d+[%pP]?$' FLOAT_RE = r'^(\d+)\.(\d+)?$' -REMOTE_URL_RE = r'ftp:|https?:|s3:|gs:|data:([\w-]+\/[\w-]+)?(;[\w-]+=[\w-]+)*;base64,([a-zA-Z0-9\/+\n=]+)$' +REMOTE_URL_RE = r'ftp:|https?:|s3:|gs:|data:([\w-]+\/[\w-]+(\+[\w-]+)?)?(;[\w-]+=[\w-]+)*;base64,([a-zA-Z0-9\/+\n=]+)$' __LAYER_KEYWORD_PARAMS = [("font_weight", "normal"), ("font_style", "normal"), ("text_decoration", "none"), @@ -65,27 +71,38 @@ 'use_root_path', 'version', 'long_url_signature', + 'signature_algorithm', ] __SIMPLE_UPLOAD_PARAMS = [ "public_id", + "public_id_prefix", "callback", "format", "type", "backup", "faces", "image_metadata", + "media_metadata", "exif", "colors", "use_filename", "unique_filename", + "display_name", + "use_filename_as_display_name", "discard_original_filename", + "filename_override", "invalidate", "notification_url", "eager_notification_url", "eager_async", + "eval", + "on_success", "proxy", "folder", + "asset_folder", + "use_asset_folder_as_public_id_prefix", + "unique_display_name", "overwrite", "moderation", "raw_convert", @@ -95,6 +112,7 @@ "categorization", "detection", "similarity_search", + "visual_search", "background_removal", "upload_preset", "phash", @@ -102,6 +120,7 @@ "auto_tagging", "async", "cinemagraph_analysis", + "accessibility_analysis", ] __SERIALIZED_UPLOAD_PARAMS = [ @@ -113,6 +132,7 @@ "allowed_formats", "face_coordinates", "custom_coordinates", + "regions", "context", "auto_tagging", "responsive_breakpoints", @@ -122,25 +142,56 @@ upload_params = __SIMPLE_UPLOAD_PARAMS + __SERIALIZED_UPLOAD_PARAMS +_SIMPLE_TRANSFORMATION_PARAMS = { + "ac": "audio_codec", + "af": "audio_frequency", + "br": "bit_rate", + "cs": "color_space", + "d": "default_image", + "dl": "delay", + "dn": "density", + "f": "fetch_format", + "g": "gravity", + "p": "prefix", + "pg": "page", + "sp": "streaming_profile", + "vs": "video_sampling", + } + +SHORT_URL_SIGNATURE_LENGTH = 8 +LONG_URL_SIGNATURE_LENGTH = 32 + +SIGNATURE_SHA1 = "sha1" +SIGNATURE_SHA256 = "sha256" -def compute_hex_hash(s): +signature_algorithms = { + SIGNATURE_SHA1: hashlib.sha1, + SIGNATURE_SHA256: hashlib.sha256, +} + + +def compute_hex_hash(s, algorithm=SIGNATURE_SHA1): """ - Compute hash and convert the result to HEX string + Computes string hash using specified algorithm and return HEX string representation of hash. - :param s: string to process + :param s: String to compute hash for + :param algorithm: The name of algorithm to use for computing hash - :return: HEX string + :return: HEX string of computed hash value """ - return hashlib.sha1(to_bytes(s)).hexdigest() + try: + hash_fn = signature_algorithms[algorithm] + except KeyError: + raise ValueError('Unsupported hash algorithm: {}'.format(algorithm)) + return hash_fn(to_bytes(s)).hexdigest() def build_array(arg): - if isinstance(arg, list): + if isinstance(arg, (list, tuple)): return arg elif arg is None: return [] - else: - return [arg] + return [arg] def build_list_of_dicts(val): @@ -189,8 +240,7 @@ def encode_double_array(array): array = build_array(array) if len(array) > 0 and isinstance(array[0], list): return "|".join([",".join([str(i) for i in build_array(inner)]) for inner in array]) - else: - return encode_list([str(i) for i in array]) + return encode_list([str(i) for i in array]) def encode_dict(arg): @@ -200,42 +250,80 @@ def encode_dict(arg): else: items = arg.iteritems() return "|".join((k + "=" + v) for k, v in items) - else: - return arg + return arg -def encode_context(context): +def normalize_context_value(value): """ - :param context: dict of context to be encoded - :return: a joined string of all keys and values properly escaped and separated by a pipe character + Escape "=" and "|" delimiter characters and json encode lists + + :param value: Value to escape + :type value: int or str or list or tuple + + :return: The normalized value + :rtype: str """ + if isinstance(value, (list, tuple)): + value = json_encode(value) + + return str(value).replace("=", "\\=").replace("|", "\\|") + + +def encode_context(context): + """ + Encode metadata fields based on incoming value. + + List and tuple values are encoded to json strings. + + :param context: dict of context to be encoded + + :return: a joined string of all keys and values properly escaped and separated by a pipe character + """ if not isinstance(context, dict): return context - return "|".join(("{}={}".format(k, v.replace("=", "\\=").replace("|", "\\|"))) for k, v in iteritems(context)) + return "|".join(("{}={}".format(k, normalize_context_value(v))) for k, v in iteritems(context)) -def json_encode(value): +def json_encode(value, sort_keys=False): """ Converts value to a json encoded string :param value: value to be encoded + :param sort_keys: whether to sort keys :return: JSON encoded string """ - return json.dumps(value, default=__json_serializer, separators=(',', ':')) + + if isinstance(value, str) or value is None: + return value + + return json.dumps(value, default=__json_serializer, separators=(',', ':'), sort_keys=sort_keys) + + +def encode_date_to_usage_api_format(date_obj): + """ + Encodes date object to `dd-mm-yyyy` format string + + :param date_obj: datetime.date object to encode + + :return: Encoded date as a string + """ + return date_obj.strftime('%d-%m-%Y') def patch_fetch_format(options): """ When upload type is fetch, remove the format options. In addition, set the fetch_format options to the format value unless it was already set. - Mutates the options parameter! + Mutates the "options" parameter! :param options: URL and transformation options """ - if options.get("type", "upload") != "fetch": + use_fetch_format = options.pop("use_fetch_format", cloudinary.config().use_fetch_format) + + if options.get("type", "upload") != "fetch" and not use_fetch_format: return resource_format = options.pop("format", None) @@ -273,8 +361,7 @@ def generate_transformation_string(**options): def recurse(bs): if isinstance(bs, dict): return generate_transformation_string(**bs)[0] - else: - return generate_transformation_string(transformation=bs)[0] + return generate_transformation_string(transformation=bs)[0] base_transformations = list(map(recurse, base_transformations)) named_transformation = None @@ -297,8 +384,17 @@ def recurse(bs): flags = ".".join(build_array(options.pop("flags", None))) dpr = options.pop("dpr", cloudinary.config().dpr) duration = norm_range_value(options.pop("duration", None)) - start_offset = norm_auto_range_value(options.pop("start_offset", None)) - end_offset = norm_range_value(options.pop("end_offset", None)) + + so_raw = options.pop("start_offset", None) + start_offset = norm_auto_range_value(so_raw) + if start_offset == None: + start_offset = so_raw + + eo_raw = options.pop("end_offset", None) + end_offset = norm_range_value(eo_raw) + if end_offset == None: + end_offset = eo_raw + offset = split_range(options.pop("offset", None)) if offset: start_offset = norm_auto_range_value(offset[0]) @@ -346,23 +442,8 @@ def recurse(bs): "vc": video_codec, "z": normalize_expression(options.pop('zoom', None)) } - simple_params = { - "ac": "audio_codec", - "af": "audio_frequency", - "br": "bit_rate", - "cs": "color_space", - "d": "default_image", - "dl": "delay", - "dn": "density", - "f": "fetch_format", - "g": "gravity", - "p": "prefix", - "pg": "page", - "sp": "streaming_profile", - "vs": "video_sampling", - } - for param, option in simple_params.items(): + for param, option in _SIMPLE_TRANSFORMATION_PARAMS.items(): params[param] = options.pop(option, None) variables = options.pop('variables', {}) @@ -441,8 +522,7 @@ def split_range(range): return [range[0], range[-1]] elif isinstance(range, string_types) and re.match(RANGE_RE, range): return range.split("..", 1) - else: - return None + return None def norm_range_value(value): @@ -474,6 +554,9 @@ def process_video_codec_param(param): out_param = out_param + ':' + param['profile'] if 'level' in param: out_param = out_param + ':' + param['level'] + if param.get('b_frames') is False: + out_param = out_param + ':' + 'bframes_no' + return out_param @@ -486,7 +569,7 @@ def process_radius(param): raise ValueError("Invalid radius param") return ':'.join(normalize_expression(t) for t in param) - return str(param) + return normalize_expression(str(param)) def process_params(params): @@ -495,17 +578,39 @@ def process_params(params): processed_params = {} for key, value in params.items(): if isinstance(value, list) or isinstance(value, tuple): + if len(value) == 2 and value[0] == "file": # keep file parameter as is. + processed_params[key] = value + continue value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)} processed_params.update(value_list) - elif value: + elif value is not None: processed_params[key] = value return processed_params def cleanup_params(params): + """ + Cleans and normalizes parameters when calculating signature in Upload API. + + :param params: + :return: + """ return dict([(k, __safe_value(v)) for (k, v) in params.items() if v is not None and not v == ""]) +def normalize_params(params): + """ + Normalizes Admin API parameters. + + :param params: + :return: + """ + if not params or not isinstance(params, dict): + return params + + return dict([(k, __bool_string(v)) for (k, v) in params.items() if v is not None and not v == ""]) + + def sign_request(params, options): api_key = options.get("api_key", cloudinary.config().api_key) if not api_key: @@ -513,18 +618,71 @@ def sign_request(params, options): api_secret = options.get("api_secret", cloudinary.config().api_secret) if not api_secret: raise ValueError("Must supply api_secret") + signature_algorithm = options.get("signature_algorithm", cloudinary.config().signature_algorithm) + signature_version = options.get("signature_version", cloudinary.config().signature_version) params = cleanup_params(params) - params["signature"] = api_sign_request(params, api_secret) + params["signature"] = api_sign_request(params, api_secret, signature_algorithm, signature_version) params["api_key"] = api_key return params -def api_sign_request(params_to_sign, api_secret): - params = [(k + "=" + (",".join(v) if isinstance(v, list) else str(v))) for k, v in params_to_sign.items() if v] - to_sign = "&".join(sorted(params)) - return compute_hex_hash(to_sign + api_secret) +def api_sign_request(params_to_sign, api_secret, algorithm=SIGNATURE_SHA1, signature_version=2): + """ + Signs API request parameters using the specified algorithm and signature version. + + :param params_to_sign: Parameters to include in the signature + :param api_secret: API secret key + :param algorithm: Signature algorithm (default: SHA1) + :param signature_version: Signature version (default: 2) + - Version 1: Original behavior without parameter encoding + - Version 2+: Includes parameter encoding to prevent parameter smuggling + :return: Computed signature + """ + to_sign = api_string_to_sign(params_to_sign, signature_version) + return compute_hex_hash(to_sign + api_secret, algorithm) + + +def api_string_to_sign(params_to_sign, signature_version=2): + """ + Generates a string to be signed for API requests. + + :param params_to_sign: Parameters to include in the signature + :param signature_version: Version of signature algorithm to use: + - Version 1: Original behavior without parameter encoding + - Version 2+ (default): Includes parameter encoding to prevent parameter smuggling + :return: String to be signed + """ + params = [] + for k, v in params_to_sign.items(): + if v: + if isinstance(v, list): + value = ",".join(v) + elif isinstance(v, bool): + value = str(v).lower() + else: + value = str(v) + + param_string = k + "=" + value + if signature_version >= 2: + param_string = _encode_param(param_string) + params.append(param_string) + + return "&".join(sorted(params)) + + +def _encode_param(value): + """ + Encodes a parameter for safe inclusion in URL query strings. + + Specifically replaces "&" characters with their percent-encoded equivalent "%26" + to prevent them from being interpreted as parameter separators in URL query strings. + + :param value: The parameter to encode + :return: Encoded parameter + """ + return str(value).replace("&", "%26") def breakpoint_settings_mapper(breakpoint_settings): @@ -635,6 +793,25 @@ def unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, return prefix +def build_distribution_domain(options): + source = options.pop('source', '') + cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name or None) + if cloud_name is None: + raise ValueError("Must supply cloud_name in tag or in configuration") + secure = options.pop("secure", cloudinary.config().secure) + private_cdn = options.pop("private_cdn", cloudinary.config().private_cdn) + cname = options.pop("cname", cloudinary.config().cname) + secure_distribution = options.pop("secure_distribution", + cloudinary.config().secure_distribution) + cdn_subdomain = options.pop("cdn_subdomain", cloudinary.config().cdn_subdomain) + secure_cdn_subdomain = options.pop("secure_cdn_subdomain", + cloudinary.config().secure_cdn_subdomain) + + return unsigned_download_url_prefix( + source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, + cname, secure, secure_distribution) + + def merge(*dict_args): result = None for dictionary in dict_args: @@ -663,25 +840,15 @@ def cloudinary_url(source, **options): version = options.pop("version", None) format = options.pop("format", None) - cdn_subdomain = options.pop("cdn_subdomain", cloudinary.config().cdn_subdomain) - secure_cdn_subdomain = options.pop("secure_cdn_subdomain", - cloudinary.config().secure_cdn_subdomain) - cname = options.pop("cname", cloudinary.config().cname) shorten = options.pop("shorten", cloudinary.config().shorten) - cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name or None) - if cloud_name is None: - raise ValueError("Must supply cloud_name in tag or in configuration") - secure = options.pop("secure", cloudinary.config().secure) - private_cdn = options.pop("private_cdn", cloudinary.config().private_cdn) - secure_distribution = options.pop("secure_distribution", - cloudinary.config().secure_distribution) sign_url = options.pop("sign_url", cloudinary.config().sign_url) api_secret = options.pop("api_secret", cloudinary.config().api_secret) url_suffix = options.pop("url_suffix", None) use_root_path = options.pop("use_root_path", cloudinary.config().use_root_path) auth_token = options.pop("auth_token", None) long_url_signature = options.pop("long_url_signature", cloudinary.config().long_url_signature) + signature_algorithm = options.pop("signature_algorithm", cloudinary.config().signature_algorithm) if auth_token is not False: auth_token = merge(cloudinary.config().auth_token, auth_token) @@ -705,16 +872,24 @@ def cloudinary_url(source, **options): transformation = re.sub(r'([^:])/+', r'\1/', transformation) signature = None - if sign_url and not auth_token: + if sign_url and (not auth_token or auth_token.pop('set_url_signature', False)): to_sign = "/".join(__compact([transformation, source_to_sign])) - hash_fn, chars_length = (hashlib.sha256, 32) if long_url_signature else (hashlib.sha1, 8) + if long_url_signature: + # Long signature forces SHA256 + signature_algorithm = SIGNATURE_SHA256 + chars_length = LONG_URL_SIGNATURE_LENGTH + else: + chars_length = SHORT_URL_SIGNATURE_LENGTH + if signature_algorithm not in signature_algorithms: + raise ValueError("Unsupported signature algorithm '{}'".format(signature_algorithm)) + hash_fn = signature_algorithms[signature_algorithm] signature = "s--" + to_string( base64.urlsafe_b64encode( hash_fn(to_bytes(to_sign + api_secret)).digest())[0:chars_length]) + "--" - prefix = unsigned_download_url_prefix( - source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, - cname, secure, secure_distribution) + options["source"] = source + prefix = build_distribution_domain(options) + source = "/".join(__compact( [prefix, resource_type, type, signature, transformation, version, source])) if sign_url and auth_token: @@ -724,15 +899,30 @@ def cloudinary_url(source, **options): return source, options -def cloudinary_api_url(action='upload', **options): - cloudinary_prefix = options.get("upload_prefix", cloudinary.config().upload_prefix)\ +def base_api_url(path, **options): + cloudinary_prefix = options.get("upload_prefix", cloudinary.config().upload_prefix) \ or "https://api.cloudinary.com" cloud_name = options.get("cloud_name", cloudinary.config().cloud_name) + if not cloud_name: raise ValueError("Must supply cloud_name") + + path = build_array(path) + + return encode_unicode_url("/".join([cloudinary_prefix, cloudinary.API_VERSION, cloud_name] + path)) + + +def cloudinary_api_url(action='upload', **options): resource_type = options.get("resource_type", "image") - return encode_unicode_url("/".join([cloudinary_prefix, "v1_1", cloud_name, resource_type, action])) + return base_api_url([resource_type, action], **options) + + +def cloudinary_api_download_url(action, params, **options): + params = params.copy() + params["mode"] = "download" + cloudinary_params = sign_request(params, options) + return cloudinary_api_url(action, **options) + "?" + urlencode(bracketize_seq(cloudinary_params), True) def cloudinary_scaled_url(source, width, transformation, options): @@ -833,11 +1023,7 @@ def bracketize_seq(params): def download_archive_url(**options): - params = options.copy() - params.update(mode="download") - cloudinary_params = sign_request(archive_params(**params), options) - return cloudinary_api_url("generate_archive", **options) + "?" + \ - urlencode(bracketize_seq(cloudinary_params), True) + return cloudinary_api_download_url(action="generate_archive", params=archive_params(**options), **options) def download_zip_url(**options): @@ -846,6 +1032,47 @@ def download_zip_url(**options): return download_archive_url(**new_options) +def download_folder(folder_path, **options): + """ + Creates and returns a URL that when invoked creates an archive of a folder. + :param folder_path: The full path from the root that is used to generate download url. + :type folder_path: str + :param options: Additional options. + :type options: dict, optional + :return: Signed URL to download the folder. + :rtype: str + """ + options["prefixes"] = folder_path + options.setdefault("resource_type", "all") + + return download_archive_url(**options) + + +def download_backedup_asset(asset_id, version_id, **options): + """ + The returned url allows downloading the backedup asset based on the the asset ID and the version ID. + + Parameters asset_id and version_id are returned with api.resource(, versions=True) API call. + + :param asset_id: The asset ID of the asset. + :type asset_id: str + :param version_id: The version ID of the asset. + :type version_id: str + :param options: Additional options. + :type options: dict, optional + :return:The signed URL for downloading backup version of the asset. + :rtype: str + """ + params = { + "timestamp": options.get("timestamp", now()), + "asset_id": asset_id, + "version_id": version_id + } + cloudinary_params = sign_request(params, options) + + return base_api_url("download_backup", **options) + "?" + urlencode(bracketize_seq(cloudinary_params), True) + + def generate_auth_token(**options): token_options = merge(cloudinary.config().auth_token, options) return auth_token.generate(**token_options) @@ -873,6 +1100,7 @@ def archive_params(**options): "skip_transformation_name": options.get("skip_transformation_name"), "tags": options.get("tags") and build_array(options.get("tags")), "target_format": options.get("target_format"), + "target_asset_folder": options.get("target_asset_folder"), "target_public_id": options.get("target_public_id"), "target_tags": options.get("target_tags") and build_array(options.get("target_tags")), "timestamp": timestamp, @@ -927,7 +1155,8 @@ def build_custom_headers(headers): def build_upload_params(**options): - params = {param_name: options.get(param_name) for param_name in __SIMPLE_UPLOAD_PARAMS} + params = {param_name: options.get(param_name) for param_name in __SIMPLE_UPLOAD_PARAMS if param_name in options} + params["upload_preset"] = params.pop("upload_preset", cloudinary.config().upload_preset) serialized_params = { "timestamp": now(), @@ -939,6 +1168,7 @@ def build_upload_params(**options): "allowed_formats": options.get("allowed_formats") and encode_list(build_array(options["allowed_formats"])), "face_coordinates": encode_double_array(options.get("face_coordinates")), "custom_coordinates": encode_double_array(options.get("custom_coordinates")), + "regions": json_encode(options.get("regions")), "context": encode_context(options.get("context")), "auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")), "responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")), @@ -954,7 +1184,62 @@ def build_upload_params(**options): return params +def handle_file_parameter(file, filename): + if not file: + return None + + if PathLibPathType and isinstance(file, PathLibPathType): + name = filename or file.name + data = file.read_bytes() + elif isinstance(file, string_types): + if is_remote_url(file): + # URL + name = None + data = file + else: + # file path + name = filename or file + with open(file, "rb") as opened: + data = opened.read() + elif hasattr(file, 'read') and callable(file.read): + # stream + data = file.read() + name = filename or (file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream") + elif isinstance(file, tuple): + name, data = file + else: + # Not a string, not a stream + name = filename or "file" + data = file + + return (name, data) if name else data + + +def build_multi_and_sprite_params(**options): + """ + Build params for multi, download_multi, generate_sprite, and download_generated_sprite methods + """ + tag = options.get("tag") + urls = options.get("urls") + if bool(tag) == bool(urls): + raise ValueError("Either 'tag' or 'urls' parameter has to be set but not both") + params = { + "mode": options.get("mode"), + "timestamp": now(), + "async": options.get("async"), + "notification_url": options.get("notification_url"), + "tag": tag, + "urls": urls, + "transformation": generate_transformation_string(fetch_format=options.get("format"), **options)[0] + } + return params + + def __process_text_options(layer, layer_parameter): + text_style = str(layer.get("text_style", "")) + if text_style and not text_style.isspace(): + return text_style + font_family = layer.get("font_family") font_size = layer.get("font_size") keywords = [] @@ -995,8 +1280,21 @@ def __process_text_options(layer, layer_parameter): def process_layer(layer, layer_parameter): - if isinstance(layer, string_types) and layer.startswith("fetch:"): - layer = {"url": layer[len('fetch:'):]} + if isinstance(layer, string_types): + resource_type = None + if layer.startswith("fetch:"): + url = layer[len('fetch:'):] + elif layer.find(":fetch:", 0, 12) != -1: + resource_type, _, url = layer.split(":", 2) + else: + # nothing to process, a raw string, keep as is. + return layer + + # handle remote fetch URL + layer = {"url": url, "type": "fetch"} + if resource_type: + layer["resource_type"] = resource_type + if not isinstance(layer, dict): return layer @@ -1005,19 +1303,19 @@ def process_layer(layer, layer_parameter): type = layer.get("type") public_id = layer.get("public_id") format = layer.get("format") - fetch = layer.get("url") + fetch_url = layer.get("url") components = list() if text is not None and resource_type is None: resource_type = "text" - if fetch and resource_type is None: - resource_type = "fetch" + if fetch_url and type is None: + type = "fetch" if public_id is not None and format is not None: public_id = public_id + "." + format - if public_id is None and resource_type != "text" and resource_type != "fetch": + if public_id is None and resource_type != "text" and type != "fetch": raise ValueError("Must supply public_id for for non-text " + layer_parameter) if resource_type is not None and resource_type != "image": @@ -1041,8 +1339,6 @@ def process_layer(layer, layer_parameter): if text is not None: var_pattern = VAR_NAME_RE - match = re.findall(var_pattern, text) - parts = filter(lambda p: p is not None, re.split(var_pattern, text)) encoded_text = [] for part in parts: @@ -1052,11 +1348,9 @@ def process_layer(layer, layer_parameter): encoded_text.append(smart_escape(smart_escape(part, r"([,/])"))) text = ''.join(encoded_text) - # text = text.replace("%2C", "%252C") - # text = text.replace("/", "%252F") components.append(text) - elif resource_type == "fetch": - b64 = base64_encode_url(fetch) + elif type == "fetch": + b64 = base64url_encode(fetch_url) components.append(b64) else: public_id = public_id.replace("/", ':') @@ -1083,22 +1377,38 @@ def process_layer(layer, layer_parameter): PREDEFINED_VARS = { "aspect_ratio": "ar", + "aspectRatio": "ar", "current_page": "cp", + "currentPage": "cp", "face_count": "fc", + "faceCount": "fc", "height": "h", "initial_aspect_ratio": "iar", + "initialAspectRatio": "iar", + "trimmed_aspect_ratio": "tar", + "trimmedAspectRatio": "tar", "initial_height": "ih", + "initialHeight": "ih", "initial_width": "iw", + "initialWidth": "iw", "page_count": "pc", + "pageCount": "pc", "page_x": "px", + "pageX": "px", "page_y": "py", + "pageY": "py", "tags": "tags", "width": "w", "duration": "du", "initial_duration": "idu", + "initialDuration": "idu", + "illustration_score": "ils", + "illustrationScore": "ils", + "context": "ctx" } -replaceRE = "((\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*|\^)(?=[ _])|(?=|<=|&&|!=|>|=|<|/|-|\\+|\\*|\\^)(?=[ _])|(\\$_*[^_ ]+)|(? 3: + from django.urls import re_path as url +else: + from django.conf.urls import url from .views import index diff --git a/prepare.sh b/prepare.sh index c68dc9ac..79e55d41 100755 --- a/prepare.sh +++ b/prepare.sh @@ -1,7 +1,16 @@ #!/bin/bash -/bin/rm -rf cloudinary/static -mkdir -p cloudinary/static -cd cloudinary/static -curl -L https://github.com/cloudinary/cloudinary_js/tarball/master | tar zxvf - --strip=1 --exclude test '*/html' '*/js' +/bin/rm -rf cloudinary/static/cloudinary +mkdir -p cloudinary/static/cloudinary +cd cloudinary/static/cloudinary + +OPTIONS= + +# GNU tar does not support wildcards by default, it needs explicit --wildcards option, +# while BSD tar does not recognize this option +if tar --version | grep -q 'gnu'; then + OPTIONS='--wildcards' +fi + +curl -L https://github.com/cloudinary/cloudinary_js/tarball/master | tar zxvf - --strip=1 --exclude test $OPTIONS '*/html' '*/js' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..b50eaf32 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[project] +name = "cloudinary" +description = "Python and Django SDK for Cloudinary" +version = "1.44.1" + +authors = [{ name = "Cloudinary", email = "info@cloudinary.com" }] +license = { file = "LICENSE.txt" } +keywords = ["cloudinary", "image", "video", "upload", "crop", "resize", "filter", "transformation", "manipulation", "cdn"] +readme = "README.md" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Multimedia :: Graphics", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Sound/Audio :: Conversion", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Conversion", + "Topic :: Software Development :: Libraries :: Python Modules" +] +dependencies = [ + "six", + "urllib3>=1.26.5", + "certifi" +] + +[project.optional-dependencies] +dev = [ + "tox", + "pytest==4.6; python_version < '3.7'", + "pytest; python_version >= '3.7'" +] + +[project.urls] +Homepage = "https://cloudinary.com" +Source = "https://github.com/cloudinary/pycloudinary" +Changelog = "https://raw.githubusercontent.com/cloudinary/pycloudinary/master/CHANGELOG.md" + +[tool.setuptools] +include-package-data = true +zip-safe = false + +[tool.setuptools.packages.find] +exclude = ["samples", "tools", "test*", "django_tests*", "venv*"] +namespaces = false + + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/samples/README.md b/samples/README.md index 9ea0bdb9..d0b9e964 100644 --- a/samples/README.md +++ b/samples/README.md @@ -34,3 +34,11 @@ A simple GAE application that performs image upload and generates on the transfo The source code and more details are available here: [https://github.com/cloudinary/pycloudinary/tree/master/samples/gae](https://github.com/cloudinary/pycloudinary/tree/master/samples/gae) + +## SpookyShots + +Spooky Pet Image App is a fun platform that transforms ordinary pet images into spooky, Halloween-themed creations. Whether you're looking to give your cat a spooky makeover or place any pet in a chilling Halloween setting, this app has everything you need for a spooky transformation! + +The source code and more details are available here: + +[https://github.com/cloudinary/pycloudinary/tree/master/samples/spookyshots](https://github.com/cloudinary/pycloudinary/tree/master/samples/spookyshots) \ No newline at end of file diff --git a/samples/gae/requirements.txt b/samples/gae/requirements.txt index da07ff93..5fd060cf 100644 --- a/samples/gae/requirements.txt +++ b/samples/gae/requirements.txt @@ -2,5 +2,6 @@ git+git://github.com/cloudinary/pycloudinary.git docopt==0.6.2 gaenv==0.1.10.post0 six==1.10.0 -urllib3==1.24.2 +urllib3==1.* webapp2==2.5.2 +setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/samples/spookyshots/.env.example b/samples/spookyshots/.env.example new file mode 100644 index 00000000..801d240e --- /dev/null +++ b/samples/spookyshots/.env.example @@ -0,0 +1,3 @@ +CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name +CLOUDINARY_API_KEY=your_cloudinary_api_key +CLOUDINARY_API_SECRET=your_cloudinary_api_secret \ No newline at end of file diff --git a/samples/spookyshots/README.md b/samples/spookyshots/README.md new file mode 100644 index 00000000..1e62125f --- /dev/null +++ b/samples/spookyshots/README.md @@ -0,0 +1,48 @@ +# Spooky Pet Image App + +**Spooky Pet Image App** is a fun platform that transforms ordinary pet images into spooky, Halloween-themed creations. Whether you're looking to give your cat a spooky makeover or place any pet in a chilling Halloween setting, this app has everything you need for a spooky transformation! + +## Example Image Generated +### Original +![alexander-london-mJaD10XeD7w-unsplash](https://github.com/user-attachments/assets/98afa889-364a-4337-98ff-347f2a3a94e2) + +### Transformed +![user_uploaded_alexander-london-mJaD10XeD7w-unsplash-min](https://github.com/user-attachments/assets/e3e1dde3-4252-499b-80a5-4b67942b2751) + + +## Installation + +### Steps + +1. **Clone the repository**: + ```bash + git clone https://github.com/cloudinary/pycloudinary.git + cd pycloudinary/samples/spookyshots + ``` + +2. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +3. **Set up Cloudinary credentials**: + - Inside the root directory of the project, rename `.env.example` to `.env`. + - Open the `.env` file and fill in your Cloudinary credentials: + ``` + CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name + CLOUDINARY_API_KEY=your_cloudinary_api_key + CLOUDINARY_API_SECRET=your_cloudinary_api_secret + ``` + +4. **Run the app**: + ```bash + streamlit run main.py + ``` + +5. **Open the app**: + After running the command, the app should automatically open in your browser. If not, open the browser and go to: + ``` + http://localhost:8501 + ``` + +Enjoy transforming your pets for Halloween! diff --git a/samples/spookyshots/main.py b/samples/spookyshots/main.py new file mode 100644 index 00000000..6b50fd11 --- /dev/null +++ b/samples/spookyshots/main.py @@ -0,0 +1,180 @@ +import streamlit as st +from streamlit_option_menu import option_menu +import cloudinary +from cloudinary import CloudinaryImage +import cloudinary.uploader +import cloudinary.api +from dotenv import load_dotenv +import os +import time + +load_dotenv() +cloudinary.reset_config() + +MAX_FILE_SIZE_MB = 5 +MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 + +with st.sidebar: + selected = option_menu( + menu_title="Navigation", + options=["Home", "Spooky Pet Background Generator", "Spooky Cat Face Transformer"], + icons=["house", "image", "skull"], + menu_icon="cast", + default_index=0, + ) + +if selected == "Home": + st.title("Welcome to the Spooky Pet Image App! ~Powered by Cloudinary") + + st.write(""" + **Spooky Pet Image App** is a fun and creative platform that transforms ordinary pet images into spooky, Halloween-themed masterpieces. + Whether you're looking to give your cat a spooky makeover or place your pet in a chilling Halloween setting, this app has you covered! + + ### Features: + - **Spooky Pet Background Generator**: Upload an image of any pet, and the app will replace the background with a dark, foggy Halloween scene featuring eerie trees, glowing pumpkins, a haunted house, and more. + - **Spooky Cat Face Transformer**: Specifically designed for cats, this feature transforms your cat into a demonic version with glowing red eyes, sharp fangs, bat wings, and dark mist under a blood moon. You can also modify the transformation prompt for a more personalized spooky effect. + + This app leverages Cloudinary's powerful Generative AI features to make your pets look extra spooky this Halloween. Try it out, and share the spooky transformations with your friends! + """) + +if selected == "Spooky Pet Background Generator": + st.title("Spooky Halloween Pet Image Transformer") + + upload_option = st.radio("Select image source:", ("Upload a file", "Enter an image URL")) + + if upload_option == "Upload a file": + uploaded_file = st.file_uploader("Upload an image (jpg, jpeg, png)", type=["jpg", "jpeg", "png"]) + else: + image_url = st.text_input("Enter the direct URL of the image (jpg, jpeg, png)") + + default_prompt = "A dark foggy Halloween night with a full moon in the sky surrounded by twisted trees Scattered glowing pumpkins with carved faces placed around an old broken fence in the background a shadowy haunted house with dimly lit windows" + + modify_prompt = st.checkbox("Do you want to modify the generative Halloween background prompt?", value=False) + + custom_prompt = st.text_input( + "Optional: Modify the generative Halloween background prompt", + value=default_prompt, + disabled=not modify_prompt + ) + + if st.button("Submit"): + if upload_option == "Upload a file" and uploaded_file: + if uploaded_file.size > MAX_FILE_SIZE_BYTES: + st.warning(f"File size exceeds the 5 MB limit. Please upload a smaller file.") + else: + with st.spinner("Generating image... Please have patience while the image is being processed by Cloudinary."): + upload_result = cloudinary.uploader.upload( + uploaded_file, + public_id=f"user_uploaded_{uploaded_file.name[:6]}", + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + halloween_bg_image_url = CloudinaryImage(public_id).image( + effect=f"gen_background_replace:prompt_{custom_prompt}" + ) + + start_index = halloween_bg_image_url.find('src="') + len('src="') + end_index = halloween_bg_image_url.find('"', start_index) + generated_image_url = halloween_bg_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to apply the background. Please try again.") + + elif upload_option == "Enter an image URL" and image_url: + with st.spinner("Generating image... Please have patience while the image is being processed by Cloudinary."): + unique_id = f"user_uploaded_url_{int(time.time())}" + upload_result = cloudinary.uploader.upload( + image_url, + public_id=unique_id, + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + halloween_bg_image_url = CloudinaryImage(public_id).image( + effect=f"gen_background_replace:prompt_{custom_prompt}" + ) + + start_index = halloween_bg_image_url.find('src="') + len('src="') + end_index = halloween_bg_image_url.find('"', start_index) + generated_image_url = halloween_bg_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to apply the background. Please try again.") + else: + st.write("Please upload an image or provide a URL to proceed.") + +if selected == "Spooky Cat Face Transformer": + st.title("Spooky Cat Face Transformer") + + upload_option = st.radio("Select image source:", ("Upload a file", "Enter an image URL")) + + if upload_option == "Upload a file": + uploaded_cat_pic = st.file_uploader("Upload a cat image to give it a spooky transformation! (jpg, jpeg, png)", type=["jpg", "jpeg", "png"]) + else: + cat_image_url = st.text_input("Enter the direct URL of the cat image (jpg, jpeg, png)") + + default_cat_prompt = "A demonic cat with glowing red eyes sharp fangs and dark mist swirling around it under a blood moon" + + modify_cat_prompt = st.checkbox("Do you want to modify the spooky cat transformation prompt?", value=False) + + custom_cat_prompt = st.text_input( + "Optional: Modify the spooky cat transformation prompt", + value=default_cat_prompt, + disabled=not modify_cat_prompt + ) + + if st.button("Transform to Spooky"): + if upload_option == "Upload a file" and uploaded_cat_pic: + if uploaded_cat_pic.size > MAX_FILE_SIZE_BYTES: + st.warning(f"File size exceeds the 5 MB limit. Please upload a smaller file.") + else: + with st.spinner("Generating your cat's spooky transformation... Please wait while Cloudinary processes the image."): + upload_result = cloudinary.uploader.upload( + uploaded_cat_pic, + public_id=f"user_spooky_cat_{uploaded_cat_pic.name[:6]}", + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + spooky_image_url = CloudinaryImage(public_id).image( + effect=f"gen_replace:from_cat;to_{custom_cat_prompt}" + ) + + start_index = spooky_image_url.find('src="') + len('src="') + end_index = spooky_image_url.find('"', start_index) + generated_image_url = spooky_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to generate the spooky transformation. Please try again.") + + elif upload_option == "Enter an image URL" and cat_image_url: + with st.spinner("Generating your cat's spooky transformation... Please wait while Cloudinary processes the image."): + unique_id = f"user_uploaded_url_{int(time.time())}" + upload_result = cloudinary.uploader.upload( + cat_image_url, + public_id=unique_id, + unique_filename=True, + overwrite=False + ) + public_id = upload_result['public_id'] + spooky_image_url = CloudinaryImage(public_id).image( + effect=f"gen_replace:from_cat;to_{custom_cat_prompt}" + ) + + start_index = spooky_image_url.find('src="') + len('src="') + end_index = spooky_image_url.find('"', start_index) + generated_image_url = spooky_image_url[start_index:end_index] if start_index != -1 and end_index != -1 else None + + if generated_image_url: + st.image(generated_image_url) + else: + st.write("Failed to generate the spooky transformation. Please try again.") + else: + st.write("Please upload an image or provide a URL to proceed.") diff --git a/samples/spookyshots/requirements.txt b/samples/spookyshots/requirements.txt new file mode 100644 index 00000000..c81d4217 --- /dev/null +++ b/samples/spookyshots/requirements.txt @@ -0,0 +1,6 @@ +streamlit +cloudinary +python-dotenv +streamlit_option_menu +tornado>=6.4.2 # not directly required, pinned by Snyk to avoid a vulnerability +urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 406686a8..00000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[egg_info] -#tag_build = dev -#tag_svn_revision = true diff --git a/setup.py b/setup.py index c7495d39..3e770cc2 100644 --- a/setup.py +++ b/setup.py @@ -2,61 +2,72 @@ from setuptools import find_packages, setup -version = "1.21.0" +if version_info[0] >= 3: + setup() +else: + # Following code is legacy (Python 2.7 compatibility) and will be removed in the future! + # TODO: Remove in next major update (when dropping Python 2.7 compatibility) + version = "1.44.1" -with open('README.rst') as file: - long_description = file.read() + with open('README.md') as file: + long_description = file.read() -setup(name='cloudinary', - version=version, - description="Python and Django SDK for Cloudinary", - long_description=long_description, - keywords='cloudinary image video upload crop resize filter transformation manipulation cdn ', - author='Cloudinary', - author_email='info@cloudinary.com', - url='http://cloudinary.com', - license='MIT', - packages=find_packages(exclude=['ez_setup', 'examples', 'test', 'django_tests', 'django_tests.*']), - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Web Environment", - "Framework :: Django", - "Framework :: Django :: 1.8", - "Framework :: Django :: 1.9", - "Framework :: Django :: 1.10", - "Framework :: Django :: 1.11", - "Framework :: Django :: 2.0", - "Framework :: Django :: 2.1", - "Framework :: Django :: 2.2", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - "Topic :: Multimedia :: Graphics", - "Topic :: Multimedia :: Graphics :: Graphics Conversion", - "Topic :: Multimedia :: Sound/Audio", - "Topic :: Multimedia :: Sound/Audio :: Conversion", - "Topic :: Multimedia :: Video", - "Topic :: Multimedia :: Video :: Conversion", - "Topic :: Software Development :: Libraries :: Python Modules" - ], - include_package_data=True, - zip_safe=False, - test_suite="test", - install_requires=[ - "six", - "urllib3", - "certifi" - ], - tests_require=[ - "mock" + ("<4" if version_info < (3, 6) else "") - ], - ) + setup(name='cloudinary', + version=version, + description="Python and Django SDK for Cloudinary", + long_description=long_description, + long_description_content_type='text/markdown', + keywords='cloudinary image video upload crop resize filter transformation manipulation cdn ', + author='Cloudinary', + author_email='info@cloudinary.com', + url='https://cloudinary.com', + project_urls={ + 'Source': 'https://github.com/cloudinary/pycloudinary', + 'Changelog ': 'https://raw.githubusercontent.com/cloudinary/pycloudinary/master/CHANGELOG.md' + }, + license='MIT', + packages=find_packages(exclude=['ez_setup', 'examples', 'test', 'django_tests', 'django_tests.*']), + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Multimedia :: Graphics", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Sound/Audio :: Conversion", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Conversion", + "Topic :: Software Development :: Libraries :: Python Modules" + ], + include_package_data=True, + zip_safe=False, + test_suite="test", + install_requires=[ + "six", + "urllib3>=1.26.5", + "certifi" + ], + tests_require=[ + "mock<4", + "pytest" + ], + ) diff --git a/test/addon_types.py b/test/addon_types.py new file mode 100644 index 00000000..b6e8aa6b --- /dev/null +++ b/test/addon_types.py @@ -0,0 +1,21 @@ +ADDON_ALL = 'all' # Test all addons. +ADDON_ASPOSE = 'aspose' # Aspose Document Conversion. +ADDON_AZURE = 'azure' # Microsoft Azure Video Indexer. +ADDON_BG_REMOVAL = 'bgremoval' # Cloudinary AI Background Removal. +ADDON_FACIAL_ATTRIBUTES_DETECTION = 'facialattributesdetection' # Advanced Facial Attributes Detection. +# Google AI Video Moderation, Google AI, Video Transcription, Google Auto Tagging, Google Automatic Video Tagging, +# Google Translation. +ADDON_GOOGLE = 'google' +ADDON_IMAGGA = 'imagga' # Imagga Auto Tagging, Imagga Crop and Scale. +ADDON_JPEGMINI = 'jpegmini' # JPEGmini Image Optimization. +ADDON_LIGHTROOM = 'lightroom' # Adobe Photoshop Lightroom (BETA). +ADDON_METADEFENDER = 'metadefender' # MetaDefender Anti-Malware Protection. +ADDON_NEURAL_ARTWORK = 'neuralartwork' # Neural Artwork Style Transfer. +ADDON_OBJECT_AWARE_CROPPING = 'objectawarecropping' # Cloudinary Object-Aware Cropping. +ADDON_OCR = 'ocr' # OCR Text Detection and Extraction. +ADDON_PIXELZ = 'pixelz' # Remove the Background. +# Amazon Rekognition AI Moderation, Amazon Rekognition Auto Tagging, Amazon Rekognition Celebrity Detection. +ADDON_REKOGNITION = 'rekognition' +ADDON_URL2PNG = 'url2png' # URL2PNG Website Screenshots. +ADDON_VIESUS = 'viesus' # VIESUS Automatic Image Enhancement. +ADDON_WEBPURIFY = 'webpurify' # WebPurify Image Moderation. diff --git a/test/helper_test.py b/test/helper_test.py index 9292a699..c4579cd9 100644 --- a/test/helper_test.py +++ b/test/helper_test.py @@ -1,20 +1,33 @@ # -*- coding: latin-1 -*- -from contextlib import contextmanager +import json import os import random import re import sys import time +import traceback +import unittest +from contextlib import contextmanager from datetime import timedelta, tzinfo from functools import wraps -import traceback import six from urllib3 import HTTPResponse from urllib3._collections import HTTPHeaderDict +from collections import defaultdict -from cloudinary import utils, logger, api +from cloudinary import utils, logger, api, urlparse +from cloudinary.compat import parse_qsl from cloudinary.exceptions import NotFound +from test.addon_types import ADDON_ALL + +try: + from unittest import mock +except ImportError: + # Python 2.7 + import mock + +patch = mock.patch SUFFIX = os.environ.get('TRAVIS_JOB_ID') or random.randint(10000, 99999) @@ -23,6 +36,7 @@ RESOURCES_PATH = "test/resources/" TEST_IMAGE = RESOURCES_PATH + "logo.png" +TEST_IMAGE_SIZE = 3381 TEST_UNICODE_IMAGE = RESOURCES_PATH + u"üni_näme_lögö.png" TEST_DOC = RESOURCES_PATH + "docx.docx" TEST_ICON = RESOURCES_PATH + "favicon.ico" @@ -30,10 +44,26 @@ TEST_TAG = "pycloudinary_test" UNIQUE_TAG = "{0}_{1}".format(TEST_TAG, SUFFIX) UNIQUE_TEST_ID = UNIQUE_TAG -UNIQUE_TEST_FOLDER = UNIQUE_TAG + "_folder" +UNIQUE_SUB_ACCOUNT_ID = UNIQUE_TAG +TEST_FOLDER = "test_folder" +UNIQUE_TEST_FOLDER = UNIQUE_TAG + TEST_FOLDER ZERO = timedelta(0) +EVAL_STR = 'if (resource_info["width"] < 450) { upload_options["quality_analysis"] = true }; ' \ + 'upload_options["context"] = "width=" + resource_info["width"]' + +ON_SUCCESS_STR = 'current_asset.update({tags: ["autocaption"]});' + +try: + # urllib3 2.x support + # noinspection PyProtectedMember + import urllib3._request_methods + + URLLIB3_REQUEST = "urllib3._request_methods.RequestMethods.request" +except ImportError: + URLLIB3_REQUEST = "urllib3.request.RequestMethods.request" + class UTC(tzinfo): """UTC""" @@ -49,30 +79,118 @@ def dst(self, dt): def get_method(mocker): - return mocker.call_args[0][0] + return mocker.call_args[1]["method"] + + +def get_uri(mocker): + return urlparse(mocker.call_args[1]["url"]).path -def get_request_url(mocker): - return mocker.call_args[0][1] +def get_headers(mocker): + return mocker.call_args[1]["headers"] -def get_uri(args): - return args[1] +def get_params_from_url(mocker): + return parse_query_params(mocker.call_args[1]["url"]) -def get_params(args): - return args[2] or {} +def parse_query_params(url): + """ + Parses the query parameters from a URL into a dictionary. + + :param url: The URL string to parse. + :return: Dictionary of query parameters with values as lists. + """ + parsed_url = urlparse(url) + query = parsed_url.query + pairs = parse_qsl(query, keep_blank_values=True) + + params = {} + list_keys = set() + + for key, value in pairs: + if key.endswith('[]'): + key = key[:-2] # Remove the trailing '[]' + list_keys.add(key) + + if key in params: + if key in list_keys: + params[key].append(value) + else: + # If previously a scalar, convert to list + if isinstance(params[key], list): + params[key].append(value) + else: + params[key] = [params[key], value] + else: + if key in list_keys: + params[key] = [value] + else: + params[key] = value + + return params + + +def clean_params(params): + """ + Cleans the parameter keys by stripping '[]' if present. + + :param params: Dictionary of query parameters with values as lists. + :return: Cleaned dictionary with bracket-less keys. + """ + cleaned = {} + for key, values in params.items(): + if key.endswith('[]'): + key = key[:-2] # Remove the trailing '[]' + cleaned[key] = values + return cleaned + + +def get_params(mocker): + """ + Extracts query parameters from mocked urllib3.request `fields` param. + Supports both list and dict values of `fields`. Returns params as dictionary. + Supports two list params formats: + - {"urls[0]": "http://host1", "urls[1]": "http://host2"} + - [("urls[]", "http://host1"), ("urls[]", "http://host2")] + In both cases the result would be {"urls": ["http://host1", "http://host2"]} + """ + if get_headers(mocker).get("Content-Type", None) == "application/json" and get_method(mocker).upper() != "GET": + return get_json_body(mocker) + if get_method(mocker).upper() == "GET": + return get_params_from_url(mocker) + + if not mocker.call_args[1].get("fields"): + return {} + + params = {} + reg = re.compile(r'^(.*)\[\d*]$') + fields = mocker.call_args[1].get("fields") + fields = fields.items() if isinstance(fields, dict) else fields + for k, v in fields: + match = reg.match(k) + if match: + name = match.group(1) + if name not in params: + params[name] = [] + params[name].append(v) + else: + params[k] = v + return params + + +def get_json_body(mocker): + return json.loads(mocker.call_args[1]["body"].decode('utf-8') or {}) def get_param(mocker, name): """ Return the value of the parameter :param mocker: the mocked object - :param name: the name of the paramer + :param name: the name of the parameter :return: the value of the parameter if present or None """ - args, kargs = mocker.call_args - params = get_params(args) + params = get_params(mocker) return params.get(name) @@ -83,10 +201,7 @@ def get_list_param(mocker, name): :param name: the name of the parameter :return: a list of values """ - args, kargs = mocker.call_args - params = get_params(args) - reg = re.compile(r'{}\[\d*\]'.format(name)) - return [params[key] for key in params.keys() if reg.match(key)] + return get_param(mocker, name) def http_response_mock(body="", headers=None, status=200): @@ -99,10 +214,10 @@ def http_response_mock(body="", headers=None, status=200): return HTTPResponse(body, HTTPHeaderDict(headers), status=status) -def api_response_mock(): - return http_response_mock('{"foo":"bar"}', {"x-featureratelimit-limit": '0', - "x-featureratelimit-reset": 'Sat, 01 Apr 2017 22:00:00 GMT', - "x-featureratelimit-remaining": '0'}) +def api_response_mock(body='{"foo":"bar"}'): + return http_response_mock(body, {"x-featureratelimit-limit": '0', + "x-featureratelimit-reset": 'Sat, 01 Apr 2017 22:00:00 GMT', + "x-featureratelimit-remaining": '0'}) def uploader_response_mock(): @@ -134,6 +249,7 @@ def retry_assertion(num_tries=3, delay=3): :param num_tries: Number of tries to perform :param delay: Delay in seconds between retries """ + def retry_decorator(func): @wraps(func) def retry_func(*args, **kwargs): @@ -183,3 +299,55 @@ def cleanup_test_transformation(params): for transformation in transformations_with_options[0]: with ignore_exception(suppress_traceback_classes=(NotFound,)): api.delete_transformation(transformation, **options) + + +def should_test_addon(addon): + """ + Checks whether a certain add-on should be tested. + :param addon: The add-on name. + :type addon: str + :return: True if that add-on should be tested, False otherwise. + :rtype: bool + """ + cld_test_addons = os.environ.get('CLD_TEST_ADDONS').lower() + if cld_test_addons == ADDON_ALL: + return True + cld_test_addons_list = [addon_name.strip() for addon_name in cld_test_addons.split(',')] + return addon in cld_test_addons_list + + +class CldTestCase(unittest.TestCase): + """ + A custom test case class that extends unittest.TestCase. + It provides the assertCountEqual method for Python 2.7 compatibility, + handling unhashable elements by serializing them. + """ + + if six.PY2: + def assertCountEqual(self, list1, list2, msg=None): + """ + Fail if two sequences do not contain the same elements the same number of times, + regardless of their order. Handles unhashable elements by serializing them. + This is a compatibility method for Python 2.7. + """ + + def serialize_item(item): + try: + # Attempt to serialize the item to a JSON string + return json.dumps(item, sort_keys=True) + except (TypeError, ValueError): + # Fallback: use the string representation + return str(item) + + def count_elements(lst): + counts = defaultdict(int) + for item in lst: + serialized = serialize_item(item) + counts[serialized] += 1 + return counts + + count1 = count_elements(list1) + count2 = count_elements(list2) + if count1 != count2: + standard_msg = '%s != %s' % (count1, count2) + self.fail(self._formatMessage(msg, standard_msg)) diff --git a/test/test_api.py b/test/test_api.py index b3de2807..ed26c03e 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,20 +1,25 @@ +from datetime import datetime, timedelta import time import unittest from collections import OrderedDict import six -from mock import patch + from urllib3 import disable_warnings, ProxyManager, PoolManager import cloudinary from cloudinary import api, uploader, utils -from test.helper_test import SUFFIX, TEST_IMAGE, get_uri, get_params, get_list_param, get_param, TEST_DOC, get_method, \ - UNIQUE_TAG, api_response_mock, ignore_exception, cleanup_test_resources_by_tag, cleanup_test_transformation, \ - cleanup_test_resources, UNIQUE_TEST_FOLDER +from cloudinary.utils import fq_public_id +from test.helper_test import SUFFIX, TEST_IMAGE, get_uri, get_headers, get_params, get_list_param, get_param, \ + TEST_DOC, get_method, UNIQUE_TAG, api_response_mock, ignore_exception, cleanup_test_resources_by_tag, \ + cleanup_test_transformation, cleanup_test_resources, UNIQUE_TEST_FOLDER, EVAL_STR, get_json_body, REMOTE_TEST_IMAGE, \ + TEST_IMAGE_SIZE, URLLIB3_REQUEST, patch from cloudinary.exceptions import BadRequest, NotFound MOCK_RESPONSE = api_response_mock() +METADATA_EXTERNAL_ID = "metadata_external_id_{}".format(UNIQUE_TAG) +METADATA_DEFAULT_VALUE = "metadata_default_value_{}".format(UNIQUE_TAG) UNIQUE_API_TAG = 'api_{}'.format(UNIQUE_TAG) API_TEST_TAG = "api_test_{}_tag".format(SUFFIX) API_TEST_PREFIX = "api_test_{}".format(SUFFIX) @@ -25,6 +30,11 @@ API_TEST_ID5 = "api_test_{}5".format(SUFFIX) API_TEST_ID6 = "api_test_{}6".format(SUFFIX) API_TEST_ID7 = "api_test_{}7".format(SUFFIX) +API_TEST_ASSET_ID = "4af5a0d1d4047808528b5425d166c101" +API_TEST_ASSET_ID_VERSION_ID = "ded32c1fa9b710b04574f0676133c00a" +API_TEST_ASSET_ID_VERSION_ID_2 = "aae2bae059d13e1ef0ec1742033bb5f7" +API_TEST_ASSET_ID2 = "4af5a0d1d4047808528b5425d166c102" +API_TEST_ASSET_ID3 = "4af5a0d1d4047808528b5425d166c103" API_TEST_TRANS = "api_test_transformation_{}".format(SUFFIX) API_TEST_TRANS2 = "api_test_transformation_{}2".format(SUFFIX) API_TEST_TRANS3 = "api_test_transformation_{}3".format(SUFFIX) @@ -38,6 +48,7 @@ API_TEST_TRANS_SEPIA = {"crop": "lfill", "width": 400, "effect": "sepia"} API_TEST_TRANS_SEPIA_STR = "c_lfill,e_sepia,w_400" API_TEST_PRESET = "api_test_upload_preset" +ASSET_FOLDER = "asset_folder" PREFIX = "test_folder_{}".format(SUFFIX) MAPPING_TEST_ID = "api_test_upload_mapping_{}".format(SUFFIX) RESTORE_TEST_ID = "api_test_restore_{}".format(SUFFIX) @@ -53,14 +64,29 @@ def setUpClass(cls): if not cloudinary.config().api_secret: return print("Running tests for cloud: {}".format(cloudinary.config().cloud_name)) - for id in [API_TEST_ID, API_TEST_ID2]: - uploader.upload(TEST_IMAGE, - public_id=id, tags=[API_TEST_TAG, ], - context="key=value", eager=[API_TEST_TRANS_SCALE100], - overwrite=True) + + api.add_metadata_field({ + "external_id": METADATA_EXTERNAL_ID, + "label": METADATA_EXTERNAL_ID, + "type": "string", + "default_value": METADATA_DEFAULT_VALUE + }) + + global API_TEST_ASSET_ID, API_TEST_ASSET_ID2 + API_TEST_ASSET_ID = cls._upload_test_asset(API_TEST_ID) + API_TEST_ASSET_ID2 = cls._upload_test_asset(API_TEST_ID2) + + @staticmethod + def _upload_test_asset(public_id): + return uploader.upload(TEST_IMAGE, + public_id=public_id, tags=[API_TEST_TAG, ], + context="key=value", eager=[API_TEST_TRANS_SCALE100], + overwrite=True)["asset_id"] @classmethod def tearDownClass(cls): + api.delete_metadata_field(METADATA_EXTERNAL_ID) + cleanup_test_resources([([API_TEST_ID, API_TEST_ID2, API_TEST_ID3, API_TEST_ID4, API_TEST_ID5],)]) cleanup_test_transformation([ @@ -75,6 +101,27 @@ def tearDownClass(cls): with ignore_exception(suppress_traceback_classes=(NotFound,)): api.delete_upload_mapping(MAPPING_TEST_ID) + def assert_usage_result(self, usage_api_response): + """Asserts that a given object fits the generic structure of the usage API response + + See: `Sample response of usage API `_ + + :param usage_api_response: The response from usage API + """ + keys = ["plan", + "last_updated", + "transformations", + "objects", + "bandwidth", + "storage", + "requests", + "resources", + "derived_resources", + "media_limits"] + + for key in keys: + self.assertIn(key, usage_api_response) + def test_http_connector(self): """ should create proper http connector in case api_proxy is set """ cert_kwargs = { @@ -85,7 +132,7 @@ def test_http_connector(self): http = utils.get_http_connector(conf, cert_kwargs) self.assertIsInstance(http, PoolManager) - conf = cloudinary.config(api_proxy='http://www.example.com:3128') + conf = cloudinary.config(api_proxy='https://www.example.com:3128') http = utils.get_http_connector(conf, cert_kwargs) cloudinary.reset_config() @@ -94,10 +141,20 @@ def test_http_connector(self): @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_rate_limits(self): """ should include details of the account's rate limits""" - result = api.ping() - self.assertIsInstance(result.rate_limit_allowed, int) - self.assertIsInstance(result.rate_limit_reset_at, tuple) - self.assertIsInstance(result.rate_limit_remaining, int) + results = [ + api.ping(), + api.root_folders(), + api.resource_types(), + ] + + for result in results: + self.assertIsInstance(result.rate_limit_allowed, int) + self.assertIsInstance(result.rate_limit_reset_at, tuple) + self.assertIsInstance(result.rate_limit_remaining, int) + + self.assertGreater(result.rate_limit_allowed, 0) + self.assertIsNotNone(result.rate_limit_reset_at) + self.assertGreater(result.rate_limit_remaining, 0) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test01_resource_types(self): @@ -106,14 +163,14 @@ def test01_resource_types(self): test01_resource_types.tags = ['safe'] - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test02_resources(self, mocker): """ should allow listing resources """ mocker.return_value = MOCK_RESPONSE api.resources() - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/resources/image')) + + self.assertTrue(get_uri(mocker).endswith('/resources/image')) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test03_resources_cursor(self): @@ -128,52 +185,96 @@ def test03_resources_cursor(self): self.assertEqual(len(result2["resources"]), 1) self.assertNotEqual(result2["resources"][0]["public_id"], result["resources"][0]["public_id"]) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test04_resources_types(self, mocker): """ should allow listing resources by type """ mocker.return_value = MOCK_RESPONSE api.resources(type="upload", context=True, tags=True) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/resources/image/upload')) - self.assertTrue(get_params(args)['context']) - self.assertTrue(get_params(args)['tags']) + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload')) + self.assertTrue(get_params(mocker)['context']) + self.assertTrue(get_params(mocker)['tags']) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test05_resources_prefix(self, mocker): """ should allow listing resources by prefix """ mocker.return_value = MOCK_RESPONSE api.resources(prefix=API_TEST_PREFIX, context=True, tags=True, type="upload") - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/resources/image/upload')) - self.assertEqual(get_params(args)['prefix'], API_TEST_PREFIX) - self.assertTrue(get_params(args)['context']) - self.assertTrue(get_params(args)['tags']) - @patch('urllib3.request.RequestMethods.request') + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload')) + self.assertEqual(get_params(mocker)['prefix'], API_TEST_PREFIX) + self.assertTrue(get_params(mocker)['context']) + self.assertTrue(get_params(mocker)['tags']) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test06_resources_fields(self, mocker): + """ should allow listing resources and returning only specified fields """ + mocker.return_value = MOCK_RESPONSE + api.resources(fields=["tags", "secure_url"], type="upload") + + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload')) + self.assertEqual(get_params(mocker)['fields'], "tags,secure_url") + + api.resources(fields="context,url", type="upload") + + self.assertEqual(get_params(mocker)['fields'], "context,url") + + api.resources(fields="", type="upload") + + self.assertNotIn('fields', get_params(mocker)) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test06_resources_tag(self, mocker): """ should allow listing resources by tag """ mocker.return_value = MOCK_RESPONSE api.resources_by_tag(API_TEST_TAG, context=True, tags=True) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/resources/image/tags/{}'.format(API_TEST_TAG))) - self.assertTrue(get_params(args)['context']) - self.assertTrue(get_params(args)['tags']) - @patch('urllib3.request.RequestMethods.request') + self.assertTrue(get_uri(mocker).endswith('/resources/image/tags/{}'.format(API_TEST_TAG))) + self.assertTrue(get_params(mocker)['context']) + self.assertTrue(get_params(mocker)['tags']) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test06a_resources_by_ids(self, mocker): """ should allow listing resources by public ids """ mocker.return_value = MOCK_RESPONSE api.resources_by_ids([API_TEST_ID, API_TEST_ID2], context=True, tags=True) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/resources/image/upload'), get_uri(args)) + + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload'), get_uri(mocker)) self.assertIn(API_TEST_ID, get_list_param(mocker, 'public_ids')) self.assertIn(API_TEST_ID2, get_list_param(mocker, 'public_ids')) - self.assertEqual(get_param(mocker, 'context'), True) - self.assertEqual(get_param(mocker, 'tags'), True) + self.assertEqual(get_param(mocker, 'context'), 'true') + self.assertEqual(get_param(mocker, 'tags'), 'true') + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test06b_resources_by_asset_id(self, mocker): + """ should allow listing resources by public ids """ + mocker.return_value = MOCK_RESPONSE + api.resources_by_asset_ids([API_TEST_ASSET_ID], context=True, tags=True) + + self.assertTrue(get_uri(mocker).endswith('/resources/by_asset_ids'), get_uri(mocker)) + self.assertIn(API_TEST_ASSET_ID, get_list_param(mocker, 'asset_ids')) + self.assertEqual(get_param(mocker, 'context'), 'true') + self.assertEqual(get_param(mocker, 'tags'), 'true') + self.assertEqual(get_list_param(mocker, 'asset_ids').__len__(), 1) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test06c_resources_by_asset_ids(self, mocker): + """ should allow listing resources by public ids """ + mocker.return_value = MOCK_RESPONSE + api.resources_by_asset_ids([API_TEST_ASSET_ID, API_TEST_ASSET_ID2], context=True, tags=True) + + self.assertTrue(get_uri(mocker).endswith('/resources/by_asset_ids'), get_uri(mocker)) + self.assertIn(API_TEST_ASSET_ID, get_list_param(mocker, 'asset_ids')) + self.assertIn(API_TEST_ASSET_ID2, get_list_param(mocker, 'asset_ids')) + self.assertEqual(get_param(mocker, 'context'), 'true') + self.assertEqual(get_param(mocker, 'tags'), 'true') + self.assertEqual(get_list_param(mocker, 'asset_ids').__len__(), 2) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_resources_by_context(self): @@ -189,19 +290,67 @@ def test_resources_by_context(self): result = api.resources_by_context(API_TEST_CONTEXT_KEY, API_TEST_CONTEXT_VALUE1) self.assertEqual(len(result["resources"]), 1) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test06b_resources_direction(self, mocker): """ should allow listing resources in both directions """ mocker.return_value = MOCK_RESPONSE api.resources_by_tag(API_TEST_TAG, direction="asc", type="upload") - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/resources/image/tags/{}'.format(API_TEST_TAG))) - self.assertEqual(get_params(args)['direction'], 'asc') + + self.assertTrue(get_uri(mocker).endswith('/resources/image/tags/{}'.format(API_TEST_TAG))) + self.assertEqual(get_params(mocker)['direction'], 'asc') api.resources_by_tag(API_TEST_TAG, direction="desc", type="upload") - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/resources/image/tags/{}'.format(API_TEST_TAG))) - self.assertEqual(get_params(args)['direction'], 'desc') + + self.assertTrue(get_uri(mocker).endswith('/resources/image/tags/{}'.format(API_TEST_TAG))) + self.assertEqual(get_params(mocker)['direction'], 'desc') + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_visual_search(self, mocker): + """ should allow using visual search """ + mocker.return_value = MOCK_RESPONSE + + test_params = {"image_url": REMOTE_TEST_IMAGE, "image_asset_id": API_TEST_ASSET_ID, "text": "sample image"} + + for param_name, param_value in test_params.items(): + api.visual_search(**{param_name: param_value}) + + args, kwargs = mocker.call_args + self.assertTrue(get_uri(mocker).endswith('/resources/visual_search')) + self.assertEqual('POST', get_method(mocker)) + + actual_params = get_params(mocker) + self.assertEqual(param_value, actual_params[param_name]) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_visual_search_image_file(self, mocker): + """ should allow using visual search """ + mocker.return_value = MOCK_RESPONSE + + api.visual_search(image_file=TEST_IMAGE) + + self.assertTrue(get_uri(mocker).endswith('/resources/visual_search')) + self.assertEqual('POST', get_method(mocker)) + + params = get_params(mocker) + self.assertIn('image_file', params) + self.assertEqual(2, len(params['image_file'])) + self.assertEqual('file', params['image_file'][0]) + self.assertEqual(TEST_IMAGE_SIZE, len(params['image_file'][1])) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_extra_headers(self, mocker): + """Should support extra headers""" + mocker.return_value = MOCK_RESPONSE + uploader.upload(TEST_IMAGE, extra_headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/58.0.3029.110 Safari/537.3'}) + headers = get_headers(mocker) + self.assertEqual(headers.get('User-Agent'), 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/58.0.3029.110 Safari/537.3') @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test07_resource_metadata(self): @@ -209,9 +358,43 @@ def test07_resource_metadata(self): resource = api.resource(API_TEST_ID) self.assertNotEqual(resource, None) self.assertEqual(resource["public_id"], API_TEST_ID) - self.assertEqual(resource["bytes"], 3381) + self.assertEqual(resource["bytes"], TEST_IMAGE_SIZE) + self.assertEqual(len(resource["derived"]), 1, "{} should have one derived resource.".format(API_TEST_ID)) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test07_a_resource_by_asset_id(self, mocker): + mocker.return_value = MOCK_RESPONSE + api.resource_by_asset_id(API_TEST_ASSET_ID, quality_analysis=True, colors=True, accessibility_analysis=True, + media_metadata=True) + + self.assertTrue(get_uri(mocker).endswith('/resources/{}'.format(API_TEST_ASSET_ID))) + self.assertTrue(get_params(mocker)['quality_analysis']) + self.assertTrue(get_params(mocker)['colors']) + self.assertTrue(get_params(mocker)['accessibility_analysis']) + self.assertTrue(get_params(mocker)['media_metadata']) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test07_b_resource_by_asset_id(self): + # should allow get resource by asset_id + resource = api.resource_by_asset_id(API_TEST_ASSET_ID) + self.assertNotEqual(resource, None) + self.assertEqual(resource["asset_id"], API_TEST_ASSET_ID) + self.assertEqual(resource["public_id"], API_TEST_ID) + self.assertEqual(resource["bytes"], TEST_IMAGE_SIZE) self.assertEqual(len(resource["derived"]), 1, "{} should have one derived resource.".format(API_TEST_ID)) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_resources_by_asset_folder(self, mocker): + mocker.return_value = MOCK_RESPONSE + api.resources_by_asset_folder(ASSET_FOLDER, context=True, tags=True) + + self.assertTrue(get_uri(mocker).endswith('/resources/by_asset_folder')) + self.assertTrue(get_params(mocker)['context']) + self.assertTrue(get_params(mocker)['tags']) + self.assertEqual(ASSET_FOLDER, get_param(mocker, "asset_folder")) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test07a_resource_quality_analysis(self): """ should allow getting resource quality analysis """ @@ -222,26 +405,39 @@ def test07a_resource_quality_analysis(self): self.assertIn("focus", resource["quality_analysis"]) self.assertIsInstance(resource["quality_analysis"]["focus"], float) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test07b_resource_allows_derived_next_cursor_parameter(self, mocker): """ should allow derived_next_cursor parameter """ mocker.return_value = MOCK_RESPONSE api.resource(API_TEST_ID, derived_next_cursor=NEXT_CURSOR) args, kwargs = mocker.call_args - self.assertTrue("derived_next_cursor" in get_params(args)) - - @patch('urllib3.request.RequestMethods.request') + self.assertTrue("derived_next_cursor" in get_params(mocker)) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test07c_resource_allows_versions(self, mocker): + """ should allow versions parameter """ + mocker.return_value = MOCK_RESPONSE + + api.resource(API_TEST_ID, versions=True) + + params = get_params(mocker) + + self.assertIn("versions", params) + self.assertTrue(params["versions"]) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test08_delete_derived(self, mocker): """ should allow deleting derived resource """ mocker.return_value = MOCK_RESPONSE api.delete_derived_resources([API_TEST_ID]) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/derived_resources')) + + self.assertTrue(get_uri(mocker).endswith('/derived_resources')) self.assertIn(API_TEST_ID, get_list_param(mocker, 'derived_resource_ids')) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test08a_delete_derived_by_transformation(self, mocker): """ should allow deleting derived resource by transformations """ @@ -252,9 +448,8 @@ def test08a_delete_derived_by_transformation(self, mocker): mocker.return_value = MOCK_RESPONSE api.delete_derived_by_transformation(public_resource_id, transformation) - method, url, params = mocker.call_args[0][0:3] - self.assertEqual('DELETE', method) - self.assertTrue(url.endswith('/resources/image/upload')) + self.assertEqual('DELETE', get_method(mocker)) + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload')) self.assertIn(public_resource_id, get_list_param(mocker, 'public_ids')) self.assertEqual(get_param(mocker, 'transformations'), utils.build_eager([transformation])) self.assertTrue(get_param(mocker, 'keep_original')) @@ -263,50 +458,62 @@ def test08a_delete_derived_by_transformation(self, mocker): api.delete_derived_by_transformation( [public_resource_id, public_resource_id2], [transformation, transformation2], resource_type='raw', type='fetch', invalidate=True, foo='bar') - method, url, params = mocker.call_args[0][0:3] - self.assertEqual('DELETE', method) - self.assertTrue(url.endswith('/resources/raw/fetch')) + self.assertEqual('DELETE', get_method(mocker)) + self.assertTrue(get_uri(mocker).endswith('/resources/raw/fetch')) self.assertIn(public_resource_id, get_list_param(mocker, 'public_ids')) self.assertIn(public_resource_id2, get_list_param(mocker, 'public_ids')) self.assertEqual(get_param(mocker, 'transformations'), utils.build_eager([transformation, transformation2])) self.assertTrue(get_param(mocker, 'keep_original')) self.assertTrue(get_param(mocker, 'invalidate')) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test09_delete_resources(self, mocker): """ should allow deleting resources """ mocker.return_value = MOCK_RESPONSE api.delete_resources([API_TEST_ID, API_TEST_ID2]) - args, kargs = mocker.call_args - self.assertEqual(args[0], 'DELETE') - self.assertTrue(get_uri(args).endswith('/resources/image/upload')) + + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload')) param = get_list_param(mocker, 'public_ids') self.assertIn(API_TEST_ID, param) self.assertIn(API_TEST_ID2, param) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test09_delete_resources_by_asset_ids(self, mocker): + """ should allow deleting resources by asset_ids""" + mocker.return_value = MOCK_RESPONSE + api.delete_resources_by_asset_ids([API_TEST_ASSET_ID, API_TEST_ASSET_ID2]) + + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources')) + param = get_json_body(mocker)['asset_ids'] + self.assertIn(API_TEST_ASSET_ID, param) + self.assertIn(API_TEST_ASSET_ID2, param) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test09a_delete_resources_by_prefix(self, mocker): """ should allow deleting resources by prefix """ mocker.return_value = MOCK_RESPONSE api.delete_resources_by_prefix("api_test") - args, kargs = mocker.call_args - self.assertEqual(args[0], 'DELETE') - self.assertTrue(get_uri(args).endswith('/resources/image/upload')) - self.assertEqual(get_params(args)['prefix'], "api_test") - @patch('urllib3.request.RequestMethods.request') + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload')) + self.assertEqual(get_params(mocker)['prefix'], "api_test") + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test09b_delete_resources_by_tag(self, mocker): """ should allow deleting resources by tags """ mocker.return_value = MOCK_RESPONSE api.delete_resources_by_tag("api_test_tag_for_delete") - args, kargs = mocker.call_args - self.assertEqual(args[0], 'DELETE') - self.assertTrue(get_uri(args).endswith('/resources/image/tags/api_test_tag_for_delete')) - @patch('urllib3.request.RequestMethods.request') + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/image/tags/api_test_tag_for_delete')) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test09c_delete_resources_by_transformations(self, mocker): """ should allow deleting resources by transformations """ @@ -328,37 +535,102 @@ def test09c_delete_resources_by_transformations(self, mocker): self.assertEqual(get_method(mocker), 'DELETE') self.assertEqual(get_param(mocker, 'transformations'), 'c_crop,w_100') - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test09_delete_resources_tuple(self, mocker): """ should allow deleting resources """ mocker.return_value = MOCK_RESPONSE api.delete_resources((API_TEST_ID, API_TEST_ID2,)) - args, kargs = mocker.call_args - self.assertEqual(args[0], 'DELETE') - self.assertTrue(get_uri(args).endswith('/resources/image/upload')) + + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/image/upload')) param = get_list_param(mocker, 'public_ids') self.assertIn(API_TEST_ID, param) self.assertIn(API_TEST_ID2, param) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_add_related_assets(self, mocker): + """ should allow adding related assets """ + mocker.return_value = MOCK_RESPONSE + api.add_related_assets(API_TEST_ID, [fq_public_id(API_TEST_ID2), fq_public_id(API_TEST_ID3)]) + + self.assertEqual(get_method(mocker), 'POST') + self.assertTrue(get_uri(mocker).endswith('/resources/related_assets/image/upload/' + API_TEST_ID)) + param = get_json_body(mocker)['assets_to_relate'] + self.assertIn(fq_public_id(API_TEST_ID2), param) + self.assertIn(fq_public_id(API_TEST_ID3), param) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_add_related_assets_by_asset_ids(self, mocker): + """ should allow adding related assets by asset ids""" + mocker.return_value = MOCK_RESPONSE + api.add_related_assets_by_asset_ids(API_TEST_ASSET_ID, [API_TEST_ASSET_ID2, API_TEST_ASSET_ID3]) + args, _ = mocker.call_args + self.assertEqual(get_method(mocker), 'POST') + self.assertTrue(get_uri(mocker).endswith('/resources/related_assets/' + API_TEST_ASSET_ID)) + param = get_json_body(mocker)['assets_to_relate'] + self.assertIn(API_TEST_ASSET_ID2, param) + self.assertIn(API_TEST_ASSET_ID3, param) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_delete_related_assets(self, mocker): + """ should allow deleting related assets """ + mocker.return_value = MOCK_RESPONSE + api.delete_related_assets(API_TEST_ID, [fq_public_id(API_TEST_ID2), fq_public_id(API_TEST_ID3)]) + + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/related_assets/image/upload/' + API_TEST_ID)) + param = get_json_body(mocker)['assets_to_unrelate'] + self.assertIn(fq_public_id(API_TEST_ID2), param) + self.assertIn(fq_public_id(API_TEST_ID3), param) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_delete_related_assets_by_asset_ids(self, mocker): + """ should allow deleting related assets by asset ids """ + mocker.return_value = MOCK_RESPONSE + api.delete_related_assets_by_asset_ids(API_TEST_ASSET_ID, [API_TEST_ASSET_ID2, API_TEST_ASSET_ID3]) + args, _ = mocker.call_args + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/related_assets/' + API_TEST_ASSET_ID)) + param = get_json_body(mocker)['assets_to_unrelate'] + self.assertIn(API_TEST_ASSET_ID2, param) + self.assertIn(API_TEST_ASSET_ID3, param) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_delete_backed_up_assets(self, mocker): + """ should allow deleting backed up versions of an asset by asset id""" + mocker.return_value = MOCK_RESPONSE + api.delete_backed_up_assets(API_TEST_ASSET_ID, [API_TEST_ASSET_ID_VERSION_ID, API_TEST_ASSET_ID_VERSION_ID_2]) + args, _ = mocker.call_args + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/resources/backup/' + API_TEST_ASSET_ID)) + version_ids = get_json_body(mocker)['version_ids'] + self.assertIn(API_TEST_ASSET_ID_VERSION_ID, version_ids) + self.assertIn(API_TEST_ASSET_ID_VERSION_ID_2, version_ids) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test10_tags(self, mocker): """ should allow listing tags """ mocker.return_value = MOCK_RESPONSE api.tags() - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/tags/image')) - @patch('urllib3.request.RequestMethods.request') + self.assertTrue(get_uri(mocker).endswith('/tags/image')) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test11_tags_prefix(self, mocker): """ should allow listing tag by prefix """ mocker.return_value = MOCK_RESPONSE api.tags(prefix=API_TEST_PREFIX) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('/tags/image')) - self.assertEqual(get_params(args)['prefix'], API_TEST_PREFIX) + + self.assertTrue(get_uri(mocker).endswith('/tags/image')) + self.assertEqual(get_params(mocker)['prefix'], API_TEST_PREFIX) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test12_transformations(self): @@ -369,24 +641,23 @@ def test12_transformations(self): self.assertIsNotNone(transformation) self.assertIn("used", transformation) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test12a_transformations_cursor(self, mocker): """ should allow listing transformations with cursor """ mocker.return_value = MOCK_RESPONSE api.transformation(API_TEST_TRANS_SCALE100, next_cursor=NEXT_CURSOR, max_results=10) - params = mocker.call_args[0][2] - self.assertEqual(params['next_cursor'], NEXT_CURSOR) - self.assertEqual(params['max_results'], 10) + self.assertEqual(get_param(mocker, 'next_cursor'), NEXT_CURSOR) + self.assertEqual(get_param(mocker, 'max_results'), '10') - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_transformations_list_named(self, mocker): """ should allow listing only named transformations""" mocker.return_value = MOCK_RESPONSE api.transformations(named=True) - params = mocker.call_args[0][2] - self.assertEqual(params['named'], True) + params = get_params(mocker) + self.assertEqual(params['named'], 'true') @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test13_transformation_metadata(self): @@ -398,29 +669,29 @@ def test13_transformation_metadata(self): self.assertNotEqual(transformation, None) self.assertEqual(transformation["info"], [API_TEST_TRANS_SCALE100]) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test14_transformation_update(self, mocker): """ should allow updating transformation allowed_for_strict """ mocker.return_value = MOCK_RESPONSE api.update_transformation(API_TEST_TRANS_SCALE100_STR, allowed_for_strict=True) - args, kargs = mocker.call_args - self.assertEqual(args[0], 'PUT') - self.assertTrue(get_uri(args).endswith('/transformations')) - self.assertTrue(get_params(args)['allowed_for_strict']) - self.assertEqual(API_TEST_TRANS_SCALE100_STR, get_params(args)['transformation']) - @patch('urllib3.request.RequestMethods.request') + self.assertEqual(get_method(mocker), 'PUT') + self.assertTrue(get_uri(mocker).endswith('/transformations')) + self.assertTrue(get_params(mocker)['allowed_for_strict']) + self.assertEqual(API_TEST_TRANS_SCALE100_STR, get_params(mocker)['transformation']) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test15_transformation_create(self, mocker): """ should allow creating named transformation """ mocker.return_value = MOCK_RESPONSE api.create_transformation(API_TEST_TRANS, {"crop": "scale", "width": 102}) - args, kargs = mocker.call_args - self.assertEqual(args[0], 'POST') - self.assertTrue(get_uri(args).endswith('/transformations'), get_uri(args)) - self.assertEqual(get_params(args)['transformation'], 'c_scale,w_102') - self.assertEqual(API_TEST_TRANS, get_params(args)['name']) + + self.assertEqual(get_method(mocker), 'POST') + self.assertTrue(get_uri(mocker).endswith('/transformations'), get_uri(mocker)) + self.assertEqual(get_params(mocker)['transformation'], 'c_scale,w_102') + self.assertEqual(API_TEST_TRANS, get_params(mocker)['name']) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test15a_transformation_unsafe_update(self): @@ -432,7 +703,7 @@ def test15a_transformation_unsafe_update(self): self.assertEqual(transformation["info"], [{"crop": "scale", "width": 103}]) self.assertEqual(transformation["used"], False) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test15b_transformation_create_unnamed_with_format(self, mocker): """ should allow creating unnamed transformation with extension""" @@ -445,14 +716,12 @@ def test15b_transformation_create_unnamed_with_format(self, mocker): api.create_transformation(with_extension_str, with_extension) - args, kargs = mocker.call_args - - self.assertEqual(args[0], 'POST') - self.assertTrue(get_uri(args).endswith('/transformations'), get_uri(args)) - self.assertEqual(with_extension_str, get_params(args)['transformation']) - self.assertEqual(with_extension_str, get_params(args)['name']) + self.assertEqual(get_method(mocker), 'POST') + self.assertTrue(get_uri(mocker).endswith('/transformations'), get_uri(mocker)) + self.assertEqual(with_extension_str, get_params(mocker)['transformation']) + self.assertEqual(with_extension_str, get_params(mocker)['name']) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test15c_transformation_create_unnamed_with_empty_format(self, mocker): """ should allow creating unnamed transformation with empty extension""" @@ -465,12 +734,10 @@ def test15c_transformation_create_unnamed_with_empty_format(self, mocker): api.create_transformation(with_extension_str, with_extension) - args, kargs = mocker.call_args - - self.assertEqual(args[0], 'POST') - self.assertTrue(get_uri(args).endswith('/transformations'), get_uri(args)) - self.assertEqual(with_extension_str, get_params(args)['transformation']) - self.assertEqual(with_extension_str, get_params(args)['name']) + self.assertEqual(get_method(mocker), 'POST') + self.assertTrue(get_uri(mocker).endswith('/transformations'), get_uri(mocker)) + self.assertEqual(with_extension_str, get_params(mocker)['transformation']) + self.assertEqual(with_extension_str, get_params(mocker)['name']) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test16_transformation_delete(self): @@ -480,21 +747,51 @@ def test16_transformation_delete(self): api.delete_transformation(API_TEST_TRANS2) self.assertRaises(NotFound, api.transformation, API_TEST_TRANS2) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test17_transformation_implicit(self, mocker): """ should allow deleting implicit transformation """ mocker.return_value = MOCK_RESPONSE api.delete_transformation(API_TEST_TRANS_SCALE100) - args, kargs = mocker.call_args - self.assertEqual(args[0], 'DELETE') - self.assertTrue(get_uri(args).endswith('/transformations')) - self.assertEqual(API_TEST_TRANS_SCALE100_STR, get_params(args)['transformation']) + + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/transformations')) + self.assertEqual(API_TEST_TRANS_SCALE100_STR, get_params(mocker)['transformation']) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test18_usage(self): """ should support usage API """ - self.assertIn("last_updated", api.usage()) + self.assert_usage_result(api.usage()) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test18a_usage_by_date(self): + """ Should return usage values for a specific date """ + yesterday = datetime.now() - timedelta(1) + + result_1 = api.usage(date=yesterday) + self.assert_usage_result(result_1) + + result_2 = api.usage(date=utils.encode_date_to_usage_api_format(yesterday)) + self.assert_usage_result(result_2) + + # Verify that the structure of the response is of a single day + self.assertNotIn("limit", result_1["bandwidth"]) + self.assertNotIn("used_percent", result_1["bandwidth"]) + + self.assertNotIn("limit", result_2["bandwidth"]) + self.assertNotIn("used_percent", result_2["bandwidth"]) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_config(self): + """ should support config API """ + res = api.config() + + self.assertEqual(cloudinary.config().cloud_name, res["cloud_name"]) + self.assertNotIn("settings", res) + + res = api.config(settings=True) + + self.assertIn("settings", res) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @unittest.skip("Skip delete all derived resources by default") @@ -521,14 +818,23 @@ def test20_manual_moderation(self): self.assertEqual(api_result["moderation"][0]["status"], "approved") self.assertEqual(api_result["moderation"][0]["kind"], "manual") - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test21_notification_url(self, mocker): """ should support notification_url param """ mocker.return_value = MOCK_RESPONSE api.update("api_test", notification_url="http://example.com") - params = mocker.call_args[0][2] - self.assertEqual(params['notification_url'], "http://example.com") + notification_url = get_param(mocker, 'notification_url') + self.assertEqual(notification_url, "http://example.com") + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test21_update_metadata(self, mocker): + """ should support notification_url param """ + mocker.return_value = MOCK_RESPONSE + api.update("api_test", metadata={"key": "value"}) + metadata = get_param(mocker, "metadata") + self.assertEqual(metadata, "key=value") @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test22_raw_conversion(self): @@ -549,17 +855,17 @@ def test24_detection(self): with six.assertRaisesRegex(self, BadRequest, 'Illegal value'): api.update(API_TEST_ID, detection="illegal") - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test26_1_ocr(self, mocker): """ should support requesting ocr """ mocker.return_value = MOCK_RESPONSE api.update(API_TEST_ID, ocr='adv_ocr') - args, kargs = mocker.call_args - params = get_params(args) + + params = get_params(mocker) self.assertEqual(params['ocr'], 'adv_ocr') - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test26_2_quality_override(self, mocker): """ should support quality_override """ @@ -567,37 +873,37 @@ def test26_2_quality_override(self, mocker): test_values = ['auto:advanced', 'auto:best', '80:420', 'none'] for quality in test_values: api.update("api_test", quality_override=quality) - params = mocker.call_args[0][2] - self.assertEqual(params['quality_override'], quality) + quality_override = get_param(mocker, 'quality_override') + self.assertEqual(quality_override, quality) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test27_start_at(self, mocker): """ should allow listing resources by start date """ mocker.return_value = MOCK_RESPONSE start_at = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()) api.resources(type="upload", start_at=start_at, direction="asc") - args, kargs = mocker.call_args - params = get_params(args) + + params = get_params(mocker) self.assertEqual(params['start_at'], start_at) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test28_create_upload_preset(self, mocker): """ should allow creating upload_presets """ mocker.return_value = MOCK_RESPONSE - api.create_upload_preset(name=API_TEST_PRESET, folder="folder", live=True) - - args, kargs = mocker.call_args + api.create_upload_preset(name=API_TEST_PRESET, folder="folder", live=True, + eval=EVAL_STR) - self.assertTrue(get_uri(args).endswith("/upload_presets")) + self.assertTrue(get_uri(mocker).endswith("/upload_presets")) self.assertEqual("POST", get_method(mocker)) self.assertEqual(get_param(mocker, "name"), API_TEST_PRESET) self.assertEqual(get_param(mocker, "folder"), "folder") self.assertTrue(get_param(mocker, "live")) + self.assertEqual(EVAL_STR, get_param(mocker, "eval")) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test28a_list_upload_presets(self, mocker): """ should allow listing upload_presets """ @@ -605,12 +911,10 @@ def test28a_list_upload_presets(self, mocker): api.upload_presets() - args, kargs = mocker.call_args - - self.assertTrue(get_uri(args).endswith("/upload_presets")) + self.assertTrue(get_uri(mocker).endswith("/upload_presets")) self.assertEqual("GET", get_method(mocker)) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test29_get_upload_presets(self, mocker): """ should allow getting a single upload_preset """ @@ -618,34 +922,34 @@ def test29_get_upload_presets(self, mocker): api.upload_preset(API_TEST_PRESET) - args, kargs = mocker.call_args - - self.assertTrue(get_uri(args).endswith("/upload_presets/" + API_TEST_PRESET)) + self.assertTrue(get_uri(mocker).endswith("/upload_presets/" + API_TEST_PRESET)) self.assertEqual("GET", get_method(mocker)) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test30_delete_upload_presets(self, mocker): """ should allow deleting upload_presets """ mocker.return_value = MOCK_RESPONSE api.delete_upload_preset(API_TEST_PRESET) - args, kargs = mocker.call_args - self.assertEqual(args[0], 'DELETE') - self.assertTrue(get_uri(args).endswith('/upload_presets/{}'.format(API_TEST_PRESET))) - @patch('urllib3.request.RequestMethods.request') + self.assertEqual(get_method(mocker), 'DELETE') + self.assertTrue(get_uri(mocker).endswith('/upload_presets/{}'.format(API_TEST_PRESET))) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test31_update_upload_presets(self, mocker): """ should allow getting a single upload_preset """ mocker.return_value = MOCK_RESPONSE - api.update_upload_preset(API_TEST_PRESET, colors=True, unsigned=True, disallow_public_id=True, live=True) - args, kargs = mocker.call_args - self.assertEqual(args[0], 'PUT') - self.assertTrue(get_uri(args).endswith('/upload_presets/{}'.format(API_TEST_PRESET))) - self.assertTrue(get_params(args)['colors']) - self.assertTrue(get_params(args)['unsigned']) - self.assertTrue(get_params(args)['disallow_public_id']) - self.assertTrue(get_params(args)['live']) + api.update_upload_preset(API_TEST_PRESET, colors=True, unsigned=True, disallow_public_id=True, live=True, + eval=EVAL_STR) + + self.assertEqual(get_method(mocker), 'PUT') + self.assertTrue(get_uri(mocker).endswith('/upload_presets/{}'.format(API_TEST_PRESET))) + self.assertTrue(get_params(mocker)['colors']) + self.assertTrue(get_params(mocker)['unsigned']) + self.assertTrue(get_params(mocker)['disallow_public_id']) + self.assertTrue(get_params(mocker)['live']) + self.assertEqual(EVAL_STR, get_param(mocker, "eval")) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test32_background_removal(self): @@ -653,6 +957,30 @@ def test32_background_removal(self): with six.assertRaisesRegex(self, BadRequest, 'Illegal value'): api.update(API_TEST_ID, background_removal="illegal") + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test33_update_asset(self, mocker): + """Should pass update params """ + mocker.return_value = MOCK_RESPONSE + api.update(API_TEST_ID, + asset_folder="folder_new_update", + display_name="new_display_name", + unique_display_name=True, + regions=OrderedDict((("box_1", [[1, 2], [3, 4]]), ("box_2", [[5, 6], [7, 8]])))) + + self.assertEqual("folder_new_update", get_param(mocker, "asset_folder")) + self.assertEqual("new_display_name", get_param(mocker, "display_name")) + self.assertTrue(get_param(mocker, "unique_display_name")) + self.assertEqual('{"box_1":[[1,2],[3,4]],"box_2":[[5,6],[7,8]]}', get_param(mocker, "regions")) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test34_update_clear_invalid(self, mocker): + """Should pass folder decoupling params """ + mocker.return_value = MOCK_RESPONSE + api.update(API_TEST_ID, clear_invalid=True) + self.assertTrue(get_param(mocker, "clear_invalid")) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @unittest.skip("For this test to work, 'Auto-create folders' should be enabled in the Upload Settings, " + "and the account should be empty of folders. " + @@ -672,55 +1000,63 @@ def test_folder_listing(self): with six.assertRaisesRegex(self, NotFound): api.subfolders(PREFIX) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) def test_create_folder(self, mocker): """ should create folder """ mocker.return_value = MOCK_RESPONSE api.create_folder(UNIQUE_TEST_FOLDER) - args, kargs = mocker.call_args - self.assertEqual("POST", get_method(mocker)) - self.assertTrue(get_uri(args).endswith('/folders/' + UNIQUE_TEST_FOLDER)) + self.assertTrue(get_uri(mocker).endswith('/folders/' + UNIQUE_TEST_FOLDER)) + + @patch(URLLIB3_REQUEST) + def test_rename_folder(self, mocker): + """ should rename folder """ + mocker.return_value = MOCK_RESPONSE + + api.rename_folder(UNIQUE_TEST_FOLDER, UNIQUE_TEST_FOLDER + "_new") + + self.assertEqual("PUT", get_method(mocker)) + self.assertTrue(get_uri(mocker).endswith('/folders/' + UNIQUE_TEST_FOLDER)) + self.assertTrue("to_folder" in get_params(mocker)) + self.assertEqual(UNIQUE_TEST_FOLDER + "_new", get_params(mocker)["to_folder"]) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) def test_delete_folder(self, mocker): """ should delete folder """ mocker.return_value = MOCK_RESPONSE - api.delete_folder(UNIQUE_TEST_FOLDER) - - args, kargs = mocker.call_args + api.delete_folder(UNIQUE_TEST_FOLDER, skip_backup=True) self.assertEqual("DELETE", get_method(mocker)) - self.assertTrue(get_uri(args).endswith('/folders/' + UNIQUE_TEST_FOLDER)) + self.assertTrue(get_uri(mocker).endswith('/folders/' + UNIQUE_TEST_FOLDER)) + self.assertTrue("skip_backup" in get_params(mocker)) + self.assertEqual(True, get_params(mocker)["skip_backup"]) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_root_folders_allows_next_cursor_and_max_results_parameter(self, mocker): """ should allow next_cursor and max_results parameters """ mocker.return_value = MOCK_RESPONSE - + api.root_folders(next_cursor=NEXT_CURSOR, max_results=10) - - args, kwargs = mocker.call_args - - self.assertTrue("next_cursor" in get_params(args)) - self.assertTrue("max_results" in get_params(args)) - @patch('urllib3.request.RequestMethods.request') + self.assertTrue("next_cursor" in get_params(mocker)) + self.assertTrue("max_results" in get_params(mocker)) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_subfolders_allows_next_cursor_and_max_results_parameter(self, mocker): """ should allow next_cursor and max_results parameters """ mocker.return_value = MOCK_RESPONSE - + api.subfolders(API_TEST_ID, next_cursor=NEXT_CURSOR, max_results=10) - + args, kwargs = mocker.call_args - - self.assertTrue("next_cursor" in get_params(args)) - self.assertTrue("max_results" in get_params(args)) + + self.assertTrue("next_cursor" in get_params(mocker)) + self.assertTrue("max_results" in get_params(mocker)) def test_CloudinaryImage_len(self): """Tests the __len__ function on CloudinaryImage""" @@ -739,7 +1075,7 @@ def test_restore(self): uploader.upload(TEST_IMAGE, public_id=RESTORE_TEST_ID, backup=True, tags=[UNIQUE_API_TAG]) resource = api.resource(RESTORE_TEST_ID) self.assertNotEqual(resource, None) - self.assertEqual(resource["bytes"], 3381) + self.assertEqual(resource["bytes"], TEST_IMAGE_SIZE) api.delete_resources([RESTORE_TEST_ID]) resource = api.resource(RESTORE_TEST_ID) self.assertNotEqual(resource, None) @@ -748,10 +1084,40 @@ def test_restore(self): response = api.restore([RESTORE_TEST_ID]) info = response[RESTORE_TEST_ID] self.assertNotEqual(info, None) - self.assertEqual(info["bytes"], 3381) + self.assertEqual(info["bytes"], TEST_IMAGE_SIZE) resource = api.resource(RESTORE_TEST_ID) self.assertNotEqual(resource, None) - self.assertEqual(resource["bytes"], 3381) + self.assertEqual(resource["bytes"], TEST_IMAGE_SIZE) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_restore_versions(self, mocker): + mocker.return_value = MOCK_RESPONSE + + public_ids = ["pub1", "pub2"] + versions = ["ver1", "ver2"] + + api.restore(public_ids, versions=versions) + + json_body = get_json_body(mocker) + + self.assertListEqual(public_ids, json_body["public_ids"]) + self.assertListEqual(versions, json_body["versions"]) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_restore_by_asset_ids(self, mocker): + mocker.return_value = MOCK_RESPONSE + + asset_ids = [API_TEST_ASSET_ID, API_TEST_ASSET_ID2] + versions = ["ver1", "ver2"] + + api.restore_by_asset_ids(asset_ids, versions=versions) + + json_body = get_json_body(mocker) + + self.assertListEqual(asset_ids, json_body["asset_ids"]) + self.assertListEqual(versions, json_body["versions"]) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_mapping(self): @@ -763,38 +1129,39 @@ def test_upload_mapping(self): result = api.upload_mapping(MAPPING_TEST_ID) self.assertEqual(result["template"], "http://res.cloudinary.com") result = api.upload_mappings() - self.assertIn({"folder": MAPPING_TEST_ID, "template": "http://res.cloudinary.com"}, - result["mappings"]) + self.assertTrue(any( + mapping.get("folder") == MAPPING_TEST_ID and mapping.get("template") == "http://res.cloudinary.com" for + mapping in result["mappings"])) api.delete_upload_mapping(MAPPING_TEST_ID) result = api.upload_mappings() self.assertNotIn(MAPPING_TEST_ID, [mapping.get("folder") for mapping in result["mappings"]]) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_publish_by_ids(self, mocker): mocker.return_value = MOCK_RESPONSE api.publish_by_ids(["pub1", "pub2"]) - self.assertTrue(get_uri(mocker.call_args[0]).endswith('/resources/image/publish_resources')) + self.assertTrue(get_uri(mocker).endswith('/resources/image/publish_resources')) self.assertIn('pub1', get_list_param(mocker, 'public_ids')) self.assertIn('pub2', get_list_param(mocker, 'public_ids')) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_publish_by_prefix(self, mocker): mocker.return_value = MOCK_RESPONSE api.publish_by_prefix("pub_prefix") - self.assertTrue(get_uri(mocker.call_args[0]).endswith('/resources/image/publish_resources')) + self.assertTrue(get_uri(mocker).endswith('/resources/image/publish_resources')) self.assertEqual(get_param(mocker, 'prefix'), 'pub_prefix') - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_publish_by_tag(self, mocker): mocker.return_value = MOCK_RESPONSE api.publish_by_tag("pub_tag") - self.assertTrue(get_uri(mocker.call_args[0]).endswith('/resources/image/publish_resources')) + self.assertTrue(get_uri(mocker).endswith('/resources/image/publish_resources')) self.assertEqual(get_param(mocker, 'tag'), "pub_tag") - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_update_access_control(self, mocker): """ should allow the user to define ACL in the update parameters """ @@ -807,23 +1174,30 @@ def test_update_access_control(self, mocker): api.update(API_TEST_ID, access_control=acl) - params = get_params(mocker.call_args[0]) + params = get_params(mocker) self.assertIn("access_control", params) self.assertEqual(exp_acl, params["access_control"]) - @patch('urllib3.request.RequestMethods.request') - def test_cinemagraph_analysis_resource(self, mocker): - """ should allow the user to pass cinemagraph_analysis in the resource function """ + @patch(URLLIB3_REQUEST) + def test_various_resource_parameters(self, mocker): + """ should allow the user to pass various parameters to the resource function """ mocker.return_value = MOCK_RESPONSE - api.resource(API_TEST_ID, cinemagraph_analysis=True) + options = { + "cinemagraph_analysis": True, + "accessibility_analysis": True, + "related": True, + "related_next_cursor": NEXT_CURSOR, + } - params = get_params(mocker.call_args[0]) + api.resource(TEST_IMAGE, **options) - self.assertIn("cinemagraph_analysis", params) + params = get_params(mocker) + for param in options.keys(): + self.assertIn(param, params) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) def test_api_url_escapes_special_characters(self, mocker): """ should escape special characters in api url """ mocker.return_value = MOCK_RESPONSE @@ -832,7 +1206,84 @@ def test_api_url_escapes_special_characters(self, mocker): args, kwargs = mocker.call_args - self.assertTrue(get_uri(args).endswith('a%20b%2Bc%20d-e%3F%3Ff%28g%29h')) + self.assertTrue(get_uri(mocker).endswith('a%20b%2Bc%20d-e%3F%3Ff%28g%29h')) + + def test_structured_metadata_in_resources_api(self): + result = api.resources(prefix=API_TEST_ID, type="upload", metadata=True) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertIn("metadata", resource) + + result = api.resources(prefix=API_TEST_ID, type="upload", metadata=False) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertNotIn("metadata", resource) + + def test_structured_metadata_in_resources_by_tag_api(self): + result = api.resources_by_tag(API_TEST_TAG, metadata=True) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertIn("metadata", resource) + + result = api.resources_by_tag(API_TEST_TAG, metadata=False) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertNotIn("metadata", resource) + + def test_structured_metadata_in_resources_by_context_api(self): + uploader.upload(TEST_IMAGE, + tags=[UNIQUE_API_TAG], + context="{}={}".format(API_TEST_CONTEXT_KEY, API_TEST_CONTEXT_VALUE1)) + + result = api.resources_by_context(API_TEST_CONTEXT_KEY, API_TEST_CONTEXT_VALUE1, metadata=True) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertIn("metadata", resource) + + result = api.resources_by_context(API_TEST_CONTEXT_KEY, API_TEST_CONTEXT_VALUE1, metadata=False) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertNotIn("metadata", resource) + + def test_structured_metadata_in_resources_by_moderation_api(self): + uploader.upload(TEST_IMAGE, moderation="manual", tags=[UNIQUE_API_TAG]) + + result = api.resources_by_moderation("manual", "pending", metadata=True) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertIn("metadata", resource) + + result = api.resources_by_moderation("manual", "pending", metadata=False) + + self.assertTrue(result["resources"]) + for resource in result["resources"]: + self.assertNotIn("metadata", resource) + + @patch(URLLIB3_REQUEST) + def test_analyze(self, mocker): + mocker.return_value = MOCK_RESPONSE + + options = { + "analysis_type": "captioning", + "uri": "https://res.cloudinary.com/demo/image/upload/dog", + } + + api.analyze(input_type="uri", **options) + + uri = get_uri(mocker) + self.assertIn("/v2/", uri) + self.assertTrue(uri.endswith("/analysis/analyze/uri")) + + params = get_json_body(mocker) + for param in options.keys(): + self.assertIn(param, params) if __name__ == '__main__': diff --git a/test/test_api_authorization.py b/test/test_api_authorization.py new file mode 100644 index 00000000..982d6544 --- /dev/null +++ b/test/test_api_authorization.py @@ -0,0 +1,124 @@ +import unittest + +import six + +import cloudinary +from cloudinary import api +from cloudinary import uploader +from test.helper_test import TEST_IMAGE, get_headers, get_params, URLLIB3_REQUEST, patch +from test.test_api import MOCK_RESPONSE +from test.test_config import OAUTH_TOKEN, CLOUD_NAME, API_KEY, API_SECRET +from test.test_uploader import API_TEST_PRESET + + +class ApiAuthorizationTest(unittest.TestCase): + def setUp(self): + self.config = cloudinary.config(cloud_name=CLOUD_NAME, api_key=API_KEY, api_secret=API_SECRET) + + @patch(URLLIB3_REQUEST) + def test_oauth_token_admin_api(self, mocker): + self.config.oauth_token = OAUTH_TOKEN + mocker.return_value = MOCK_RESPONSE + + api.ping() + + headers = get_headers(mocker) + + self.assertTrue("authorization" in headers) + self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) + + @patch(URLLIB3_REQUEST) + def test_oauth_token_as_an_option_admin_api(self, mocker): + mocker.return_value = MOCK_RESPONSE + + api.ping(oauth_token=OAUTH_TOKEN) + + headers = get_headers(mocker) + + self.assertTrue("authorization" in headers) + self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) + + @patch(URLLIB3_REQUEST) + def test_key_and_secret_admin_api(self, mocker): + self.config.oauth_token = None + mocker.return_value = MOCK_RESPONSE + + api.ping() + + headers = get_headers(mocker) + + self.assertTrue("authorization" in headers) + self.assertEqual("Basic a2V5OnNlY3JldA==", headers["authorization"]) + + @patch(URLLIB3_REQUEST) + def test_missing_credentials_admin_api(self, mocker): + self.config.oauth_token = None + self.config.api_key = None + self.config.api_secret = None + + mocker.return_value = MOCK_RESPONSE + + with six.assertRaisesRegex(self, Exception, "Must supply api_key"): + api.ping() + + @patch(URLLIB3_REQUEST) + def test_oauth_token_upload_api(self, mocker): + self.config.oauth_token = OAUTH_TOKEN + mocker.return_value = MOCK_RESPONSE + + uploader.upload(TEST_IMAGE) + + headers = get_headers(mocker) + + self.assertTrue("authorization" in headers) + self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) + + params = get_params(mocker) + self.assertNotIn("signature", params) + + @patch(URLLIB3_REQUEST) + def test_oauth_token_as_an_option_upload_api(self, mocker): + mocker.return_value = MOCK_RESPONSE + + uploader.upload(TEST_IMAGE, oauth_token=OAUTH_TOKEN) + + headers = get_headers(mocker) + + self.assertTrue("authorization" in headers) + self.assertEqual("Bearer {}".format(OAUTH_TOKEN), headers["authorization"]) + + @patch(URLLIB3_REQUEST) + def test_key_and_secret_upload_api(self, mocker): + self.config.oauth_token = None + mocker.return_value = MOCK_RESPONSE + + uploader.upload(TEST_IMAGE) + + headers = get_headers(mocker) + self.assertNotIn("authorization", headers) + + params = get_params(mocker) + self.assertIn("signature", params) + self.assertIn("api_key", params) + + @patch(URLLIB3_REQUEST) + def test_missing_credentials_upload_api(self, mocker): + self.config.oauth_token = None + self.config.api_key = None + self.config.api_secret = None + + mocker.return_value = MOCK_RESPONSE + + with six.assertRaisesRegex(self, Exception, "Must supply api_key"): + uploader.upload(TEST_IMAGE) + + # no credentials required for unsigned upload + uploader.unsigned_upload(TEST_IMAGE, upload_preset=API_TEST_PRESET) + + args, _ = mocker.call_args + params = get_params(mocker) + self.assertTrue("upload_preset" in params) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_archive.py b/test/test_archive.py index 123ff679..acc62dce 100644 --- a/test/test_archive.py +++ b/test/test_archive.py @@ -9,13 +9,12 @@ import cloudinary.poster.streaminghttp from cloudinary import uploader, utils -from mock import patch import six import urllib3 from urllib3 import disable_warnings from test.helper_test import SUFFIX, TEST_IMAGE, api_response_mock, cleanup_test_resources_by_tag, UNIQUE_TEST_ID, \ - get_uri, get_list_param + get_uri, get_list_param, get_params, URLLIB3_REQUEST, patch MOCK_RESPONSE = api_response_mock() @@ -48,7 +47,7 @@ def test_create_archive(self): tags=[TEST_TAG], transformations=[{"width": 0.5}, {"width": 2.0}], target_tags=[TEST_TAG_RAW]) self.assertEqual(4, result2.get("file_count")) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_optional_parameters(self, mocker): """should allow optional parameters""" @@ -60,7 +59,7 @@ def test_optional_parameters(self, mocker): allow_missing=True, skip_transformation_name=True, ) - params = mocker.call_args[0][2] + params = get_params(mocker) self.assertEqual(params['expires_at'], expires_at) self.assertTrue(params['allow_missing']) self.assertTrue(params['skip_transformation_name']) @@ -89,7 +88,32 @@ def test_download_zip_url_options(self): upload_prefix = cloudinary.config().upload_prefix or "https://api.cloudinary.com" six.assertRegex(self, result, r'^{0}/v1_1/demo/.*$'.format(upload_prefix)) - @patch('urllib3.request.RequestMethods.request') + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_download_folder(self): + """Should generate and return a url for downloading a folder""" + # Should return url with resource_type image + download_folder_url = utils.download_folder(folder_path="samples/", resource_type="image") + self.assertIn("image", download_folder_url) + + # Should return valid url + download_folder_url = utils.download_folder(folder_path="folder/") + self.assertTrue(download_folder_url) + self.assertIn("generate_archive", download_folder_url) + + # Should flatten folder + download_folder_url = utils.download_folder(folder_path="folder/", flatten_folders=True) + self.assertIn("flatten_folders", download_folder_url) + + # Should expire_at folder + expiration_time = int(time.time() + 60) + download_folder_url = utils.download_folder(folder_path="folder/", expires_at=expiration_time) + self.assertIn("expires_at", download_folder_url) + + # Should use original file_name of folder + download_folder_url = utils.download_folder(folder_path="folder/", use_original_filename=True) + self.assertIn("use_original_filename", download_folder_url) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_create_archive_multiple_resource_types(self, mocker): """should allow fully_qualified_public_ids""" @@ -106,11 +130,17 @@ def test_create_archive_multiple_resource_types(self, mocker): fully_qualified_public_ids=test_ids ) - args, kargs = mocker.call_args - - self.assertTrue(get_uri(args).endswith('/auto/generate_archive')) + self.assertTrue(get_uri(mocker).endswith('/auto/generate_archive')) self.assertEqual(test_ids, get_list_param(mocker, 'fully_qualified_public_ids')) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_download_backedup_asset(self): + download_backedup_asset_url = utils.download_backedup_asset('b71b23d9c89a81a254b88a91a9dad8cd', + '0e493356d8a40b856c4863c026891a4e') + + self.assertIn("asset_id", download_backedup_asset_url) + self.assertIn("version_id", download_backedup_asset_url) + if __name__ == '__main__': unittest.main() diff --git a/test/test_auth_token.py b/test/test_auth_token.py index bc440721..349365f9 100644 --- a/test/test_auth_token.py +++ b/test/test_auth_token.py @@ -11,9 +11,11 @@ class AuthTokenTest(unittest.TestCase): def setUp(self): self.url_backup = os.environ.get("CLOUDINARY_URL") - os.environ["CLOUDINARY_URL"] = "cloudinary://a:b@test123" + os.environ["CLOUDINARY_URL"] = ("cloudinary://a:b@test123?" + "auth_token[duration]=300" + "&auth_token[start_time]=11111111" + "&auth_token[key]=" + KEY) cloudinary.reset_config() - cloudinary.config(auth_token={"key": KEY, "duration": 300, "start_time": 11111111}) def tearDown(self): with ignore_exception(): @@ -64,6 +66,16 @@ def test_explicit_authToken_should_override_global_setting(self): "w_300/sample.jpg?__cld_token__=st=222222222~exp=222222322~hmac" "=55cfe516530461213fe3b3606014533b1eca8ff60aeab79d1bb84c9322eebc1f") + def test_should_set_url_signature(self): + cloudinary.config(private_cdn=True) + url, _ = cloudinary.utils.cloudinary_url("sample.jpg", sign_url=True, + auth_token={"key": ALT_KEY, "start_time": 222222222, "duration": 100, + "set_url_signature": True}, + type="authenticated", transformation={"crop": "scale", "width": 300}) + self.assertEqual("http://test123-res.cloudinary.com/image/authenticated/s--Ok4O32K7--/" + "c_scale,w_300/sample.jpg?__cld_token__=st=222222222~exp=222222322~hmac" + "=92a55aaed531b2dab074074bbd1430120119f9cb1b901656925dda2e514a63cc", url) + def test_should_compute_expiration_as_start_time_plus_duration(self): cloudinary.config(private_cdn=True) token = {"key": KEY, "start_time": 11111111, "duration": 300} @@ -75,8 +87,17 @@ def test_should_compute_expiration_as_start_time_plus_duration(self): def test_generate_token_string(self): user = "foobar" # we can't rely on the default "now" value in tests - token_options = {"key": KEY, "duration": 300, "acl": "/*/t_%s" % user} - token_options["start_time"] = 222222222 # we can't rely on the default "now" value in tests + token_options = {"key": KEY, "duration": 300, "acl": "/*/t_%s" % user, "start_time": 222222222} + cookie_token = cloudinary.utils.generate_auth_token(**token_options) + self.assertEqual( + cookie_token, + "__cld_token__=st=222222222~exp=222222522~acl=%2f*%2ft_foobar~hmac=" + "8e39600cc18cec339b21fe2b05fcb64b98de373355f8ce732c35710d8b10259f" + ) + + def test_generate_token_string_from_string_values(self): + user = "foobar" + token_options = {"key": KEY, "duration": "300", "acl": "/*/t_%s" % user, "start_time": "222222222"} cookie_token = cloudinary.utils.generate_auth_token(**token_options) self.assertEqual( cookie_token, @@ -96,9 +117,37 @@ def test_should_ignore_url_if_acl_is_provided(self): acl_token_url_ignored ) + def test_should_support_multiple_acls(self): + token_options = {"key": KEY, "duration": 3600, + "acl": ["/i/a/*", "/i/a/*", "/i/a/*"], + "start_time": 222222222} + + cookie_token = cloudinary.utils.generate_auth_token(**token_options) + self.assertEqual( + cookie_token, + "__cld_token__=st=222222222~exp=222225822~acl=%2fi%2fa%2f*!%2fi%2fa%2f*!" + "%2fi%2fa%2f*~hmac=10d9ad42d6ed66dce2386c4b564b2aa25a6ac668e5b90d070363a03c9842965f" + ) + def test_must_provide_expiration_or_duration(self): self.assertRaises(Exception, cloudinary.utils.generate_auth_token, acl="*", expiration=None, duration=None) + def test_must_provide_acl_or_url(self): + self.assertRaises(Exception, cloudinary.utils.generate_auth_token, start_time=1111111111, duration=300) + + def test_should_support_url_without_acl(self): + url_token = cloudinary.utils.generate_auth_token( + start_time=1111111111, + duration=300, + url="http://res.cloudinary.com/test123/image/upload/v1486020273/sample.jpg" + ) + + self.assertEqual( + url_token, + "__cld_token__=st=1111111111~exp=1111111411~hmac=" + "639406f8c07fc6a1613e1f6192baba631f9d5719185a32049281e94e15c5619b" + ) + if __name__ == '__main__': unittest.main() diff --git a/test/test_cloudinary_resource.py b/test/test_cloudinary_resource.py index bfad6efb..099374d9 100644 --- a/test/test_cloudinary_resource.py +++ b/test/test_cloudinary_resource.py @@ -1,12 +1,12 @@ from unittest import TestCase -import mock from urllib3 import disable_warnings import cloudinary from cloudinary import CloudinaryResource from cloudinary import uploader -from test.helper_test import SUFFIX, TEST_IMAGE, http_response_mock, get_request_url, cleanup_test_resources_by_tag +from test.helper_test import SUFFIX, TEST_IMAGE, http_response_mock, get_uri, cleanup_test_resources_by_tag, \ + URLLIB3_REQUEST, mock, retry_assertion disable_warnings() @@ -76,16 +76,16 @@ def test_image(self): self.assertNotIn(' src="{url}'.format(url=self.res.build_url(width="auto", crop="scale")), image) self.assertIn('data-src="{url}'.format(url=self.res.build_url(width="auto", crop="scale")), image) - @mock.patch('urllib3.request.RequestMethods.request', return_value=mocked_response) + @mock.patch(URLLIB3_REQUEST, return_value=mocked_response) def test_fetch_breakpoints(self, mocked_request): """Should retrieve responsive breakpoints from cloudinary resource (mocked)""" actual_breakpoints = self.res._fetch_breakpoints() self.assertEqual(self.mocked_breakpoints, actual_breakpoints) - self.assertIn(self.expected_transformation, get_request_url(mocked_request)) + self.assertIn(self.expected_transformation, get_uri(mocked_request)) - @mock.patch('urllib3.request.RequestMethods.request', return_value=mocked_response) + @mock.patch(URLLIB3_REQUEST, return_value=mocked_response) def test_fetch_breakpoints_with_transformation(self, mocked_request): """Should retrieve responsive breakpoints from cloudinary resource with custom transformation (mocked)""" srcset = {"transformation": self.crop_transformation} @@ -94,8 +94,9 @@ def test_fetch_breakpoints_with_transformation(self, mocked_request): self.assertEqual(self.mocked_breakpoints, actual_breakpoints) self.assertIn(self.crop_transformation_str + "/" + self.expected_transformation, - get_request_url(mocked_request)) + get_uri(mocked_request)) + @retry_assertion() def test_fetch_breakpoints_real(self): """Should retrieve responsive breakpoints from cloudinary resource (real request)""" actual_breakpoints = self.res._fetch_breakpoints() diff --git a/test/test_config.py b/test/test_config.py index 3c0f7656..21cd23cd 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,10 +1,40 @@ +import os from unittest import TestCase import cloudinary from cloudinary.provisioning import account_config +from test.helper_test import mock + +CLOUD_NAME = 'test123' +API_KEY = 'key' +API_SECRET = 'secret' +OAUTH_TOKEN = 'NTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZj17' +URL_WITH_OAUTH_TOKEN = 'cloudinary://{}?oauth_token={}'.format(CLOUD_NAME, OAUTH_TOKEN) + + +MOCKED_SETTINGS = { + 'api_secret': 'secret_from_settings' +} + + +def _clean_env(): + for key in os.environ.keys(): + if key.startswith("CLOUDINARY_"): + if key != "CLOUDINARY_URL": + del os.environ[key] class TestConfig(TestCase): + def setUp(self): + self._environ_backup = os.environ.copy() + _clean_env() + + def tearDown(self): + _clean_env() + + for key, val in self._environ_backup.items(): + os.environ[key] = val + def test_parse_cloudinary_url(self): config = cloudinary.config() parsed_url = config._parse_cloudinary_url('cloudinary://key:secret@test123?foo[bar]=value') @@ -68,3 +98,53 @@ def test_cloudinary_account_url_invalid_scheme(self): with self.assertRaises(ValueError): parsed_url = config._parse_cloudinary_url(cloudinary_account_url) config._setup_from_parsed_url(parsed_url) + + def test_support_CLOUDINARY_prefixed_environment_variables(self): + os.environ["CLOUDINARY_CLOUD_NAME"] = "c" + os.environ["CLOUDINARY_API_KEY"] = "k" + os.environ["CLOUDINARY_API_SECRET"] = "s" + os.environ["CLOUDINARY_SECURE_DISTRIBUTION"] = "sd" + os.environ["CLOUDINARY_PRIVATE_CDN"] = "false" + os.environ["CLOUDINARY_SECURE"] = "true" + + config = cloudinary.Config() + + self.assertEqual(config.cloud_name, "c") + self.assertEqual(config.api_key, "k") + self.assertEqual(config.api_secret, "s") + self.assertEqual(config.secure_distribution, "sd") + self.assertFalse(config.private_cdn) + self.assertTrue(config.secure) + + def test_overwrites_only_existing_keys_from_environment(self): + os.environ["CLOUDINARY_CLOUD_NAME"] = "c" + os.environ["CLOUDINARY_API_KEY"] = "key_from_env" + + with mock.patch('cloudinary.import_django_settings', return_value=MOCKED_SETTINGS): + config = cloudinary.Config() + + self.assertEqual(config.cloud_name, "c") + self.assertEqual(config.api_key, "key_from_env") + self.assertEqual(config.api_secret, "secret_from_settings") + + def test_config_from_url_without_key_and_secret_but_with_oauth_token(self): + config = cloudinary.config() + parsed_url = config._parse_cloudinary_url(URL_WITH_OAUTH_TOKEN) + config._setup_from_parsed_url(parsed_url) + + self.assertEqual(config.cloud_name, CLOUD_NAME) + self.assertEqual(config.oauth_token, OAUTH_TOKEN) + self.assertIsNone(config.api_key) + self.assertIsNone(config.api_secret) + + def test_config_from_url_with_key_and_secret_and_oauth_token(self): + config = cloudinary.config() + parsed_url = config._parse_cloudinary_url( + 'cloudinary://{}:{}@test123?oauth_token={}'.format(API_KEY, API_SECRET, OAUTH_TOKEN) + ) + config._setup_from_parsed_url(parsed_url) + + self.assertEqual(config.cloud_name, CLOUD_NAME) + self.assertEqual(config.oauth_token, OAUTH_TOKEN) + self.assertEqual(config.api_key, API_KEY) + self.assertEqual(config.api_secret, API_SECRET) diff --git a/test/test_expression_normalization.py b/test/test_expression_normalization.py new file mode 100644 index 00000000..d9f564c1 --- /dev/null +++ b/test/test_expression_normalization.py @@ -0,0 +1,120 @@ +import contextlib +import unittest + +from cloudinary.utils import normalize_expression, generate_transformation_string, _SIMPLE_TRANSFORMATION_PARAMS + +NORMALIZATION_EXAMPLES = { + 'None is not affected': [None, None], + 'number replaced with a string value': [10, '10'], + 'empty string is not affected': ['', ''], + 'single space is replaced with a single underscore': [' ', '_'], + 'blank string is replaced with a single underscore': [' ', '_'], + 'underscore is not affected': ['_', '_'], + 'sequence of underscores and spaces is replaced with a single underscore': [' _ __ _', '_'], + 'arbitrary text is not affected': ['foobar', 'foobar'], + 'double ampersand replaced with and operator': ['foo && bar', 'foo_and_bar'], + 'double ampersand with no space at the end is not affected': ['foo&&bar', 'foo&&bar'], + 'width recognized as variable and replaced with w': ['width', 'w'], + 'initial aspect ratio recognized as variable and replaced with iar': ['initial_aspect_ratio', 'iar'], + 'duration is recognized as a variable and replaced with du': ['duration', 'du'], + 'duration after : is not a variable and is not affected': ['preview:duration_2', 'preview:duration_2'], + '$width recognized as user variable and not affected': ['$width', '$width'], + '$initial_aspect_ratio recognized as user variable followed by aspect_ratio variable': [ + '$initial_aspect_ratio', + '$initial_ar', + ], + '$mywidth recognized as user variable and not affected': ['$mywidth', '$mywidth'], + '$widthwidth recognized as user variable and not affected': ['$widthwidth', '$widthwidth'], + '$_width recognized as user variable and not affected': ['$_width', '$_width'], + '$__width recognized as user variable and not affected': ['$__width', '$_width'], + '$$width recognized as user variable and not affected': ['$$width', '$$width'], + '$height recognized as user variable and not affected': ['$height_100', '$height_100'], + '$heightt_100 recognized as user variable and not affected': ['$heightt_100', '$heightt_100'], + '$$height_100 recognized as user variable and not affected': ['$$height_100', '$$height_100'], + '$heightmy_100 recognized as user variable and not affected': ['$heightmy_100', '$heightmy_100'], + '$myheight_100 recognized as user variable and not affected': ['$myheight_100', '$myheight_100'], + '$heightheight_100 recognized as user variable and not affected': [ + '$heightheight_100', + '$heightheight_100', + ], + '$theheight_100 recognized as user variable and not affected': ['$theheight_100', '$theheight_100'], + '$__height_100 recognized as user variable and not affected': ['$__height_100', '$_height_100'] +} + + +class ExpressionNormalizationTest(unittest.TestCase): + + def test_expression_normalization(self): + for description, (input_expression, expected_expression) in NORMALIZATION_EXAMPLES.items(): + with self.subTest(description, input_expression=input_expression): + self.assertEqual(expected_expression, normalize_expression(input_expression)) + + def test_predefined_parameters_normalization(self): + normalized_params = ( + 'angle', + 'aspect_ratio', + 'dpr', + 'effect', + 'height', + 'opacity', + 'quality', + 'width', + 'x', + 'y', + 'start_offset', + 'end_offset', + 'zoom' + ) + + value = 'width * 2' + normalized_value = 'w_mul_2' + + for param in normalized_params: + with self.subTest('should normalize value in {}'.format(param), param=param): + + options = {param: value} + + # Set no_html_sizes + if param in ['height', 'width']: + options['crop'] = 'fit' + + result = generate_transformation_string(**options) + + self.assertEqual(result[1], {}) + self.assertFalse(value in result[0]) + self.assertTrue(normalized_value in result[0]) + + def test_simple_parameters_normalization(self): + value = 'width * 2' + normalized_value = 'w_mul_2' + not_normalized_params = list(_SIMPLE_TRANSFORMATION_PARAMS.values()) + not_normalized_params.extend(['overlay', 'underlay']) + + for param in not_normalized_params: + with self.subTest('should not normalize value in {}'.format(param), param=param): + options = {param: value} + + result = generate_transformation_string(**options) + + self.assertTrue(value in result[0]) + self.assertFalse(normalized_value in result[0]) + + def test_support_start_offset(self): + result = generate_transformation_string(**{"width": "100", "start_offset": "idu - 5"}) + self.assertIn("so_idu_sub_5", result[0]) + + result = generate_transformation_string(**{"width": "100", "start_offset": "$logotime"}) + self.assertIn("so_$logotime", result[0]) + + def test_support_end_offset(self): + result = generate_transformation_string(**{"width": "100", "end_offset": "idu - 5"}) + self.assertIn("eo_idu_sub_5", result[0]) + + result = generate_transformation_string(**{"width": "100", "end_offset": "$logotime"}) + self.assertIn("eo_$logotime", result[0]) + + if not hasattr(unittest.TestCase, "subTest"): + # Support Python before version 3.4 + @contextlib.contextmanager + def subTest(self, msg="", **params): + yield diff --git a/test/test_image.py b/test/test_image.py index b9795adc..904b7964 100644 --- a/test/test_image.py +++ b/test/test_image.py @@ -6,10 +6,10 @@ from collections import OrderedDict import six -from mock import mock import cloudinary from cloudinary import CloudinaryImage +from test.helper_test import mock class ImageTest(unittest.TestCase): @@ -140,7 +140,7 @@ def test_width_auto_breakpoints(self): six.assertRegex(self, tag, expected_re) def _common_image_tag_helper(self, tag_name, public_id, common_trans_str, custom_trans_str=None, - srcset_breakpoints=None, attributes=None, is_void=False): + srcset_breakpoints=None, attributes=None, is_void=False): """ Helper method for generating expected img and source tags @@ -342,14 +342,14 @@ def test_srcset_width_height_removed(self): def test_srcset_invalid_values(self): """Should raise ValueError on invalid values""" invalid_srcset_params = [ - {'sizes': True}, # srcset data not provided - {'max_width': 300, 'max_images': 3}, # no min_width - {'min_width': '1', 'max_width': 300, 'max_images': 3}, # invalid min_width - {'min_width': 100, 'max_images': 3}, # no max_width - {'min_width': 100, 'max_width': '3', 'max_images': 3}, # invalid max_width - {'min_width': 200, 'max_width': 100, 'max_images': 3}, # min_width > max_width - {'min_width': 100, 'max_width': 300}, # no max_images - {'min_width': 100, 'max_width': 300, 'max_images': 0}, # invalid max_images + {'sizes': True}, # srcset data not provided + {'max_width': 300, 'max_images': 3}, # no min_width + {'min_width': '1', 'max_width': 300, 'max_images': 3}, # invalid min_width + {'min_width': 100, 'max_images': 3}, # no max_width + {'min_width': 100, 'max_width': '3', 'max_images': 3}, # invalid max_width + {'min_width': 200, 'max_width': 100, 'max_images': 3}, # min_width > max_width + {'min_width': 100, 'max_width': 300}, # no max_images + {'min_width': 100, 'max_width': 300, 'max_images': 0}, # invalid max_images {'min_width': 100, 'max_width': 300, 'max_images': -17}, # invalid max_images {'min_width': 100, 'max_width': 300, 'max_images': '3'}, # invalid max_images ] @@ -402,7 +402,7 @@ def test_source_tag_media_query(self): media = {"min_width": self.min_width, "max_width": self.max_width} tag = CloudinaryImage(self.full_public_id).source(media=media) expected_media = "(min-width: {min}px) and (max-width: {max}px)".format(min=self.min_width, - max=self.max_width) + max=self.max_width) expected_tag = self._get_expected_cl_source_tag(self.full_public_id, "", attributes={"media": expected_media}) self.assertEqual(expected_tag, tag) diff --git a/test/test_metadata.py b/test/test_metadata.py index a3264372..9870f784 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -1,9 +1,7 @@ -from datetime import datetime, timedelta -import json import time import unittest +from datetime import datetime, timedelta -from mock import patch from six import text_type from urllib3 import disable_warnings @@ -11,7 +9,8 @@ from cloudinary import api from cloudinary.exceptions import BadRequest, NotFound from test.helper_test import ( - UNIQUE_TEST_ID, get_uri, get_params, get_method, api_response_mock, ignore_exception + UNIQUE_TEST_ID, get_uri, get_params, get_method, api_response_mock, ignore_exception, get_json_body, + URLLIB3_REQUEST, patch ) MOCK_RESPONSE = api_response_mock() @@ -172,17 +171,16 @@ def assert_metadata_field_datasource(self, datasource): if "state" in datasource["values"][0]: self.assertIn(datasource["values"][0]["state"], ["active", "inactive"]) - @patch("urllib3.request.RequestMethods.request") + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test01_list_metadata_fields(self, mocker): """Test getting a list of all metadata fields""" mocker.return_value = MOCK_RESPONSE api.list_metadata_fields() - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith("/metadata_fields")) + self.assertTrue(get_uri(mocker).endswith("/metadata_fields")) self.assertEqual(get_method(mocker), "GET") - self.assertFalse(get_params(args).get("fields")) + self.assertFalse(get_params(mocker).get("fields")) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test02_get_metadata_field(self): @@ -191,7 +189,7 @@ def test02_get_metadata_field(self): self.assert_metadata_field(result, "string", {"label": EXTERNAL_ID_GENERAL}) - @patch("urllib3.request.RequestMethods.request") + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test03_create_string_metadata_field(self, mocker): """Test creating a string metadata field""" @@ -200,18 +198,23 @@ def test03_create_string_metadata_field(self, mocker): "external_id": EXTERNAL_ID_STRING, "label": EXTERNAL_ID_STRING, "type": "string", + "restrictions": {"readonly_ui": True}, + "mandatory": False, + "default_disabled": True }) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith("/metadata_fields")) + self.assertTrue(get_uri(mocker).endswith("/metadata_fields")) self.assertEqual(get_method(mocker), "POST") - self.assertEqual(json.loads(kargs["body"].decode('utf-8')), { + self.assertEqual(get_json_body(mocker), { + 'default_disabled': True, "type": "string", "external_id": EXTERNAL_ID_STRING, "label": EXTERNAL_ID_STRING, + 'mandatory': False, + "restrictions": {"readonly_ui": True} }) - @patch("urllib3.request.RequestMethods.request") + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test04_create_int_metadata_field(self, mocker): """Test creating an integer metadata field""" @@ -221,11 +224,10 @@ def test04_create_int_metadata_field(self, mocker): "label": EXTERNAL_ID_INT, "type": "integer", }) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith("/metadata_fields")) + self.assertTrue(get_uri(mocker).endswith("/metadata_fields")) self.assertEqual(get_method(mocker), "POST") - self.assertEqual(json.loads(kargs["body"].decode('utf-8')), { + self.assertEqual(get_json_body(mocker), { "type": "integer", "external_id": EXTERNAL_ID_INT, "label": EXTERNAL_ID_INT, @@ -238,15 +240,17 @@ def test05_create_date_metadata_field(self): "external_id": EXTERNAL_ID_DATE, "label": EXTERNAL_ID_DATE, "type": "date", + "restrictions": {"readonly_ui": True} }) self.assert_metadata_field(result, "date", { "label": EXTERNAL_ID_DATE, "external_id": EXTERNAL_ID_DATE, "mandatory": False, + "restrictions": {"readonly_ui": True} }) - @patch("urllib3.request.RequestMethods.request") + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test06_create_enum_metadata_field(self, mocker): """Test creating an Enum metadata field""" @@ -259,11 +263,10 @@ def test06_create_enum_metadata_field(self, mocker): "label": EXTERNAL_ID_ENUM, "type": "enum", }) - args, kargs = mocker.call_args - self.assertTrue(get_uri(args).endswith("/metadata_fields")) + self.assertTrue(get_uri(mocker).endswith("/metadata_fields")) self.assertEqual(get_method(mocker), "POST") - self.assertEqual(json.loads(kargs["body"].decode('utf-8')), { + self.assertEqual(get_json_body(mocker), { "datasource": { "values": DATASOURCE_SINGLE, }, @@ -282,12 +285,14 @@ def test07_create_set_metadata_field(self): "external_id": EXTERNAL_ID_SET, "label": EXTERNAL_ID_SET, "type": "set", + "allow_dynamic_list_values": True, }) self.assert_metadata_field(result, "set", { "label": EXTERNAL_ID_SET, "external_id": EXTERNAL_ID_SET, "mandatory": False, + "allow_dynamic_list_values": True, }) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -305,6 +310,7 @@ def test08_update_metadata_field(self): "type": "integer", "mandatory": True, "default_value": new_default_value, + "restrictions": {"readonly_ui": True} }) self.assert_metadata_field(result, "string", { @@ -312,6 +318,7 @@ def test08_update_metadata_field(self): "label": new_label, "default_value": new_default_value, "mandatory": True, + "restrictions": {"readonly_ui": True} }) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -331,33 +338,18 @@ def test09_update_metadata_field_datasource(self): self.assertEqual(len(DATASOURCE_MULTIPLE), len(result["values"])) self.assertEqual(DATASOURCE_SINGLE[0]["value"], result["values"][0]["value"]) - @patch("urllib3.request.RequestMethods.request") + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test10_delete_metadata_field(self, mocker): """Test deleting a metadata field definition by its external id.""" mocker.return_value = MOCK_RESPONSE api.delete_metadata_field(EXTERNAL_ID_DELETE) - args, kargs = mocker.call_args target_uri = "/metadata_fields/{}".format(EXTERNAL_ID_DELETE) - self.assertTrue(get_uri(args).endswith(target_uri)) + self.assertTrue(get_uri(mocker).endswith(target_uri)) self.assertEqual(get_method(mocker), "DELETE") - self.assertEqual(json.loads(kargs["body"].decode('utf-8')), {}) - - @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") - def test11_delete_metadata_field_does_not_release_external_id(self): - """Test deleting a metadata field definition then attempting to create a - new one with the same external id which should fail. - """ - api.delete_metadata_field(EXTERNAL_ID_DELETE_2) - - with self.assertRaises(BadRequest): - api.add_metadata_field({ - "external_id": EXTERNAL_ID_DELETE_2, - "label": EXTERNAL_ID_DELETE_2, - "type": "integer", - }) + self.assertEqual(get_json_body(mocker), {}) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test12_delete_metadata_field_data_source(self): @@ -473,6 +465,69 @@ def test15_restore_metadata_field_datasource(self): self.assert_metadata_field_datasource(result) self.assertEqual(len(result["values"]), 3) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_order_by_asc_by_default_in_a_metadata_field_data_source(self): + # datasource is set with values in the order v2, v3, v4 + result = api.reorder_metadata_field_datasource(EXTERNAL_ID_SET_3, 'value') + + self.assert_metadata_field_datasource(result) + + self.assertEqual(result['values'][0]['value'], DATASOURCE_MULTIPLE[0]['value']) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_order_by_asc_in_a_metadata_field_data_source(self): + # datasource is set with values in the order v2, v3, v4 + result = api.reorder_metadata_field_datasource(EXTERNAL_ID_SET_3, 'value', 'asc') + + self.assert_metadata_field_datasource(result) + + self.assertEqual(result['values'][0]['value'], DATASOURCE_MULTIPLE[0]['value']) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_order_by_desc_in_a_metadata_field_data_source(self): + # datasource is set with values in the order v2, v3, v4 + result = api.reorder_metadata_field_datasource(EXTERNAL_ID_SET_3, 'value', 'desc') + + self.assert_metadata_field_datasource(result) + + self.assertEqual(result['values'][0]['value'], DATASOURCE_MULTIPLE[-1]['value']) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_reorder_metadata_fields_by_label(self, mocker): + """Test the reorder of metadata fields for label order by asc""" + mocker.return_value = MOCK_RESPONSE + api.reorder_metadata_fields('label', 'asc') + + self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order")) + self.assertEqual(get_method(mocker), "PUT") + self.assertEqual(get_json_body(mocker)['order_by'], "label") + self.assertEqual(get_json_body(mocker)['direction'], "asc") + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_reorder_metadata_fields_by_external_id(self, mocker): + """Test the reorder of metadata fields for external_id order by desc""" + mocker.return_value = MOCK_RESPONSE + api.reorder_metadata_fields('external_id', 'desc') + + self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order")) + self.assertEqual(get_method(mocker), "PUT") + self.assertEqual(get_json_body(mocker)['order_by'], "external_id") + self.assertEqual(get_json_body(mocker)['direction'], "desc") + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_reorder_metadata_fields_by_created_at(self, mocker): + """Test the reorder of metadata fields for created_at order by asc""" + mocker.return_value = MOCK_RESPONSE + api.reorder_metadata_fields('created_at', 'asc') + + self.assertTrue(get_uri(mocker).endswith("/metadata_fields/order")) + self.assertEqual(get_method(mocker), "PUT") + self.assertEqual(get_json_body(mocker)['order_by'], "created_at") + self.assertEqual(get_json_body(mocker)['direction'], "asc") + if __name__ == "__main__": unittest.main() diff --git a/test/test_metadata_rules.py b/test/test_metadata_rules.py new file mode 100644 index 00000000..88fd6499 --- /dev/null +++ b/test/test_metadata_rules.py @@ -0,0 +1,227 @@ +import unittest +from six import text_type +from urllib3 import disable_warnings + +import cloudinary +from cloudinary import api +from cloudinary.exceptions import BadRequest, NotFound +from test.helper_test import ( + UNIQUE_TEST_ID, get_uri, get_params, get_method, api_response_mock, ignore_exception, get_json_body, + URLLIB3_REQUEST, patch +) + +MOCK_RESPONSE = api_response_mock() + +# External IDs for metadata fields and metadata_rules that should be created and later deleted +EXTERNAL_ID_ENUM = "metadata_external_id_enum_{}".format(UNIQUE_TEST_ID) +EXTERNAL_ID_SET = "metadata_external_id_set_{}".format(UNIQUE_TEST_ID) +EXTERNAL_ID_METADATA_RULE_GENERAL = "metadata_rule_id_general_{}".format(UNIQUE_TEST_ID) +EXTERNAL_ID_METADATA_RULE_DELETE = "metadata_rule_id_deletion_{}".format(UNIQUE_TEST_ID) + +# Sample datasource data +DATASOURCE_ENTRY_EXTERNAL_ID = "metadata_datasource_entry_external_id{}".format(UNIQUE_TEST_ID) + +disable_warnings() + +class MetadataRulesTest(unittest.TestCase): + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test01_list_metadata_rules(self, mocker): + """Test getting a list of all metadata rules""" + + mocker.return_value = MOCK_RESPONSE + api.list_metadata_rules() + + self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) + self.assertEqual(get_method(mocker), "GET") + + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test02_create_metadata_rule(self, mocker): + """Test creating an and metadata rule""" + + mocker.return_value = MOCK_RESPONSE + api.add_metadata_rule({ + "metadata_field_id": EXTERNAL_ID_ENUM, + "condition": { "metadata_field_id": EXTERNAL_ID_SET, "equals": DATASOURCE_ENTRY_EXTERNAL_ID }, + "result": { "enable": True, "activate_values": "all" }, + "name": EXTERNAL_ID_ENUM + }) + + self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) + self.assertEqual(get_method(mocker), "POST") + self.assertEqual(get_json_body(mocker), { + "metadata_field_id": EXTERNAL_ID_ENUM, + "name": EXTERNAL_ID_ENUM, + "condition": { "metadata_field_id": EXTERNAL_ID_SET, "equals": DATASOURCE_ENTRY_EXTERNAL_ID }, + "result": { "enable": True, "activate_values": "all" }, + }) + + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test03_create_and_metadata_rule(self, mocker): + """Test creating an and metadata rule""" + + mocker.return_value = MOCK_RESPONSE + api.add_metadata_rule({ + "metadata_field_id": EXTERNAL_ID_ENUM, + "condition": {"and": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]}, + "result": { "enable": True, "apply_value": {"value": "value1_and_value2","mode": "default"}}, + "name": EXTERNAL_ID_ENUM + "_AND" + }) + + self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) + self.assertEqual(get_method(mocker), "POST") + self.assertEqual(get_json_body(mocker), { + "metadata_field_id": EXTERNAL_ID_ENUM, + "name": EXTERNAL_ID_ENUM + "_AND" , + "condition": {"and": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]}, + "result": { "enable": True, "apply_value": {"value": "value1_and_value2","mode": "default"}}, + }) + + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test04_create_or_metadata_rule(self,mocker): + """Test creating an or metadata rule""" + + mocker.return_value = MOCK_RESPONSE + api.add_metadata_rule({ + "metadata_field_id": EXTERNAL_ID_ENUM, + "condition": {"or": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]}, + "result": { "enable": True, "apply_value": {"value": "value1_or_value2","mode": "default"}}, + "name": EXTERNAL_ID_ENUM + "_OR" + }) + + self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) + self.assertEqual(get_method(mocker), "POST") + self.assertEqual(get_json_body(mocker), { + "metadata_field_id": EXTERNAL_ID_ENUM, + "name": EXTERNAL_ID_ENUM + "_OR", + "condition": {"or": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]}, + "result": { "enable": True, "apply_value": {"value": "value1_or_value2","mode": "default"}}, + }) + + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test05_create_and_or_metadata_rule(self, mocker): + """Test creating an and+or metadata rule""" + + mocker.return_value = MOCK_RESPONSE + api.add_metadata_rule({ + "metadata_field_id": EXTERNAL_ID_ENUM, + "condition": {"and": [ + { "metadata_field_id": EXTERNAL_ID_SET, "populated": True }, + {"or": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]} + ]}, + "result": { "enable": True, "activate_values": "all"}, + "name": EXTERNAL_ID_ENUM + "_AND_OR" + }) + + self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) + self.assertEqual(get_method(mocker), "POST") + self.assertEqual(get_json_body(mocker), { + "metadata_field_id": EXTERNAL_ID_ENUM, + "name": EXTERNAL_ID_ENUM + "_AND_OR", + "condition": {"and": [ + { "metadata_field_id": EXTERNAL_ID_SET, "populated": True }, + {"or": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]} + ]}, + "result": { "enable": True, "activate_values": "all"}, + }) + + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test06_create_or_and_metadata_rule(self,mocker): + """Test creating an or+and metadata rule""" + + mocker.return_value = MOCK_RESPONSE + api.add_metadata_rule({ + "metadata_field_id": EXTERNAL_ID_ENUM, + "condition": {"or": [ + {"metadata_field_id": EXTERNAL_ID_SET, "populated": False }, + {"and": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]} + ]}, + "result": { "enable": True, "activate_values": {"external_ids": ["value1","value2"]}}, + "name": EXTERNAL_ID_ENUM + "_OR_AND" + }) + + self.assertTrue(get_uri(mocker).endswith("/metadata_rules")) + self.assertEqual(get_method(mocker), "POST") + self.assertEqual(get_json_body(mocker), { + "metadata_field_id": EXTERNAL_ID_ENUM, + "name": EXTERNAL_ID_ENUM + "_OR_AND", + "condition": {"or": [ + {"metadata_field_id": EXTERNAL_ID_SET, "populated": False }, + {"and": [ + {"metadata_field_id": EXTERNAL_ID_SET,"includes": [DATASOURCE_ENTRY_EXTERNAL_ID]}, + {"metadata_field_id": EXTERNAL_ID_SET,"includes": ["value2"]} + ]} + ]}, + "result": { "enable": True, "activate_values": {"external_ids": ["value1","value2"]}}, + }) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test07_update_metadata_rule(self,mocker): + """Update a metadata rule by external id""" + mocker.return_value = MOCK_RESPONSE + + new_name = "update_metadata_rule_new_name{}".format(EXTERNAL_ID_METADATA_RULE_GENERAL) + + api.update_metadata_rule(EXTERNAL_ID_METADATA_RULE_GENERAL, { + "metadata_field_id": EXTERNAL_ID_ENUM, + "name": new_name + "_inactive", + "condition": {}, + "result": {}, + "state": "inactive" + }) + + target_uri = "/metadata_rules/{}".format(EXTERNAL_ID_METADATA_RULE_GENERAL) + self.assertTrue(get_uri(mocker).endswith(target_uri)) + self.assertEqual(get_method(mocker), "PUT") + self.assertEqual(get_params(mocker).get("state"), "inactive") + self.assertEqual(get_params(mocker).get("name"), new_name + "_inactive") + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test08_delete_metadata_rule(self, mocker): + """Test deleting a metadata rule definition by its external id.""" + + mocker.return_value = MOCK_RESPONSE + api.delete_metadata_rule(EXTERNAL_ID_METADATA_RULE_DELETE) + + target_uri = "/metadata_rules/{}".format(EXTERNAL_ID_METADATA_RULE_DELETE) + self.assertTrue(get_uri(mocker).endswith(target_uri)) + self.assertEqual(get_method(mocker), "DELETE") + + self.assertEqual(get_json_body(mocker), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_provisioning_api.py b/test/test_provisioning_api.py index 9cde2ecf..132c1966 100644 --- a/test/test_provisioning_api.py +++ b/test/test_provisioning_api.py @@ -1,11 +1,14 @@ import unittest from datetime import datetime +import six from urllib3 import disable_warnings import cloudinary.provisioning.account from cloudinary.provisioning import account_config, reset_config -from cloudinary.exceptions import AuthorizationRequired +from cloudinary.exceptions import AuthorizationRequired, NotFound + +from test.helper_test import UNIQUE_SUB_ACCOUNT_ID, UNIQUE_TEST_ID disable_warnings() @@ -18,8 +21,10 @@ class AccountApiTest(unittest.TestCase): @classmethod def setUpClass(cls): now = datetime.now().strftime("%m-%d-%Y") - user_name = "SDK TEST " + now - user_email = "sdk-test" + now + "@cloudinary.com" + cls.user_name_1 = "SDK TEST " + now + cls.user_name_2 = "SDK TEST 2 " + now + user_email_1 = "sdk-test" + now + "@cloudinary.com" + user_email_2 = "sdk-test2" + now + "@cloudinary.com" user_role = "billing" reset_config() @@ -27,11 +32,14 @@ def setUpClass(cls): if not config.account_id or not config.provisioning_api_key or not config.provisioning_api_secret: return - res = cloudinary.provisioning.create_sub_account("justname" + now, enabled=True) - cls.cloud_id = res["id"] + create_sub_account_res = cloudinary.provisioning.create_sub_account("justname" + now, enabled=True) + cls.cloud_id = create_sub_account_res["id"] + + create_user_1 = cloudinary.provisioning.create_user(cls.user_name_1, user_email_1, user_role) + cls.user_id_1 = create_user_1["id"] - create_user = cloudinary.provisioning.create_user(user_name, user_email, user_role) - cls.user_id = create_user["id"] + create_user_2 = cloudinary.provisioning.create_user(cls.user_name_2, user_email_2, user_role) + cls.user_id_2 = create_user_2["id"] create_user_group = cloudinary.provisioning.create_user_group("test-group-" + now) cls.group_id = create_user_group["id"] @@ -44,8 +52,11 @@ def tearDownClass(cls): delete_sub_account = cloudinary.provisioning.delete_sub_account(cls.cloud_id) assert delete_sub_account["message"] == "ok" - delete_user = cloudinary.provisioning.delete_user(cls.user_id) - assert delete_user["message"] == "ok" + delete_user_1 = cloudinary.provisioning.delete_user(cls.user_id_1) + assert delete_user_1["message"] == "ok" + + delete_user_2 = cloudinary.provisioning.delete_user(cls.user_id_2) + assert delete_user_2["message"] == "ok" delete_user_group = cloudinary.provisioning.delete_user_group(cls.group_id) assert delete_user_group['ok'] @@ -93,23 +104,72 @@ def test_update_user(self): new_email_address = "updated" + now + "@cloudinary.com" new_name = "updated" - res = cloudinary.provisioning.update_user(self.user_id, new_name, new_email_address) + res = cloudinary.provisioning.update_user(self.user_id_1, new_name, new_email_address) self.assertEqual(new_name, res["name"]) self.assertEqual(new_email_address, res["email"]) - res = cloudinary.provisioning.user(self.user_id) - self.assertEqual(self.user_id, res["id"]) + res = cloudinary.provisioning.user(self.user_id_1) + self.assertEqual(self.user_id_1, res["id"]) self.assertEqual(new_email_address, res["email"]) res = cloudinary.provisioning.users() user_by_id = [user for user in res["users"] - if user["id"] == self.user_id] + if user["id"] == self.user_id_1] self.assertEqual(len(user_by_id), 1) @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, "requires provisioning_api_key/provisioning_api_secret") def test_get_users(self): - res = cloudinary.provisioning.users(user_ids=[self.user_id]) + res = cloudinary.provisioning.users(user_ids=[self.user_id_1]) + self.assertEqual(len(res["users"]), 1) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_pending_users(self): + res = cloudinary.provisioning.users(user_ids=[self.user_id_1], pending=True) + self.assertEqual(len(res["users"]), 1) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_non_pending_users(self): + res = cloudinary.provisioning.users(user_ids=[self.user_id_1], pending=False) + self.assertEqual(len(res["users"]), 0) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_pending_and_non_pending_users(self): + res = cloudinary.provisioning.users(user_ids=[self.user_id_1], pending=None) + self.assertEqual(len(res["users"]), 1) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_users_by_prefix(self): + res_1 = cloudinary.provisioning.users(pending=True, prefix=self.user_name_2[:-1]) + res_2 = cloudinary.provisioning.users(pending=True, prefix=self.user_name_2+'zzz') + self.assertEqual(len(res_1["users"]), 1) + self.assertEqual(len(res_2["users"]), 0) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_users_by_sub_account_id(self): + res = cloudinary.provisioning.users(pending=True, user_ids=[self.user_id_2], sub_account_id=self.cloud_id) + self.assertEqual(len(res["users"]), 1) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_users_by_nonexistent_sub_account_id(self): + with six.assertRaisesRegex(self, NotFound, "Cannot find sub account with id {}".format(UNIQUE_SUB_ACCOUNT_ID)): + cloudinary.provisioning.users(pending=True, sub_account_id=UNIQUE_SUB_ACCOUNT_ID) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_users_by_login(self): + res = cloudinary.provisioning.users(user_ids=[self.user_id_1], pending=None, + last_login="true", from_date=datetime.today(), to_date=datetime.today()) + self.assertEqual(len(res["users"]), 0) + + res = cloudinary.provisioning.users(user_ids=[self.user_id_1], pending=None, + last_login="false", from_date=datetime.today(), to_date=datetime.today()) self.assertEqual(len(res["users"]), 1) @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, @@ -126,14 +186,14 @@ def test_update_user_group(self): @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, "requires provisioning_api_key/provisioning_api_secret") def test_add_remove_user_from_group(self): - res = cloudinary.provisioning.add_user_to_group(self.group_id, self.user_id) + res = cloudinary.provisioning.add_user_to_group(self.group_id, self.user_id_1) self.assertEqual(len(res["users"]), 1) group_users_data = cloudinary.provisioning.user_group_users(self.group_id) self.assertEqual(len(group_users_data["users"]), 1) remove_users_from_group_resp = cloudinary.provisioning.remove_user_from_group(self.group_id, - self.user_id) + self.user_id_1) self.assertEqual(len(remove_users_from_group_resp["users"]), 0) @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, @@ -147,6 +207,62 @@ def test_get_user_groups(self): # Ensure we can find our ID in the list(Which means we got a real list as a response) self.assertEqual(group_by_id[0]["id"], self.group_id) + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_get_access_keys(self): + res = cloudinary.provisioning.access_keys(self.cloud_id) + + self.assertGreater(res["total"], 0) + self.assertGreater(len(res["access_keys"]), 0) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_generate_access_key(self): + key_name = UNIQUE_TEST_ID + "_test_key" + res = cloudinary.provisioning.generate_access_key(self.cloud_id, name=key_name, enabled=False) + + self.assertEqual(key_name, res["name"]) + self.assertEqual(False, res["enabled"]) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_update_access_key(self): + key_name = UNIQUE_TEST_ID + "_before_update_test_key" + updated_key_name = UNIQUE_TEST_ID + "_updated_test_key" + + key_res = cloudinary.provisioning.generate_access_key(self.cloud_id, name=key_name, enabled=False) + + self.assertEqual(key_name, key_res["name"]) + self.assertEqual(False, key_res["enabled"]) + + res = cloudinary.provisioning.update_access_key(self.cloud_id, key_res["api_key"], + name=updated_key_name, enabled=True, dedicated_for="webhooks") + + self.assertEqual(updated_key_name, res["name"]) + self.assertEqual(True, res["enabled"]) + self.assertEqual(1, len(res["dedicated_for"])) + self.assertEqual("webhooks", res["dedicated_for"][0]) + + @unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret, + "requires provisioning_api_key/provisioning_api_secret") + def test_delete_access_key(self): + key_name = UNIQUE_TEST_ID + "_delete_key" + named_key_name = UNIQUE_TEST_ID + "_delete_by_name_key" + + key_res = cloudinary.provisioning.generate_access_key(self.cloud_id, name=key_name, enabled=True) + self.assertEqual(key_name, key_res["name"]) + self.assertEqual(True, key_res["enabled"]) + + named_key_res = cloudinary.provisioning.generate_access_key(self.cloud_id, name=named_key_name, enabled=True) + self.assertEqual(named_key_name, named_key_res["name"]) + self.assertEqual(True, named_key_res["enabled"]) + + key_del_res = cloudinary.provisioning.delete_access_key(self.cloud_id, named_key_res["api_key"]) + self.assertEqual("ok", key_del_res["message"]) + + named_key_del_res = cloudinary.provisioning.delete_access_key(self.cloud_id, name=key_name) + self.assertEqual("ok", named_key_del_res["message"]) + if __name__ == '__main__': unittest.main() diff --git a/test/test_search.py b/test/test_search.py index c51b45ee..833678d4 100644 --- a/test/test_search.py +++ b/test/test_search.py @@ -1,3 +1,4 @@ +import json import os import time import unittest @@ -6,20 +7,27 @@ from urllib3 import disable_warnings import cloudinary -from cloudinary import uploader -from cloudinary.search import Search -from test.helper_test import SUFFIX, TEST_IMAGE, TEST_TAG, UNIQUE_TAG, retry_assertion, cleanup_test_resources_by_tag +from cloudinary import uploader, SearchFolders, Search +from test.helper_test import SUFFIX, TEST_IMAGE, TEST_TAG, UNIQUE_TAG, TEST_FOLDER, UNIQUE_TEST_FOLDER, \ + retry_assertion, cleanup_test_resources_by_tag, URLLIB3_REQUEST, get_json_body, get_uri, patch +from test.test_api import MOCK_RESPONSE, NEXT_CURSOR +from test.test_config import CLOUD_NAME, API_KEY, API_SECRET + +CUSTOM_AGGREGATION = {"type": "bytes", + "ranges": [{"key": "tiny", "to": 500}, {"key": "medium", "from": 501, "to": 1999}, + {"key": "big", "from": 2000}]} TEST_TAG = 'search_{}'.format(TEST_TAG) UNIQUE_TAG = 'search_{}'.format(UNIQUE_TAG) - TEST_IMAGES_COUNT = 3 MAX_INDEX_RETRIES = 10 -public_ids = ["api_test{0}_{1}".format(i, SUFFIX) for i in range(0, TEST_IMAGES_COUNT)] +public_ids = ["{0}/search_test{1}_{1}".format(UNIQUE_TEST_FOLDER, i, SUFFIX) for i in range(0, TEST_IMAGES_COUNT)] upload_results = ["++"] +FOLDERS_SEARCH_EXPRESSION = "path:{}*".format(TEST_FOLDER) + disable_warnings() @@ -56,6 +64,9 @@ def setUp(self): def tearDownClass(cls): cleanup_test_resources_by_tag([(UNIQUE_TAG,)]) + def tearDown(self): + cloudinary.reset_config() + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_should_create_empty_json(self): @@ -93,6 +104,11 @@ def test_should_add_aggregations_arguments_as_array_as_dict(self): query = Search().aggregate('format').aggregate('size_category').as_dict() self.assertEqual(query, {"aggregate": ["format", "size_category"]}) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_should_add_custom_aggregations_arguments_as_array_as_dict(self): + query = Search().aggregate('format').aggregate('size_category').aggregate(CUSTOM_AGGREGATION).as_dict() + self.assertEqual(query, {"aggregate": ["format", "size_category", CUSTOM_AGGREGATION]}) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_should_add_with_field_as_dict(self): @@ -119,6 +135,28 @@ def test_should_return_resource(self): results = Search().expression("public_id={0}".format(public_ids[0])).execute() self.assertEqual(len(results['resources']), 1) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + @unittest.skipIf(not os.environ.get('RUN_SEARCH_TESTS', False), + "For this test to work, 'Advanced search' should be enabled for your cloud. " + + "Use env variable RUN_SEARCH_TESTS=1 if you really want to test it.") + @retry_assertion() + def test_should_return_resource_by_asset_id_equals(self): + + asset_id = upload_results[1]["asset_id"] + results = Search().expression("asset_id={0}".format(asset_id)).execute() + self.assertEqual(len(results['resources']), 1) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + @unittest.skipIf(not os.environ.get('RUN_SEARCH_TESTS', False), + "For this test to work, 'Advanced search' should be enabled for your cloud. " + + "Use env variable RUN_SEARCH_TESTS=1 if you really want to test it.") + @retry_assertion() + def test_should_return_resource_by_asset_id_colon(self): + + asset_id = upload_results[1]["asset_id"] + results = Search().expression("asset_id:{0}".format(asset_id)).execute() + self.assertEqual(len(results['resources']), 1) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @unittest.skipIf(not os.environ.get('RUN_SEARCH_TESTS', False), "For this test to work, 'Advanced search' should be enabled for your cloud. " + @@ -162,8 +200,8 @@ def test_should_include_context(self): @retry_assertion() def test_should_include_context_tags_and_image_metadata(self): - results = Search().expression("tags={0}".format(UNIQUE_TAG)).\ - with_field('context').with_field('tags').\ + results = Search().expression("tags={0}".format(UNIQUE_TAG)). \ + with_field('context').with_field('tags'). \ with_field('image_metadata').execute() self.assertEqual(len(results['resources']), TEST_IMAGES_COUNT) @@ -172,6 +210,141 @@ def test_should_include_context_tags_and_image_metadata(self): self.assertTrue('image_metadata' in res) self.assertEqual(len(res['tags']), 2) + @patch(URLLIB3_REQUEST) + def test_should_not_duplicate_values(self, mocker): + mocker.return_value = MOCK_RESPONSE + override_custom_aggregation = dict(CUSTOM_AGGREGATION, ranges=[]) + Search() \ + .sort_by('created_at', 'asc') \ + .sort_by('public_id', 'asc') \ + .sort_by('created_at') \ + .aggregate('format') \ + .aggregate('format') \ + .aggregate(['resource_type', 'type']) \ + .aggregate(override_custom_aggregation) \ + .aggregate(CUSTOM_AGGREGATION) \ + .with_field('context') \ + .with_field('context') \ + .with_field('tags') \ + .fields(('tags', 'context')) \ + .fields('metadata') \ + .fields('tags') \ + .execute() + + _, args = mocker.call_args + result = json.loads(args['body']) + + self.assertEqual(result, { + 'sort_by': [ + {'created_at': 'desc'}, + {'public_id': 'asc'}, + ], + 'aggregate': ['format', 'resource_type', 'type', CUSTOM_AGGREGATION], + 'with_field': ['context', 'tags'], + 'fields': ['tags', 'context', 'metadata'], + }) + + def test_should_build_search_url(self): + cloudinary.config(cloud_name=CLOUD_NAME, api_key=API_KEY, api_secret=API_SECRET, secure=True) + + search = Search() \ + .expression("resource_type:image AND tags=kitten AND uploaded_at>1d AND bytes>1m") \ + .sort_by("public_id", "desc") \ + .max_results(30) + + b64query = "eyJleHByZXNzaW9uIjoicmVzb3VyY2VfdHlwZTppbWFnZSBBTkQgdGFncz1raXR0ZW4gQU5EIHVwbG9hZGVkX2F0" \ + "PjFkIEFORCBieXRlcz4xbSIsIm1heF9yZXN1bHRzIjozMCwic29ydF9ieSI6W3sicHVibGljX2lkIjoiZGVzYyJ9XX0=" + + ttl300_sig = "431454b74cefa342e2f03e2d589b2e901babb8db6e6b149abf25bc0dd7ab20b7" + ttl1000_sig = "25b91426a37d4f633a9b34383c63889ff8952e7ffecef29a17d600eeb3db0db7" + + # default usage + self.assertEqual("https://res.cloudinary.com/{cloud}/search/{sig}/{ttl}/{query}".format( + cloud=CLOUD_NAME, + sig=ttl300_sig, + ttl=300, + query=b64query + ), + search.to_url() + ) + + # same signature with next cursor + self.assertEqual("https://res.cloudinary.com/{cloud}/search/{sig}/{ttl}/{query}/{cursor}".format( + cloud=CLOUD_NAME, + sig=ttl300_sig, + ttl=300, + query=b64query, + cursor=NEXT_CURSOR + ), + search.to_url(next_cursor=NEXT_CURSOR) + ) + + # with custom ttl and next cursor + self.assertEqual("https://res.cloudinary.com/{cloud}/search/{sig}/{ttl}/{query}/{cursor}".format( + cloud=CLOUD_NAME, + sig=ttl1000_sig, + ttl=1000, + query=b64query, + cursor=NEXT_CURSOR + ), + search.to_url(ttl=1000, next_cursor=NEXT_CURSOR) + ) + + # ttl and cursor are set from the class + self.assertEqual("https://res.cloudinary.com/{cloud}/search/{sig}/{ttl}/{query}/{cursor}".format( + cloud=CLOUD_NAME, + sig=ttl1000_sig, + ttl=1000, + query=b64query, + cursor=NEXT_CURSOR + ), + search.ttl(1000).next_cursor(NEXT_CURSOR).to_url() + ) + + # private cdn + self.assertEqual("https://{cloud}-res.cloudinary.com/search/{sig}/{ttl}/{query}".format( + cloud=CLOUD_NAME, + sig=ttl300_sig, + ttl=300, + query=b64query + ), + search.to_url(ttl=300, next_cursor="", private_cdn=True) + ) + + # private cdn from config + cloudinary.config(private_cdn=True) + self.assertEqual("https://{cloud}-res.cloudinary.com/search/{sig}/{ttl}/{query}".format( + cloud=CLOUD_NAME, + sig=ttl300_sig, + ttl=300, + query=b64query + ), + search.to_url(ttl=300, next_cursor="") + ) + + @patch(URLLIB3_REQUEST) + def test_should_search_folders_endpoint(self, mocker): + mocker.return_value = MOCK_RESPONSE + + SearchFolders() \ + .expression(FOLDERS_SEARCH_EXPRESSION) \ + .execute() + + result = get_json_body(mocker) + + self.assertTrue(get_uri(mocker).endswith('folders/search')) + + self.assertEqual({'expression': FOLDERS_SEARCH_EXPRESSION}, result) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_should_search_folders(self): + + results = SearchFolders() \ + .max_results(1) \ + .execute() + + self.assertEqual(1, len(results['folders'])) + if __name__ == '__main__': unittest.main() diff --git a/test/test_uploader.py b/test/test_uploader.py index 7481c66b..be233087 100644 --- a/test/test_uploader.py +++ b/test/test_uploader.py @@ -6,18 +6,19 @@ from datetime import datetime import six -from mock import patch from urllib3 import disable_warnings -from urllib3.util import parse_url import cloudinary from cloudinary import api, uploader, utils, exceptions from cloudinary.cache import responsive_breakpoints_cache from cloudinary.cache.adapter.key_value_cache_adapter import KeyValueCacheAdapter -from test.helper_test import uploader_response_mock, SUFFIX, TEST_IMAGE, get_params, TEST_ICON, TEST_DOC, \ - REMOTE_TEST_IMAGE, UTC, populate_large_file, TEST_UNICODE_IMAGE, get_uri, get_method, get_param, \ - cleanup_test_resources_by_tag, cleanup_test_transformation, cleanup_test_resources, ignore_exception +from cloudinary.compat import urlparse, parse_qs from test.cache.storage.dummy_cache_storage import DummyCacheStorage +from test.helper_test import uploader_response_mock, SUFFIX, TEST_IMAGE, get_params, get_headers, TEST_ICON, TEST_DOC, \ + REMOTE_TEST_IMAGE, UTC, populate_large_file, TEST_UNICODE_IMAGE, get_uri, get_method, get_param, \ + cleanup_test_resources_by_tag, cleanup_test_transformation, cleanup_test_resources, EVAL_STR, ON_SUCCESS_STR, \ + URLLIB3_REQUEST, patch, retry_assertion, CldTestCase +from test.test_utils import TEST_ID, TEST_FOLDER MOCK_RESPONSE = uploader_response_mock() @@ -48,50 +49,87 @@ METADATA_FIELD_UNIQUE_EXTERNAL_ID = 'metadata_field_external_id_{}'.format(UNIQUE_ID) METADATA_FIELD_VALUE = 'metadata_field_value_{}'.format(UNIQUE_ID) + +DATASOURCE_ENTRY_1 = "metadata_datasource_entry_external_id_1_{}".format(UNIQUE_ID) +DATASOURCE_ENTRY_2 = "metadata_datasource_entry_external_id_2_{}".format(UNIQUE_ID) + +METADATA_FIELD_EXTERNAL_ID_SET = "metadata_upload_external_id_set_{}".format(UNIQUE_ID) +METADATA_FIELD_SET_DATASOURCE_MULTIPLE = [ + { + "value": "v1", + "external_id": DATASOURCE_ENTRY_1, + }, + { + "value": "v2", + "external_id": DATASOURCE_ENTRY_2, + } +] + METADATA_FIELDS = { METADATA_FIELD_UNIQUE_EXTERNAL_ID: METADATA_FIELD_VALUE, + METADATA_FIELD_EXTERNAL_ID_SET: [DATASOURCE_ENTRY_1, DATASOURCE_ENTRY_2] } +FD_PID_PREFIX = "fd_public_id_prefix" +ASSET_FOLDER = "asset_folder" +DISPLAY_NAME = "test" + disable_warnings() -class UploaderTest(unittest.TestCase): +class UploaderTest(CldTestCase): rbp_trans = {"angle": 45, "crop": "scale"} rbp_format = "png" rbp_values = [206, 50] rbp_params = { "use_cache": True, "responsive_breakpoints": - [ - { - "create_derived": False, - "transformation": + [ { - "angle": 90 + "create_derived": False, + "transformation": + { + "angle": 90 + }, + "format": "gif" }, - "format": "gif" - }, - { - "create_derived": False, - "transformation": rbp_trans, - "format": rbp_format - }, - { - "create_derived": False - } - ], + { + "create_derived": False, + "transformation": rbp_trans, + "format": rbp_format + }, + { + "create_derived": False + } + ], "type": "upload" } - def setUp(self): + @classmethod + def setUpClass(cls): cloudinary.reset_config() + if not cloudinary.config().api_secret: + return + + print("Running tests for cloud: {}".format(cloudinary.config().cloud_name)) - with ignore_exception(suppress_traceback_classes=(exceptions.BadRequest,)): - api.add_metadata_field({ - "external_id": METADATA_FIELD_UNIQUE_EXTERNAL_ID, - "label": METADATA_FIELD_UNIQUE_EXTERNAL_ID, - "type": "string", - }) + api.add_metadata_field({ + "external_id": METADATA_FIELD_UNIQUE_EXTERNAL_ID, + "label": METADATA_FIELD_UNIQUE_EXTERNAL_ID, + "type": "string", + }) + + api.add_metadata_field({ + "external_id": METADATA_FIELD_EXTERNAL_ID_SET, + "label": METADATA_FIELD_EXTERNAL_ID_SET, + "type": "set", + "datasource": { + "values": METADATA_FIELD_SET_DATASOURCE_MULTIPLE, + }, + }) + + def setUp(self): + cloudinary.reset_config() @classmethod def tearDownClass(cls): @@ -111,8 +149,8 @@ def tearDownClass(cls): ([TEST_TRANS_SCALE2_STR, TEST_TRANS_SCALE2_PNG_STR],), ]) - with ignore_exception(suppress_traceback_classes=(exceptions.BadRequest,)): - api.delete_metadata_field(METADATA_FIELD_UNIQUE_EXTERNAL_ID) + api.delete_metadata_field(METADATA_FIELD_UNIQUE_EXTERNAL_ID) + api.delete_metadata_field(METADATA_FIELD_EXTERNAL_ID_SET) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload(self): @@ -130,6 +168,26 @@ def test_upload(self): self.assertIn(METADATA_FIELD_UNIQUE_EXTERNAL_ID, result['metadata']) self.assertEqual(result['metadata'].get(METADATA_FIELD_UNIQUE_EXTERNAL_ID), METADATA_FIELD_VALUE) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_upload_path_lib_path(self): + """Should successfully upload a pathlib.Path file object""" + try: + import pathlib + except ImportError: + self.skipTest("pathlib is not supported") + return + + path_lib_image_path = pathlib.Path(TEST_IMAGE) + + result = uploader.upload(path_lib_image_path, tags=[UNIQUE_TAG]) + + self.assertEqual(result["width"], TEST_IMAGE_WIDTH) + self.assertEqual(path_lib_image_path.stem, result["original_filename"]) + + result = uploader.upload_large(path_lib_image_path, tags=[UNIQUE_TAG], resource_type="image") + + self.assertEqual(result["width"], TEST_IMAGE_WIDTH) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_unicode_filename(self): """Should successfully upload file with unicode characters""" @@ -174,24 +232,49 @@ def test_upload_custom_filename(self): self.assertEqual(os.path.splitext(custom_filename)[0], result["original_filename"]) - @patch('urllib3.request.RequestMethods.request') + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_upload_filename_override(self): + """should successfully override original_filename""" + + result = uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG], filename_override='overridden') + + self.assertEqual('overridden', result["original_filename"]) + + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_async(self, mocker): """Should pass async value """ mocker.return_value = MOCK_RESPONSE async_option = {"async": True} uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG], **async_option) - params = mocker.call_args[0][2] - self.assertTrue(params['async']) + self.assertTrue(get_param(mocker, 'async')) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_upload_folder_decoupling(self, mocker): + """Should pass folder decoupling params """ + mocker.return_value = MOCK_RESPONSE + + fd_params = {"public_id_prefix": FD_PID_PREFIX, "asset_folder": ASSET_FOLDER, "display_name": DISPLAY_NAME, + "use_filename_as_display_name": True, "folder": TEST_FOLDER, + "use_asset_folder_as_public_id_prefix": True} + uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG], **fd_params) + + self.assertEqual(FD_PID_PREFIX, get_param(mocker, "public_id_prefix")) + self.assertEqual(ASSET_FOLDER, get_param(mocker, "asset_folder")) + self.assertEqual(DISPLAY_NAME, get_param(mocker, "display_name")) + self.assertEqual("1", get_param(mocker, "use_filename_as_display_name")) + self.assertEqual("1", get_param(mocker, "use_asset_folder_as_public_id_prefix")) + self.assertEqual(TEST_FOLDER, get_param(mocker, "folder")) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_ocr(self, mocker): """Should pass ocr value """ mocker.return_value = MOCK_RESPONSE uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG], ocr='adv_ocr') - args, kargs = mocker.call_args - self.assertEqual(get_params(args)['ocr'], 'adv_ocr') + + self.assertEqual(get_params(mocker)['ocr'], 'adv_ocr') @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_quality_analysis(self): @@ -215,7 +298,7 @@ def test_quality_analysis(self): self.assertIn("focus", result["quality_analysis"]) self.assertIsInstance(result["quality_analysis"]["focus"], float) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_quality_override(self, mocker): """Should pass quality_override """ @@ -223,12 +306,12 @@ def test_quality_override(self, mocker): test_values = ['auto:advanced', 'auto:best', '80:420', 'none'] for quality in test_values: uploader.upload(TEST_IMAGE, tags=UNIQUE_TAG, quality_override=quality) - params = mocker.call_args[0][2] - self.assertEqual(params['quality_override'], quality) + quality_override = get_param(mocker, 'quality_override') + self.assertEqual(quality_override, quality) # verify explicit works too uploader.explicit(TEST_IMAGE, quality_override='auto:best') - params = mocker.call_args[0][2] - self.assertEqual(params['quality_override'], 'auto:best') + quality_override = get_param(mocker, 'quality_override') + self.assertEqual(quality_override, 'auto:best') @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_url(self): @@ -295,16 +378,44 @@ def test_rename(self): uploader.rename(result2["public_id"], result["public_id"] + "2", overwrite=True) self.assertEqual(api.resource(result["public_id"] + "2")["format"], "ico") - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_rename_parameters(self, mocker): """Should support to_type, invalidate, and overwrite """ mocker.return_value = MOCK_RESPONSE uploader.rename(TEST_IMAGE, TEST_IMAGE + "2", to_type='raw', invalidate=True, overwrite=False) - args, kargs = mocker.call_args - self.assertEqual(get_params(args)['to_type'], 'raw') - self.assertTrue(get_params(args)['invalidate']) - self.assertTrue(get_params(args)['overwrite']) + + self.assertEqual(get_params(mocker)['to_type'], 'raw') + self.assertTrue(get_params(mocker)['invalidate']) + self.assertTrue(get_params(mocker)['overwrite']) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_rename_supports_context(self, mocker): + """Should support context""" + mocker.return_value = MOCK_RESPONSE + + uploader.rename(TEST_IMAGE, TEST_IMAGE + "2", context=True) + + self.assertTrue(get_params(mocker)['context']) + + uploader.rename(TEST_IMAGE, TEST_IMAGE + "2") + + self.assertIsNone(get_params(mocker).get('context')) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_rename_supports_metadata(self, mocker): + """Should support metadata""" + mocker.return_value = MOCK_RESPONSE + + uploader.rename(TEST_IMAGE, TEST_IMAGE + "2", metadata=True) + + self.assertTrue(get_params(mocker)['metadata']) + + uploader.rename(TEST_IMAGE, TEST_IMAGE + "2") + + self.assertIsNone(get_params(mocker).get('metadata')) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_use_filename(self): @@ -316,16 +427,10 @@ def test_use_filename(self): @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_explicit(self): - """Should support explicit """ - result = uploader.explicit("cloudinary", type="twitter_name", eager=[TEST_TRANS_SCALE2_PNG], tags=[UNIQUE_TAG]) - params = dict(TEST_TRANS_SCALE2_PNG, type="twitter_name", version=result["version"]) - url = utils.cloudinary_url("cloudinary", **params)[0] - actual = result["eager"][0]["url"] - self.assertEqual(parse_url(actual).path, parse_url(url).path) - # Test explicit with metadata resource = uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG]) - result_metadata = uploader.explicit(resource['public_id'], type="upload", metadata=METADATA_FIELDS, tags=[UNIQUE_TAG]) + result_metadata = uploader.explicit(resource['public_id'], type="upload", metadata=METADATA_FIELDS, + tags=[UNIQUE_TAG]) self.assertIn(METADATA_FIELD_UNIQUE_EXTERNAL_ID, result_metadata['metadata']) self.assertEqual(result_metadata['metadata'].get(METADATA_FIELD_UNIQUE_EXTERNAL_ID), METADATA_FIELD_VALUE) @@ -342,6 +447,19 @@ def test_explicit_responsive_breakpoints_cache(self): self.assertEqual(self.rbp_values, cache_value) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_explicit_error_handling_not_found(self): + # Test explicit error handling + with six.assertRaisesRegex(self, exceptions.NotFound, "Resource not found"): + uploader.explicit(UNIQUE_ID + "_does_not_exist", type="upload", tags=[UNIQUE_TAG]) + + not_found_res = uploader.explicit(UNIQUE_ID + "_does_not_exist", type="upload", tags=[UNIQUE_TAG], + return_error=True) + + self.assertEqual(not_found_res["error"]["http_code"], 404) + self.assertTrue(not_found_res["error"]["message"].startswith("Resource not found")) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_update_metadata(self): metadata = {METADATA_FIELD_UNIQUE_EXTERNAL_ID: "test"} @@ -354,6 +472,13 @@ def test_update_metadata(self): "public_ids": public_ids, }) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_clear_invalid(self, mocker): + mocker.return_value = MOCK_RESPONSE + uploader.update_metadata(METADATA_FIELDS, public_ids=[TEST_ID], clear_invalid=True) + self.assertTrue(get_param(mocker, "clear_invalid")) + def test_upload_with_metadata(self): """Upload should support `metadata` parameter""" result = uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG], metadata=METADATA_FIELDS) @@ -395,6 +520,19 @@ def test_header(self): uploader.upload(TEST_IMAGE, headers=["Link: 1"], tags=[UNIQUE_TAG]) uploader.upload(TEST_IMAGE, headers={"Link": "1"}, tags=[UNIQUE_TAG]) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_extra_headers(self, mocker): + """Should support extra headers""" + mocker.return_value = MOCK_RESPONSE + uploader.upload(TEST_IMAGE, extra_headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/58.0.3029.110 Safari/537.3'}) + headers = get_headers(mocker) + self.assertEqual(headers.get('User-Agent'), 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/58.0.3029.110 Safari/537.3') + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_text(self): """Should successfully generate text image """ @@ -402,6 +540,81 @@ def test_text(self): self.assertGreater(result["width"], 1) self.assertGreater(result["height"], 1) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_create_slideshow_from_manifest_transformation(self, mocker): + """Should create slideshow from a manifest transformation""" + mocker.return_value = MOCK_RESPONSE + + slideshow_manifest = "w_352;h_240;du_5;fps_30;vars_(slides_((media_s64:aHR0cHM6Ly9y" + \ + "ZXMuY2xvdWRpbmFyeS5jb20vZGVtby9pbWFnZS91cGxvYWQvY291cGxl);(media_s64:aH" + \ + "R0cHM6Ly9yZXMuY2xvdWRpbmFyeS5jb20vZGVtby9pbWFnZS91cGxvYWQvc2FtcGxl)))" + + uploader.create_slideshow( + manifest_transformation={ + "custom_function": { + "function_type": "render", + "source": slideshow_manifest, + } + }, + transformation={"fetch_format": "auto", "quality": "auto"}, + tags=['tag1', 'tag2', 'tag3'] + ) + + args, _ = mocker.call_args + + self.assertTrue(get_uri(mocker).endswith('/video/create_slideshow')) + + self.assertEqual("fn_render:" + slideshow_manifest, get_params(mocker)['manifest_transformation']) + self.assertEqual("f_auto,q_auto", get_params(mocker)['transformation']) + self.assertEqual("tag1,tag2,tag3", get_params(mocker)['tags']) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_create_slideshow_from_manifest_json(self, mocker): + """Should create slideshow from a manifest json""" + mocker.return_value = MOCK_RESPONSE + slideshow_manifest_json = OrderedDict(( + ("w", 848), + ("h", 480), + ("du", 6), + ("fps", 30), + ("vars", OrderedDict(( + ("sdur", 500), + ("tdur", 500), + ("slides", [ + {"media": "i:protests9"}, + {"media": "i:protests8"}, + {"media": "i:protests7"}, + {"media": "i:protests6"}, + {"media": "i:protests2"}, + {"media": "i:protests1"} + ]) + ))) + )) + + slideshow_manifest_json_str = '{"w":848,"h":480,"du":6,"fps":30,"vars":{"sdur":500,"tdur":500,' + \ + '"slides":[{"media":"i:protests9"},{"media":"i:protests8"},' + \ + '{"media":"i:protests7"},{"media":"i:protests6"},{"media":"i:protests2"},' + \ + '{"media":"i:protests1"}]}}' + notification_url = "https://example.com" + + uploader.create_slideshow( + manifest_json=slideshow_manifest_json, + overwrite=True, + public_id=TEST_ID, + notification_url=notification_url, + upload_preset=API_TEST_PRESET + ) + + args, _ = mocker.call_args + + self.assertEqual(slideshow_manifest_json_str, get_params(mocker)["manifest_json"]) + self.assertEqual("1", get_params(mocker)["overwrite"]) + self.assertEqual(TEST_ID, get_params(mocker)["public_id"]) + self.assertEqual(notification_url, get_params(mocker)["notification_url"]) + self.assertEqual(API_TEST_PRESET, get_params(mocker)["upload_preset"]) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_tags(self): """Should successfully upload file """ @@ -417,6 +630,28 @@ def test_tags(self): self.assertEqual(api.resource(result["public_id"])["tags"], ["tag3"]) uploader.replace_tag(UNIQUE_TAG, result["public_id"]) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + @retry_assertion() + def test_multiple_tags(self): + """ Should support adding multiple tags: list ["tag1","tag2"] and comma-separated "tag1,tag2" """ + result = uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG]) + result2 = uploader.upload(TEST_IMAGE, tags=[UNIQUE_TAG]) + + uploader.add_tag(["tag1", "tag2"], [result["public_id"], result2["public_id"]]) + uploader.add_tag("tag3,tag4", [result["public_id"], result2["public_id"]]) + self.assertEqual(api.resource(result["public_id"])["tags"], ["tag1", "tag2", "tag3", "tag4", UNIQUE_TAG]) + self.assertEqual(api.resource(result2["public_id"])["tags"], ["tag1", "tag2", "tag3", "tag4", UNIQUE_TAG]) + + uploader.remove_tag(["tag1", "tag2"], result["public_id"]) + uploader.remove_tag("tag3,tag4", result2["public_id"]) + self.assertEqual(api.resource(result["public_id"])["tags"], ["tag3", "tag4", UNIQUE_TAG]) + self.assertEqual(api.resource(result2["public_id"])["tags"], ["tag1", "tag2", UNIQUE_TAG]) + + uploader.replace_tag(["tag5", UNIQUE_TAG], result["public_id"]) + uploader.replace_tag("tag7," + UNIQUE_TAG, result2["public_id"]) + self.assertEqual(api.resource(result["public_id"])["tags"], ["tag5", UNIQUE_TAG]) + self.assertEqual(api.resource(result2["public_id"])["tags"], ["tag7", UNIQUE_TAG]) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_remove_all_tags(self): """Should successfully remove all tags""" @@ -518,7 +753,7 @@ def test_categorization(self): @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_detection(self): """Should support requesting detection """ - with six.assertRaisesRegex(self, exceptions.Error, 'Detection is invalid'): + with six.assertRaisesRegex(self, exceptions.Error, "Detection invalid model 'illegal'"): uploader.upload(TEST_IMAGE, detection="illegal", tags=[UNIQUE_TAG]) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -535,7 +770,7 @@ def test_upload_large(self): resource = uploader.upload_large(temp_file_name, chunk_size=LARGE_CHUNK_SIZE, tags=["upload_large_tag", UNIQUE_TAG]) - self.assertEqual(resource["tags"], ["upload_large_tag", UNIQUE_TAG]) + self.assertCountEqual(resource["tags"], ["upload_large_tag", UNIQUE_TAG]) self.assertEqual(resource["resource_type"], "raw") self.assertEqual(resource["original_filename"], temp_file_filename) @@ -543,7 +778,7 @@ def test_upload_large(self): tags=["upload_large_tag", UNIQUE_TAG], resource_type="image", use_filename=True, unique_filename=False, filename=filename) - self.assertEqual(resource2["tags"], ["upload_large_tag", UNIQUE_TAG]) + self.assertCountEqual(resource2["tags"], ["upload_large_tag", UNIQUE_TAG]) self.assertEqual(resource2["resource_type"], "image") self.assertEqual(resource2["original_filename"], filename) self.assertEqual(resource2["original_filename"], resource2["public_id"]) @@ -553,7 +788,7 @@ def test_upload_large(self): resource3 = uploader.upload_large(temp_file_name, chunk_size=LARGE_FILE_SIZE, tags=["upload_large_tag", UNIQUE_TAG]) - self.assertEqual(resource3["tags"], ["upload_large_tag", UNIQUE_TAG]) + self.assertCountEqual(resource3["tags"], ["upload_large_tag", UNIQUE_TAG]) self.assertEqual(resource3["resource_type"], "raw") @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") @@ -577,12 +812,12 @@ def test_upload_large_file_io(self): resource = uploader.upload_large(temp_file, chunk_size=LARGE_CHUNK_SIZE, tags=["upload_large_tag", UNIQUE_TAG], resource_type="image") - self.assertEqual(resource["tags"], ["upload_large_tag", UNIQUE_TAG]) + self.assertCountEqual(resource["tags"], ["upload_large_tag", UNIQUE_TAG]) self.assertEqual(resource["resource_type"], "image") self.assertEqual(resource["width"], LARGE_FILE_WIDTH) self.assertEqual(resource["height"], LARGE_FILE_HEIGHT) - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_upload_preset(self, mocker): """Should support unsigned uploading using presets """ @@ -590,14 +825,38 @@ def test_upload_preset(self, mocker): uploader.unsigned_upload(TEST_IMAGE, API_TEST_PRESET) - args, kargs = mocker.call_args - - self.assertTrue(get_uri(args).endswith("/image/upload")) + self.assertTrue(get_uri(mocker).endswith("/image/upload")) self.assertEqual("POST", get_method(mocker)) self.assertIsNotNone(get_param(mocker, "file")) self.assertIsNone(get_param(mocker, "signature")) self.assertEqual(get_param(mocker, "upload_preset"), API_TEST_PRESET) + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_upload_preset_in_config(self, mocker): + """Should support uploading using presets from config""" + mocker.return_value = MOCK_RESPONSE + cloudinary.config().upload_preset = API_TEST_PRESET + uploader.upload(TEST_IMAGE) + + self.assertTrue(get_uri(mocker).endswith("/image/upload")) + self.assertEqual("POST", get_method(mocker)) + self.assertIsNotNone(get_param(mocker, "file")) + self.assertEqual(get_param(mocker, "upload_preset"), API_TEST_PRESET) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test1_upload_preset_in_config(self, mocker): + """Should support overwriting upload presets in config""" + mocker.return_value = MOCK_RESPONSE + cloudinary.config().upload_preset = API_TEST_PRESET + uploader.upload(TEST_IMAGE, upload_preset=None) + + self.assertTrue(get_uri(mocker).endswith("/image/upload")) + self.assertEqual("POST", get_method(mocker)) + self.assertIsNotNone(get_param(mocker, "file")) + self.assertEqual(get_param(mocker, "upload_preset"), None) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") def test_background_removal(self): """Should support requesting background_removal """ @@ -637,31 +896,31 @@ def test_responsive_breakpoints(self): "a_45") @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") - @patch('urllib3.request.RequestMethods.request') + @patch(URLLIB3_REQUEST) def test_access_control(self, request_mock): request_mock.return_value = MOCK_RESPONSE # Should accept a dictionary of strings acl = OrderedDict((("access_type", "anonymous"), - ("start", "2018-02-22 16:20:57 +0200"), - ("end", "2018-03-22 00:00 +0200"))) + ("start", "2018-02-22 16:20:57 +0200"), + ("end", "2018-03-22 00:00 +0200"))) exp_acl = '[{"access_type":"anonymous","start":"2018-02-22 16:20:57 +0200","end":"2018-03-22 00:00 +0200"}]' uploader.upload(TEST_IMAGE, access_control=acl) - params = get_params(request_mock.call_args[0]) + params = get_params(request_mock) self.assertIn("access_control", params) self.assertEqual(exp_acl, params["access_control"]) # Should accept a dictionary of datetime objects acl_2 = OrderedDict((("access_type", "anonymous"), - ("start", datetime.strptime("2019-02-22 16:20:57Z", "%Y-%m-%d %H:%M:%SZ")), - ("end", datetime(2019, 3, 22, 0, 0, tzinfo=UTC())))) + ("start", datetime.strptime("2019-02-22 16:20:57Z", "%Y-%m-%d %H:%M:%SZ")), + ("end", datetime(2019, 3, 22, 0, 0, tzinfo=UTC())))) exp_acl_2 = '[{"access_type":"anonymous","start":"2019-02-22T16:20:57","end":"2019-03-22T00:00:00+00:00"}]' uploader.upload(TEST_IMAGE, access_control=acl_2) - params = get_params(request_mock.call_args[0]) + params = get_params(request_mock) self.assertEqual(exp_acl_2, params["access_control"]) @@ -670,17 +929,17 @@ def test_access_control(self, request_mock): exp_acl_str = '[{"access_type":"anonymous","start":"2019-02-22 16:20:57 +0200","end":"2019-03-22 00:00 +0200"}]' uploader.upload(TEST_IMAGE, access_control=acl_str) - params = get_params(request_mock.call_args[0]) + params = get_params(request_mock) self.assertEqual(exp_acl_str, params["access_control"]) # Should accept a list of all the above values list_of_acl = [acl, acl_2, acl_str] # Remove starting "[" and ending "]" in all expected strings and combine them into one string - expected_list_of_acl = "[" + ",".join([v[1:-1] for v in(exp_acl, exp_acl_2, exp_acl_str)]) + "]" + expected_list_of_acl = "[" + ",".join([v[1:-1] for v in (exp_acl, exp_acl_2, exp_acl_str)]) + "]" uploader.upload(TEST_IMAGE, access_control=list_of_acl) - params = get_params(request_mock.call_args[0]) + params = get_params(request_mock) self.assertEqual(expected_list_of_acl, params["access_control"]) @@ -690,20 +949,148 @@ def test_access_control(self, request_mock): with self.assertRaises(ValueError): uploader.upload(TEST_IMAGE, access_control=invalid_value) - @patch('urllib3.request.RequestMethods.request') - def test_cinemagraph_analysis(self, request_mock): - """Should support cinemagraph analysis in upload and explicit""" + @patch(URLLIB3_REQUEST) + def test_various_upload_parameters(self, request_mock): + """Should support various parameters in upload and explicit""" request_mock.return_value = MOCK_RESPONSE - uploader.upload(TEST_IMAGE, cinemagraph_analysis=True) + options = { + 'cinemagraph_analysis': True, + 'accessibility_analysis': True, + 'media_metadata': True, + 'visual_search': True, + 'on_success': ON_SUCCESS_STR, + 'regions': {"box_1": [[1, 2], [3, 4]], "box_2": [[5, 6], [7, 8]]} + } - params = request_mock.call_args[0][2] - self.assertIn("cinemagraph_analysis", params) + uploader.upload(TEST_IMAGE, **options) - uploader.explicit(TEST_IMAGE, cinemagraph_analysis=True) + params = get_params(request_mock) + for param in options.keys(): + self.assertIn(param, params) - params = request_mock.call_args[0][2] - self.assertIn("cinemagraph_analysis", params) + uploader.explicit(TEST_IMAGE, **options) + + params = get_params(request_mock) + for param in options.keys(): + self.assertIn(param, params) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_eval_upload_parameter(self): + """Should support eval in upload""" + result = uploader.upload(TEST_IMAGE, eval=EVAL_STR, tags=[UNIQUE_TAG]) + self.assertEqual(str(result['context']['custom']['width']), str(TEST_IMAGE_WIDTH)) + self.assertIsInstance(result['quality_analysis'], dict) + self.assertIsInstance(result['quality_analysis']['focus'], float) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_generate_sprite(self): + """Should generate a sprite from all images associated with a tag or from the image urls""" + sprite_test_tag = "sprite_test_tag{}".format(SUFFIX) + images_quantity_in_sprite = 2 + + upload_result_1 = uploader.upload(TEST_IMAGE, tags=[sprite_test_tag, UNIQUE_TAG], + public_id="sprite_test_tag_1{}".format(SUFFIX)) + upload_result_2 = uploader.upload(TEST_IMAGE, tags=[sprite_test_tag, UNIQUE_TAG], + public_id="sprite_test_tag_2{}".format(SUFFIX)) + + result = uploader.generate_sprite(tag=sprite_test_tag, tags=[UNIQUE_TAG]) + self.assertEqual(len(result["image_infos"]), images_quantity_in_sprite) + + urls = [upload_result_1.get('url'), upload_result_2.get('url')] + result = uploader.generate_sprite(urls=urls, tags=[UNIQUE_TAG]) + self.assertEqual(len(result["image_infos"]), images_quantity_in_sprite) + + result = uploader.generate_sprite(sprite_test_tag, transformation={"raw_transformation": "w_100"}) + self.assertIn("w_100", result["css_url"]) + + result = uploader.generate_sprite(sprite_test_tag, format="jpg", width=100) + uploader.destroy(result.get("public_id")) + self.assertIn("f_jpg,w_100", result["css_url"]) + + def test_download_sprite(self): + """Should generate signed download url for sprite""" + sprite_test_tag = "sprite_tag" + url_1 = "https://res.cloudinary.com/demo/image/upload/sample" + url_2 = "https://res.cloudinary.com/demo/image/upload/car" + + url_from_tag = uploader.download_generated_sprite(tag=sprite_test_tag) + url_from_urls = uploader.download_generated_sprite(urls=[url_1, url_2]) + + self.assertTrue(url_from_tag.startswith( + "https://api.cloudinary.com/v1_1/" + cloudinary.config().cloud_name + "/image/sprite")) + self.assertTrue(url_from_urls.startswith( + "https://api.cloudinary.com/v1_1/" + cloudinary.config().cloud_name + "/image/sprite")) + + parameters = parse_qs(urlparse(url_from_tag).query) + self.assertEqual(sprite_test_tag, parameters["tag"][0]) + self.assertEqual("download", parameters["mode"][0]) + self.assertIn("timestamp", parameters) + self.assertIn("signature", parameters) + + parameters = parse_qs(urlparse(url_from_urls).query) + self.assertIn(url_1, parameters["urls[]"]) + self.assertIn(url_2, parameters["urls[]"]) + self.assertEqual("download", parameters["mode"][0]) + self.assertIn("timestamp", parameters) + self.assertIn("signature", parameters) + + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_multi(self): + """Should generate a GIF, video or a PDF from all images associated with a tag or from the image urls""" + multi_test_tag = "multi_test_tag{}".format(SUFFIX) + upload_result_1 = uploader.upload(TEST_IMAGE, tags=[multi_test_tag, UNIQUE_TAG]) + upload_result_2 = uploader.upload(TEST_IMAGE, tags=[multi_test_tag, UNIQUE_TAG]) + + # Generate multi from urls + urls = [upload_result_1.get('url'), upload_result_2.get('url')] + result = uploader.multi(urls=urls, crop="crop", width="0.5") + self.assertTrue(result.get("url").endswith(".gif")) + self.assertIn("w_0.5", result.get("url")) + + # Generate multi from tag + result = uploader.multi(tag=multi_test_tag, transformation={"raw_transformation": "c_crop,w_0.5"}) + pdf_result = uploader.multi(tag=multi_test_tag, width=111, format="pdf") + + self.assertTrue(result.get("url").endswith(".gif")) + self.assertIn("w_0.5", result.get("url")) + self.assertTrue(pdf_result.get("url").endswith(".pdf")) + self.assertIn("w_111", pdf_result.get("url")) + + def test_download_multi(self): + """Should generate signed download url for multi""" + multi_test_tag = "multi_test_tag" + url_1 = "https://res.cloudinary.com/demo/image/upload/sample" + url_2 = "https://res.cloudinary.com/demo/image/upload/car" + + url_from_tag = uploader.download_multi(tag=multi_test_tag) + url_from_urls = uploader.download_multi(urls=[url_1, url_2]) + + self.assertTrue(url_from_tag.startswith( + "https://api.cloudinary.com/v1_1/" + cloudinary.config().cloud_name + "/image/multi")) + self.assertTrue(url_from_urls.startswith( + "https://api.cloudinary.com/v1_1/" + cloudinary.config().cloud_name + "/image/multi")) + + parameters = parse_qs(urlparse(url_from_tag).query) + self.assertEqual(multi_test_tag, parameters["tag"][0]) + self.assertEqual("download", parameters["mode"][0]) + self.assertIn("timestamp", parameters) + self.assertIn("signature", parameters) + + parameters = parse_qs(urlparse(url_from_urls).query) + self.assertIn(url_1, parameters["urls[]"]) + self.assertIn(url_2, parameters["urls[]"]) + self.assertEqual("download", parameters["mode"][0]) + self.assertIn("timestamp", parameters) + self.assertIn("signature", parameters) + + @patch(URLLIB3_REQUEST) + @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") + def test_create_zip_with_target_asset_folder(self, mocker): + """Should pass target_asset_folder parameter on archive generation""" + mocker.return_value = MOCK_RESPONSE + uploader.create_zip(tags="test-tag", target_asset_folder="test-asset-folder") + self.assertEqual("test-asset-folder", get_param(mocker, "target_asset_folder")) if __name__ == '__main__': diff --git a/test/test_utils.py b/test/test_utils.py index 283fe3ef..57f19e1c 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -4,7 +4,6 @@ import tempfile import unittest import uuid -import json import time from collections import OrderedDict from datetime import datetime, date @@ -12,11 +11,11 @@ from os.path import getsize import six -from mock import patch import cloudinary.utils from cloudinary import CL_BLANK from cloudinary.utils import ( + api_sign_request, build_list_of_dicts, json_encode, encode_unicode_url, @@ -31,7 +30,7 @@ verify_api_response_signature, ) from cloudinary.compat import to_bytes -from test.helper_test import TEST_IMAGE, REMOTE_TEST_IMAGE +from test.helper_test import TEST_IMAGE, REMOTE_TEST_IMAGE, patch from test.test_api import ( API_TEST_TRANS_SCALE100, API_TEST_TRANS_SCALE100_STR, @@ -46,7 +45,8 @@ VIDEO_UPLOAD_PATH = DEFAULT_ROOT_PATH + 'video/upload/' TEST_ID = 'test' -FETCH_URL = "http://cloudinary.com/images/logo.png" +FETCH_URL = "https://cloudinary.com/images/logo.png" +FETCH_VIDEO_URL = "https://demo-res.cloudinary.com/videos/dog.mp4" IMAGE_VERSION = "1234" IMAGE_VERSION_STR = "v" + IMAGE_VERSION @@ -55,6 +55,9 @@ MOCKED_NOW = 1549533574 API_SECRET = 'X7qLTrsES31MzxxkxPPA-pAGGfU' +API_SIGN_REQUEST_TEST_SECRET = "hdcixPpR2iKERPwqvH6sHdK9cyac" +API_SIGN_REQUEST_CLOUD_NAME = "dn6ot3ged" + class TestUtils(unittest.TestCase): crop_transformation = {'crop': 'crop', 'width': 100} @@ -75,7 +78,8 @@ def setUp(self): cname=None, # for these tests without actual upload, we ignore cname api_key="a", api_secret="b", secure_distribution=None, - private_cdn=False) + private_cdn=False, + signature_version=2) def __test_cloudinary_url(self, public_id=TEST_ID, options=None, expected_url=None, expected_options=None): if expected_options is None: @@ -167,6 +171,7 @@ def test_radius(self): ({"radius": 10}, "r_10"), ({"radius": "10"}, "r_10"), ({"radius": "$v", "variables": [("$v", 10)]}, "$v_10,r_$v"), + ({"radius": "width * 2"}, "r_w_mul_2"), ({"radius": [10, 20]}, "r_10:20"), ({"radius": "10:20"}, "r_10:20"), ({"radius": "10:$v", "variables": [("$v", 20)]}, "$v_20,r_10:$v"), @@ -436,6 +441,14 @@ def test_fetch_overlay(self): + "l_fetch:aHR0cDovL2Nsb3VkaW5hcnkuY29tL2ltYWdlcy9vbGRfbG9nby5wbmc=/" + "test")) + """should support video overlay""" + self.__test_cloudinary_url( + options={"overlay": "video:fetch:" + FETCH_VIDEO_URL}, + expected_url=( + DEFAULT_UPLOAD_PATH + + "l_video:fetch:aHR0cHM6Ly9kZW1vLXJlcy5jbG91ZGluYXJ5LmNvbS92aWRlb3MvZG9nLm1wNA==/" + + "test")) + self.__test_cloudinary_url( options={ "overlay": { @@ -444,8 +457,18 @@ def test_fetch_overlay(self): expected_url=( DEFAULT_UPLOAD_PATH + "l_fetch:" - "aHR0cHM6Ly91cGxvYWQud2lraW1lZGlhLm9yZy93aWtpcGVkaWEvY29" - "tbW9ucy8yLzJiLyVFQSVCMyVBMCVFQyVCMCVCRCVFQSVCMCVBRiVFQiVCMiU4Qy5qcGc=/" + "aHR0cHM6Ly91cGxvYWQud2lraW1lZGlhLm9yZy93aWtpcGVkaWEvY29tbW9ucy8yLzJiL-qzoOywveqwr-uyjC5qcGc=/" + "test")) + + self.__test_cloudinary_url( + options={ + "overlay": { + "url": + "https://www.test.com/test/JE01118-YGP900_1_lar.jpg?version=432023"}}, + expected_url=( + DEFAULT_UPLOAD_PATH + + "l_fetch:" + "aHR0cHM6Ly93d3cudGVzdC5jb20vdGVzdC9KRTAxMTE4LVlHUDkwMF8xX2xhci5qcGc_dmVyc2lvbj00MzIwMjM=/" "test")) def test_underlay(self): @@ -638,6 +661,28 @@ def test_signed_url(self): options={"version": 1234, "type": "fetch", "sign_url": True}, expected_url=DEFAULT_ROOT_PATH + "image/fetch/s--hH_YcbiS--/v1234/http://google.com/path/to/image.png") + self.__test_cloudinary_url( + public_id="image.jpg", + options={"type": "authenticated", "transformation": {"color": "red", "overlay": {"text": "Cool%F0%9F%98%8D", "font_family": "Times", "font_size": 70, "font_weight": "bold"}}, + "sign_url": True}, + expected_url="http://res.cloudinary.com/test123/image/authenticated/s--Uqk1a-5W--/co_red,l_text:Times_70_bold:Cool%25F0%259F%2598%258D/image.jpg") + + self.__test_cloudinary_url( + public_id="image.jpg", + options={"type": "authenticated", "transformation": {"raw_transformation": "co_red,l_text:Times_70_bold:Cool%25F0%259F%2598%258D"}, + "sign_url": True}, + expected_url="http://res.cloudinary.com/test123/image/authenticated/s--Uqk1a-5W--/co_red,l_text:Times_70_bold:Cool%25F0%259F%2598%258D/image.jpg") + + def test_signed_url_sha256(self): + sha256_config = cloudinary.Config() + sha256_config.update(**vars(cloudinary.config())) + sha256_config.update(signature_algorithm=cloudinary.utils.SIGNATURE_SHA256) + with patch('cloudinary.config', return_value=sha256_config): + self.__test_cloudinary_url( + public_id="sample.jpg", + options={"sign_url": True}, + expected_url=DEFAULT_UPLOAD_PATH + "s--2hbrSMPO--/sample.jpg") + def test_disallow_url_suffix_in_non_upload_types(self): with self.assertRaises(ValueError): cloudinary.utils.cloudinary_url("test", url_suffix="hello", private_cdn=True, type="facebook") @@ -783,6 +828,18 @@ def test_video_codec(self): 'level': '3.1'}}, expected_url=VIDEO_UPLOAD_PATH + "vc_h264:basic:3.1/video_id") + # b_frames=True -> should not add b_frames parameter + self.__test_cloudinary_url(public_id="video_id", options={'resource_type': 'video', + 'video_codec': {'codec': 'h265', 'profile': 'auto', + 'level': 'auto', 'b_frames': True}}, + expected_url=VIDEO_UPLOAD_PATH + "vc_h265:auto:auto/video_id") + + # should support a b_frames parameter - b_frames=False -> bframes_no + self.__test_cloudinary_url(public_id="video_id", options={'resource_type': 'video', + 'video_codec': {'codec': 'h265', 'profile': 'auto', + 'level': 'auto', 'b_frames': False}}, + expected_url=VIDEO_UPLOAD_PATH + "vc_h265:auto:auto:bframes_no/video_id") + def test_audio_codec(self): # should support a string value self.__test_cloudinary_url(public_id="video_id", options={'resource_type': 'video', 'audio_codec': 'acc'}, @@ -871,7 +928,7 @@ def test_offset(self): def test_user_agent(self): with patch('cloudinary.USER_PLATFORM', ''): agent = cloudinary.get_user_agent() - six.assertRegex(self, agent, r'^CloudinaryPython\/\d\.\d+\.\d+ \(Python \d\.\d+\.\d+\)$') + six.assertRegex(self, agent, r'^CloudinaryPython\/\d\.\d+\.\d+ \(.*; Python \d\.\d+\.\d+\)$') platform = 'MyPlatform/1.2.3 (Test code)' with patch('cloudinary.USER_PLATFORM', platform): @@ -915,14 +972,36 @@ def test_overlay_options(self): 'font_family': "Arial", 'font_size': 40}, "subtitles:Arial_40:sample_sub_he.srt"), ({'url': "https://upload.wikimedia.org/wikipedia/commons/2/2b/고창갯벌.jpg"}, - "fetch:aHR0cHM6Ly91cGxvYWQud2lraW1lZGlhLm9yZy93aWtpcGVkaWEvY29" - "tbW9ucy8yLzJiLyVFQSVCMyVBMCVFQyVCMCVCRCVFQSVCMCVBRiVFQiVCMiU4Qy5qcGc=") + "fetch:aHR0cHM6Ly91cGxvYWQud2lraW1lZGlhLm9yZy93aWtpcGVkaWEvY29tbW9ucy8yLzJiL-qzoOywveqwr-uyjC5qcGc="), + ({'url': FETCH_VIDEO_URL, "resource_type": "video"}, + "video:fetch:aHR0cHM6Ly9kZW1vLXJlcy5jbG91ZGluYXJ5LmNvbS92aWRlb3MvZG9nLm1wNA==") ] for options, expected in tests: result = cloudinary.utils.process_layer(options, "overlay") self.assertEqual(expected, result) + def test_text_layer_style_identifier_variables(self): + options = { + "transformation": [ + { + "variables": [ + ["$style", "!Arial_12!"], + ] + }, + { + "overlay": { + "text": "hello-world", + "text_style": "$style" + } + } + ] + } + + public_id = "sample" + url, _ = cloudinary.utils.cloudinary_url(public_id, **options) + self.assertEqual(DEFAULT_UPLOAD_PATH + "$style_!Arial_12!/l_text:$style:hello-world/sample", url) + def test_overlay_error_1(self): """ Must supply font_family for text in overlay """ with self.assertRaises(ValueError): @@ -964,6 +1043,28 @@ def test_normalize_expression_with_predefined_variables(self): self.assertEqual(normalized, expected) + def test_transformation_with_complex_predefined_variables(self): + transformation = { + "transformation": [ + { + "variables": [ + ["$aheight", 300], + ["$mywidth", "100"] + ] + }, + { + "width": "3 + $mywidth * 3 + 4 / 2 * initialWidth * $mywidth", + "height": "3 * initialHeight + $aheight" + } + ] + } + + normalized = cloudinary.utils.generate_transformation_string(**transformation)[0] + expected = \ + "$aheight_300,$mywidth_100/h_3_mul_ih_add_$aheight,w_3_add_$mywidth_mul_3_add_4_div_2_mul_iw_mul_$mywidth" + + self.assertEqual(normalized, expected) + def test_merge(self): a = {"foo": "foo", "bar": "foo"} b = {"foo": "bar"} @@ -1009,6 +1110,13 @@ def test_should_place_defined_variables_before_ordered(self): transformation, options = cloudinary.utils.generate_transformation_string(**options) self.assertEqual('$first_2,$second_1,$z_5,$foo_$z_mul_2', transformation) + def test_should_use_context_value_as_user_variables(self): + options = {"variables": [["$xpos", "ctx:!x_pos!_to_f"], ["$ypos", "ctx:!y_pos!_to_f"]], + "crop": "crop", "x": "$xpos * w", "y": "$ypos * h"} + transformation, options = cloudinary.utils.generate_transformation_string(**options) + self.assertIn('$xpos_ctx:!x_pos!_to_f,$ypos_ctx:!y_pos!_to_f,c_crop,x_$xpos_mul_w,y_$ypos_mul_h', + transformation) + def test_should_support_text_values(self): public_id = "sample" options = {"effect": "$efname:100", "$efname": "!blur!"} @@ -1035,6 +1143,8 @@ def test_should_support_string_interpolation(self): def test_encode_context(self): self.assertEqual("", cloudinary.utils.encode_context({})) self.assertEqual("a=b", cloudinary.utils.encode_context({"a": "b"})) + # list values are encoded to a json string + self.assertEqual('a=["b","c"]', cloudinary.utils.encode_context({"a": ["b", "c"]})) # using OrderedDict for tests consistency self.assertEqual("a=b|c=d", cloudinary.utils.encode_context(OrderedDict((("a", "b"), ("c", "d"))))) # test that special characters are unchanged @@ -1109,6 +1219,7 @@ def test_is_remote_url(self): "data:image/gif;charset=utf8;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "data:image/gif;param1=value1;param2=value2;base64," + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "data:image/svg+xml;charset=utf-8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPg", CL_BLANK ] @@ -1249,6 +1360,19 @@ def test_verify_api_response_signature(self): verify_api_response_signature(public_id, test_version, api_response_signature) self.assertEqual(str(e.exception), 'Api secret key is empty') + def test_verify_api_response_signature_sha256(self): + public_id = 'tests/logo.png' + test_version = 1 + api_response_signature = 'cc69ae4ed73303fbf4a55f2ae5fc7e34ad3a5c387724bfcde447a2957cacdfea' + + with patch('cloudinary.config', return_value=cloudinary.config(api_secret=API_SECRET)): + self.assertTrue(verify_api_response_signature( + public_id, + test_version, + api_response_signature, + cloudinary.utils.SIGNATURE_SHA256 + )) + def test_verify_notification_signature(self): valid_for = 60 signature = 'dfe82de1d9083fe0b7ea68070649f9a15b8874da' @@ -1289,6 +1413,16 @@ def test_verify_notification_signature(self): verify_notification_signature(body, valid_response_timestamp, signature, valid_for) self.assertEqual(str(e.exception), 'Api secret key is empty') + def test_verify_notification_signature_sha256(self): + with patch('time.time', return_value=MOCKED_NOW): + with patch('cloudinary.config', return_value=cloudinary.config(api_secret="someApiSecret")): + self.assertTrue(verify_notification_signature( + "{}", + 0, + "d5497e1a206ad0ba29ad09a7c0c5f22e939682d15009c15ab3199f62fefbd14b", + valid_for=time.time(), + algorithm=cloudinary.utils.SIGNATURE_SHA256)) + def test_support_long_url_signature(self): """should generate short signature by default and long signature if long_url_signature=True""" image_name = "sample.jpg" @@ -1311,6 +1445,152 @@ def test_support_long_url_signature(self): options={"sign_url": True}, expected_url=DEFAULT_UPLOAD_PATH + long_signature + "/" + image_name) + def test_api_sign_request_sha1(self): + params = dict(cloud_name=API_SIGN_REQUEST_CLOUD_NAME, timestamp=1568810420, username="user@cloudinary.com") + signature = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET) + expected = "14c00ba6d0dfdedbc86b316847d95b9e6cd46d94" + self.assertEqual(expected, signature) + + def test_api_sign_request_sha256(self): + params = dict(cloud_name=API_SIGN_REQUEST_CLOUD_NAME, timestamp=1568810420, username="user@cloudinary.com") + signature = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET, cloudinary.utils.SIGNATURE_SHA256) + expected = "45ddaa4fa01f0c2826f32f669d2e4514faf275fe6df053f1a150e7beae58a3bd" + self.assertEqual(expected, signature) + + def test_api_sign_request_prevents_parameter_smuggling(self): + """Should prevent parameter smuggling via & characters in parameter values""" + # Test with notification_url containing & characters + params_with_ampersand = { + "cloud_name": API_SIGN_REQUEST_CLOUD_NAME, + "timestamp": 1568810420, + "notification_url": "https://fake.com/callback?a=1&tags=hello,world" + } + + signature_with_ampersand = api_sign_request(params_with_ampersand, API_SIGN_REQUEST_TEST_SECRET) + + # Test that attempting to smuggle parameters by splitting the notification_url fails + params_smuggled = { + "cloud_name": API_SIGN_REQUEST_CLOUD_NAME, + "timestamp": 1568810420, + "notification_url": "https://fake.com/callback?a=1", + "tags": "hello,world" # This would be smuggled if & encoding didn't work + } + + signature_smuggled = api_sign_request(params_smuggled, API_SIGN_REQUEST_TEST_SECRET) + + # The signatures should be different, proving that parameter smuggling is prevented + self.assertNotEqual(signature_with_ampersand, signature_smuggled, + "Signatures should be different to prevent parameter smuggling") + + # Verify the expected signature for the properly encoded case + expected_signature = "4fdf465dd89451cc1ed8ec5b3e314e8a51695704" + self.assertEqual(expected_signature, signature_with_ampersand) + + # Verify the expected signature for the smuggled parameters case + expected_smuggled_signature = "7b4e3a539ff1fa6e6700c41b3a2ee77586a025f9" + self.assertEqual(expected_smuggled_signature, signature_smuggled) + + def test_api_sign_request_signature_versions(self): + """Should use signature version 1 (without parameter encoding) for backward compatibility""" + public_id_with_ampersand = 'tests/logo&version=2' + test_version = 1 + + expected_signature_v1 = api_sign_request( + {'public_id': public_id_with_ampersand, 'version': test_version}, + API_SIGN_REQUEST_TEST_SECRET, + cloudinary.utils.SIGNATURE_SHA1, + signature_version=1 + ) + + expected_signature_v2 = api_sign_request( + {'public_id': public_id_with_ampersand, 'version': test_version}, + API_SIGN_REQUEST_TEST_SECRET, + cloudinary.utils.SIGNATURE_SHA1, + signature_version=2 + ) + + self.assertNotEqual(expected_signature_v1, expected_signature_v2) + + # verify_api_response_signature should use version 1 for backward compatibility + with patch('cloudinary.config', return_value=cloudinary.config(api_secret=API_SIGN_REQUEST_TEST_SECRET)): + self.assertTrue( + verify_api_response_signature( + public_id_with_ampersand, + test_version, + expected_signature_v1 + ) + ) + + self.assertFalse( + verify_api_response_signature( + public_id_with_ampersand, + test_version, + expected_signature_v2 + ) + ) + + def test_signature_version_config_support(self): + """Should use signature_version from config and produce different signatures for v1 vs v2""" + # Use params with & characters to show the encoding difference between versions + params = {'public_id': 'test&image', 'notification_url': 'https://example.com/callback?param=value&other=data'} + + # Test with config signature_version = 1 + cloudinary.config().signature_version = 1 + + # Test sign_request function uses config values + options_with_config = {'api_key': 'test_key', 'api_secret': API_SIGN_REQUEST_TEST_SECRET} + signed_params_config_v1 = cloudinary.utils.sign_request(params.copy(), options_with_config) + + # Test explicit signature version + options_explicit_v1 = options_with_config.copy() + options_explicit_v1['signature_version'] = 1 + signed_params_explicit_v1 = cloudinary.utils.sign_request(params.copy(), options_explicit_v1) + + self.assertEqual(signed_params_config_v1['signature'], signed_params_explicit_v1['signature']) + + # Test with config signature_version = 2 + cloudinary.config().signature_version = 2 + + signed_params_config_v2 = cloudinary.utils.sign_request(params.copy(), options_with_config) + + options_explicit_v2 = options_with_config.copy() + options_explicit_v2['signature_version'] = 2 + signed_params_explicit_v2 = cloudinary.utils.sign_request(params.copy(), options_explicit_v2) + + self.assertEqual(signed_params_config_v2['signature'], signed_params_explicit_v2['signature']) + + # Verify that v1 and v2 actually produce different signatures due to parameter encoding + self.assertNotEqual(signed_params_config_v1['signature'], signed_params_config_v2['signature'], + "Signature v1 and v2 should be different for parameters with & characters") + + def test_sign_request_with_signature_version(self): + """Should support signature_version parameter in sign_request function""" + params = {'public_id': 'test_image', 'version': 1234} + options = {'api_key': 'test_key', 'api_secret': API_SIGN_REQUEST_TEST_SECRET} + + # Test with signature_version in options + options_v1 = options.copy() + options_v1['signature_version'] = 1 + signed_params_v1 = cloudinary.utils.sign_request(params.copy(), options_v1) + + options_v2 = options.copy() + options_v2['signature_version'] = 2 + signed_params_v2 = cloudinary.utils.sign_request(params.copy(), options_v2) + + # The signatures should be different for different versions (for params with & characters) + # For these simple params without & they might be the same, but let's test the structure + self.assertIn('signature', signed_params_v1) + self.assertIn('signature', signed_params_v2) + self.assertIn('api_key', signed_params_v1) + self.assertIn('api_key', signed_params_v2) + + # Test that signature_version is passed through correctly + expected_sig_v1 = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET, cloudinary.utils.SIGNATURE_SHA1, 1) + expected_sig_v2 = api_sign_request(params, API_SIGN_REQUEST_TEST_SECRET, cloudinary.utils.SIGNATURE_SHA1, 2) + + self.assertEqual(signed_params_v1['signature'], expected_sig_v1) + self.assertEqual(signed_params_v2['signature'], expected_sig_v2) + if __name__ == '__main__': unittest.main() diff --git a/test/test_video.py b/test/test_video.py index bd8f6563..652de999 100644 --- a/test/test_video.py +++ b/test/test_video.py @@ -169,6 +169,20 @@ def test_video_tag_default_sources(self): self.video.video(poster=expected_url.format('', 'jpg'), sources=self.video.default_video_sources) ) + def test_video_tag_default_sources_use_fetch_format(self): + video = CloudinaryVideo("sample.mp4") + expected_url = VIDEO_UPLOAD_PATH + "f_{1},{0}/sample.mp4" + + self.assertEqual( + "", + video.video(sources=self.video.default_video_sources, use_fetch_format=True) + ) + def test_video_tag_custom_sources(self): custom_sources = [ { diff --git a/test/utils/test_unique.py b/test/utils/test_unique.py new file mode 100644 index 00000000..4e517f50 --- /dev/null +++ b/test/utils/test_unique.py @@ -0,0 +1,29 @@ +import unittest + +from cloudinary.utils import unique + + +class UniqueTest(unittest.TestCase): + def test_when_collection_is_array_with_no_key_function(self): + self.assertEqual( + unique(["image", "picture", "banana", "image", "picture"]), + ["image", "picture", "banana"] + ) + + def test_when_collection_is_array_with_key_function(self): + self.assertEqual( + unique(["image", "word1", "picture", "banana", "image", "picture"], key=len), + ["image", "picture", "banana"] + ) + + def test_handles_hashes_with_correct_key_function(self): + self.assertEqual( + unique( + [{"image": "up"}, {"picture": 1}, {"banana": "left"}, {"image": "down"}, {"picture": 0}], + key=lambda x: next(iter(x))), + [{"image": "down"}, {"picture": 0}, {"banana": "left"}] + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/allocate_test_cloud.sh b/tools/allocate_test_cloud.sh new file mode 100755 index 00000000..0da785f2 --- /dev/null +++ b/tools/allocate_test_cloud.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +API_ENDPOINT="https://sub-account-testing.cloudinary.com/create_sub_account" + +SDK_NAME="${1}" + +CLOUD_DETAILS=$(curl -sS -d "{\"prefix\" : \"${SDK_NAME}\"}" "${API_ENDPOINT}") + +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"]))' diff --git a/tools/get_test_cloud.sh b/tools/get_test_cloud.sh new file mode 100755 index 00000000..a908d09e --- /dev/null +++ b/tools/get_test_cloud.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PY_VER=$(python -V 2>&1 | head -n 1 | cut -d ' ' -f 2); +SDK_VER=$(grep -oiP '(?<=version \= \")([a-zA-Z0-9\-.]+)(?=")' setup.py) + + +bash ${DIR}/allocate_test_cloud.sh "Python ${PY_VER} SDK ${SDK_VER}" diff --git a/tools/update_version.sh b/tools/update_version.sh index 8723d3ea..5f7ac203 100755 --- a/tools/update_version.sh +++ b/tools/update_version.sh @@ -2,40 +2,21 @@ # Update version number and prepare for publishing the new version -set -o errexit +set -e -# Extract the last entry or entry for a given version -# The function is not currently used in this file. -# Examples: -# changelog_last_entry -# changelog_last_entry 1.10.0 -# +# Empty to run the rest of the line and "echo" for a dry run +CMD_PREFIX= -function changelog_last_entry -{ - sed -e "1,/^${1}/d" -e '/^=/d' -e '/^$/d' -e '/^[0-9]/,$d' CHANGELOG.md -} +# Add a quote if this is a dry run +QUOTE= -function verify_dependencies -{ - # Test if the gnu grep is installed - if ! grep --version | grep -q GNU - then - echo "GNU grep is required for this script" - echo "You can install it using the following command:" - echo "" - echo "brew install grep --with-default-names" - exit - fi +NEW_VERSION= - if [[ -z "$(type -t git-changelog)" ]] - then - echo "git-extras packages is not installed." - echo "You can install it using the following command:" - echo "" - echo "brew install git-extras" - exit - fi +UPDATE_ONLY=false + +function echo_err +{ + echo "$@" 1>&2; } function usage @@ -43,91 +24,195 @@ function usage echo "Usage: $0 [parameters]" echo " -v | --version " echo " -d | --dry-run print the commands without executing them" + echo " -u | --update-only only update the version" echo " -h | --help print this information and exit" echo - echo "For example: $0 -v 1.9.2" + echo "For example: $0 -v 1.2.3" } function process_arguments { - # Empty to run the rest of the line and "echo" for a dry run - cmd_prefix= - - while [ "$1" != "" ]; do + while [[ "$1" != "" ]]; do case $1 in -v | --version ) shift - new_version=${1:-} - if ! [[ "$new_version" =~ [0-9]+\.[0-9]+\.[0-9]+(\-.+)? ]]; then - echo "You must supply a new version after -v or --version" - echo "For example:" - echo " 1.11.0" - echo " 1.11.0-rc1" - echo "" - usage - exit 1 + NEW_VERSION=${1:-} + if ! [[ "${NEW_VERSION}" =~ [0-9]+\.[0-9]+\.[0-9]+(\-.+)? ]]; then + echo_err "You must supply a new version after -v or --version" + echo_err "For example:" + echo_err " 1.2.3" + echo_err " 1.2.3-rc1" + echo_err "" + usage; return 1 fi ;; -d | --dry-run ) - cmd_prefix=echo + CMD_PREFIX=echo echo "Dry Run" echo "" ;; + -u | --update-only ) + UPDATE_ONLY=true + echo "Only update version" + echo "" + ;; -h | --help ) - usage - exit + usage; return 0 ;; * ) - usage - exit 1 + usage; return 1 esac shift || true done } +# Intentionally make pushd silent +function pushd +{ + command pushd "$@" > /dev/null +} + +# Intentionally make popd silent +function popd +{ + command popd > /dev/null +} + +# Check if one version is less than or equal than other +# Example: +# ver_lte 1.2.3 1.2.3 && echo "yes" || echo "no" # yes +# ver_lte 1.2.3 1.2.4 && echo "yes" || echo "no" # yes +# ver_lte 1.2.4 1.2.3 && echo "yes" || echo "no" # no +function ver_lte +{ + [[ "$1" = "`echo -e "$1\n$2" | sort -V | head -n1`" ]] +} + +# Extract the last entry or entry for a given version +# The function is not currently used in this file. +# Examples: +# changelog_last_entry +# changelog_last_entry 1.10.0 +# +function changelog_last_entry +{ + sed -e "1,/^${1}/d" -e '/^=/d' -e '/^$/d' -e '/^[0-9]/,$d' CHANGELOG.md +} + +function verify_dependencies +{ + # Test if the gnu grep is installed + if ! grep --version | grep -q GNU + then + echo_err "GNU grep is required for this script" + echo_err "You can install it using the following command:" + echo_err "" + echo_err "brew install grep --with-default-names" + return 1 + fi + + if [[ "${UPDATE_ONLY}" = true ]]; then + return 0; + fi + + if [[ -z "$(type -t git-changelog)" ]] + then + echo_err "git-extras packages is not installed." + echo_err "You can install it using the following command:" + echo_err "" + echo_err "brew install git-extras" + return 1 + fi +} + +# Replace old string only if it is present in the file, otherwise return 1 +function safe_replace +{ + local old=$1 + local new=$2 + local file=$3 + + grep -q "${old}" "${file}" || { echo_err "${old} was not found in ${file}"; return 1; } + + ${CMD_PREFIX} sed -i.bak -e "${QUOTE}s/${old}/${new}/${QUOTE}" -- "${file}" && rm -- "${file}.bak" +} + function update_version { + if [[ -z "${NEW_VERSION}" ]]; then + usage; return 1 + fi + + # Enter git root + pushd $(git rev-parse --show-toplevel) + + local current_version=`grep -oiP '(?<=version \= \")([a-zA-Z0-9\-.]+)(?=")' setup.py` + + if [[ -z "${current_version}" ]]; then + echo_err "Failed getting current version, please check directory structure and/or contact developer" + return 1 + fi - local current_version=`grep -oiP "(?<=VERSION \= \")([0-9.]+)(?=\")" cloudinary/__init__.py` # Use literal dot character in regular expression local current_version_re=${current_version//./\\.} - echo "# Current version is $current_version" - echo "# New version is ${new_version}" - if [ -n "$new_version" ]; then - local __GIT_ROOT=$(git rev-parse --show-toplevel) - local __ORIGINAL_DIR=$PWD - cd $__GIT_ROOT - - # add a quote if this is a dry run - __q=${cmd_prefix:+"'"} - - $cmd_prefix sed -E -i '.bak' \ - "${__q}s/version = \"${current_version_re}\"/version = \"${new_version}\"/${__q}" \ - setup.py - - $cmd_prefix sed -E -i '.bak' \ - "${__q}s/VERSION = \"${current_version_re}\"/VERSION = \"${new_version}\"/${__q}" \ - cloudinary/__init__.py - - $cmd_prefix git changelog -t $new_version || true - - echo "" - echo "# After editing CHANGELOG.md, issue these commands:" - echo git add setup.py cloudinary/__init__.py CHANGELOG.md - echo git commit -m "\"Version ${new_version}\"" - echo sed \ - -e "'1,/^${new_version//./\\.}/d'" \ - -e "'/^=/d'" -e "'/^$/d'" \ - -e "'/^[0-9]/,\$d'" CHANGELOG.md \| git tag -a "'${new_version}'" --file=- - cd $__ORIGINAL_DIR - else - usage - exit + echo "# Current version is: ${current_version}" + echo "# New version is: ${NEW_VERSION}" + + ver_lte "${NEW_VERSION}" "${current_version}" && { echo_err "New version is not greater than current version"; return 1; } + + # Add a quote if this is a dry run + QUOTE=${CMD_PREFIX:+"'"} + + safe_replace "version = \"${current_version_re}\""\ + "version = \"${NEW_VERSION}\""\ + setup.py\ + || return 1 + safe_replace "VERSION = \"${current_version_re}\""\ + "VERSION = \"${NEW_VERSION}\""\ + cloudinary/__init__.py\ + || return 1 + safe_replace "version = \"${current_version_re}\""\ + "version = \"${NEW_VERSION}\""\ + pyproject.toml\ + || return 1 + + if [[ "${UPDATE_ONLY}" = true ]]; then + popd; + return 0; fi + ${CMD_PREFIX} git changelog -t ${NEW_VERSION} || true + + echo "" + echo "# After editing CHANGELOG.md, optionally review changes and issue these commands:" + echo git add setup.py cloudinary/__init__.py pyproject.toml CHANGELOG.md + echo git commit -m "\"Version ${NEW_VERSION}\"" + echo sed -e "'1,/^${NEW_VERSION//./\\.}/d'" \ + -e "'/^=/d'" \ + -e "'/^$/d'" \ + -e "'/^[0-9]/,\$d'" \ + CHANGELOG.md \ + \| git tag -a "'${NEW_VERSION}'" --file=- + + # Don't run those commands on dry run + [[ -n "${CMD_PREFIX}" ]] && { popd; return 0; } + + echo "" + read -p "Run the above commands automatically? (y/N): " confirm && [[ ${confirm} == [yY] || ${confirm} == [yY][eE][sS] ]] || { popd; return 0; } + + git git add setup.py cloudinary/__init__.py pyproject.toml CHANGELOG.md + git commit -m "Version ${NEW_VERSION}" + sed -e "1,/^${NEW_VERSION//./\\.}/d" \ + -e "/^=/d" \ + -e "/^$/d" \ + -e "/^[0-9]/,\$d" \ + CHANGELOG.md \ + | git tag -a "${NEW_VERSION}" --file=- + + popd } +process_arguments "$@" verify_dependencies -process_arguments $* update_version diff --git a/tox.ini b/tox.ini index bf901692..dee98c68 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,23 @@ [tox] envlist = - py{27,34,35,36,37,38}-core - py{27,34,35}-django{18,19,110} - py{27,34,35,36}-django{111} - py{36,37,38}-django{20,21,22,30} + py{27,39,310,311,312,313}-core + py{27}-django{111} + py{39,310,311,312,313}-django{32,42,50,51} + [testenv] usedevelop = True commands = - core: python setup.py test {env:P_ARGS:} - django{18,19,110,111,20,21,22,30}: django-admin.py test -v2 django_tests {env:D_ARGS:} + core: python -m pytest test + django{111,32}: django-admin.py test -v2 django_tests {env:D_ARGS:} + django{42,50,51}: django-admin test -v2 django_tests {env:D_ARGS:} passenv = * deps = - django{18,19,110,111,20,21,22,30}: mock - django18: Django>=1.8,<1.9 - django19: Django>=1.9,<1.10 - django110: Django>=1.10,<1.11 + pytest + py27: mock django111: Django>=1.11,<1.12 - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 - django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 + django32: Django>=3.2,<3.3 + django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 setenv = DJANGO_SETTINGS_MODULE=django_tests.settings