├── VERSION ├── tests ├── __init__.py ├── data │ ├── bird.jpg │ └── doom.mp4 ├── security_test.py ├── audiovisual_test.py ├── utils_test.py ├── filestack_helpers_test.py ├── uploads │ └── test_upload_external_url.py ├── tasks_test.py ├── client_test.py ├── multipart_test.py ├── intelligent_ingestion_test.py ├── filelink_test.py └── transformation_test.py ├── filestack ├── uploads │ ├── __init__.py │ ├── external_url.py │ ├── multipart.py │ └── intelligent_ingestion.py ├── mixins │ ├── __init__.py │ ├── common.py │ └── imagetransformation.py ├── models │ ├── __init__.py │ ├── security.py │ ├── transformation.py │ ├── audiovisual.py │ ├── filelink.py │ └── client.py ├── exceptions.py ├── trafarets.py ├── __init__.py ├── utils.py └── helpers.py ├── docs ├── requirements.txt ├── source │ ├── _static │ │ └── img │ │ │ ├── fs_logo.png │ │ │ ├── logo-fs-bright.png │ │ │ └── filestack_logo.svg │ ├── api_reference.rst │ ├── index.rst │ ├── installation.rst │ ├── conf.py │ └── uploading_files.rst ├── Makefile └── make.bat ├── requirements.txt ├── .flake8 ├── .gitignore ├── examples ├── transform_and_download.py ├── video_upload_and_conversion.py ├── tags.py ├── upload_and_delete.py ├── fallback.py ├── intelligent_ingestion.py ├── createpdf.py ├── smart_crop.py ├── animate.py ├── convert_pdf.py ├── doc_to_image.py ├── transform_with_security.py ├── transform_and_store_with_security.py ├── overwite_and_download.py └── workflows.py ├── logo.svg ├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── setup.py ├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md └── LICENSE /VERSION: -------------------------------------------------------------------------------- 1 | 4.0.0 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /filestack/uploads/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/bird.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filestack/filestack-python/HEAD/tests/data/bird.jpg -------------------------------------------------------------------------------- /tests/data/doom.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filestack/filestack-python/HEAD/tests/data/doom.mp4 -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==2.1.2 2 | sphinx-rtd-theme==0.4.3 3 | trafaret==2.0.2 4 | requests>=2.25.1 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=4.6.3 2 | pytest-cov>=2.5.0 3 | requests>=2.31.0 4 | responses==0.14.0 5 | trafaret==2.0.2 6 | -------------------------------------------------------------------------------- /filestack/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .imagetransformation import ImageTransformationMixin 2 | from .common import CommonMixin 3 | -------------------------------------------------------------------------------- /docs/source/_static/img/fs_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filestack/filestack-python/HEAD/docs/source/_static/img/fs_logo.png -------------------------------------------------------------------------------- /docs/source/_static/img/logo-fs-bright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filestack/filestack-python/HEAD/docs/source/_static/img/logo-fs-bright.png -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | per-file-ignores = 4 | filestack/__init__.py:F401,E402 5 | filestack/models/__init__.py:F401 6 | filestack/mixins/__init__.py:F401 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | .cache/ 4 | temp_files/ 5 | .vscode/ 6 | test.zip 7 | tests/data/test_download.jpg 8 | .coverage 9 | .idea 10 | .venv 11 | 12 | docs/build/* 13 | 14 | venv 15 | -------------------------------------------------------------------------------- /filestack/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .filelink import Filelink 2 | from .client import Client 3 | from .transformation import Transformation 4 | from .security import Security 5 | from .audiovisual import AudioVisual 6 | -------------------------------------------------------------------------------- /filestack/exceptions.py: -------------------------------------------------------------------------------- 1 | class FilestackHTTPError(Exception): 2 | """ 3 | Custom HTTPError instead of requests.exceptions.HTTPError to add response body. 4 | 5 | References: 6 | - https://github.com/psf/requests/pull/4234 7 | """ 8 | -------------------------------------------------------------------------------- /examples/transform_and_download.py: -------------------------------------------------------------------------------- 1 | from filestack import Client 2 | 3 | client = Client('') 4 | transform = client.transform_external('https://upload.wikimedia.org/wikipedia/commons/3/32/House_sparrow04.jpg') 5 | transform.resize(width=500, height=500).enhance() 6 | 7 | print(transform.url) 8 | print(transform.download('bird.jpg')) 9 | -------------------------------------------------------------------------------- /examples/video_upload_and_conversion.py: -------------------------------------------------------------------------------- 1 | from filestack import Client 2 | 3 | client = Client('', security=sec) 9 | filelink = client.upload(url='http://weknownyourdreamz.com/images/birds/birds-04.jpg') 10 | 11 | tags = filelink.tags() 12 | sfw = filelink.sfw() 13 | print(tags) 14 | print(sfw) 15 | -------------------------------------------------------------------------------- /examples/upload_and_delete.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # policy expires on 5/6/2099 4 | policy = {'call': ['read', 'remove', 'store'], 'expiry': 4081759876} 5 | security = Security(policy, '') 6 | 7 | client = Client(apikey='', security=security) 8 | filelink = client.upload_url(url='https://www.wbu.com/wp-content/uploads/2016/07/540x340-found-a-bird-450x283.jpg') 9 | 10 | filelink.delete() 11 | -------------------------------------------------------------------------------- /examples/fallback.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # create security object 4 | json_policy = {"call":["pick","read","stat","write","writeUrl","store","convert"],"expiry":1717099200} 5 | security = Security(json_policy, '') 6 | APIKEY = '' 7 | 8 | client = Client(apikey=APIKEY, security=security) 9 | transform = client.transform_external('/') 10 | transform.fallback(file='HANDLER', cache=12) 11 | print(transform.url) 12 | -------------------------------------------------------------------------------- /examples/intelligent_ingestion.py: -------------------------------------------------------------------------------- 1 | # IMPORTANT: 2 | # Please remember that not all application can use Filestack Intelligent Ingestion. 3 | # To enable this feature, please contact Filestack Support 4 | 5 | from filestack import Client 6 | 7 | filepath = '/path/to/video.mp4' 8 | APIKEY = '' 9 | 10 | client = Client(APIKEY) 11 | 12 | # specify intelligent=True agrument to use Filestack Intelligent Ingestion 13 | filelink = client.upload(filepath=filepath, intelligent=True) 14 | -------------------------------------------------------------------------------- /examples/createpdf.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # create security object 4 | json_policy = {"call":["pick","read","stat","write","writeUrl","store","convert"],"expiry":1717099200} 5 | security = Security(json_policy, '') 6 | APIKEY = '' 7 | 8 | client = Client(apikey=APIKEY, security=security) 9 | transform = client.transform_external('/pdfcreate/[,]') 10 | transform.pdfcreate(engine='mupdf') 11 | print(transform.url) 12 | 13 | -------------------------------------------------------------------------------- /examples/smart_crop.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # create security object 4 | json_policy = {"call":["pick","read","stat","write","writeUrl","store","convert"],"expiry":1717099200} 5 | security = Security(json_policy, '') 6 | APIKEY = '' 7 | 8 | client = Client(apikey=APIKEY, security=security) 9 | transform = client.transform_external('/smart_crop=width:100,height:100/HANDLER') 10 | transform.smart_crop(width=100, height=100) 11 | print(transform.url) 12 | -------------------------------------------------------------------------------- /examples/animate.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # create security object 4 | json_policy = {"expiry":1717101000,"call":["pick","read","stat","write","writeUrl","store","convert"]} 5 | security = Security(json_policy, '') 6 | APIKEY = '' 7 | 8 | client = Client(apikey=APIKEY, security=security) 9 | transform = client.transform_external('/animate=fit:scale,width:200,height:300/[,]') 10 | transform.animate(fit='scale',width=200,height=300,loop=0,delay=1000) 11 | print(transform.url) 12 | -------------------------------------------------------------------------------- /examples/convert_pdf.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # create security object 4 | json_policy = {"call":["pick","read","stat","write","writeUrl","store","convert"],"expiry":1717099200} 5 | security = Security(json_policy, '') 6 | APIKEY = '' 7 | 8 | client = Client(apikey=APIKEY, security=security) 9 | transform = client.transform_external('pdfconvert=pageorientation:landscape/HANDLER') 10 | transform.pdf_convert(pageorientation='landscape', pageformat='a4', metadata=True) 11 | print(transform.url) 12 | 13 | -------------------------------------------------------------------------------- /examples/doc_to_image.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # create security object 4 | json_policy = {"call":["pick","read","stat","write","writeUrl","store","convert"],"expiry":1717099200} 5 | security = Security(json_policy, '') 6 | APIKEY = '' 7 | 8 | client = Client(apikey=APIKEY, security=security) 9 | transform = client.transform_external('/doc_to_images/') 10 | transform.doc_to_images(pages=[1], engine='imagemagick', format='png', quality=100, density=100, hidden_slides=False) 11 | print(transform.url) 12 | -------------------------------------------------------------------------------- /examples/transform_with_security.py: -------------------------------------------------------------------------------- 1 | from filestack import Client, Security 2 | 3 | # policy expires on 5/6/2099 4 | policy = {'call': ['read', 'remove', 'store'], 'expiry': 4081759876} 5 | 6 | security = Security(policy, '') 7 | 8 | client = Client('', security=security) 9 | 10 | transform = client.transform_external('https://images.unsplash.com/photo-1446776877081-d282a0f896e2?dpr=1&auto=format&fit=crop&w=1500&h=998&q=80&cs=tinysrgb&crop=&bg=') 11 | transform.blackwhite(threshold=50).flip() 12 | 13 | print(transform.signed_url()) 14 | -------------------------------------------------------------------------------- /examples/transform_and_store_with_security.py: -------------------------------------------------------------------------------- 1 | from filestack import Security, Filelink 2 | 3 | policy = {"expiry": 253381964415} 4 | 5 | security = Security(policy, '') 6 | 7 | link = Filelink('YOUR_FILE_HANDLE', security=security) 8 | 9 | # Storing is Only Allowed on Transform Objects 10 | transform_obj = link.sepia() 11 | 12 | new_link = transform_obj.store( 13 | filename='filename', location='S3', path='/py-test/', container='filestack-test', 14 | region='us-west-2', access='public', base64decode=True 15 | ) 16 | 17 | print(new_link.url) 18 | -------------------------------------------------------------------------------- /docs/source/api_reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | Client 5 | ------ 6 | 7 | .. autoclass:: filestack.Client 8 | :special-members: __init__ 9 | :members: 10 | 11 | 12 | Filelink 13 | -------- 14 | 15 | .. autoclass:: filestack.Filelink 16 | :special-members: __init__ 17 | :members: 18 | :inherited-members: 19 | 20 | 21 | Security 22 | -------------- 23 | 24 | .. autoclass:: filestack.Security 25 | :special-members: __init__ 26 | :members: 27 | 28 | 29 | Transformation 30 | -------------- 31 | 32 | .. autoclass:: filestack.Transformation 33 | :members: 34 | :inherited-members: 35 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/_static/img/filestack_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Rectangle-356 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/security_test.py: -------------------------------------------------------------------------------- 1 | from filestack import Security 2 | 3 | 4 | def test_security(): 5 | policy = {'expires': 123} 6 | security_obj = Security(policy, 'secret') 7 | assert security_obj.policy == policy 8 | assert security_obj.policy_b64 == 'eyJleHBpcmVzIjogMTIzfQ==' 9 | assert security_obj.signature == '379d2ba0d5be34eddf09f873b7f38643dc51599b0afcd564f52733b52d698748' 10 | 11 | 12 | def test_security_as_url_string(): 13 | policy = {'expires': 999999999999} 14 | security_obj = Security(policy, 'secret') 15 | 16 | assert security_obj.as_url_string() == ( 17 | 'security=p:eyJleHBpcmVzIjogOTk5OTk5OTk5OTk5fQ==,' 18 | 's:8c75305f7615776a892ddd165111dba0fa24b45107024a55a7170a7d1d60157a' 19 | ) 20 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | filestack-python 2 | ================ 3 | 4 | This is the official Python SDK for `Filestack `_ - API and content management system that makes it easy to add powerful file uploading and transformation capabilities to any web or mobile application. 5 | 6 | .. code-block:: python 7 | 8 | from filestack import Client 9 | 10 | cli = Client('') 11 | filelink = cli.upload(filepath='/path/to/image.jpg') 12 | print(filelink.url) # => 'https://cdn.filestackcontent.com/' 13 | 14 | 15 | Table of Contents 16 | ----------------- 17 | 18 | .. toctree:: 19 | 20 | Installation & Quickstart 21 | Uploading files 22 | API Reference 23 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Rectangle-356 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/overwite_and_download.py: -------------------------------------------------------------------------------- 1 | from filestack import Filelink, security 2 | 3 | # create security object 4 | json_policy = {"YOUR_JSON": "POLICY"} 5 | 6 | security = security(json_policy, '') 7 | 8 | # initialize filelink object 9 | filelink = Filelink('', apikey='', security=security) 10 | 11 | # overwrite file 12 | filelink.overwrite(url='https://images.unsplash.com/photo-1444005233317-7fb24f0da789?dpr=1&auto=format&fit=crop&w=1500&h=844&q=80&cs=tinysrgb&crop=&bg=') 13 | 14 | print(filelink.url) 15 | 16 | # download file 17 | filelink.download('') 18 | 19 | # get filelink's metadata 20 | response = filelink.get_metadata() 21 | if response.ok: 22 | metadata = response.json() 23 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation & Quickstart 2 | ========================= 3 | 4 | `filestack-python` can be installed from PyPI: 5 | 6 | 7 | .. code-block:: sh 8 | 9 | $ pip install filestack-python 10 | 11 | or directly from GitHub: 12 | 13 | .. code-block:: sh 14 | 15 | $ pip install git+https://github.com/filestack/filestack-python.git 16 | 17 | 18 | To upload a file, all you need to do is to create an instance of :class:`filestack.Client` with your Filestack API Key and use the :py:meth:`upload()` method: 19 | 20 | .. code-block:: python 21 | :linenos: 22 | 23 | from filestack import Client 24 | 25 | cli = Client('') 26 | filelink = cli.upload(filepath='path/to/video.mp4') 27 | 28 | 29 | Proceed :doc:`uploading_files` to section to learn more about file uploaing options. 30 | -------------------------------------------------------------------------------- /examples/workflows.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script contains examples of using Filestack Workflows 3 | in Python SDK. Workflows are compatible with uploading using 4 | pathfile (by default with multipart=True) and external URL. 5 | * It is essential to upload either from pathfile or url. 6 | """ 7 | 8 | from filestack import Client 9 | 10 | Filestack_API_Key = '' 11 | file_path = '' 12 | file_url = '' 13 | 14 | client = Client(Filestack_API_Key) 15 | 16 | # You should put your Workflows IDs in the parameters as a list. 17 | store_params = { 18 | 'workflows': [ 19 | '', 20 | '', 21 | '', 22 | '', 23 | '' 24 | ] 25 | } 26 | 27 | new_filelink = client.upload( 28 | filepath=file_path, 29 | # url=file_url, 30 | params=store_params 31 | ) 32 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Filestack Python Package release to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | jobs: 8 | pypi-publish: 9 | name: Publish release to PyPI 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/p/filestack-python 14 | permissions: 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.12" 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel 26 | - name: Build package 27 | run: | 28 | python setup.py sdist bdist_wheel # Could also be python -m build 29 | - name: Publish package distributions to PyPI 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | -------------------------------------------------------------------------------- /filestack/trafarets.py: -------------------------------------------------------------------------------- 1 | import trafaret as t 2 | 3 | STORE_LOCATION_SCHEMA = t.Enum('S3', 'gcs', 'azure', 'rackspace', 'dropbox') 4 | 5 | 6 | def validate_upload_tags(d): 7 | t.List(t.String, max_length=10).check(list(d.keys())) 8 | t.Mapping(t.String(max_length=128), t.String(max_length=256)).check(d) 9 | return d 10 | 11 | 12 | STORE_SCHEMA = t.Dict( 13 | t.Key('filename', optional=True, trafaret=t.String), 14 | t.Key('mimetype', optional=True, trafaret=t.String), 15 | t.Key('location', optional=True, trafaret=t.String), 16 | t.Key('path', optional=True, trafaret=t.String), 17 | t.Key('container', optional=True, trafaret=t.String), 18 | t.Key('region', optional=True, trafaret=t.String), 19 | t.Key('access', optional=True, trafaret=t.String), 20 | t.Key('base64decode', optional=True, trafaret=t.Bool), 21 | t.Key('workflows', optional=True, trafaret=t.List(t.String)), 22 | t.Key('upload_tags', optional=True, trafaret=validate_upload_tags), 23 | ) 24 | -------------------------------------------------------------------------------- /tests/audiovisual_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import responses 3 | 4 | from filestack import AudioVisual, Filelink 5 | 6 | 7 | APIKEY = 'APIKEY' 8 | HANDLE = 'SOMEHANDLE' 9 | URL = 'https://cdn.filestackcontent.com/{}'.format(HANDLE) 10 | PROCESS_URL = 'https://process.filestackapi.com/{}'.format(HANDLE) 11 | 12 | 13 | @pytest.fixture 14 | def av(): 15 | return AudioVisual(PROCESS_URL, 'someuuid', 'sometimetstamp', apikey=APIKEY) 16 | 17 | 18 | @responses.activate 19 | def test_status(av): 20 | responses.add(responses.GET, 'https://process.filestackapi.com/SOMEHANDLE', json={'status': 'completed'}) 21 | assert av.status == 'completed' 22 | 23 | 24 | @responses.activate 25 | def test_convert(av): 26 | responses.add( 27 | responses.GET, 'https://process.filestackapi.com/SOMEHANDLE', 28 | json={'status': 'completed', 'data': {'url': URL}} 29 | ) 30 | filelink = av.to_filelink() 31 | assert isinstance(filelink, Filelink) 32 | assert filelink.handle == HANDLE 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | python-version: [3.7, 3.8, 3.9, 3.10, 3.11, 3.12] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install requirements 19 | run: pip install -r requirements.txt 20 | - name: Run tests 21 | run: pytest -v --cov=filestack tests 22 | 23 | static-analysis: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: 3.12 31 | - name: Install tools 32 | run: pip install flake8 bandit 33 | - name: Run analysis 34 | run: | 35 | flake8 filestack/ 36 | bandit -r -lll filestack/ 37 | -------------------------------------------------------------------------------- /filestack/uploads/external_url.py: -------------------------------------------------------------------------------- 1 | from filestack import config 2 | from filestack.utils import requests 3 | 4 | 5 | def upload_external_url(url, apikey, storage, store_params=None, security=None): 6 | store_params = store_params or {} 7 | if storage and not store_params.get('location'): 8 | store_params['location'] = storage 9 | 10 | # remove params that are currently not supported in external url upload 11 | for item in ('mimetype', 'upload_tags'): 12 | store_params.pop(item, None) 13 | 14 | payload = { 15 | 'apikey': apikey, 16 | 'sources': [url], 17 | 'tasks': [{ 18 | 'name': 'store', 19 | 'params': store_params 20 | }] 21 | } 22 | 23 | if security is not None: 24 | payload['tasks'].append({ 25 | 'name': 'security', 26 | 'params': { 27 | 'policy': security.policy_b64, 28 | 'signature': security.signature 29 | } 30 | }) 31 | 32 | response = requests.post('{}/process'.format(config.CDN_URL), json=payload) 33 | return response.json() 34 | -------------------------------------------------------------------------------- /filestack/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '4.0.0' 2 | 3 | 4 | class CFG: 5 | API_URL = 'https://www.filestackapi.com/api' 6 | DEFAULT_CHUNK_SIZE = 5 * 1024 ** 2 7 | DEFAULT_UPLOAD_MIMETYPE = 'application/octet-stream' 8 | 9 | HEADERS = { 10 | 'User-Agent': 'filestack-python {}'.format(__version__), 11 | 'Filestack-Source': 'Python-{}'.format(__version__) 12 | } 13 | 14 | def __init__(self): 15 | self.CNAME = '' 16 | 17 | @property 18 | def CDN_URL(self): 19 | return 'https://cdn.{}'.format(self.CNAME or 'filestackcontent.com') 20 | 21 | @property 22 | def MULTIPART_START_URL(self): 23 | return 'https://upload.{}/multipart/start'.format( 24 | self.CNAME or 'filestackapi.com' 25 | ) 26 | 27 | 28 | config = CFG() 29 | 30 | 31 | from .models.client import Client 32 | from .models.filelink import Filelink 33 | from .models.security import Security 34 | from .models.transformation import Transformation 35 | from .models.audiovisual import AudioVisual 36 | from .mixins.common import CommonMixin 37 | from .mixins.imagetransformation import ImageTransformationMixin 38 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import responses 3 | 4 | from filestack import __version__ 5 | from filestack.utils import requests 6 | 7 | TEST_URL = 'http://just.some.url/' 8 | 9 | 10 | @responses.activate 11 | def test_req_wrapper_overwrite_headers(): 12 | responses.add(responses.POST, TEST_URL) 13 | requests.post(TEST_URL) 14 | mocked_request = responses.calls[0].request 15 | assert mocked_request.url == TEST_URL 16 | assert 'Filestack-Trace-Id' in mocked_request.headers 17 | assert 'Filestack-Trace-Span' in mocked_request.headers 18 | assert 'filestack-python {}'.format(__version__) == mocked_request.headers['User-Agent'] 19 | assert 'Python-{}'.format(__version__) == mocked_request.headers['Filestack-Source'] 20 | 21 | 22 | @responses.activate 23 | def test_req_wrapper_use_provided_headers(): 24 | responses.add(responses.POST, TEST_URL) 25 | custom_headers = {'something': 'used explicitly'} 26 | requests.post(TEST_URL, headers=custom_headers) 27 | print(responses.calls[0].request.headers) 28 | assert responses.calls[0].request.url == TEST_URL 29 | assert responses.calls[0].request.headers['something'] == 'used explicitly' 30 | 31 | 32 | @responses.activate 33 | def test_req_wrapper_raise_exception(): 34 | responses.add(responses.POST, TEST_URL, status=500, body=b'oops!') 35 | with pytest.raises(Exception, match='oops!'): 36 | requests.post(TEST_URL) 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from setuptools import setup, find_packages 4 | 5 | 6 | def read(filename): 7 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 8 | 9 | 10 | def read_version(): 11 | with open('filestack/__init__.py') as f: 12 | return re.search(r'__version__ = \'(.+)\'$', f.readline()).group(1) 13 | 14 | 15 | setup( 16 | name='filestack-python', 17 | version=read_version(), 18 | license='Apache 2.0', 19 | description='Filestack Python SDK', 20 | long_description='Visit: https://github.com/filestack/filestack-python', 21 | url='https://github.com/filestack/filestack-python', 22 | author='filestack.com', 23 | author_email='support@filestack.com', 24 | packages=find_packages(), 25 | install_requires=[ 26 | 'requests>=2.31.0', 27 | 'trafaret==2.0.2' 28 | ], 29 | classifiers=[ 30 | 'Development Status :: 4 - Beta', 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: Apache Software License', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 3.7', 35 | 'Programming Language :: Python :: 3.8', 36 | 'Programming Language :: Python :: 3.9', 37 | 'Programming Language :: Python :: 3.10', 38 | 'Programming Language :: Python :: 3.11', 39 | 'Programming Language :: Python :: 3.12', 40 | 'Topic :: Internet :: WWW/HTTP', 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /tests/filestack_helpers_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from filestack.helpers import verify_webhook_signature 3 | 4 | 5 | @pytest.mark.parametrize('signature, expected_result', [ 6 | ('57cbb25386c3d6ff758a7a75cf52ba02cf2b0a1a2d6d5dfb9c886553ca6011cb', True), 7 | ('incorrect-signature', False), 8 | ]) 9 | def test_webhook_verification(signature, expected_result): 10 | secret = 'webhook-secret' 11 | body = b'{"text": {"filename": "filename.jpg", "key": "kGaeljnga9wkysK6Z_filename.jpg"}}' 12 | 13 | headers = { 14 | 'FS-Signature': signature, 15 | 'FS-Timestamp': 123456789999 16 | } 17 | result, details = verify_webhook_signature(secret, body, headers) 18 | assert result is expected_result 19 | if expected_result is False: 20 | assert 'Signature mismatch' in details['error'] 21 | 22 | 23 | @pytest.mark.parametrize('secret, body, headers, err_msg', [ 24 | ('hook-secret', b'body', 'should be a dict', 'value is not a dict'), 25 | (1, b'body', {'FS-Signature': 'abc', 'FS-Timestamp': 123}, 'value is not a string'), 26 | ('hook-secret', b'', {'FS-Timestamp': 123}, 'fs-signature header is missing'), 27 | ('hook-secret', ['incorrect'], {'FS-Signature': 'abc', 'FS-Timestamp': 123}, 'Invalid webhook body'), 28 | ]) 29 | def test_agrument_validation(secret, body, headers, err_msg): 30 | result, details = verify_webhook_signature(secret, body, headers) 31 | assert result is False 32 | assert err_msg in details['error'] 33 | -------------------------------------------------------------------------------- /filestack/models/security.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | import json 5 | 6 | 7 | class Security: 8 | """ 9 | Security objects are used to sign API calls. 10 | To learn more about Filestack Security, please visit https://www.filestack.com/docs/concepts/security 11 | 12 | >>> sec = Security({'expiry': 1562763146, 'call': ['read']}, 'SECURITY-SECRET') 13 | >>> sec.policy 14 | {'expiry': 1562763146, 'call': ['read']} 15 | >>> sec.policy_b64 16 | 'eyJjYWxsIjogWyJyZWFkIl0sICJleHBpcnkiOiAxNTYyNzYzMTQ2fQ==' 17 | >>> sec.signature 18 | '89f1325dca54cfce976163fb692bb266f28129525b8c6bb0eeadf4b7d450e2f0' 19 | """ 20 | def __init__(self, policy, secret): 21 | """ 22 | Args: 23 | policy (dict): policy to be used 24 | secret (str): your application secret 25 | """ 26 | self.policy = policy 27 | self.secret = secret 28 | self.policy_b64 = base64.urlsafe_b64encode(json.dumps(policy, sort_keys=True).encode('utf-8')).decode('utf-8') 29 | self.signature = hmac.new( 30 | secret.encode('utf-8'), self.policy_b64.encode('utf-8'), hashlib.sha256 31 | ).hexdigest() 32 | 33 | def as_url_string(self): 34 | """ 35 | Returns the security part of signed urls 36 | 37 | Returns: 38 | str: url part in the form of :data:`security=p:\\,s:\\` 39 | """ 40 | return 'security=p:{},s:{}'.format(self.policy_b64, self.signature) 41 | -------------------------------------------------------------------------------- /filestack/models/transformation.py: -------------------------------------------------------------------------------- 1 | from filestack import config 2 | 3 | from filestack.mixins import ImageTransformationMixin, CommonMixin 4 | 5 | 6 | class Transformation(ImageTransformationMixin, CommonMixin): 7 | """ 8 | Transformation objects represent the result of image transformation performed 9 | on Filelinks or other Transformations (as they can be chained). 10 | Unless explicitly stored, no Filelinks are created when 11 | image transformations are performed. 12 | 13 | >>> from filestack import Filelink 14 | >>> transformation= Filelink('sm9IEXAMPLEQuzfJykmA').resize(width=800) 15 | >>> transformation.url 16 | 'https://cdn.filestackcontent.com/resize=width:800/sm9IEXAMPLEQuzfJykmA' 17 | >>> new_filelink = transformation.store() 18 | >>> new_filelink.url 19 | 'https://cdn.filestackcontent.com/NEW_HANDLE' 20 | """ 21 | 22 | def __init__(self, apikey=None, handle=None, external_url=None, security=None): 23 | self.apikey = apikey 24 | self.handle = handle 25 | self.security = security 26 | self.external_url = external_url 27 | self._transformation_tasks = [] 28 | 29 | def _build_url(self, security=None): 30 | url_elements = [config.CDN_URL, self.handle or self.external_url] 31 | 32 | if self._transformation_tasks: 33 | tasks_str = '/'.join(self._transformation_tasks) 34 | url_elements.insert(1, tasks_str) 35 | 36 | if self.external_url: 37 | url_elements.insert(1, self.apikey) 38 | 39 | if security is not None: 40 | url_elements.insert(-1, security.as_url_string()) 41 | return '/'.join(url_elements) 42 | -------------------------------------------------------------------------------- /tests/uploads/test_upload_external_url.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | 6 | from filestack.uploads.external_url import upload_external_url 7 | 8 | url = 'http://image.url' 9 | apikey = 'TESTAPIKEY' 10 | PROCESS_URL = 'https://cdn.filestackcontent.com/process' 11 | 12 | 13 | @pytest.mark.parametrize('default_storage, store_params, security, expected_store_tasks', [ 14 | ( 15 | 'S3,', 16 | {'location': 'gcs'}, 17 | None, 18 | [ 19 | { 20 | 'name': 'store', 'params': {'location': 'gcs'} 21 | } 22 | ] 23 | ), 24 | ( 25 | 'gcs', 26 | {'path': 'new-path/', 'mimetype': 'application/json'}, 27 | type('SecurityMock', (), {'policy_b64': 'abc', 'signature': '123'}), 28 | [ 29 | { 30 | 'name': 'store', 'params': {'path': 'new-path/', 'location': 'gcs'} 31 | }, 32 | { 33 | 'name': 'security', 'params': {'policy': 'abc', 'signature': '123'} 34 | } 35 | ] 36 | ) 37 | ]) 38 | @responses.activate 39 | def test_upload_with_store_params(default_storage, store_params, security, expected_store_tasks): 40 | expected_payload = { 41 | 'apikey': 'TESTAPIKEY', 42 | 'sources': ['http://image.url'], 43 | 'tasks': expected_store_tasks 44 | } 45 | responses.add(responses.POST, PROCESS_URL, json={'handle': 'newHandle'}) 46 | upload_response = upload_external_url( 47 | url, apikey, default_storage, store_params=store_params, security=security 48 | ) 49 | assert upload_response['handle'] == 'newHandle' 50 | req_payload = json.loads(responses.calls[0].request.body.decode()) 51 | assert req_payload == expected_payload 52 | -------------------------------------------------------------------------------- /tests/tasks_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import responses 5 | 6 | from filestack import Transformation, AudioVisual 7 | from filestack import config 8 | 9 | APIKEY = 'SOMEAPIKEY' 10 | HANDLE = 'SOMEHANDLE' 11 | EXTERNAL_URL = 'SOMEEXTERNALURL' 12 | 13 | 14 | @pytest.fixture 15 | def transform(): 16 | return Transformation(apikey=APIKEY, external_url=EXTERNAL_URL) 17 | 18 | 19 | def test_createpdf(transform): 20 | target_url = '{}/{}/pdfcreate/{}/[v7MSSKswR0mvEwZS9LD0,Sr5CrtQPSs5TTZzor1Cw]'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 21 | obj = transform.pdfcreate(engine='mupdf') 22 | assert obj.url 23 | 24 | def test_animate(transform): 25 | target_url = '{}/{}/animate=fit:scale,width:200,height:300/{}/[OjKeBAuBTIWygi1NE8fx,WTY6jjIaTPOvWY9KsNh9]'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 26 | obj = transform.animate(fit='scale',width=200,height=300,loop=0,delay=1000) 27 | assert obj.url 28 | 29 | def test_doc_to_images(transform): 30 | target_url = '{}/{}/doc_to_images/{}/3zOWSOLQ0SEdphqVil9Q'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 31 | obj = transform.doc_to_images(pages=[1], engine='imagemagick', format='png', quality=100, density=100, hidden_slides=False) 32 | assert obj.url 33 | 34 | def test_smart_crop(transform): 35 | target_url = '{}/{}/smart_crop=width:100,height:100/{}/v7MSSKswR0mvEwZS9LD0'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 36 | obj = transform.smart_crop(width=100, height=100) 37 | assert obj.url 38 | 39 | def test_pdf_convert(transform): 40 | target_url = '{}/{}/pdfconvert=pageorientation:landscape/{}/3zOWSOLQ0SEdphqVil9Q'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 41 | obj = transform.pdf_convert(pageorientation='landscape', pageformat='a4', metadata=True) 42 | assert obj.url 43 | 44 | def test_fallback(transform): 45 | target_url = '{}/{}/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 46 | obj = transform.fallback(file='3zOWSOLQ0SEdphqVil9Q', cache=12) 47 | assert obj.url 48 | -------------------------------------------------------------------------------- /filestack/models/audiovisual.py: -------------------------------------------------------------------------------- 1 | import filestack.models 2 | from filestack.utils import requests 3 | 4 | 5 | class AudioVisual: 6 | 7 | def __init__(self, url, uuid, timestamp, apikey=None, security=None): 8 | """ 9 | AudioVisual instances provide a bridge between transform and filelinks, and allow 10 | you to check the status of a conversion and convert to a Filelink once completed 11 | 12 | ```python 13 | from filestack import Client 14 | 15 | client = Client("") 16 | filelink = client.upload(filepath='path/to/file/doom.mp4') 17 | av_convert= filelink.av_convert(width=100, height=100) 18 | while av_convert.status != 'completed': 19 | print(av_convert.status) 20 | 21 | filelink = av_convert.to_filelink() 22 | print(filelink.url) 23 | ``` 24 | """ 25 | self.url = url 26 | self.apikey = apikey 27 | self.security = security 28 | self.uuid = uuid 29 | self.timestamp = timestamp 30 | 31 | def to_filelink(self): 32 | """ 33 | Checks is the status of the conversion is complete and, if so, converts to a Filelink 34 | 35 | *returns* [Filestack.Filelink] 36 | 37 | ```python 38 | filelink = av_convert.to_filelink() 39 | ``` 40 | """ 41 | if self.status != 'completed': 42 | raise Exception('Audio/video conversion not complete!') 43 | 44 | response = requests.get(self.url).json() 45 | handle = response['data']['url'].split('/')[-1] 46 | return filestack.models.Filelink(handle, apikey=self.apikey, security=self.security) 47 | 48 | @property 49 | def status(self): 50 | """ 51 | Returns the status of the AV conversion (makes a GET request) 52 | 53 | *returns* [String] 54 | 55 | ```python 56 | av_convert= filelink.av_convert(width=100, height=100) 57 | while av_convert.status != 'completed': 58 | print(av_convert.status) 59 | ``` 60 | """ 61 | return requests.get(self.url).json()['status'] 62 | -------------------------------------------------------------------------------- /filestack/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | import string 3 | import random 4 | from functools import partial 5 | import requests as original_requests 6 | from requests.exceptions import HTTPError 7 | 8 | from filestack import config 9 | from filestack.exceptions import FilestackHTTPError 10 | 11 | 12 | def unique_id(length=10): 13 | return ''.join(random.choice(string.ascii_letters + string.digits) for i in range(length)) 14 | 15 | 16 | class RequestsWrapper: 17 | """ 18 | This class wraps selected methods from requests package and adds 19 | default headers if no headers were specified. 20 | """ 21 | def __getattr__(self, name): 22 | if name in ('get', 'post', 'put', 'delete'): 23 | return partial(self.handle_request, name) 24 | return original_requests.__getattribute__(name) 25 | 26 | def handle_request(self, name, *args, **kwargs): 27 | if 'headers' not in kwargs: 28 | kwargs['headers'] = config.HEADERS 29 | kwargs['headers']['Filestack-Trace-Id'] = '{}-{}'.format(int(time.time()), unique_id()) 30 | kwargs['headers']['Filestack-Trace-Span'] = 'pythonsdk-{}'.format(unique_id()) 31 | 32 | requests_method = getattr(original_requests, name) 33 | response = requests_method(*args, **kwargs) 34 | 35 | try: 36 | response.raise_for_status() 37 | except HTTPError as e: 38 | raise FilestackHTTPError(response.text) from e 39 | 40 | return response 41 | 42 | 43 | requests = RequestsWrapper() 44 | 45 | 46 | def return_transform_task(transformation, params): 47 | transform_tasks = [] 48 | 49 | for key, value in params.items(): 50 | if isinstance(value, list): 51 | value = str(value).replace("'", "").replace('"', '').replace(" ", "") 52 | if isinstance(value, bool): 53 | value = str(value).lower() 54 | 55 | transform_tasks.append('{}:{}'.format(key, value)) 56 | 57 | transform_tasks = sorted(transform_tasks) 58 | 59 | if len(transform_tasks) > 0: 60 | transformation_url = '{}={}'.format(transformation, ','.join(transform_tasks)) 61 | else: 62 | transformation_url = transformation 63 | 64 | return transformation_url 65 | -------------------------------------------------------------------------------- /filestack/helpers.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import hashlib 3 | 4 | import trafaret as t 5 | 6 | 7 | def check_body(val): 8 | if isinstance(val, str) or isinstance(val, bytes): 9 | return val 10 | return t.DataError('Invalid webhook body. Expected: string or bytes') 11 | 12 | 13 | def check_headers(headers): 14 | if not isinstance(headers, dict): 15 | return t.DataError('value is not a dict') 16 | 17 | headers = dict((k.lower(), v) for k, v in headers.items()) 18 | for item in ('fs-signature', 'fs-timestamp'): 19 | if item.lower() not in headers: 20 | return t.DataError('{} header is missing'.format(item)) 21 | return headers 22 | 23 | 24 | VerificationArguments = t.Dict({ 25 | 'secret': t.String, 26 | 'body': t.Call(check_body), 27 | 'headers': t.Call(check_headers) 28 | }) 29 | 30 | 31 | def verify_webhook_signature(secret=None, body=None, headers=None): 32 | """ 33 | Checks if webhook, which you received was sent Filestack, 34 | based on your secret for webhook endpoint which was generated in Filestack developer portal. 35 | Body suppose to be raw content of received webhook 36 | 37 | returns [Tuple] 38 | ```python 39 | from filestack import Client 40 | 41 | result, details = verify_webhook_signature( 42 | 'secret', b'{"webhook_content": "received_from_filestack"}', 43 | {'FS-Timestamp': '1558367878', 'FS-Signature': 'filestack-signature'} 44 | ) 45 | ``` 46 | Positive verification result: True, {} 47 | Negative verification result: False, {'error': 'error details'} 48 | """ 49 | try: 50 | VerificationArguments.check({ 51 | 'secret': secret, 'body': body, 'headers': headers 52 | }) 53 | except t.DataError as e: 54 | return False, {'error': str(e.as_dict())} 55 | 56 | lowercase_headers = dict((k.lower(), v) for k, v in headers.items()) 57 | if isinstance(body, bytes): 58 | body = body.decode('utf-8') 59 | 60 | lowercase_headers = dict((k.lower(), v) for k, v in headers.items()) 61 | hmac_data = '{}.{}'.format(lowercase_headers['fs-timestamp'], body) 62 | signature = hmac.new(secret.encode('utf-8'), hmac_data.encode('utf-8'), hashlib.sha256).hexdigest() 63 | expected = lowercase_headers['fs-signature'] 64 | if signature != expected: 65 | return False, {'error': 'Signature mismatch! Expected: {}. Got: {}'.format(expected, signature)} 66 | return True, {} 67 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('../../')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'filestack-python' 21 | copyright = '2019, Filestack Dev Team' 22 | author = 'Filestack Dev Team' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.0.1' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.napoleon' 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | # This pattern also affects html_static_path and html_extra_path. 44 | exclude_patterns = [] 45 | 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | # The theme to use for HTML and HTML Help pages. See the documentation for 50 | # a list of builtin themes. 51 | # 52 | # html_theme = 'alabaster' 53 | html_theme = 'sphinx_rtd_theme' 54 | html_logo = '_static/img/logo-fs-bright.png' 55 | 56 | html_theme_options = { 57 | 'logo_only': True, 58 | 'collapse_navigation' : False 59 | } 60 | 61 | # Add any paths that contain custom static files (such as style sheets) here, 62 | # relative to this directory. They are copied after the builtin static files, 63 | # so a file named "default.css" will overwrite the builtin "default.css". 64 | html_static_path = ['_static'] 65 | 66 | # use the older (pre-2.0.0 Sphinx) way in which function arguments (Parameters) 67 | # are rendered 68 | html4_writer = True 69 | -------------------------------------------------------------------------------- /tests/client_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | from unittest.mock import patch, mock_open 3 | 4 | import pytest 5 | import responses 6 | from trafaret import DataError 7 | 8 | import filestack.models 9 | from filestack import Client, Filelink, Transformation, Security 10 | 11 | 12 | APIKEY = 'APIKEY' 13 | HANDLE = 'SOMEHANDLE' 14 | 15 | 16 | @pytest.fixture 17 | def client(): 18 | return Client(APIKEY) 19 | 20 | 21 | def test_api_set(client): 22 | assert client.apikey == APIKEY 23 | 24 | 25 | def test_wrong_storage(): 26 | kwargs = {'apikey': APIKEY, 'storage': 'googlecloud'} 27 | pytest.raises(DataError, Client, **kwargs) 28 | 29 | 30 | @responses.activate 31 | def test_store_external_url(client): 32 | responses.add( 33 | responses.POST, 'https://cdn.filestackcontent.com/process', json={'handle': HANDLE} 34 | ) 35 | filelink = client.upload_url(url='http://a.bc') 36 | 37 | assert isinstance(filelink, Filelink) 38 | assert filelink.handle == HANDLE 39 | 40 | 41 | @patch('filestack.models.client.multipart_upload') 42 | def test_store_filepath(upload_mock, client): 43 | upload_mock.return_value = {'handle': HANDLE} 44 | filelink = client.upload(filepath='path/to/image.jpg') 45 | 46 | assert isinstance(filelink, Filelink) 47 | assert filelink.handle == HANDLE 48 | upload_mock.assert_called_once_with('APIKEY', 'path/to/image.jpg', None, 'S3', params=None, security=None) 49 | 50 | 51 | @patch('filestack.models.client.multipart_upload') 52 | @patch('filestack.models.client.upload_external_url') 53 | def test_security_inheritance(upload_external_mock, multipart_mock): 54 | upload_external_mock.return_value = {'handle': 'URL_HANDLE'} 55 | multipart_mock.return_value = {'handle': 'FILE_HANDLE'} 56 | 57 | policy = {'expiry': 1900} 58 | cli = Client(APIKEY, security=Security(policy, 'SECRET')) 59 | 60 | flink_from_url = cli.upload_url('https://just.some/url') 61 | assert flink_from_url.handle == 'URL_HANDLE' 62 | assert flink_from_url.security.policy == policy 63 | 64 | flink = cli.upload(filepath='/dummy/path') 65 | assert flink.handle == 'FILE_HANDLE' 66 | assert flink.security.policy == policy 67 | 68 | 69 | def test_url_screenshot(client): 70 | external_url = 'https//www.someexternalurl' 71 | transform = client.urlscreenshot(external_url) 72 | assert isinstance(transform, filestack.models.Transformation) 73 | assert transform.apikey == APIKEY 74 | 75 | 76 | def test_transform_external(client): 77 | new_transform = client.transform_external('SOMEURL') 78 | assert isinstance(new_transform, Transformation) 79 | 80 | 81 | @responses.activate 82 | def test_zip(client): 83 | responses.add( 84 | responses.GET, re.compile('https://cdn.filestackcontent.com/APIKEY/zip*'), 85 | body=b'zip-bytes' 86 | ) 87 | m = mock_open() 88 | with patch('filestack.models.client.open', m): 89 | zip_size = client.zip('test.zip', ['handle1', 'handle2']) 90 | 91 | assert zip_size == 9 92 | m().write.assert_called_once_with(b'zip-bytes') 93 | -------------------------------------------------------------------------------- /tests/multipart_test.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | from collections import defaultdict 4 | 5 | import responses 6 | import pytest 7 | 8 | from filestack import Client 9 | from filestack import config 10 | from filestack.uploads.multipart import upload_chunk, Chunk 11 | 12 | APIKEY = 'APIKEY' 13 | HANDLE = 'SOMEHANDLE' 14 | 15 | 16 | @pytest.fixture 17 | def multipart_mock(): 18 | with responses.RequestsMock() as rsps: 19 | rsps.add( 20 | responses.POST, config.MULTIPART_START_URL, status=200, 21 | json={ 22 | 'region': 'us-east-1', 'upload_id': 'someuuid', 'uri': 'someuri', 23 | 'location_url': 'fs-uploads.com' 24 | } 25 | ) 26 | rsps.add( 27 | responses.POST, 'https://fs-uploads.com/multipart/upload', 28 | status=200, content_type='application/json', 29 | json={'url': 'http://somewhere.on.s3', 'headers': {'filestack': 'header'}} 30 | ) 31 | rsps.add(responses.PUT, 'http://somewhere.on.s3', json={}, headers={'ETag': 'abc'}) 32 | rsps.add( 33 | responses.POST, 'https://fs-uploads.com/multipart/complete', status=200, 34 | json={'url': 'https://cdn.filestackcontent.com/{}'.format(HANDLE), 'handle': HANDLE} 35 | ) 36 | yield rsps 37 | 38 | 39 | def test_upload_filepath(multipart_mock): 40 | client = Client(APIKEY) 41 | filelink = client.upload(filepath='tests/data/doom.mp4') 42 | assert filelink.handle == HANDLE 43 | assert filelink.upload_response == {'url': 'https://cdn.filestackcontent.com/{}'.format(HANDLE), 'handle': HANDLE} 44 | 45 | 46 | def test_upload_file_obj(multipart_mock): 47 | file_content = b'file bytes' 48 | filelink = Client(APIKEY).upload(file_obj=io.BytesIO(file_content)) 49 | assert filelink.handle == HANDLE 50 | assert multipart_mock.calls[2].request.headers['filestack'] == 'header' 51 | assert multipart_mock.calls[2].request.body == file_content 52 | 53 | 54 | def test_upload_with_workflows(multipart_mock): 55 | workflow_ids = ['workflow-id-1', 'workflow-id-2'] 56 | store_params = {'workflows': workflow_ids} 57 | client = Client(APIKEY) 58 | filelink = client.upload(filepath='tests/data/bird.jpg', store_params=store_params) 59 | assert filelink.handle == HANDLE 60 | multipart_complete_payload = json.loads(multipart_mock.calls[3].request.body.decode()) 61 | assert multipart_complete_payload['store']['workflows'] == workflow_ids 62 | 63 | 64 | @responses.activate 65 | def test_upload_chunk(): 66 | responses.add( 67 | responses.POST, 'https://fsuploads.com/multipart/upload', 68 | status=200, json={'url': 'http://s3-upload.url', 'headers': {}} 69 | ) 70 | responses.add(responses.PUT, 'http://s3-upload.url', headers={'ETag': 'etagX'}) 71 | 72 | chunk = Chunk(num=123, seek_point=0, filepath='tests/data/doom.mp4') 73 | start_response = defaultdict(str) 74 | start_response['location_url'] = 'fsuploads.com' 75 | upload_result = upload_chunk('apikey', 'filename', 's3', start_response, chunk) 76 | assert upload_result == {'part_number': 123, 'etag': 'etagX'} 77 | -------------------------------------------------------------------------------- /docs/source/uploading_files.rst: -------------------------------------------------------------------------------- 1 | Uploading files 2 | =============== 3 | 4 | Filestack Python SDK allows you to uploads local files, file-like objects and files from external urls. 5 | 6 | 7 | Local files 8 | ----------- 9 | 10 | .. code-block:: python 11 | :linenos: 12 | 13 | from filestack import Client 14 | 15 | cli = Client('') 16 | filelink = cli.upload(filepath='path/to/video.mp4') 17 | 18 | # upload to non-default storage provider under specific path 19 | store_params = { 20 | 'location': 'gcs', # Google Cloud Storage 21 | 'path': 'folder/subfolder/video_file.mpg' 22 | } 23 | filelink = cli.upload(filepath='path/to/video.mp4', store_params=store_params) 24 | 25 | 26 | File-like objects 27 | ----------------- 28 | 29 | .. code-block:: python 30 | :linenos: 31 | 32 | from filestack import Client 33 | 34 | cli = Client('') 35 | with open('path/to/video.mp4', 'rb') as f: 36 | filelink = cli.upload(file_obj=f) 37 | 38 | 39 | To upload in-memory bytes: 40 | 41 | .. code-block:: python 42 | :linenos: 43 | 44 | import io 45 | from filestack import Client 46 | 47 | bytes_to_upload = b'content' 48 | 49 | cli = Client('') 50 | filelink = cli.upload(file_obj=io.BytesIO(bytes_to_upload)) 51 | 52 | 53 | External urls 54 | ------------- 55 | 56 | .. code-block:: python 57 | :linenos: 58 | 59 | from filestack import Client 60 | 61 | cli = Client('') 62 | filelink = cli.upload_url(url='https://f4fcdn.eu/wp-content/uploads/2018/06/krakowmain.jpg') 63 | 64 | 65 | Store params 66 | ------------ 67 | 68 | Each upload function shown above takes a :data:`store_params` argument which is a Python dictionary with following keys (all are optional): 69 | 70 | .. code-block:: python 71 | :linenos: 72 | 73 | store_params = { 74 | 'filename': 'string', 75 | 'location': 'string', 76 | 'path': 'string', 77 | 'container': 'string', 78 | 'mimetype': 'string', 79 | 'region': 'string', 80 | 'access': 'string', 81 | 'base64decode': True|False, 82 | 'workflows': ['workflow-id-1', 'workflow-id-2'], 83 | 'upload_tags': { 84 | 'key': 'value', 85 | 'key2': 'value' 86 | } 87 | } 88 | 89 | * **filename** - name for the stored file 90 | * **location** - storage provider to be used 91 | * **path** - the path to store the file within the specified container 92 | * **container** - the bucket or container (folder) in which to store the file (does not apply when storing to Dropbox) 93 | * **mimetype** - mime type that should be stored in file's metadata 94 | * **region** - storage region (applies to S3 only) 95 | * **access** - should the file be stored as :data:`"public"` or :data:`"private"` (applies to S3 only) 96 | * **base64decode** - indicates if content should be decoded before it is stored 97 | * **workflows** - IDs of `Filestack Workflows `_ that should be triggered after upload 98 | * **upload_tags** - set of :data:`key: value` pairs that will be returned with webhook for particular upload 99 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Filestack-Python Changelog 2 | 3 | ### 4.0.0 (September 17th, 2024) 4 | - Compatibility fix for AWS Lambda with Python 3.11 [#73](https://github.com/filestack/filestack-python/pull/73) 5 | - A bunch of transform features added [#74](https://github.com/filestack/filestack-python/pull/75) 6 | - Bumped requests lib version from 2.25.1 to >=2.31.0 [#75](https://github.com/filestack/filestack-python/pull/75) 7 | 8 | ### 3.5.0 (September 17th, 2021) 9 | - Broaden pinned requests version from 2.24.0 to >=2.25.1 [#61](https://github.com/filestack/filestack-python/pull/61) 10 | - Use Client's storage when uploading external urls [#62](https://github.com/filestack/filestack-python/pull/62) 11 | 12 | ### 3.4.0 (May 13th, 2021) 13 | - Store upload response in Filelink object [#59](https://github.com/filestack/filestack-python/pull/59) 14 | 15 | ### 3.3.0 (March 12th, 2021) 16 | - Added Image OCR [#52](https://github.com/filestack/filestack-python/pull/52) 17 | - Changed how files from external urls are uploaded [#54](https://github.com/filestack/filestack-python/pull/54) 18 | - Added custom exception for HTTP errors [#57](https://github.com/filestack/filestack-python/pull/57) 19 | 20 | ### 3.2.1 (July 22nd, 2020) 21 | - FS-7797 changed http method used when uploading external urls 22 | 23 | ### 3.2.0 (June 2nd, 2020) 24 | - Added upload tags 25 | 26 | ### 3.1.0 (April 14th, 2020) 27 | - Transformations added: auto_image, minify_js, minify_css 28 | - Added configurable CNAME 29 | - Fixed path param in store task 30 | 31 | ### 3.0.1 (July 22nd, 2019) 32 | - Added enhance presets 33 | - Set filelink security when uploading from url 34 | 35 | ### 3.0.0 (July 16th, 2019) 36 | - Dropped support for Python 2.7 37 | - client.upload() now handles local files and file-like objects, accepts keyword arguments only 38 | - client.upload_url() handles external url uploads 39 | - Please see [API Reference](https://filestack-python.readthedocs.io) to learn more 40 | 41 | ### 2.8.1 (July 3th, 2019) 42 | - Fixed store params in external url upload 43 | 44 | ### 2.8.0 (June 17th, 2019) 45 | - Simplified FII uploads 46 | - Updated uploads to use JSON-based API 47 | 48 | ### 2.7.0 (May 30th, 2019) 49 | - Refactored and renamed webhook verification method 50 | - Added new transformation tasks: callbak, pdf_info and pdf_convert 51 | 52 | ### 2.6.0 (May 29th, 2019) 53 | - Added webhook signature verification 54 | 55 | ### 2.5.0 (May 20th, 2019) 56 | - Added support for [Filestack Workflows](https://www.filestack.com/products/workflows/) 57 | 58 | ### 2.4.0 (March 18th, 2019) 59 | - Added default mimetype for multipart uploads 60 | - Refactored multipart uploads 61 | 62 | ### 2.3.0 (August 22th, 2017) 63 | - FS-1556 Add SDK reference 64 | - FS-1399 added intelligent ingestion 65 | 66 | ### 2.2.1 (July 19th, 2017) 67 | - FS-1364 Add all response data to AudioVisual class 68 | 69 | ### 2.1.1 (July 7th, 2017) 70 | - FS-1365 Fix security formatting for non-transform URLs 71 | - Add generic error handling to make_call utility function 72 | 73 | ### 2.1.0 (June 26th, 2017) 74 | - FS-1134 Add autotagging and SFW detection 75 | - FS-1117 Mulipart upload now sends user parameters 76 | - FS-1116 Upload parameters are now optional 77 | - Fixes to security and mulipart uploads 78 | 79 | ### 2.0.4 80 | - FS-1116 Make upload parameters all optional 81 | - FS-1117 Ensure params are being sent during multipart upload 82 | 83 | ### 2.0.2 - 2.0.3 84 | - Modify setup.py for PyPi deployment 85 | 86 | ### 2.0.1 87 | - Modify setup.py for PyPi deployment 88 | 89 | ### 2.0.0 (June 1, 2017) 90 | - Updated to new version, including transformations and multipart uploads 91 | 92 | -------------------------------------------------------------------------------- /tests/intelligent_ingestion_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | 6 | from filestack.uploads.intelligent_ingestion import upload_part 7 | 8 | 9 | @responses.activate 10 | def test_upload_part_success(): 11 | responses.add( 12 | responses.POST, 'https://fs-upload.com/multipart/upload', 13 | json={'url': 'http://s3.url', 'headers': {'filestack': 'headers'}} 14 | ) 15 | responses.add(responses.PUT, 'http://s3.url') 16 | responses.add(responses.POST, 'https://fs-upload.com/multipart/commit') 17 | 18 | part = {'seek_point': 0, 'num': 1} 19 | start_response = { 20 | 'uri': 'fs-upload.com', 'location_url': 'fs-upload.com', 'region': 'region', 'upload_id': 'abc' 21 | } 22 | upload_part('Aaaaapikey', 'file.txt', 'tests/data/doom.mp4', 1234, 's3', start_response, part) 23 | multipart_upload_payload = json.loads(responses.calls[0].request.body.decode()) 24 | assert multipart_upload_payload == { 25 | 'apikey': 'Aaaaapikey', 'uri': 'fs-upload.com', 'region': 'region', 26 | 'upload_id': 'abc', 'store': {'location': 's3'}, 27 | 'part': 1, 'size': 5415034, 'md5': 'IuNjhgPo2wbzGFo6f7WhUA==', 'offset': 0, 'fii': True 28 | } 29 | with open('tests/data/doom.mp4', 'rb') as f: 30 | assert responses.calls[1].request.body == f.read() 31 | multipart_commit_payload = json.loads(responses.calls[2].request.body.decode()) 32 | assert multipart_commit_payload == { 33 | 'apikey': 'Aaaaapikey', 'uri': 'fs-upload.com', 'region': 'region', 34 | 'upload_id': 'abc', 'store': {'location': 's3'}, 'part': 1, 'size': 1234 35 | } 36 | 37 | 38 | @responses.activate 39 | def test_upload_part_with_resize(): 40 | responses.add( 41 | responses.POST, 'https://fs-upload.com/multipart/upload', 42 | json={'url': 'https://s3.url', 'headers': {'filestack': 'headers'}} 43 | ) 44 | responses.add(responses.PUT, 'https://s3.url', status=400) 45 | responses.add(responses.PUT, 'https://s3.url') # chunks 1 & 2 of part 1 46 | responses.add(responses.POST, 'https://fs-upload.com/multipart/commit') 47 | 48 | start_response = { 49 | 'uri': 'fs-upload.com', 'location_url': 'fs-upload.com', 'region': 'region', 'upload_id': 'abc' 50 | } 51 | part = {'seek_point': 0, 'num': 1} 52 | upload_part('Aaaaapikey', 'file.txt', 'tests/data/doom.mp4', 5415034, 's3', start_response, part) 53 | 54 | responses.assert_call_count('https://fs-upload.com/multipart/upload', 3) 55 | responses.assert_call_count('https://s3.url', 3) 56 | assert len(responses.calls[1].request.body) == 5415034 57 | assert len(responses.calls[3].request.body) == 4194304 58 | assert len(responses.calls[5].request.body) == 1220730 59 | 60 | 61 | @responses.activate 62 | def test_min_chunk_size_exception(): 63 | responses.reset() 64 | responses.add( 65 | responses.POST, 'https://fs-upload.com/multipart/upload', 66 | json={'url': 'https://upload.url', 'headers': {'filestack': 'headers'}} 67 | ) 68 | responses.add(responses.PUT, 'https://upload.url', status=400) 69 | 70 | part = {'seek_point': 0, 'num': 1} 71 | start_response = { 72 | 'uri': 'fs-upload.com', 'location_url': 'fs-upload.com', 'region': 'region', 'upload_id': 'abc' 73 | } 74 | with pytest.raises(Exception, match='Minimal chunk size failed'): 75 | upload_part('Aaaaapikey', 'file.txt', 'tests/data/doom.mp4', 5415034, 's3', start_response, part) 76 | 77 | chunk_sizes = [len(call.request.body) for call in responses.calls if call.request.method == 'PUT'] 78 | assert chunk_sizes[-1] == 32768 # check size of last attempt 79 | -------------------------------------------------------------------------------- /filestack/uploads/multipart.py: -------------------------------------------------------------------------------- 1 | import os 2 | import hashlib 3 | import mimetypes 4 | import multiprocessing 5 | from base64 import b64encode 6 | from functools import partial 7 | from concurrent.futures import ThreadPoolExecutor 8 | 9 | from filestack import config 10 | from filestack.utils import requests 11 | 12 | 13 | class Chunk: 14 | def __init__(self, num, seek_point, data=None, filepath=None): 15 | self.num = num 16 | self.seek_point = seek_point 17 | self.data = data 18 | self.filepath = filepath 19 | 20 | def __repr__(self): 21 | return ''.format(self.num, self.seek_point) 22 | 23 | @property 24 | def bytes(self): 25 | if self.data: 26 | return self.data 27 | 28 | with open(self.filepath, 'rb') as f: 29 | f.seek(self.seek_point) 30 | data = f.read(config.DEFAULT_CHUNK_SIZE) 31 | 32 | return data 33 | 34 | 35 | def multipart_request(url, payload, params=None, security=None): 36 | for key in ('path', 'location', 'region', 'container', 'access'): 37 | if key in params: 38 | payload['store'][key] = params[key] 39 | 40 | if security: 41 | payload.update({ 42 | 'policy': security.policy_b64, 43 | 'signature': security.signature 44 | }) 45 | 46 | return requests.post(url, json=payload).json() 47 | 48 | 49 | def make_chunks(filepath=None, file_obj=None, filesize=None): 50 | chunks = [] 51 | for num, seek_point in enumerate(range(0, filesize, config.DEFAULT_CHUNK_SIZE)): 52 | if filepath: 53 | chunks.append(Chunk(num + 1, seek_point, filepath=filepath)) 54 | else: # file_obj 55 | file_obj.seek(seek_point) 56 | chunks.append(Chunk(num + 1, seek_point, data=file_obj.read(config.DEFAULT_CHUNK_SIZE))) 57 | 58 | if file_obj: 59 | del file_obj 60 | 61 | return chunks 62 | 63 | 64 | def upload_chunk(apikey, filename, storage, start_response, chunk): 65 | payload = { 66 | 'apikey': apikey, 67 | 'part': chunk.num, 68 | 'size': len(chunk.bytes), 69 | 'md5': b64encode(hashlib.md5(chunk.bytes).digest()).strip().decode('utf-8'), 70 | 'uri': start_response['uri'], 71 | 'region': start_response['region'], 72 | 'upload_id': start_response['upload_id'], 73 | 'store': { 74 | 'location': storage, 75 | } 76 | } 77 | 78 | fs_resp = requests.post( 79 | 'https://{}/multipart/upload'.format(start_response['location_url']), 80 | json=payload 81 | ).json() 82 | 83 | resp = requests.put(fs_resp['url'], headers=fs_resp['headers'], data=chunk.bytes) 84 | 85 | return {'part_number': chunk.num, 'etag': resp.headers['ETag']} 86 | 87 | 88 | def multipart_upload(apikey, filepath, file_obj, storage, params=None, security=None): 89 | params = params or {} 90 | 91 | upload_processes = multiprocessing.cpu_count() 92 | 93 | if filepath: 94 | filename = params.get('filename') or os.path.split(filepath)[1] 95 | mimetype = params.get('mimetype') or mimetypes.guess_type(filepath)[0] or config.DEFAULT_UPLOAD_MIMETYPE 96 | filesize = os.path.getsize(filepath) 97 | else: 98 | filename = params.get('filename', 'unnamed_file') 99 | mimetype = params.get('mimetype') or config.DEFAULT_UPLOAD_MIMETYPE 100 | file_obj.seek(0, os.SEEK_END) 101 | filesize = file_obj.tell() 102 | 103 | payload = { 104 | 'apikey': apikey, 105 | 'filename': filename, 106 | 'mimetype': mimetype, 107 | 'size': filesize, 108 | 'store': { 109 | 'location': storage 110 | } 111 | } 112 | 113 | chunks = make_chunks(filepath, file_obj, filesize) 114 | start_response = multipart_request(config.MULTIPART_START_URL, payload, params, security) 115 | upload_func = partial(upload_chunk, apikey, filename, storage, start_response) 116 | 117 | with ThreadPoolExecutor(max_workers=upload_processes) as executor: 118 | uploaded_parts = list(executor.map(upload_func, chunks)) 119 | 120 | location_url = start_response.pop('location_url') 121 | payload.update(start_response) 122 | payload['parts'] = uploaded_parts 123 | 124 | if 'workflows' in params: 125 | payload['store']['workflows'] = params.pop('workflows') 126 | 127 | if 'upload_tags' in params: 128 | payload['upload_tags'] = params.pop('upload_tags') 129 | 130 | complete_url = 'https://{}/multipart/complete'.format(location_url) 131 | complete_response = multipart_request(complete_url, payload, params, security) 132 | return complete_response 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Filestack Python SDK 2 | 3 | First of all, thank you so much for being interested in contributing to Filestack Python SDK. This document will guide you through this process. You can contribute in different ways: 4 | 5 | - Reporting issues 6 | - Fixing bugs or developing new features 7 | - Creating new examples 8 | 9 | ## Issues 10 | Feel free to open new issues or participating in the discussion of the existing ones on 11 | [this repository](https://github.com/filestack/filestack-python/issues), but before doing so, please make sure that the issue is not duplicated and/or the discussion is related to the topic of the issue. 12 | 13 | ## Pull requests 14 | Code contributions are welcome following a process which guarantees the long-term maintainability of the project. 15 | You can contribute either with bugfixes or new features. Before submitting a new feature, we highly encourage you to first open a new issue describing its motivation and details and discuss it with one of the project mantainers. This will ensure that the feature fits well in the project. 16 | 17 | ### Step 1: Open a new issue (if not opened yet) 18 | Before starting to code, it is desirable to first open an issue describing the bug or the new feature. Please be sure the issue is not duplicated. 19 | 20 | ### Step 2: Fork the repository 21 | Fork the project https://github.com/filestack/filestack-python into your account. Then, check out your copy of the project locally. 22 | ``` 23 | git clone git@github.com:user/filestack/filestack-python.git 24 | cd filestack-python 25 | git remote add upstream https://github.com/filestack/filestack-python.git 26 | ``` 27 | 28 | ### Step 3: Create a new feature branch `contrib/issue-number` 29 | Put your code in a new feature branch. The name of the new branch should start with `contrib/`. This convention will help us to keep track of future changes from pull requests. 30 | ``` 31 | git checkout -b contrib/issue-number tags/tag-number 32 | ``` 33 | Note that tags/tag-number would correspond with any of the tags (for example 1.0.X). For example, suppose that the latest version of the project is v1.0.0 and you want to fix a new bug that you discovered in this version. If the new reported issue has an id, say, #186, then you would create your feature branch in this way: 34 | ``` 35 | git checkout -b contrib/issue-186 tags/1.0.X 36 | ``` 37 | 38 | ### Step 4: Committing your changes 39 | First of all, make sure that git is configured with your complete name and email address. It is desirable to use the same email of your Github account, this will help to identify the contributions: 40 | ``` 41 | git config --global user.name "Your Name" 42 | git config --global user.email "your@email.com" 43 | ``` 44 | Write a good commit message. It should describe the changes you made and its motivation. Be sure to reference the issue you are working in the commit that finishes your contribution using one the [keywords to close issues in Github](https://help.github.com/articles/closing-issues-via-commit-messages/). 45 | If your commit message is too long, try to summarize the changes in the header of the message, like this: 46 | ``` 47 | fix #xx : summarize your commit in one line 48 | 49 | If needed, explain more in detail the changes introduced in your 50 | commit and the motivation. You could introduce some background 51 | about the issue you worked in. 52 | 53 | This message can contain several paragraphs and be as long as 54 | you need, but try to do a good indentation: the columns should 55 | be shorter than 72 characters and with a proper word-wrap. 56 | The command `git log` will print this complete text in a nice 57 | way if you format it properly. 58 | ``` 59 | The header and the body of the commit message must be separated by a line in blank. The header is the message shown when running the command `git shortlog`. 60 | 61 | #### Test your code 62 | Verify that your changes are actually working by adding the required unit tests. It is desirable to include unit test covering all new features you implement. Also, if you find a bug which is not currently detected by the unit tests you might consider to implement a new one or modify the current implementation. After this, you can verify that everything works fine after your changes with: 63 | 64 | ``` 65 | pytest tests 66 | ``` 67 | 68 | ### Step 5: Push your changes 69 | 70 | Push your changes to your forked project with: 71 | ``` 72 | git push origin contrib/issue-186 73 | ``` 74 | 75 | ### Step 6: Create and submit a pull request 76 | Go to your forked project on GitHub, select your feature branch and click the “Compare, review, create a pull request button”. 77 | 78 | 79 | ### License Agreement 80 | By contributing your code, you agree to license your contribution under the terms of the [Apache 2.0 license](https://raw.githubusercontent.com/citiususc/hipster/4ca93e681ad7335acbd0bea9e49fe678d56f3519/LICENSE). 81 | 82 | Also, remember to add this header to each new file that you’ve created: 83 | 84 | ``` 85 | Copyright 2017 Filestack 86 | 87 | Licensed under the Apache License, Version 2.0 (the "License"); 88 | you may not use this file except in compliance with the License. 89 | You may obtain a copy of the License at 90 | 91 | http://www.apache.org/licenses/LICENSE-2.0 92 | 93 | Unless required by applicable law or agreed to in writing, software 94 | distributed under the License is distributed on an "AS IS" BASIS, 95 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 96 | See the License for the specific language governing permissions and 97 | limitations under the License. 98 | ``` 99 | -------------------------------------------------------------------------------- /filestack/models/filelink.py: -------------------------------------------------------------------------------- 1 | from filestack import config 2 | from filestack.utils import requests 3 | from filestack.mixins import CommonMixin 4 | from filestack.mixins import ImageTransformationMixin 5 | 6 | 7 | class Filelink(ImageTransformationMixin, CommonMixin): 8 | """ 9 | Filelink object represents a file that whas uploaded to Filestack. 10 | A filelink object can be created by uploading a file using Client instance, 11 | or by initializing Filelink class with a handle (unique id) of already uploaded file. 12 | 13 | >>> from filestack import Filelink 14 | >>> flink = Filelink('sm9IEXAMPLEQuzfJykmA') 15 | >>> flink.url 16 | 'https://cdn.filestackcontent.com/sm9IEXAMPLEQuzfJykmA' 17 | """ 18 | def __init__(self, handle, apikey=None, security=None, upload_response=None): 19 | """ 20 | Args: 21 | handle (str): The path of the file to wrap 22 | apikey (str): Filestack API key that may be required for some API calls 23 | security (:class:`filestack.Security`): Security object that will be used by default 24 | for all API calls 25 | """ 26 | self.apikey = apikey 27 | self.handle = handle 28 | self.security = security 29 | self.upload_response = upload_response 30 | 31 | def __repr__(self): 32 | return ''.format(self.handle) 33 | 34 | def _build_url(self, security=None): 35 | url_elements = [config.CDN_URL, self.handle] 36 | if security is not None: 37 | url_elements.insert(-1, security.as_url_string()) 38 | return '/'.join(url_elements) 39 | 40 | def metadata(self, attributes_list=None, security=None): 41 | """ 42 | Retrieves filelink's metadata. 43 | 44 | Args: 45 | attributes_list (list): list of attributes that you wish to receive. When not provided, 46 | default set of parameters will be returned (may differ depending on your storage settings) 47 | security (:class:`filestack.Security`): Security object that will be used 48 | to retrieve metadata 49 | 50 | >>> filelink.metadata(['size', 'filename']) 51 | {'filename': 'envelope.jpg', 'size': 171832} 52 | 53 | Returns: 54 | `dict`: A buffered writable file descriptor 55 | """ 56 | attributes_list = attributes_list or [] 57 | params = {} 58 | for item in attributes_list: 59 | params[item] = 'true' 60 | sec = security or self.security 61 | if sec is not None: 62 | params.update({'policy': sec.policy_b64, 'signature': sec.signature}) 63 | return requests.get(self.url + '/metadata', params=params).json() 64 | 65 | def delete(self, apikey=None, security=None): 66 | """ 67 | Deletes filelink. 68 | 69 | Args: 70 | apikey (str): Filestack API key that will be used for this API call 71 | security (:class:`filestack.Security`): Security object that will be used 72 | to delete filelink 73 | 74 | Returns: 75 | None 76 | """ 77 | sec = security or self.security 78 | apikey = apikey or self.apikey 79 | 80 | if sec is None: 81 | raise Exception('Security is required to delete filelink') 82 | 83 | if apikey is None: 84 | raise Exception('Apikey is required to delete filelink') 85 | 86 | url = '{}/file/{}'.format(config.API_URL, self.handle) 87 | delete_params = { 88 | 'key': apikey, 89 | 'policy': sec.policy_b64, 90 | 'signature': sec.signature 91 | } 92 | requests.delete(url, params=delete_params) 93 | 94 | def overwrite(self, *, filepath=None, url=None, file_obj=None, base64decode=False, security=None): 95 | """ 96 | Overwrites filelink with new content 97 | 98 | Args: 99 | filepach (str): path to file 100 | url (str): file URL 101 | file_obj (io.BytesIO or similar): file-like object 102 | base64decode (bool): indicates if content should be decoded before it is stored 103 | security (:class:`filestack.Security`): Security object that will be used 104 | to overwrite filelink 105 | 106 | Note: 107 | This method accepts keyword arguments only. 108 | Out of filepath, url and file_obj only one should be provided. 109 | """ 110 | sec = security or self.security 111 | if sec is None: 112 | raise Exception('Security is required to overwrite filelink') 113 | req_params = { 114 | 'policy': sec.policy_b64, 115 | 'signature': sec.signature, 116 | 'base64decode': str(base64decode).lower() 117 | } 118 | 119 | request_url = '{}/file/{}'.format(config.API_URL, self.handle) 120 | if url: 121 | requests.post(request_url, params=req_params, data={'url': url}) 122 | elif filepath: 123 | with open(filepath, 'rb') as f: 124 | files = {'fileUpload': ('filename', f, 'application/octet-stream')} 125 | requests.post(request_url, params=req_params, files=files) 126 | elif file_obj: 127 | files = {'fileUpload': ('filename', file_obj, 'application/octet-stream')} 128 | requests.post(request_url, params=req_params, files=files) 129 | else: 130 | raise Exception('filepath, file_obj or url argument must be provided') 131 | 132 | return self 133 | -------------------------------------------------------------------------------- /filestack/mixins/common.py: -------------------------------------------------------------------------------- 1 | import filestack.models 2 | from filestack.utils import requests 3 | 4 | 5 | class CommonMixin: 6 | """ 7 | Contains all functions related to the manipulation of Filelink and Transformation objects 8 | """ 9 | 10 | @property 11 | def url(self): 12 | """ 13 | Returns object's URL 14 | 15 | >>> filelink.url 16 | 'https://cdn.filestackcontent.com/FILE_HANDLE' 17 | >>> transformation.url 18 | 'https://cdn.filestackcontent.com/resize=width:800/FILE_HANDLE' 19 | 20 | Returns: 21 | str: object's URL 22 | """ 23 | return self._build_url() 24 | 25 | def signed_url(self, security=None): 26 | """ 27 | Returns object's URL signed using security object 28 | 29 | >>> filelink.url 30 | 'https://cdn.filestackcontent.com/security=p:,s:/FILE_HANDLE' 31 | >>> transformation.url 32 | 'https://cdn.filestackcontent.com/resize=width:800/security=p:,s:/FILE_HANDLE' 33 | 34 | Args: 35 | security (:class:`filestack.Security`): Security object that will be used 36 | to sign url 37 | 38 | Returns: 39 | str: object's signed URL 40 | """ 41 | sec = security or self.security 42 | if sec is None: 43 | raise ValueError('Security is required to sign url') 44 | return self._build_url(security=sec) 45 | 46 | def store(self, filename=None, location=None, path=None, container=None, 47 | region=None, access=None, base64decode=None, workflows=None): 48 | """ 49 | Stores current object as a new :class:`filestack.Filelink`. 50 | 51 | Args: 52 | filename (str): name for the stored file 53 | location (str): your storage location, one of: :data:`"s3"` :data:`"azure"` 54 | :data:`"dropbox"` :data:`"rackspace"` :data:`"gcs"` 55 | container (str): the bucket or container (folder) in which to store the file 56 | (does not apply when storing to Dropbox) 57 | path (str): the path to store the file within the specified container 58 | region (str): your storage region (applies to S3 only) 59 | access (str): :data:`"public"` or :data:`"private"` (applies to S3 only) 60 | base64decode (bool): indicates if content should be decoded before it is stored 61 | workflows (list): IDs of `Filestack Workflows 62 | `_ that should be triggered after upload 63 | 64 | Returns: 65 | :class:`filestack.Filelink`: new Filelink object 66 | """ 67 | if path: 68 | path = '"{}"'.format(path) 69 | instance = self._add_transform_task('store', locals()) 70 | response = requests.post(instance.url) 71 | return filestack.models.Filelink(handle=response.json()['handle']) 72 | 73 | def download(self, path, security=None): 74 | """ 75 | Downloads a file to the given local path and returns the size of the downloaded file if successful 76 | """ 77 | sec = security or self.security 78 | total_bytes = 0 79 | 80 | with open(path, 'wb') as f: 81 | response = requests.get(self._build_url(security=sec), stream=True) 82 | for data_chunk in response.iter_content(5 * 1024 ** 2): 83 | f.write(data_chunk) 84 | total_bytes += len(data_chunk) 85 | 86 | return total_bytes 87 | 88 | def get_content(self, security=None): 89 | """ 90 | Returns the raw byte content of a given object 91 | 92 | Returns: 93 | `bytes`: file content 94 | """ 95 | sec = security or self.security 96 | response = requests.get(self._build_url(security=sec)) 97 | return response.content 98 | 99 | def tags(self, security=None): 100 | """ 101 | Performs image tagging operation on current object (image) 102 | 103 | Args: 104 | security (:class:`filestack.Security`): Security object that will be used 105 | to perform image tagging 106 | 107 | Returns: 108 | `dict`: dictionary containing image tags 109 | """ 110 | obj = self._add_transform_task('tags', params={'self': None}) 111 | response = requests.get(obj.signed_url(security=security)) 112 | return response.json() 113 | 114 | def sfw(self, security=None): 115 | """ 116 | Performs Safe for Work detection on current object (image). 117 | 118 | Args: 119 | security (:class:`filestack.Security`): Security object that will be used 120 | to perform image tagging 121 | 122 | Returns: 123 | `dict`: dictionary containing SFW result 124 | """ 125 | obj = self._add_transform_task('sfw', params={'self': None}) 126 | response = requests.get(obj.signed_url(security=security)) 127 | return response.json() 128 | 129 | def ocr(self, security=None): 130 | """ 131 | Performs OCR on current object (image) 132 | 133 | Args: 134 | security (:class:`filestack.Security`): Security object that will be used 135 | to run OCR 136 | 137 | Returns: 138 | `dict`: dictionary containing OCR data 139 | """ 140 | obj = self._add_transform_task('ocr', params={'self': None}) 141 | response = requests.get(obj.signed_url(security=security)) 142 | return response.json() 143 | -------------------------------------------------------------------------------- /filestack/uploads/intelligent_ingestion.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | import mimetypes 5 | import multiprocessing 6 | import hashlib 7 | import logging 8 | import functools 9 | import threading 10 | from urllib3.util.retry import Retry 11 | from concurrent.futures import ThreadPoolExecutor 12 | 13 | from base64 import b64encode 14 | 15 | from filestack.utils import requests 16 | 17 | from filestack import config 18 | 19 | log = logging.getLogger(__name__) 20 | log.setLevel(logging.INFO) 21 | 22 | handler = logging.StreamHandler(sys.stdout) 23 | handler.setFormatter(logging.Formatter("%(asctime)s - %(processName)s[%(process)d] - %(levelname)s - %(message)s")) 24 | log.addHandler(handler) 25 | 26 | MB = 1024 ** 2 27 | DEFAULT_PART_SIZE = 8 * MB 28 | CHUNK_SIZE = 8 * MB 29 | MIN_CHUNK_SIZE = 32 * 1024 30 | MAX_DELAY = 4 31 | NUM_THREADS = multiprocessing.cpu_count() 32 | 33 | lock = threading.Lock() 34 | 35 | 36 | def decrease_chunk_size(): 37 | global CHUNK_SIZE 38 | CHUNK_SIZE //= 2 39 | if CHUNK_SIZE < MIN_CHUNK_SIZE: 40 | raise Exception('Minimal chunk size failed') 41 | 42 | 43 | def upload_part(apikey, filename, filepath, filesize, storage, start_response, part): 44 | with open(filepath, 'rb') as f: 45 | f.seek(part['seek_point']) 46 | part_bytes = io.BytesIO(f.read(DEFAULT_PART_SIZE)) 47 | 48 | payload_base = { 49 | 'apikey': apikey, 50 | 'uri': start_response['uri'], 51 | 'region': start_response['region'], 52 | 'upload_id': start_response['upload_id'], 53 | 'store': {'location': storage}, 54 | 'part': part['num'] 55 | } 56 | 57 | global CHUNK_SIZE 58 | chunk_data = part_bytes.read(CHUNK_SIZE) 59 | offset = 0 60 | 61 | while chunk_data: 62 | payload = payload_base.copy() 63 | payload.update({ 64 | 'size': len(chunk_data), 65 | 'md5': b64encode(hashlib.md5(chunk_data).digest()).strip().decode('utf-8'), 66 | 'offset': offset, 67 | 'fii': True 68 | }) 69 | 70 | try: 71 | url = 'https://{}/multipart/upload'.format(start_response['location_url']) 72 | api_resp = requests.post(url, json=payload).json() 73 | s3_resp = requests.put(api_resp['url'], headers=api_resp['headers'], data=chunk_data) 74 | if not s3_resp.ok: 75 | raise Exception('Incorrect S3 response') 76 | offset += len(chunk_data) 77 | chunk_data = part_bytes.read(CHUNK_SIZE) 78 | except Exception as e: 79 | log.error('Upload failed: %s', str(e)) 80 | with lock: 81 | if CHUNK_SIZE >= len(chunk_data): 82 | decrease_chunk_size() 83 | 84 | part_bytes.seek(offset) 85 | chunk_data = part_bytes.read(CHUNK_SIZE) 86 | 87 | payload = payload_base.copy() 88 | payload.update({'size': filesize}) 89 | 90 | url = 'https://{}/multipart/commit'.format(start_response['location_url']) 91 | requests.post(url, json=payload) 92 | 93 | 94 | def upload(apikey, filepath, file_obj, storage, params=None, security=None): 95 | params = params or {} 96 | 97 | filename = params.get('filename') or os.path.split(filepath)[1] 98 | mimetype = params.get('mimetype') or mimetypes.guess_type(filepath)[0] or config.DEFAULT_UPLOAD_MIMETYPE 99 | filesize = os.path.getsize(filepath) 100 | 101 | payload = { 102 | 'apikey': apikey, 103 | 'filename': filename, 104 | 'mimetype': mimetype, 105 | 'size': filesize, 106 | 'fii': True, 107 | 'store': { 108 | 'location': storage 109 | } 110 | } 111 | 112 | for key in ('path', 'location', 'region', 'container', 'access'): 113 | if key in params: 114 | payload['store'][key] = params[key] 115 | 116 | if security: 117 | payload.update({ 118 | 'policy': security.policy_b64, 119 | 'signature': security.signature 120 | }) 121 | 122 | start_response = requests.post(config.MULTIPART_START_URL, json=payload).json() 123 | parts = [ 124 | { 125 | 'seek_point': seek_point, 126 | 'num': num + 1 127 | } for num, seek_point in enumerate(range(0, filesize, DEFAULT_PART_SIZE)) 128 | ] 129 | 130 | fii_upload = functools.partial( 131 | upload_part, apikey, filename, filepath, filesize, storage, start_response 132 | ) 133 | 134 | with ThreadPoolExecutor(max_workers=NUM_THREADS) as executor: 135 | list(executor.map(fii_upload, parts)) 136 | 137 | payload.update({ 138 | 'uri': start_response['uri'], 139 | 'region': start_response['region'], 140 | 'upload_id': start_response['upload_id'], 141 | }) 142 | 143 | if 'workflows' in params: 144 | payload['store']['workflows'] = params.pop('workflows') 145 | 146 | if 'upload_tags' in params: 147 | payload['upload_tags'] = params.pop('upload_tags') 148 | 149 | complete_url = 'https://{}/multipart/complete'.format(start_response['location_url']) 150 | session = requests.Session() 151 | retries = Retry(total=7, backoff_factor=0.2, status_forcelist=[202], allowed_methods=frozenset(['POST'])) 152 | session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries)) 153 | response = session.post(complete_url, json=payload, headers=config.HEADERS) 154 | if response.status_code != 200: 155 | log.error('Did not receive a correct complete response: %s. Content %s', response, response.content) 156 | raise Exception('Invalid complete response: {}'.format(response.content)) 157 | 158 | return response.json() 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

Filestack Python

3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | This is the official Python SDK for Filestack - API and content management system that makes it easy to add powerful file uploading and transformation capabilities to any web or mobile application. 13 | 14 | ## Resources 15 | 16 | To learn more about this SDK, please visit our API Reference 17 | 18 | * [API Reference](https://filestack-python.readthedocs.io) 19 | 20 | ## Installing 21 | 22 | Install ``filestack`` with pip 23 | 24 | ```shell 25 | pip install filestack-python 26 | ``` 27 | 28 | or directly from GitHub 29 | 30 | ```shell 31 | pip install git+https://github.com/filestack/filestack-python.git 32 | ``` 33 | 34 | ## Quickstart 35 | 36 | The Filestack SDK allows you to upload and handle filelinks using two main classes: Client and Filelink. 37 | 38 | ### Uploading files with `filestack.Client` 39 | ``` python 40 | from filestack import Client 41 | client = Client('') 42 | 43 | new_filelink = client.upload(filepath='path/to/file') 44 | print(new_filelink.url) 45 | ``` 46 | 47 | #### Uploading files using Filestack Intelligent Ingestion 48 | To upload files using Filestack Intelligent Ingestion, simply add `intelligent=True` argument 49 | ```python 50 | new_filelink = client.upload(filepath='path/to/file', intelligent=True) 51 | ``` 52 | FII always uses multipart uploads. In case of network issues, it will dynamically split file parts into smaller chunks (sacrificing upload speed in favour of upload reliability). 53 | 54 | ### Working with Filelinks 55 | Filelink objects can by created by uploading new files, or by initializing `filestack.Filelink` with already existing file handle 56 | ```python 57 | from filestack import Filelink, Client 58 | 59 | client = Client('') 60 | filelink = client.upload(filepath='path/to/file') 61 | filelink.url # 'https://cdn.filestackcontent.com/FILE_HANDLE 62 | 63 | # work with previously uploaded file 64 | filelink = Filelink('FILE_HANDLE') 65 | ``` 66 | 67 | ### Basic Filelink Functions 68 | 69 | With a Filelink, you can download to a local path or get the content of a file. You can also perform various transformations. 70 | 71 | ```python 72 | file_content = new_filelink.get_content() 73 | 74 | size_in_bytes = new_filelink.download('/path/to/file') 75 | 76 | filelink.overwrite(filepath='path/to/new/file') 77 | 78 | filelink.resize(width=400).flip() 79 | 80 | filelink.delete() 81 | ``` 82 | 83 | ### Transformations 84 | 85 | You can chain transformations on both Filelinks and external URLs. Storing transformations will return a new Filelink object. 86 | 87 | ```python 88 | transform = client.transform_external('http://') 89 | new_filelink = transform.resize(width=500, height=500).flip().enhance().store() 90 | 91 | filelink = Filelink(') 92 | new_filelink = filelink.resize(width=500, height=500).flip().enhance().store() 93 | ``` 94 | 95 | You can also retrieve the transformation url at any point. 96 | 97 | ```python 98 | transform_candidate = client.transform_external('http://') 99 | transform = transform_candidate.resize(width=500, height=500).flip().enhance() 100 | print(transform.url) 101 | ``` 102 | 103 | ### Audio/Video Convert 104 | 105 | Audio and video conversion works just like any transformation, except it returns an instance of class AudioVisual, which allows you to check the status of your video conversion, as well as get its UUID and timestamp. 106 | 107 | ```python 108 | av_object = filelink.av_convert(width=100, height=100) 109 | while (av_object.status != 'completed'): 110 | print(av_object.status) 111 | print(av_object.uuid) 112 | print(av_object.timestamp) 113 | ``` 114 | 115 | The status property makes a call to the API to check its current status, and you can call to_filelink() once video is complete (this function checks its status first and will fail if not completed yet). 116 | 117 | ```python 118 | filelink = av_object.to_filelink() 119 | ``` 120 | 121 | ### Security Objects 122 | 123 | Security is set on Client or Filelink classes upon instantiation and is used to sign all API calls. 124 | 125 | ```python 126 | from filestack import Security 127 | 128 | policy = {'expiry': 253381964415} # 'expiry' is the only required key 129 | security = Security(policy, '') 130 | client = Client('>> policy = {'expiry': 253381964415, 'call': ['read']} 142 | >>> security = Security(policy, 'SECURITY-SECRET') 143 | >>> security.policy_b64 144 | 'eyJjYWxsIjogWyJyZWFkIl0sICJleHBpcnkiOiAyNTMzODE5NjQ0MTV9' 145 | >>> security.signature 146 | 'f61fa1effb0638ab5b6e208d5d2fd9343f8557d8a0bf529c6d8542935f77bb3c' 147 | ``` 148 | 149 | ### Webhook verification 150 | 151 | You can use `filestack.helpers.verify_webhook_signature` method to make sure that the webhooks you receive are sent by Filestack. 152 | 153 | ```python 154 | from filestack.helpers import verify_webhook_signature 155 | 156 | # webhook_data is raw content you receive 157 | webhook_data = b'{"action": "fp.upload", "text": {"container": "some-bucket", "url": "https://cdn.filestackcontent.com/Handle", "filename": "filename.png", "client": "Computer", "key": "key_filename.png", "type": "image/png", "size": 1000000}, "id": 50006}' 158 | 159 | result, details = verify_webhook_signature( 160 | '', 161 | webhook_data, 162 | { 163 | 'FS-Signature': '', 164 | 'FS-Timestamp': '' 165 | } 166 | ) 167 | 168 | if result is True: 169 | print('Webhook is valid and was generated by Filestack') 170 | else: 171 | raise Exception(details['error']) 172 | ``` 173 | 174 | ## Versioning 175 | 176 | Filestack Python SDK follows the [Semantic Versioning](http://semver.org/). 177 | -------------------------------------------------------------------------------- /filestack/models/client.py: -------------------------------------------------------------------------------- 1 | import filestack.models 2 | from filestack import config 3 | from filestack.uploads.external_url import upload_external_url 4 | from filestack.trafarets import STORE_LOCATION_SCHEMA, STORE_SCHEMA 5 | from filestack import utils 6 | from filestack.utils import requests 7 | from filestack.uploads import intelligent_ingestion 8 | from filestack.uploads.multipart import multipart_upload 9 | 10 | 11 | class Client: 12 | """ 13 | This class is responsible for uploading files (creating Filelinks), 14 | converting external urls to Transformation objects, taking url screenshots 15 | and returning zipped files (multiple Filelinks). 16 | 17 | In order to create a client instance, pass in your Filestack API key. 18 | You can also specify which storage should be used for your uploads 19 | and provide a Security object to sign all your API calls. 20 | 21 | >>> from filestack import Client, Security 22 | >>> security = Security(policy={'expiry': 1594200833}, secret='YOUR APP SECRET') 23 | >>> cli = Client('', storage='gcs', security=security) 24 | """ 25 | def __init__(self, apikey, storage='S3', security=None): 26 | """ 27 | Args: 28 | apikey (str): your Filestack API key 29 | storage (str): default storage to be used for uploads (one of S3, `gcs`, dropbox, azure) 30 | security (:class:`filestack.Security`): Security object that will be used by default 31 | for all API calls 32 | """ 33 | self.apikey = apikey 34 | self.security = security 35 | STORE_LOCATION_SCHEMA.check(storage) 36 | self.storage = storage 37 | 38 | def transform_external(self, external_url): 39 | """ 40 | Turns an external URL into a Filestack Transformation object 41 | 42 | >>> t_obj = client.transform_external('https://image.url') 43 | >>> t_obj.resize(width=800) # now you can do this 44 | 45 | Args: 46 | external_url (str): file URL 47 | 48 | Returns: 49 | :class:`filestack.Transformation` 50 | """ 51 | return filestack.models.Transformation(apikey=self.apikey, security=self.security, external_url=external_url) 52 | 53 | def urlscreenshot(self, url, agent=None, mode=None, width=None, height=None, delay=None): 54 | """ 55 | Takes a 'screenshot' of the given URL 56 | 57 | Args: 58 | url (str): website URL 59 | agent (str): one of: :data:`"desktop"` :data:`"mobile"` 60 | mode (str): one of: :data:`"all"` :data:`"window"` 61 | width (int): screen width 62 | height (int): screen height 63 | 64 | Returns: 65 | :class:`filestack.Transformation` 66 | """ 67 | params = locals() 68 | params.pop('self') 69 | params.pop('url') 70 | 71 | params = {k: v for k, v in params.items() if v is not None} 72 | 73 | url_task = utils.return_transform_task('urlscreenshot', params) 74 | 75 | new_transform = filestack.models.Transformation( 76 | self.apikey, security=self.security, external_url=url 77 | ) 78 | new_transform._transformation_tasks.append(url_task) 79 | 80 | return new_transform 81 | 82 | def zip(self, destination_path, files, security=None): 83 | """ 84 | Takes array of handles and downloads a compressed ZIP archive 85 | to provided path 86 | 87 | Args: 88 | destination_path (str): path where the ZIP file should be stored 89 | file (list): list of filelink handles and/or URLs 90 | security (:class:`filestack.Security`): Security object that will be used 91 | for this API call 92 | 93 | Returns: 94 | int: ZIP archive size in bytes 95 | """ 96 | url_parts = [config.CDN_URL, self.apikey, 'zip', '[{}]'.format(','.join(files))] 97 | sec = security or self.security 98 | if sec is not None: 99 | url_parts.insert(3, sec.as_url_string()) 100 | zip_url = '/'.join(url_parts) 101 | total_bytes = 0 102 | with open(destination_path, 'wb') as f: 103 | response = requests.get(zip_url, stream=True) 104 | for chunk in response.iter_content(5 * 1024 ** 2): 105 | f.write(chunk) 106 | total_bytes += len(chunk) 107 | 108 | return total_bytes 109 | 110 | def upload_url(self, url, store_params=None, security=None): 111 | """ 112 | Uploads file from external url 113 | 114 | Args: 115 | url (str): file URL 116 | store_params (dict): store parameters to be used during upload 117 | security (:class:`filestack.Security`): Security object that will be used for this API call 118 | 119 | Returns: 120 | :class:`filestack.Filelink`: new Filelink object 121 | """ 122 | sec = security or self.security 123 | upload_response = upload_external_url(url, self.apikey, self.storage, store_params, security=sec) 124 | return filestack.models.Filelink( 125 | handle=upload_response['handle'], 126 | apikey=self.apikey, 127 | security=sec, 128 | upload_response=upload_response 129 | ) 130 | 131 | def upload(self, *, filepath=None, file_obj=None, store_params=None, intelligent=False, security=None): 132 | """ 133 | Uploads local file or file-like object. 134 | 135 | Args: 136 | filepath (str): path to file 137 | file_obj (io.BytesIO or similar): file-like object 138 | store_params (dict): store parameters to be used during upload 139 | intelligent (bool): upload file using `Filestack Intelligent Ingestion 140 | `_. 141 | security (:class:`filestack.Security`): Security object that will be used for this API call 142 | 143 | Returns: 144 | :class:`filestack.Filelink`: new Filelink object 145 | 146 | Note: 147 | This method accepts keyword arguments only. 148 | Out of filepath and file_obj only one should be provided. 149 | """ 150 | if store_params: # Check the structure of parameters 151 | STORE_SCHEMA.check(store_params) 152 | 153 | upload_method = multipart_upload 154 | if intelligent: 155 | upload_method = intelligent_ingestion.upload 156 | 157 | response_json = upload_method( 158 | self.apikey, filepath, file_obj, self.storage, params=store_params, security=security or self.security 159 | ) 160 | 161 | handle = response_json['handle'] 162 | return filestack.models.Filelink( 163 | handle, 164 | apikey=self.apikey, 165 | security=self.security, 166 | upload_response=response_json 167 | ) 168 | -------------------------------------------------------------------------------- /filestack/mixins/imagetransformation.py: -------------------------------------------------------------------------------- 1 | import filestack.models 2 | from filestack import utils 3 | 4 | 5 | class ImageTransformationMixin: 6 | """ 7 | All transformations and related/dependent tasks live here. They can 8 | be directly called by Transformation or Filelink objects. 9 | """ 10 | def resize(self, width=None, height=None, fit=None, align=None): 11 | return self._add_transform_task('resize', locals()) 12 | 13 | def crop(self, dim=None): 14 | return self._add_transform_task('crop', locals()) 15 | 16 | def rotate(self, deg=None, exif=None, background=None): 17 | return self._add_transform_task('rotate', locals()) 18 | 19 | def flip(self): 20 | return self._add_transform_task('flip', locals()) 21 | 22 | def flop(self): 23 | return self._add_transform_task('flop', locals()) 24 | 25 | def watermark(self, file=None, size=None, position=None): 26 | return self._add_transform_task('watermark', locals()) 27 | 28 | def detect_faces(self, minsize=None, maxsize=None, color=None, export=None): 29 | return self._add_transform_task('detect_faces', locals()) 30 | 31 | def crop_faces(self, mode=None, width=None, height=None, faces=None, buffer=None): 32 | return self._add_transform_task('crop_faces', locals()) 33 | 34 | def pixelate_faces(self, faces=None, minsize=None, maxsize=None, buffer=None, amount=None, blur=None, type=None): 35 | return self._add_transform_task('pixelate_faces', locals()) 36 | 37 | def round_corners(self, radius=None, blur=None, background=None): 38 | return self._add_transform_task('round_corners', locals()) 39 | 40 | def vignette(self, amount=None, blurmode=None, background=None): 41 | return self._add_transform_task('vignette', locals()) 42 | 43 | def polaroid(self, color=None, rotate=None, background=None): 44 | return self._add_transform_task('polaroid', locals()) 45 | 46 | def torn_edges(self, spread=None, background=None): 47 | return self._add_transform_task('torn_edges', locals()) 48 | 49 | def shadow(self, blur=None, opacity=None, vector=None, color=None, background=None): 50 | return self._add_transform_task('shadow', locals()) 51 | 52 | def circle(self, background=None): 53 | return self._add_transform_task('circle', locals()) 54 | 55 | def border(self, width=None, color=None, background=None): 56 | return self._add_transform_task('border', locals()) 57 | 58 | def sharpen(self, amount=None): 59 | return self._add_transform_task('sharpen', locals()) 60 | 61 | def blur(self, amount=None): 62 | return self._add_transform_task('blur', locals()) 63 | 64 | def monochrome(self): 65 | return self._add_transform_task('monochrome', locals()) 66 | 67 | def blackwhite(self, threshold=None): 68 | return self._add_transform_task('blackwhite', locals()) 69 | 70 | def sepia(self, tone=None): 71 | return self._add_transform_task('sepia', locals()) 72 | 73 | def pixelate(self, amount=None): 74 | return self._add_transform_task('pixelate', locals()) 75 | 76 | def oil_paint(self, amount=None): 77 | return self._add_transform_task('oil_paint', locals()) 78 | 79 | def negative(self): 80 | return self._add_transform_task('negative', locals()) 81 | 82 | def modulate(self, brightness=None, hue=None, saturation=None): 83 | return self._add_transform_task('modulate', locals()) 84 | 85 | def partial_pixelate(self, amount=None, blur=None, type=None, objects=None): 86 | return self._add_transform_task('partial_pixelate', locals()) 87 | 88 | def partial_blur(self, amount=None, blur=None, type=None, objects=None): 89 | return self._add_transform_task('partial_blur', locals()) 90 | 91 | def collage(self, files=None, margin=None, width=None, height=None, color=None, fit=None, autorotate=None): 92 | return self._add_transform_task('collage', locals()) 93 | 94 | def upscale(self, upscale=None, noise=None, style=None): 95 | return self._add_transform_task('upscale', locals()) 96 | 97 | def enhance(self, preset=None): 98 | return self._add_transform_task('enhance', locals()) 99 | 100 | def redeye(self): 101 | return self._add_transform_task('redeye', locals()) 102 | 103 | def ascii(self, background=None, foreground=None, colored=None, size=None, reverse=None): 104 | return self._add_transform_task('ascii', locals()) 105 | 106 | def filetype_conversion(self, format=None, background=None, page=None, density=None, compress=None, 107 | quality=None, strip=None, colorspace=None, secure=None, 108 | docinfo=None, pageformat=None, pageorientation=None): 109 | return self._add_transform_task('output', locals()) 110 | 111 | def no_metadata(self): 112 | return self._add_transform_task('no_metadata', locals()) 113 | 114 | def quality(self, value=None): 115 | return self._add_transform_task('quality', locals()) 116 | 117 | def zip(self): 118 | return self._add_transform_task('zip', locals()) 119 | 120 | def fallback(self, file=None, cache=None): 121 | return self._add_transform_task('fallback', locals()) 122 | 123 | def pdf_info(self, colorinfo=None): 124 | return self._add_transform_task('pdfinfo', locals()) 125 | 126 | def pdf_convert(self, pageorientation=None, pageformat=None, pages=None, metadata=None): 127 | return self._add_transform_task('pdfconvert', locals()) 128 | 129 | def minify_js(self, gzip=None, use_babel_polyfill=None, keep_fn_name=None, keep_class_name=None, 130 | mangle=None, merge_vars=None, remove_console=None, remove_undefined=None, targets=None): 131 | return self._add_transform_task('minify_js', locals()) 132 | 133 | def minify_css(self, level=None, gzip=None): 134 | return self._add_transform_task('minify_css', locals()) 135 | 136 | def av_convert(self, *, preset=None, force=None, title=None, extname=None, filename=None, 137 | width=None, height=None, upscale=None, aspect_mode=None, two_pass=None, 138 | video_bitrate=None, fps=None, keyframe_interval=None, location=None, 139 | watermark_url=None, watermark_top=None, watermark_bottom=None, 140 | watermark_right=None, watermark_left=None, watermark_width=None, watermark_height=None, 141 | path=None, access=None, container=None, audio_bitrate=None, audio_sample_rate=None, 142 | audio_channels=None, clip_length=None, clip_offset=None): 143 | 144 | new_transform = self._add_transform_task('video_convert', locals()) 145 | response = utils.requests.get(new_transform.url).json() 146 | uuid = response['uuid'] 147 | timestamp = response['timestamp'] 148 | 149 | return filestack.models.AudioVisual( 150 | new_transform.url, uuid, timestamp, apikey=new_transform.apikey, security=new_transform.security 151 | ) 152 | 153 | def auto_image(self): 154 | return self._add_transform_task('auto_image', locals()) 155 | 156 | def doc_to_images(self, pages=None, engine=None, format=None, quality=None, density=None, hidden_slides=None): 157 | return self._add_transform_task('doc_to_images', locals()) 158 | 159 | def smart_crop(self, mode=None, width=None, height=None, fill_color=None, coords=None): 160 | return self._add_transform_task('smart_crop', locals()) 161 | 162 | def pdfcreate(self, engine=None): 163 | return self._add_transform_task('pdfcreate', locals()) 164 | 165 | def animate(self, delay=None, loop=None, width=None, height=None, fit=None, align=None, background=None): 166 | return self._add_transform_task('animate', locals()) 167 | 168 | def _add_transform_task(self, transformation, params): 169 | if isinstance(self, filestack.models.Transformation): 170 | instance = self 171 | else: 172 | instance = filestack.models.Transformation(apikey=None, security=self.security, handle=self.handle) 173 | 174 | params.pop('self') 175 | params = {k: v for k, v in params.items() if v is not None} 176 | 177 | transformation_url = utils.return_transform_task(transformation, params) 178 | instance._transformation_tasks.append(transformation_url) 179 | 180 | return instance 181 | -------------------------------------------------------------------------------- /tests/filelink_test.py: -------------------------------------------------------------------------------- 1 | import io 2 | import re 3 | from unittest.mock import mock_open, patch, ANY 4 | 5 | import pytest 6 | import responses 7 | 8 | from filestack import exceptions 9 | from filestack import Filelink, Security 10 | from filestack import config 11 | 12 | APIKEY = 'APIKEY' 13 | HANDLE = 'SOMEHANDLE' 14 | SECURITY = Security({'call': ['read'], 'expiry': 10238239}, 'APPSECRET') 15 | 16 | 17 | @pytest.fixture 18 | def filelink(): 19 | yield Filelink(HANDLE, apikey=APIKEY) 20 | 21 | 22 | @pytest.fixture 23 | def secure_filelink(): 24 | yield Filelink(HANDLE, apikey=APIKEY, security=SECURITY) 25 | 26 | 27 | def test_handle(filelink): 28 | assert filelink.handle == HANDLE 29 | 30 | 31 | def test_url(filelink): 32 | url = config.CDN_URL + '/' + HANDLE 33 | assert url == filelink.url 34 | 35 | 36 | @pytest.mark.parametrize('security_obj, expected_security_part', [ 37 | [ 38 | None, 39 | ( 40 | 'security=p:eyJjYWxsIjogWyJyZWFkIl0sICJleHBpcnkiOiAxMDIzODIzOX0=,' 41 | 's:858d1ee9c0a1f06283e495f78dc7950ff6e64136ce960465f35539791fcd486b' 42 | ) 43 | ], 44 | [ 45 | Security({'call': ['write'], 'expiry': 1655992432}, 'another-secret'), 46 | ( 47 | 'security=p:eyJjYWxsIjogWyJ3cml0ZSJdLCAiZXhwaXJ5IjogMTY1NTk5MjQzMn0=,' 48 | 's:625cc5b9beab3e939fb53935f7795919c9f57f628d43adfc14566d2ad9a4ad47' 49 | ) 50 | ], 51 | ]) 52 | def test_signed_url(security_obj, expected_security_part, secure_filelink): 53 | assert expected_security_part in secure_filelink.signed_url(security=security_obj) 54 | 55 | 56 | @responses.activate 57 | def test_get_content(filelink): 58 | responses.add( 59 | responses.GET, 'https://cdn.filestackcontent.com/{}'.format(HANDLE), body=b'file-content' 60 | ) 61 | content = filelink.get_content() 62 | assert content == b'file-content' 63 | 64 | 65 | @responses.activate 66 | def test_bad_call(filelink): 67 | responses.add( 68 | responses.GET, 'https://cdn.filestackcontent.com/{}'.format(HANDLE), status=400 69 | ) 70 | with pytest.raises(exceptions.FilestackHTTPError): 71 | filelink.get_content() 72 | 73 | 74 | @pytest.mark.parametrize('attributes, security, expected_params', [ 75 | (None, None, {}), 76 | ( 77 | None, 78 | Security({'expiry': 123}, 'secret'), 79 | { 80 | 'policy': 'eyJleHBpcnkiOiAxMjN9', 81 | 'signature': '4de8b7441b3daf0d68b4f8ebcf7e015d07aef43a1295476a1dde1aed327abc01' 82 | } 83 | ), 84 | ( 85 | ['size', 'filename'], 86 | Security({'expiry': 123}, 'secret'), 87 | { 88 | 'size': 'true', 89 | 'filename': 'true', 90 | 'policy': 'eyJleHBpcnkiOiAxMjN9', 91 | 'signature': '4de8b7441b3daf0d68b4f8ebcf7e015d07aef43a1295476a1dde1aed327abc01' 92 | } 93 | ), 94 | ]) 95 | @responses.activate 96 | def test_metadata(attributes, security, expected_params, filelink): 97 | responses.add( 98 | responses.GET, 99 | re.compile('https://cdn.filestackcontent.com/SOMEHANDLE/metadata.*'), 100 | json={'metadata': 'content'} 101 | ) 102 | metadata_response = filelink.metadata(attributes_list=attributes, security=security) 103 | assert metadata_response == {'metadata': 'content'} 104 | assert responses.calls[0].request.params == expected_params 105 | 106 | 107 | @responses.activate 108 | def test_download(filelink): 109 | responses.add( 110 | responses.GET, 'https://cdn.filestackcontent.com/{}'.format(HANDLE), body=b'file-content' 111 | ) 112 | m = mock_open() 113 | with patch('filestack.mixins.common.open', m): 114 | file_size = filelink.download('tests/data/test_download.jpg') 115 | assert file_size == 12 116 | m().write.assert_called_once_with(b'file-content') 117 | 118 | 119 | def test_tags_without_security(filelink): 120 | with pytest.raises(Exception, match=r'Security is required'): 121 | filelink.tags() 122 | 123 | 124 | @responses.activate 125 | def test_tags(secure_filelink): 126 | responses.add( 127 | responses.GET, re.compile('https://cdn.filestackcontent.com/tags/.*/{}$'.format(HANDLE)), 128 | json={'tags': {'cat': 98}} 129 | ) 130 | assert secure_filelink.tags() == {'tags': {'cat': 98}} 131 | assert responses.calls[0].request.url == '{}/tags/{}/{}'.format(config.CDN_URL, SECURITY.as_url_string(), HANDLE) 132 | 133 | 134 | @responses.activate 135 | def test_tags_on_transformation(secure_filelink): 136 | transformation = secure_filelink.resize(width=100) 137 | image_tags = {'tags': {'cat': 99}} 138 | responses.add( 139 | responses.GET, re.compile('{}/resize.*/{}$'.format(config.CDN_URL, HANDLE)), 140 | json=image_tags 141 | ) 142 | assert transformation.tags() == image_tags 143 | assert responses.calls[0].request.url == '{}/resize=width:100/tags/{}/{}'.format( 144 | config.CDN_URL, SECURITY.as_url_string(), HANDLE 145 | ) 146 | 147 | 148 | def test_sfw_without_security(filelink): 149 | with pytest.raises(Exception, match=r'Security is required'): 150 | filelink.sfw() 151 | 152 | 153 | @responses.activate 154 | def test_sfw(secure_filelink): 155 | sfw_response = {'sfw': False} 156 | responses.add(responses.GET, re.compile('{}.*'.format(config.CDN_URL)), json=sfw_response) 157 | assert secure_filelink.sfw() == sfw_response 158 | assert responses.calls[0].request.url == '{}/sfw/{}/{}'.format(config.CDN_URL, SECURITY.as_url_string(), HANDLE) 159 | 160 | 161 | @responses.activate 162 | def test_sfw_on_transformation(secure_filelink): 163 | transformation = secure_filelink.resize(width=100) 164 | sfw_response = {'sfw': True} 165 | responses.add(responses.GET, re.compile('{}/resize.*'.format(config.CDN_URL)), json=sfw_response) 166 | assert transformation.sfw() == sfw_response 167 | assert responses.calls[0].request.url == '{}/resize=width:100/sfw/{}/{}'.format( 168 | config.CDN_URL, SECURITY.as_url_string(), HANDLE 169 | ) 170 | 171 | 172 | def test_overwrite_without_security(filelink): 173 | with pytest.raises(Exception, match='Security is required'): 174 | filelink.overwrite(url='https://image.url') 175 | 176 | 177 | def test_invalid_overwrite_call(secure_filelink): 178 | with pytest.raises(Exception, match='filepath, file_obj or url argument must be provided'): 179 | secure_filelink.overwrite(base64decode=True) 180 | 181 | 182 | @pytest.mark.parametrize('decode_base64', [True, False]) 183 | @patch('filestack.models.filelink.requests.post') 184 | def test_overwrite_with_url(post_mock, decode_base64, secure_filelink): 185 | secure_filelink.overwrite(url='http://image.url', base64decode=decode_base64) 186 | post_mock.assert_called_once_with( 187 | 'https://www.filestackapi.com/api/file/{}'.format(HANDLE), 188 | data={'url': 'http://image.url'}, 189 | params={ 190 | 'policy': SECURITY.policy_b64, 191 | 'signature': SECURITY.signature, 192 | 'base64decode': 'true' if decode_base64 else 'false' 193 | } 194 | ) 195 | 196 | 197 | @patch('filestack.models.filelink.requests.post') 198 | def test_overwrite_with_filepath(post_mock, secure_filelink): 199 | with patch('filestack.models.filelink.open', mock_open(read_data='content')) as m: 200 | secure_filelink.overwrite(filepath='path/to/file') 201 | post_mock.assert_called_once_with( 202 | 'https://www.filestackapi.com/api/file/{}'.format(HANDLE), 203 | files={'fileUpload': ('filename', ANY, 'application/octet-stream')}, 204 | params={ 205 | 'policy': SECURITY.policy_b64, 206 | 'signature': SECURITY.signature, 207 | 'base64decode': 'false' 208 | } 209 | ) 210 | m.assert_called_once_with('path/to/file', 'rb') 211 | 212 | 213 | @patch('filestack.models.filelink.requests.post') 214 | def test_overwrite_with_file_obj(post_mock, secure_filelink): 215 | fobj = io.BytesIO(b'file-content') 216 | secure_filelink.overwrite(file_obj=fobj) 217 | post_mock.assert_called_once_with( 218 | 'https://www.filestackapi.com/api/file/{}'.format(HANDLE), 219 | files={'fileUpload': ('filename', fobj, 'application/octet-stream')}, 220 | params={ 221 | 'policy': SECURITY.policy_b64, 222 | 'signature': SECURITY.signature, 223 | 'base64decode': 'false' 224 | } 225 | ) 226 | 227 | 228 | @pytest.mark.parametrize('flink, exc_message', [ 229 | (Filelink('handle', apikey=APIKEY), 'Security is required'), 230 | (Filelink('handle', security=SECURITY), 'Apikey is required') 231 | ]) 232 | def test_delete_without_apikey_or_security(flink, exc_message): 233 | with pytest.raises(Exception, match=exc_message): 234 | flink.delete() 235 | 236 | 237 | @pytest.mark.parametrize('flink, security_arg', [ 238 | (Filelink(HANDLE, apikey=APIKEY), SECURITY), 239 | (Filelink(HANDLE, apikey=APIKEY, security=SECURITY), None) 240 | ]) 241 | @patch('filestack.models.filelink.requests.delete') 242 | def test_successful_delete(delete_mock, flink, security_arg): 243 | flink.delete(security=security_arg) 244 | delete_mock.assert_called_once_with( 245 | '{}/file/{}'.format(config.API_URL, HANDLE), 246 | params={ 247 | 'key': APIKEY, 248 | 'policy': SECURITY.policy_b64, 249 | 'signature': SECURITY.signature 250 | } 251 | ) 252 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/transformation_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import responses 5 | 6 | from filestack import Transformation, AudioVisual 7 | from filestack import config 8 | 9 | APIKEY = 'SOMEAPIKEY' 10 | HANDLE = 'SOMEHANDLE' 11 | EXTERNAL_URL = 'SOMEEXTERNALURL' 12 | 13 | 14 | @pytest.fixture 15 | def transform(): 16 | return Transformation(apikey=APIKEY, external_url=EXTERNAL_URL) 17 | 18 | 19 | def test_sanity(transform): 20 | assert transform.apikey == APIKEY 21 | assert transform.external_url == EXTERNAL_URL 22 | 23 | 24 | def test_resize(transform): 25 | target_url = '{}/{}/resize=height:500,width:500/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 26 | resize = transform.resize(width=500, height=500) 27 | assert resize.url == target_url 28 | 29 | 30 | def test_crop(transform): 31 | target_url = '{}/{}/crop=dim:500/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 32 | crop = transform.crop(dim=500) 33 | assert crop.url == target_url 34 | 35 | 36 | def test_rotate(transform): 37 | target_url = '{}/{}/rotate=deg:90/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 38 | rotate = transform.rotate(deg=90) 39 | assert rotate.url == target_url 40 | 41 | 42 | def test_flip(transform): 43 | target_url = '{}/{}/flip/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 44 | flip = transform.flip() 45 | assert flip.url == target_url 46 | 47 | 48 | def test_flop(transform): 49 | target_url = '{}/{}/flop/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 50 | flop = transform.flop() 51 | assert flop.url == target_url 52 | 53 | 54 | def test_watermark(transform): 55 | target_url = '{}/{}/watermark=file:somefile.jpg/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 56 | watermark = transform.watermark(file="somefile.jpg") 57 | assert watermark.url == target_url 58 | 59 | 60 | def test_detect_faces(transform): 61 | target_url = '{}/{}/detect_faces=minsize:100/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 62 | detect_faces = transform.detect_faces(minsize=100) 63 | assert detect_faces.url == target_url 64 | 65 | 66 | def test_crop_faces(transform): 67 | 68 | target_url = '{}/{}/crop_faces=width:100/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 69 | crop_faces = transform.crop_faces(width=100) 70 | assert crop_faces.url == target_url 71 | 72 | 73 | def test_pixelate_faces(transform): 74 | target_url = '{}/{}/pixelate_faces=minsize:100/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 75 | pixelate_faces = transform.pixelate_faces(minsize=100) 76 | assert pixelate_faces.url == target_url 77 | 78 | 79 | def test_round_corners(transform): 80 | target_url = '{}/{}/round_corners=radius:100/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 81 | round_corners = transform.round_corners(radius=100) 82 | assert round_corners.url == target_url 83 | 84 | 85 | def test_vignette(transform): 86 | target_url = '{}/{}/vignette=amount:50/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 87 | vignette = transform.vignette(amount=50) 88 | assert vignette.url == target_url 89 | 90 | 91 | def test_polaroid(transform): 92 | target_url = '{}/{}/polaroid=color:blue/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 93 | polaroid = transform.polaroid(color='blue') 94 | assert polaroid.url == target_url 95 | 96 | 97 | def test_torn_edges(transform): 98 | target_url = '{}/{}/torn_edges/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 99 | torn_edges = transform.torn_edges() 100 | assert torn_edges.url == target_url 101 | 102 | 103 | def test_shadow(transform): 104 | target_url = '{}/{}/shadow=blur:true/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 105 | shadow = transform.shadow(blur=True) 106 | assert shadow.url == target_url 107 | 108 | 109 | def test_circle(transform): 110 | target_url = '{}/{}/circle=background:true/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 111 | circle = transform.circle(background=True) 112 | assert circle.url == target_url 113 | 114 | 115 | def test_border(transform): 116 | target_url = '{}/{}/border=width:500/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 117 | border = transform.border(width=500) 118 | assert border.url == target_url 119 | 120 | 121 | def test_sharpen(transform): 122 | target_url = '{}/{}/sharpen=amount:50/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 123 | sharpen = transform.sharpen(amount=50) 124 | assert sharpen.url == target_url 125 | 126 | 127 | def test_blur(transform): 128 | target_url = '{}/{}/blur=amount:10/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 129 | blur = transform.blur(amount=10) 130 | assert blur.url == target_url 131 | 132 | 133 | def test_monochrome(transform): 134 | target_url = '{}/{}/monochrome/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 135 | monochrome = transform.monochrome() 136 | assert monochrome.url == target_url 137 | 138 | 139 | def test_blackwhite(transform): 140 | target_url = '{}/{}/blackwhite=threshold:50/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 141 | blackwhite = transform.blackwhite(threshold=50) 142 | assert blackwhite.url == target_url 143 | 144 | 145 | def test_sepia(transform): 146 | target_url = '{}/{}/sepia=tone:80/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 147 | sepia = transform.sepia(tone=80) 148 | assert sepia.url == target_url 149 | 150 | 151 | def test_pixelate(transform): 152 | target_url = '{}/{}/pixelate=amount:10/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 153 | pixelate = transform.pixelate(amount=10) 154 | assert pixelate.url == target_url 155 | 156 | 157 | def test_oil_paint(transform): 158 | target_url = '{}/{}/oil_paint=amount:10/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 159 | oil_paint = transform.oil_paint(amount=10) 160 | assert oil_paint.url == target_url 161 | 162 | 163 | def test_negative(transform): 164 | target_url = '{}/{}/negative/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 165 | negative = transform.negative() 166 | assert negative.url == target_url 167 | 168 | 169 | def test_modulate(transform): 170 | target_url = '{}/{}/modulate=brightness:155,hue:155,saturation:155/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 171 | modulate = transform.modulate(brightness=155, hue=155, saturation=155) 172 | assert modulate.url == target_url 173 | 174 | 175 | def test_partial_pixelate(transform): 176 | target_url = ( 177 | '{}/{}/partial_pixelate=amount:10,blur:10,' 178 | 'objects:[[x,y,width,height],[x,y,width,height]],type:rect/{}' 179 | ).format(config.CDN_URL, APIKEY, EXTERNAL_URL) 180 | 181 | partial_pixelate = transform.partial_pixelate( 182 | amount=10, blur=10, type='rect', objects='[[x,y,width,height],[x,y,width,height]]' 183 | ) 184 | assert partial_pixelate.url == target_url 185 | 186 | 187 | def test_partial_blur(transform): 188 | target_url = ( 189 | '{}/{}/partial_blur=amount:10,blur:10,' 190 | 'objects:[[x,y,width,height],[x,y,width,height]],type:rect/{}' 191 | ).format(config.CDN_URL, APIKEY, EXTERNAL_URL) 192 | 193 | partial_blur = transform.partial_blur( 194 | amount=10, blur=10, type='rect', objects='[[x,y,width,height],[x,y,width,height]]' 195 | ) 196 | assert partial_blur.url == target_url 197 | 198 | 199 | def test_collage(transform): 200 | target_url = ( 201 | '{}/{}/collage=autorotate:true,color:white,' 202 | 'files:[FILEHANDLE,FILEHANDLE2,FILEHANDLE3],fit:crop,height:1000,margin:50,width:1000/{}' 203 | ).format(config.CDN_URL, APIKEY, EXTERNAL_URL) 204 | 205 | collage = transform.collage( 206 | files='[FILEHANDLE,FILEHANDLE2,FILEHANDLE3]', margin=50, width=1000, height=1000, color='white', 207 | fit='crop', autorotate=True 208 | ) 209 | assert collage.url == target_url 210 | 211 | 212 | def test_upscale(transform): 213 | target_url = '{}/{}/upscale=noise:low,style:artwork/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 214 | upscale = transform.upscale(noise='low', style='artwork') 215 | assert upscale.url == target_url 216 | 217 | 218 | def test_enhance(transform): 219 | target_url = '{}/{}/enhance/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 220 | enhance = transform.enhance() 221 | assert enhance.url == target_url 222 | 223 | 224 | def test_redeye(transform): 225 | target_url = '{}/{}/redeye/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 226 | redeye = transform.redeye() 227 | assert redeye.url == target_url 228 | 229 | 230 | def test_ascii(transform): 231 | target_url = ( 232 | '{}/{}/ascii=background:black,colored:true,foreground:black,reverse:true,size:100/{}' 233 | ).format(config.CDN_URL, APIKEY, EXTERNAL_URL) 234 | 235 | ascii = transform.ascii(background='black', foreground='black', colored=True, size=100, reverse=True) 236 | assert ascii.url == target_url 237 | 238 | 239 | def test_zip(transform): 240 | target_url = ('{}/{}/zip/{}').format(config.CDN_URL, APIKEY, EXTERNAL_URL) 241 | assert transform.zip().url == target_url 242 | 243 | 244 | def test_fallback(transform): 245 | target_url = '{}/{}/fallback=cache:12,file:{}/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL, EXTERNAL_URL) 246 | result = transform.fallback(file=EXTERNAL_URL, cache=12) 247 | assert result.url == target_url 248 | 249 | 250 | def test_pdf_info(transform): 251 | target_url = '{}/{}/pdfinfo=colorinfo:true/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 252 | result = transform.pdf_info(colorinfo=True) 253 | assert result.url == target_url 254 | 255 | 256 | def test_pdf_convert(transform): 257 | target_url = '{}/{}/pdfconvert=pageorientation:landscape/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 258 | result = transform.pdf_convert(pageorientation='landscape') 259 | assert result.url == target_url 260 | 261 | 262 | def test_minify_css(transform): 263 | target_url = '{}/{}/minify_css/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 264 | result = transform.minify_css() 265 | assert result.url == target_url 266 | 267 | 268 | def test_minify_css_with_params(transform): 269 | target_url = '{}/{}/minify_css=gzip:false,level:1/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 270 | result = transform.minify_css(level=1, gzip=False) 271 | assert result.url == target_url 272 | 273 | 274 | def test_minify_js(transform): 275 | target_url = '{}/{}/minify_js=gzip:false,targets:not dead,> 1%/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 276 | result = transform.minify_js(gzip=False, targets='not dead,> 1%') 277 | assert result.url == target_url 278 | 279 | 280 | def quality(transform): 281 | target_url = '{}/{}/quality=value:75/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 282 | quality = transform.quality(75) 283 | 284 | assert quality.url == target_url 285 | 286 | 287 | def test_filetype_conversion(transform): 288 | target_url = ( 289 | '{}/{}/output=background:white,colorspace:input,compress:true,density:50,docinfo:true,format:png,' 290 | 'page:1,pageformat:legal,pageorientation:landscape,quality:80,secure:true,' 291 | 'strip:true/{}' 292 | ).format(config.CDN_URL, APIKEY, EXTERNAL_URL) 293 | 294 | filetype_conversion = transform.filetype_conversion( 295 | format='png', background='white', page=1, density=50, compress=True, 296 | quality=80, strip=True, colorspace='input', secure=True, 297 | docinfo=True, pageformat='legal', pageorientation='landscape' 298 | ) 299 | assert filetype_conversion.url == target_url 300 | 301 | 302 | def test_no_metadata(transform): 303 | target_url = ('{}/{}/no_metadata/{}').format(config.CDN_URL, APIKEY, EXTERNAL_URL) 304 | 305 | no_metadata = transform.no_metadata() 306 | assert no_metadata.url == target_url 307 | 308 | 309 | @responses.activate 310 | def test_chain_tasks_and_store(transform): 311 | responses.add( 312 | responses.POST, re.compile('https://cdn.filestackcontent.com/SOMEAPIKEY*'), 313 | json={'handle': HANDLE} 314 | ) 315 | transform_obj = transform.flip().resize(width=100) 316 | new_filelink = transform_obj.store(filename='filename', location='S3', container='bucket', path='folder/image.jpg') 317 | assert new_filelink.handle == HANDLE 318 | assert responses.calls[0].request.url == ( 319 | '{}/{}/flip/resize=width:100/store=container:bucket,'.format(config.CDN_URL, APIKEY) + 320 | 'filename:filename,location:S3,path:%22folder/image.jpg%22/{}'.format(EXTERNAL_URL) 321 | ) 322 | 323 | 324 | @responses.activate 325 | def test_av_convert(transform): 326 | responses.add( 327 | responses.GET, 328 | 'https://cdn.filestackcontent.com/SOMEAPIKEY/video_convert=height:500,width:500/SOMEEXTERNALURL', 329 | json={'url': transform.url, 'uuid': 'someuuid', 'timestamp': 'sometimestamp'} 330 | ) 331 | new_av = transform.av_convert(width=500, height=500) 332 | assert isinstance(new_av, AudioVisual) 333 | assert new_av.uuid == 'someuuid' 334 | assert new_av.timestamp == 'sometimestamp' 335 | 336 | 337 | def test_auto_image(transform): 338 | target_url = '{}/{}/auto_image/{}'.format(config.CDN_URL, APIKEY, EXTERNAL_URL) 339 | auto_image = transform.auto_image() 340 | assert auto_image.url == target_url 341 | --------------------------------------------------------------------------------