diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..f244026 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.9', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' # caching pip dependencies + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run tests + run: python -m unittest -v diff --git a/.gitignore b/.gitignore index d63cd22..674aa7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,6 @@ -# execution artefacts -*.pyc -.coverage -.DS_Store - -# dist artefacts -build/ +.idea dist/ +build/ cloudconvert.egg-info/ *.egg - -# dev artefacts -.idea -test.py \ No newline at end of file +**/__pycache__ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c7095a7..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python -python: - - "2.7" - - "3.2" - - "3.3" - - "3.4" - - "3.5" -install: - - pip install . - - pip install -r requirements-dev.txt -script: nosetests -sudo: false diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aa6f4b0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test*.py", + "-t", + "." + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..0d14f8e --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +The License (MIT) + +Copyright (c) 2020 Josias Montag + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c5835d --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +## cloudconvert-python + +This is the official Python SDK for the [CloudConvert](https://cloudconvert.com/api/v2) **API v2**. + +[![Tests](https://github.com/cloudconvert/cloudconvert-python/actions/workflows/run-tests.yml/badge.svg)](https://github.com/cloudconvert/cloudconvert-python/actions/workflows/run-tests.yml) +![PyPI](https://img.shields.io/pypi/v/cloudconvert) +![PyPI - Downloads](https://img.shields.io/pypi/dm/cloudconvert) + +## Installation + +``` + pip install cloudconvert +``` + +## Creating API Client + +```py + import cloudconvert + +cloudconvert.configure(api_key='API_KEY', sandbox=False) +``` + +Or set the environment variable `CLOUDCONVERT_API_KEY` and use: + +```py + import cloudconvert + +cloudconvert.default() +``` + +## Creating Jobs + +```py + import cloudconvert + +cloudconvert.configure(api_key='API_KEY') + +cloudconvert.Job.create(payload={ + "tasks": { + 'import-my-file': { + 'operation': 'import/url', + 'url': 'https://my-url' + }, + 'convert-my-file': { + 'operation': 'convert', + 'input': 'import-my-file', + 'output_format': 'pdf', + 'some_other_option': 'value' + }, + 'export-my-file': { + 'operation': 'export/url', + 'input': 'convert-my-file' + } + } +}) + +``` + +## Downloading Files + +CloudConvert can generate public URLs for using `export/url` tasks. You can use these URLs to download output files. + +```py +exported_url_task_id = "84e872fc-d823-4363-baab-eade2e05ee54" +res = cloudconvert.Task.wait(id=exported_url_task_id) # Wait for job completion +file = res.get("result").get("files")[0] +res = cloudconvert.download(filename=file['filename'], url=file['url']) +print(res) +``` + +## Uploading Files + +Uploads to CloudConvert are done via `import/upload` tasks (see +the [docs](https://cloudconvert.com/api/v2/import#import-upload-tasks)). This SDK offers a convenient upload method: + +```py +job = cloudconvert.Job.create(payload={ + 'tasks': { + 'upload-my-file': { + 'operation': 'import/upload' + } + } +}) + +upload_task_id = job['tasks'][0]['id'] + +upload_task = cloudconvert.Task.find(id=upload_task_id) +res = cloudconvert.Task.upload(file_name='path/to/sample.pdf', task=upload_task) + +res = cloudconvert.Task.find(id=upload_task_id) +``` + +## Webhook Signing + +The node SDK allows to verify webhook requests received from CloudConvert. + +```py +payloadString = '...'; # The JSON string from the raw request body. +signature = '...'; # The value of the "CloudConvert-Signature" header. +signingSecret = '...'; # You can find it in your webhook settings. + +isValid = cloudconvert.Webhook.verify(payloadString, signature, signingSecret); # returns true or false +``` + +## Signed URLs + +Signed URLs allow converting files on demand only using URL query parameters. The Python SDK allows to generate such +URLs. Therefore, you need to obtain a signed URL base and a signing secret on +the [CloudConvert Dashboard](https://cloudconvert.com/dashboard/api/v2/signed-urls). + +```py +base = 'https://s.cloudconvert.com/...' # You can find it in your signed URL settings. +signing_secret = '...' # You can find it in your signed URL settings. +cache_key = 'cache-key' # Allows caching of the result file for 24h + +job = { + "tasks": { + "import-file": { + "operation": "import/url", + "url": "https://github.com/cloudconvert/cloudconvert-php/raw/master/tests/Integration/files/input.pdf" + }, + "export-file": { + "operation": "export/url", + "input": "import-file" + } + } +} + +url = cloudconvert.SignedUrl.sign(base, signing_secret, job, cache_key); # returns the URL +``` + +## Unit Tests + +``` +python -m unittest discover -s tests/unit + +``` + +## Integration Tests + +``` +python -m unittest discover -s tests/integration + +``` + +## Resources + +* [API v2 Documentation](https://cloudconvert.com/api/v2) +* [CloudConvert Blog](https://cloudconvert.com/blog) diff --git a/README.rst b/README.rst deleted file mode 100644 index 3cbb95c..0000000 --- a/README.rst +++ /dev/null @@ -1,96 +0,0 @@ -cloudconvert-python -=================== - -This is a lightweight wrapper for the -`CloudConvert `__ API. - -Feel free to use, improve or modify this wrapper! If you have questions -contact us or open an issue on GitHub. - -.. image:: https://img.shields.io/pypi/v/cloudconvert.svg - :alt: PyPi Version - :target: https://pypi.python.org/pypi/cloudconvert -.. image:: https://travis-ci.org/cloudconvert/cloudconvert-python.svg?branch=master - :alt: Build Status - :target: https://travis-ci.org/cloudconvert/cloudconvert-python - -Quickstart ----------- - -.. code:: python - - import cloudconvert - - api = cloudconvert.Api('your_api_key') - - process = api.convert({ - 'inputformat': 'png', - 'outputformat': 'jpg', - 'input': 'upload', - 'file': open('tests/input.png', 'rb') - }) - process.wait() # wait until conversion finished - process.download("tests/output.png") # download output file - -You can use the `CloudConvert API -Console `__ to generate -ready-to-use python code snippets using this wrapper. - -Installation ------------- - -The easiest way to get the latest stable release is to grab it from -`pypi `__ using ``pip``. - -.. code:: bash - - pip install cloudconvert - -Download of multiple output files ---------------------------------- - -In some cases it might be possible that there are multiple output files -(e.g. converting a multi-page PDF to JPG). You can download them all to -one directory using the ``downloadAll()`` method. - -.. code:: python - - import cloudconvert - - api = cloudconvert.Api('your_api_key') - - process = api.convert({ - 'inputformat': 'pdf', - 'outputformat': 'jpg', - 'converteroptions': { - 'page_range': '1-3' - }, - 'input': 'upload', - 'file': open('tests/input.pdf', 'rb') - }) - process.wait() - process.downloadAll("tests") - - -Alternatively you can iterate over ``process['output']['files']`` and -download them seperately using -``process.download(localfile, remotefile)``. - -How to run tests? ------------------ - -:: - - pip install -r requirements-dev.txt - export API_KEY=your_api_key - nosetests - -Resources ---------- - -- `API Documentation `__ -- `Conversion Types `__ -- `CloudConvert Blog `__ - -.. |Build Status| image:: https://travis-ci.org/cloudconvert/cloudconvert-python.svg?branch=master - :target: https://travis-ci.org/cloudconvert/cloudconvert-python diff --git a/cloudconvert/__init__.py b/cloudconvert/__init__.py index f3b2b22..5b63cd0 100644 --- a/cloudconvert/__init__.py +++ b/cloudconvert/__init__.py @@ -1,5 +1,21 @@ -from .api import Api -from .api import Process -from .exceptions import ( - APIError, HTTPError, BadRequest, ConversionFailed, TemporaryUnavailable, InvalidResponse, InvalidParameterException -) \ No newline at end of file +from cloudconvert.cloudconvertrestclient import * +from cloudconvert.task import Task +from cloudconvert.job import Job +from cloudconvert.webhook import Webhook +from cloudconvert.signed_url import SignedUrl + +def configure(**config): + """ + Configure the REST Client With Latest API Key and Mode + :return: + """ + set_config(**config) + + +def default(): + """ + Configure the REST Client With Default API Key and Mode + :return: + """ + default_client() + diff --git a/cloudconvert/api.py b/cloudconvert/api.py deleted file mode 100644 index edc99ec..0000000 --- a/cloudconvert/api.py +++ /dev/null @@ -1,191 +0,0 @@ -import json - -try: - from urllib.parse import quote, unquote -except ImportError: - from urllib import quote, unquote - - -import io - -from requests import request, Session -from requests.exceptions import RequestException -from .urlencoder import urlencode - -from .process import Process -from .exceptions import ( - APIError, HTTPError, BadRequest, ConversionFailed, TemporaryUnavailable, InvalidResponse, InvalidParameterException -) - -class Api(object): - """ - Base CloudConvert API Wrapper for Python - """ - - endpoint = "api.cloudconvert.com" - protocol = "https" - - def __init__(self, api_key=None): - """ - Creates a new API Client. No credential check is done at this point. - - :param str api_key: API key as provided by CloudConvert (https://cloudconvert.com/user/profile) - """ - - self._api_key = api_key - - # use a requests session to reuse HTTPS connections between requests - self._session = Session() - - - - - def get(self, path, parameters=None, is_authenticated=False): - """ - 'GET' :py:func:`Client.call` wrapper. - Query string parameters can be set either directly in ``_target`` or as - keywork arguments. - :param string path: API method to call - :param string is_authenticated: If True, send authentication headers. This is - the default - """ - if parameters: - query_string = urlencode(parameters) - if '?' in path: - path = '%s&%s' % (path, query_string) - else: - path = '%s?%s' % (path, query_string) - return self.rawCall('GET', path, None, is_authenticated) - - - - def post(self, path, parameters=None, is_authenticated=False): - """ - 'POST' :py:func:`Client.call` wrapper - Body parameters can be set either directly in ``_target`` or as keywork - arguments. - :param string path: API method to call - :param string is_authenticated: If True, send authentication headers. This is - the default - """ - return self.rawCall('POST', path, parameters, is_authenticated) - - - - def delete(self, path, is_authenticated=False): - """ - 'DELETE' :py:func:`Client.call` wrapper - :param string path: API method to call - :param string is_authenticated: If True, send authentication headers. This is - the default - """ - return self.rawCall('DELETE', path, None, is_authenticated) - - - - def rawCall(self, method, path, content=None, is_authenticated=False, stream=False): - """ - Low level call helper for making HTTP requests. - :param str method: HTTP method of request (GET,POST,PUT,DELETE) - :param str path: relative url of API request - :param content: body of the request (query parameters for GET requests or body for POST requests) - :param boolean is_authenticated: if the request use authentication - :raises HTTPError: when underlying request failed for network reason - :raises InvalidResponse: when API response could not be decoded - """ - - url = path - if path.startswith("//"): - url = self.protocol + ":" + path - elif not path.startswith("http"): - url = self.protocol + "://" + self.endpoint + path - - - body = None - files = None - headers = {} - - # include payload - if content is not None: - - ## check if we upload anything - isupload = False - - try: - fileInstance=file # python 2 - except NameError: - fileInstance=io.BufferedReader # python 3 - - for key, value in content.items(): - if key == 'file': - x= "" - if isinstance(value, fileInstance): - ## if it is file: remove from content dict and add it to files dict - isupload = True - files = {key: value} - del content[key] - break - - if isupload: - url += "?" + unquote(urlencode(content)) - else: - headers['Content-type'] = 'application/json' - body = json.dumps(content) - - - # add auth header - if is_authenticated and self._api_key is not None: - headers['Authorization'] = 'Bearer ' + self._api_key - - # attempt request - try: - result = self._session.request(method, url, headers=headers, - data=body, files=files, stream=stream) - except RequestException as error: - raise HTTPError("HTTP request failed error", error) - - code = result.status_code - - # error check - if code >= 100 and code < 300: - if stream: - return result - - try: - return result.json() - except ValueError as error: - raise InvalidResponse("Failed to decode API response", error) - else: - json_result = result.json() - msg = json_result.get('message') if json_result.get('message') else json_result.get('error') - if code == 400: - raise BadRequest(msg) - elif code == 422: - raise ConversionFailed(msg) - elif code == 503: - raise TemporaryUnavailable(msg) - else: - raise APIError(msg) - - def createProcess(self, parameters): - """ - Create a new Process - :param parameters: Parameters for creating the Process. See https://cloudconvert.com/apidoc#create - :raises APIError: if the CloudConvert API returns an error - """ - result = self.post("/process", parameters, True) - return Process(self, result['url']) - - - def convert(self, parameters): - """ - Shortcut: Create a new Process and starts it - :param parameters: Parameters for starting the Process. See https://cloudconvert.com/apidoc#start - :raises APIError: if the CloudConvert API returns an error - """ - - startparameters=parameters.copy() - ## we don't need the input file for creating the process - del startparameters['file'] - process = self.createProcess(startparameters) - return process.start(parameters) diff --git a/cloudconvert/cloudconvertrestclient.py b/cloudconvert/cloudconvertrestclient.py new file mode 100644 index 0000000..a3c719e --- /dev/null +++ b/cloudconvert/cloudconvertrestclient.py @@ -0,0 +1,259 @@ +from __future__ import division + +import datetime +import requests +import json +import logging +import os +import platform +import ssl +import urllib + +import cloudconvert.utils as util +from cloudconvert.exceptions import exceptions +from cloudconvert.config import __version__, __endpoint_map__, __sync_endpoint_map__ + +log = logging.getLogger(__name__) + + +class CloudConvertRestClient(object): + # User-Agent for HTTP request + ssl_version = "" if util.older_than_27() else ssl.OPENSSL_VERSION + ssl_version_info = None if util.older_than_27() else ssl.OPENSSL_VERSION_INFO + library_details = "requests %s; python %s; %s" % ( + requests.__version__, platform.python_version(), ssl_version) + user_agent = "CloudConvertSDK/CloudConvert-Python-SDK %s (%s)" % ( + __version__, library_details) + + def __init__(self, options=None, **kwargs): + """Create Client object + Usage:: + >>> import cloudconvert.cloudconvertrestclient as cloudconvertrestclient + >>> rest_client = cloudconvertrestclient.CloudConvertRestClient(token='access_token', ssl_options={"cert": "/path/to/server.pem"}) + """ + kwargs = util.merge_dict(options or {}, kwargs) + + self.mode = 'sandbox' if kwargs.get("sandbox", False) else 'live' + + if self.mode != "live" and self.mode != "sandbox": + raise exceptions.InvalidConfig("Configuration Mode Invalid", "Received: %s" % (self.mode), + "Required: live or sandbox") + + self.endpoint = kwargs.get("endpoint", self.default_endpoint()) + self.sync_endpoint = kwargs.get("sync_endpoint", self.default_sync_endpoint()) + # Mandatory parameter, so not using `dict.get` + self.proxies = kwargs.get("proxies", None) + self.token_hash = None + # setup SSL certificate verification if private certificate provided + ssl_options = kwargs.get("ssl_options", {}) + if "cert" in ssl_options: + os.environ["REQUESTS_CA_BUNDLE"] = ssl_options["cert"] + + if kwargs.get("api_key"): + self.token_hash = { + "access_token": kwargs["api_key"], "token_type": "Bearer"} + + self.options = kwargs + + def default_endpoint(self): + return __endpoint_map__.get(self.mode) + + def default_sync_endpoint(self): + return __sync_endpoint_map__.get(self.mode) + + def request(self, url, method, body=None, headers=None): + """Make HTTP call, formats response and does error handling. Uses http_call method in CloudConvertRestClient class. + Usage:: + >>> cloudconvertrestclient.request("https://api.sandbox.cloudconvert.com/v2/jobs/JOB-ID", "GET", {}) + >>> cloudconvertrestclient.request("https://api.sandbox.cloudconvert.com/v2/tasks/TASK-ID", "POST", "{}", {} ) + """ + + http_headers = util.merge_dict( + self.headers(), headers or {}) + + try: + return self.http_call(url, method, json=body, headers=http_headers) + + # Format Error message for bad request + except exceptions.BadRequest as error: + return {"error": json.loads(error.content)} + + # Handle Expired token + except exceptions.UnauthorizedAccess as error: + if self.token_hash: + self.token_hash = None + return self.request(url, method, body, headers) + else: + raise error + + def http_call(self, url, method, **kwargs): + """Makes a http call. Logs response information. + """ + log.info('Request[%s]: %s' % (method, url)) + + if self.mode.lower() != 'live': + request_headers = kwargs.get("headers", {}) + request_body = kwargs.get("json", {}) + log.debug("Level: " + self.mode) + log.debug('Request: \nHeaders: %s\nBody: %s' % ( + str(request_headers), str(request_body))) + else: + log.info( + 'Not logging full request/response headers and body in live mode for compliance') + + start_time = datetime.datetime.now() + response = requests.request( + method, url, proxies=self.proxies, **kwargs) + + duration = datetime.datetime.now() - start_time + log.info('Response[%d]: %s, Duration: %s.%ss.' % ( + response.status_code, response.reason, duration.seconds, duration.microseconds)) + + if self.mode.lower() != 'live': + log.debug('Headers: %s\nBody: %s' % ( + str(response.headers), str(response.content))) + + return self.handle_response(response, response.content.decode('utf-8')) + + def handle_response(self, response, content): + """Validate HTTP response + """ + status = response.status_code + if status in (301, 302, 303, 307): + raise exceptions.Redirection(response, content) + elif 200 <= status <= 299: + return json.loads(content) if content else {} + elif status == 400: + raise exceptions.BadRequest(response, content) + elif status == 401: + return json.loads(content) if content else {} + elif status == 403: + raise exceptions.ForbiddenAccess(response, content) + elif status == 404: + return json.loads(content) if content else {} + elif status == 405: + raise exceptions.MethodNotAllowed(response, content) + elif status == 409: + raise exceptions.ResourceConflict(response, content) + elif status == 410: + raise exceptions.ResourceGone(response, content) + elif status == 422: + raise exceptions.ResourceInvalid(response, content) + elif 401 <= status <= 499: + raise exceptions.ClientError(response, content) + elif 500 <= status <= 599: + raise exceptions.ServerError(response, content) + else: + raise exceptions.ConnectionError( + response, content, "Unknown response code: #{response.code}") + + def headers(self): + """Default HTTP headers + """ + return { + "Authorization": ("%s %s" % (self.token_hash['token_type'], self.token_hash['access_token'])), + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": self.user_agent + } + + def get(self, action, headers=None): + """Make GET request + Usage:: + >>> cloudconvertrestclient.get("v2/tasks/TASK-ID") + >>> cloudconvertrestclient.get("v2/jobs/JOB-ID") + """ + return self.request(util.join_url(self.endpoint, action), 'GET', headers=headers or {}) + + + def get_sync(self, action, headers=None): + """Make GET request to sync API + Usage:: + >>> cloudconvertrestclient.get_sync("v2/tasks/TASK-ID") + >>> cloudconvertrestclient.get_sync("v2/jobs/JOB-ID") + """ + return self.request(util.join_url(self.sync_endpoint, action), 'GET', headers=headers or {}) + + def post(self, action, params=None, headers={}): + """Make POST request + Usage:: + >>> cloudconvertrestclient.post("v2/jobs/", {"tasks": { + "task-import-file7": { + "operation": "import/url", + "url": "https://file-examples.com/wp-content/uploads/2017/02/" + }}}) + >>> cloudconvertrestclient.post("v2/export/url", { + "input": "f1e276cf-1cfa-4cd5-8c87-1e3d07206cf3", + "file": "file-sample_100kB.doc"}) + """ + + return self.request(util.join_url(self.endpoint, action), 'POST', body=params or {}, headers={} or headers) + + def put(self, action, params=None, headers=None): + """Make PUT request + """ + return self.request(util.join_url(self.endpoint, action), 'PUT', body=params or {}, headers=headers or {}) + + def patch(self, action, params=None, headers=None): + """Make PATCH request + Usage:: + """ + return self.request(util.join_url(self.endpoint, action), 'PATCH', body=params or {}, headers=headers or {}) + + def delete(self, action, headers=None): + """Make DELETE request + """ + return self.request(util.join_url(self.endpoint, action), 'DELETE', headers=headers or {}) + + +__client__ = None + + +def download(url, filename): + """Download a file e.g. from a given url + Usage:: + >>> cloudconvert.download(url="https://exported_url", filename="sample.pdf") + """ + try: + urllib.request.urlretrieve(url, filename) + print("Downloaded file:{} successfully..".format(filename)) + return filename + except Exception as e: + print("Got exception while trying to download the file from url: {}".format(url)) + print(e) + + return None + + +def default_client(): + """Returns default api object and if not present creates a new one + By default points to developer sandbox + """ + from cloudconvert.environment_vars import CLOUDCONVERT_API_KEY + global __client__ + if __client__ is None: + try: + API_KEY = os.environ[CLOUDCONVERT_API_KEY] + except KeyError: + raise exceptions.MissingConfig( + "Required CLOUDCONVERT_API_KEY \n Refer https://cloudconvert.com/api/v2#overview") + + # Get default API mode + sandbox = True if os.environ.get("CLOUDCONVERT_SANDBOX", "false") == 'true' else False + + __client__ = CloudConvertRestClient({}, sandbox=sandbox, api_key=API_KEY) + + return __client__ + +def get_existing_client(): + """Gets an already created client if there is one, None otherwise.""" + global __client__ + return __client__ + + +def set_config(options=None, **config): + """Create new default api object with given configuration + """ + global __client__ + __client__ = CloudConvertRestClient(options or {}, **config) + return __client__ diff --git a/cloudconvert/config.py b/cloudconvert/config.py new file mode 100644 index 0000000..63dc907 --- /dev/null +++ b/cloudconvert/config.py @@ -0,0 +1,14 @@ +__version__ = "2.1.0" +__pypi_username__ = "" +__pypi_packagename__ = "cloudconvert" +__github_username__ = "cloudconvert" +__github_reponame__ = "cloudconvert-python" +__endpoint_map__ = { + "live": "https://api.cloudconvert.com", + "sandbox": "https://api.sandbox.cloudconvert.com" +} +__sync_endpoint_map__ = { + "live": "https://sync.api.cloudconvert.com", + "sandbox": "https://sync.api.sandbox.cloudconvert.com" +} +SANDBOX_API_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6IjI4YmE3OGQyZjc1NWM5ZGE3Yjg1NDRhMWRkMjg2NWM4N2U0YzI5NWI0NzQ0Zjc4ZDNmMzA3OWM2NjU3ZjI0MjVhOTMyYjIxMjU5ZGU2NWQ4In0.eyJhdWQiOiIxIiwianRpIjoiMjhiYTc4ZDJmNzU1YzlkYTdiODU0NGExZGQyODY1Yzg3ZTRjMjk1YjQ3NDRmNzhkM2YzMDc5YzY2NTdmMjQyNWE5MzJiMjEyNTlkZTY1ZDgiLCJpYXQiOjE1NTkwNjc3NzcsIm5iZiI6MTU1OTA2Nzc3NywiZXhwIjo0NzE0NzQxMzc3LCJzdWIiOiIzNzExNjc4NCIsInNjb3BlcyI6WyJ1c2VyLnJlYWQiLCJ1c2VyLndyaXRlIiwidGFzay5yZWFkIiwidGFzay53cml0ZSIsIndlYmhvb2sucmVhZCIsIndlYmhvb2sud3JpdGUiXX0.IkmkfDVGwouCH-ICFAShQMHyFAHK3y90CSoissUVD8h5HFG4GqN5DEw0IFzlPr1auUKp3H1pAvPutdIQtrDMTmUUmGMUb2dRlCAuQdqxa81Q5KAmcKDgOg2YTWOWEGMy3jETTb7W6vyNGsT_3DFMapMdeOw1jdIUTMZqW3QbSCeGXj3PMRnhI7YynaDtmktjzO9IUDHbeT2HRzzMiep97KvVZNjYtZvgM-kbUjE6Mm68_kA8JMuQeor0Yg7896JPV0YM3-MnHf7elKgoCJbfBCDAbvSX_ZYsSI7IGoLLb0mgJVfFcH_HMYAHhJj5cUEJN2Iml-FkODqrRk72bVxyJs9j1GPQBl4ORXuU9yrjUgHrRaZ5YM__LwsUQB3AuB92oyQseCjULn1sWM1PzIXCcyVjKZSpn9LAAGNf9paCF-_G9ok9tZKccRouCiYl9v5XbmuxV8hXYp6fXZxyaAkj_JN2kErVSkxYzVyyZL1e220aFFnbch6nDvLFHgi-WeTQHFQDzuHsM8RKRixV8uD7pk3de4AEYg0EWqZHCr82qY7TGdSQvuAS0QIy3B89OwQW0ROW4k3Yw0XIKgKSYWyKnc7huc7yPQUIDDDAOa5OojXrVY5ZuL_hwQMIOmejcHTKFdAgzAaVnRkC8_FfVh4wHCPBaHjze9hRp5n4O1pnPFI" diff --git a/cloudconvert/environment_vars.py b/cloudconvert/environment_vars.py new file mode 100644 index 0000000..577132c --- /dev/null +++ b/cloudconvert/environment_vars.py @@ -0,0 +1,8 @@ +"""Environment Variables to be used inside the CloudConvert-Python-REST-SDK""" + +CLOUDCONVERT_API_KEY = "API_KEY" +"""Environment variable defining the Cloud Convert REST API default +credentials as Access Token.""" + +CLOUDCONVERT_SANDBOX = "true" +"""Environment variable defining if the sandbox API is used instead of the live API""" diff --git a/cloudconvert/exceptions.py b/cloudconvert/exceptions.py deleted file mode 100644 index 8d54d68..0000000 --- a/cloudconvert/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -All exceptions used in CloudConvert Python wrapper derives from `APIError` -""" - -class APIError(Exception): - """Base CloudConvert API exception, all specific exceptions inherits from it.""" - -class HTTPError(APIError): - """Raised when the request fails at a low level (DNS, network, ...)""" - -class BadRequest(APIError): - """Raised when a the CloudConvert API returns any HTTP error code 400""" - -class ConversionFailed(APIError): - """Raised when when a the CloudConvert API returns any HTTP error code 422""" - -class TemporaryUnavailable(APIError): - """Raised when a the CloudConvert API returns any HTTP error code 503""" - -class InvalidResponse(APIError): - """Raised when api response is not valid json""" - -class InvalidParameterException(APIError): - """Raised when request contains bad parameters.""" - diff --git a/cloudconvert/exceptions/__init__.py b/cloudconvert/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudconvert/exceptions/exceptions.py b/cloudconvert/exceptions/exceptions.py new file mode 100644 index 0000000..ccd35c6 --- /dev/null +++ b/cloudconvert/exceptions/exceptions.py @@ -0,0 +1,98 @@ +class ConnectionError(Exception): + def __init__(self, response, content=None, message=None): + self.response = response + self.content = content + self.message = message + + def __str__(self): + message = "Failed." + if hasattr(self.response, 'status_code'): + message += " Response status: %s." % (self.response.status_code) + if hasattr(self.response, 'reason'): + message += " Response message: %s." % (self.response.reason) + if self.content is not None: + message += " Error message: " + str(self.content) + return message + + +class Redirection(ConnectionError): + """3xx Redirection + """ + def __str__(self): + message = super(Redirection, self).__str__() + if self.response.get('Location'): + message = "%s => %s" % (message, self.response.get('Location')) + return message + + +class MissingParam(TypeError): + pass + + +class MissingConfig(Exception): + pass + +class InvalidConfig(ValueError): + pass + + +class ClientError(ConnectionError): + """4xx Client Error + """ + pass + + +class BadRequest(ClientError): + """400 Bad Request + """ + pass + + +class UnauthorizedAccess(ClientError): + """401 Unauthorized + """ + pass + + +class ForbiddenAccess(ClientError): + """403 Forbidden + """ + pass + + +class ResourceNotFound(ClientError): + """404 Not Found + """ + pass + + +class ResourceConflict(ClientError): + """409 Conflict + """ + pass + + +class ResourceGone(ClientError): + """410 Gone + """ + pass + + +class ResourceInvalid(ClientError): + """422 Invalid + """ + pass + + +class ServerError(ConnectionError): + """5xx Server Error + """ + pass + + +class MethodNotAllowed(ClientError): + """405 Method Not Allowed + """ + + def allowed_methods(self): + return self.response['Allow'] \ No newline at end of file diff --git a/cloudconvert/job.py b/cloudconvert/job.py new file mode 100644 index 0000000..250e8df --- /dev/null +++ b/cloudconvert/job.py @@ -0,0 +1,27 @@ +from cloudconvert.resource import List, Find, Delete, Wait, Show, Create + + +class Job(List, Find, Wait, Show, Delete): + """Job class wrapping the REST v2/jobs endpoint. Enabling New Job Creation, Showing a job, Waiting for job, + Finding a job, Deleting a job. + + Usage:: + >>> jobs = Job.list({"page": 5}) + >>> job = Job.find("") + >>> Job.create() + >>> Job.delete()) # return True or False + """ + path = "v2/jobs" + + @classmethod + def create(cls, payload={}): + res = Create.create(operation="jobs", payload=payload) + try: + return res['data'] + except: + return res + + +Job.convert_resources['jobs'] = Job +Job.convert_resources['job'] = Job + diff --git a/cloudconvert/process.py b/cloudconvert/process.py deleted file mode 100644 index a4dc78a..0000000 --- a/cloudconvert/process.py +++ /dev/null @@ -1,137 +0,0 @@ -import os -import shutil -import time - -from .exceptions import ( - APIError, HTTPError, BadRequest, ConversionFailed, TemporaryUnavailable, InvalidResponse, InvalidParameterException -) - - -class Process(object): - """ - Process Object wrapper CloudConvert API - """ - - data = {} - - def __init__(self, api, url=None): - """ - Creates a new Process instance - - :param Api api: API Instance - :param str url: The Process URL - """ - - self.api = api - self.url = url - - - - def refresh(self, parameters = None): - """ - Refresh process data from API - - :param parameters: Parameters for creating the Process. See https://cloudconvert.com/apidoc#start - :raises APIError: if the CloudConvert API returns an error - """ - - self.data = self.api.get(self.url, parameters) - return self - - - def start(self, parameters): - """ - Starts the Process - - :param parameters: Parameters for creating the Process. See https://cloudconvert.com/apidoc#start - :raises APIError: if the CloudConvert API returns an error - """ - - self.data = self.api.post(self.url, parameters) - return self - - - def delete(self): - """ - Delete process from API - :raises APIError: if the CloudConvert API returns an error - """ - self.api.delete(self.url) - return self - - - - def wait(self, interval = 1): - """ - Waits for the Process to finish (or end with an error). Checks the conversion status every interval seconds. - :param int interval: Interval in seconds - :raises APIError: if the CloudConvert API returns an error - """ - while self['step']!='finished' and self['step'] !='error': - self.refresh() - time.sleep(interval) - - return self - - - - def download(self, localfile = None, remotefile = None): - """ - Download process file from API - :param str localfile: Local file name (or directory) the file should be downloaded to - :param str remotefile: Remote file name which should be downloaded (if there are multiple output files available) - :raises APIError: if the CloudConvert API returns an error - """ - if localfile is not None and os.path.isdir(localfile) and 'filename' in self.data.get('output', {}): - ## localfile is directory - localfile = os.path.normpath(localfile) + os.sep + (remotefile if remotefile is not None else self['output']['filename']) - elif localfile is None and 'filename' in self.data.get('output', {}): - ## localfile is not set -> set it to output filename - localfile = remotefile if remotefile is not None else self['output']['filename'] - - if localfile is None or os.path.isdir(localfile): - raise InvalidParameterException("localfile parameter is not set correctly") - - if 'url' not in self.data.get('output', {}): - raise APIError("There is no output file available (yet)") - - r = self.api.rawCall("GET", self['output']['url'] + ("/" + remotefile if remotefile else ""), stream=True) - - with open(localfile, 'wb') as f: - r.raw.decode_content = True - shutil.copyfileobj(r.raw, f) - - - return self - - - def downloadAll(self, directory = None): - """ - Download all output process files from API - :param str directory: Local directory the files should be downloaded to - :raises APIError: if the CloudConvert API returns an error - """ - if 'files' not in self.data.get('output', {}): - ## there are not multiple output files -> do normal download - return self.download(localfile=directory) - - for file in self["output"]["files"]: - self.download(localfile=directory, remotefile=file) - - return self - - - - def __getitem__(self, item): - """ - Make process status from API available as object attributes. - Examples: - process['step'] - process['message'] - - """ - if self.data.get(item): - return self.data.get(item) - else: - # Default behaviour - raise AttributeError \ No newline at end of file diff --git a/cloudconvert/resource.py b/cloudconvert/resource.py new file mode 100644 index 0000000..227c39e --- /dev/null +++ b/cloudconvert/resource.py @@ -0,0 +1,224 @@ +import uuid +import urllib +import cloudconvert.utils as util +from cloudconvert.cloudconvertrestclient import default_client, get_existing_client + + +class Resource(object): + """Base class for all REST services + """ + convert_resources = {} + + def __init__(self, attributes=None, api_client=None): + attributes = attributes or {} + self.__dict__['api_client'] = api_client or default_client() + + super(Resource, self).__setattr__('__data__', {}) + super(Resource, self).__setattr__('error', None) + super(Resource, self).__setattr__('headers', {}) + super(Resource, self).__setattr__('header', {}) + super(Resource, self).__setattr__('request_id', None) + self.merge(attributes) + + def generate_request_id(self): + """Generate uniq request id + """ + if self.request_id is None: + self.request_id = str(uuid.uuid4()) + return self.request_id + + def http_headers(self): + """Generate HTTP header + """ + return util.merge_dict(self.header, self.headers, + {'CloudConvert-Request-Id': self.generate_request_id()}) + + def __str__(self): + return self.__data__.__str__() + + def __repr__(self): + return self.__data__.__str__() + + def __getattr__(self, name): + return self.__data__.get(name) + + def __setattr__(self, name, value): + try: + # Handle attributes(error, header, request_id) + super(Resource, self).__getattribute__(name) + super(Resource, self).__setattr__(name, value) + except AttributeError: + self.__data__[name] = self.convert(name, value) + + def __contains__(self, item): + return item in self.__data__ + + def success(self): + return self.error is None + + def merge(self, new_attributes): + """Merge new attributes e.g. response from a post to Resource + """ + for k, v in new_attributes.items(): + setattr(self, k, v) + + def convert(self, name, value): + """Convert the attribute values to configured class + """ + if isinstance(value, dict): + cls = self.convert_resources.get(name, Resource) + return cls(value, api_client=self.api_client) + elif isinstance(value, list): + new_list = [] + for obj in value: + new_list.append(self.convert(name, obj)) + return new_list + else: + return value + + def __getitem__(self, key): + return self.__data__[key] + + def __setitem__(self, key, value): + self.__data__[key] = self.convert(key, value) + + def to_dict(self): + + def parse_object(value): + if isinstance(value, Resource): + return value.to_dict() + elif isinstance(value, list): + return list(map(parse_object, value)) + else: + return value + + return dict((key, parse_object(value)) for (key, value) in self.__data__.items()) + + def to_json(self): + + def parse_object(value): + if isinstance(value, Resource): + return value.to_dict() + elif isinstance(value, list): + return list(map(parse_object, value)) + else: + return value + + return dict((key, parse_object(value)) for (key, value) in self.__data__.items()) + + +class Find(Resource): + @classmethod + def find(cls, id): + """Locate resource e.g. job with given id + Usage:: + >>> job = Job.find("s9fsf9-s9f9sf9s-ggfgf9-fg9fg") + """ + api_client = get_existing_client() or default_client() + + url = util.join_url(cls.path, str(id)) + res = api_client.get(url) + try: + return res["data"] + except: + return res + + +class List(Resource): + list_class = Resource + + @classmethod + def all(cls, params=None): + """Get list of payments as on + https://cloudconvert.com/api/v2/tasks#tasks-list + Usage:: + >>> tasks_list = tasks.all({'status': 'waiting'}) + """ + api_client = get_existing_client() or default_client() + + if params is None: + url = cls.path + else: + url = util.join_url_params(cls.path, params) + + try: + response = api_client.get(url) + res = cls.list_class(response, api_client=api_client) + try: + return res.to_json().get("data") + except: + return res.to_json() + except AttributeError: + # To handle the case when response is JSON Array + if isinstance(response, list): + new_resp = [cls.list_class(elem, api_client=api_client) for elem in response] + return new_resp + + +class Create(Resource): + + @classmethod + def create(cls, operation=None, payload={}): + """Creates a resource e.g. task + Usage:: + >>> task = Task({}) + >>> task.create(name=TASK_NAME) # return newly created task + """ + + api_client = get_existing_client() or default_client() + url = util.join_url('v2', operation or '') + res = api_client.post(url, payload, headers={}) + + try: + return res["data"] + except: + return res + + +class Wait(Resource): + @classmethod + def wait(cls, id): + """Wait resource e.g. job with given id + Usage:: + >>> job = job.wait("s9fsf9-s9f9sf9s-ggfgf9-fg9fg") + """ + api_client = get_existing_client() or default_client() + + url = util.join_url(cls.path, str(id)) + res = api_client.get_sync(url) + try: + return res["data"] + except: + return res + + +class Show(Resource): + @classmethod + def show(cls, id): + """show resource e.g. job with given id + Usage:: + >>> job = Job.show("s9fsf9-s9f9sf9s-ggfgf9-fg9fg") + """ + api_client = get_existing_client() or default_client() + url = util.join_url(cls.path, str(id)) + res = api_client.get(url) + try: + return res["data"] + except: + return res + + +class Delete(Resource): + @classmethod + def delete(cls, id): + """Deletes a resource e.g. task + Usage:: + >>> Task.delete(TASK_ID) + """ + api_client = get_existing_client() or default_client() + url = util.join_url(cls.path, str(id)) + api_resource = Resource() + new_attributes = api_client.delete(url) + api_resource.error = None + api_resource.merge(new_attributes) + return api_resource.success() diff --git a/cloudconvert/signed_url.py b/cloudconvert/signed_url.py new file mode 100644 index 0000000..8658f80 --- /dev/null +++ b/cloudconvert/signed_url.py @@ -0,0 +1,29 @@ +import hmac +import hashlib +import json +import base64 + + +class SignedUrl(): + """SignedUrl class for create signed URLs + + Usage:: + >>> SignedUrl.create(base, signing_secret, job, cache_key) # return True or False + """ + + @classmethod + def sign(cls, base, signing_secret, job, cache_key=None): + jobJson = json.dumps(job) + jobBase64 = base64.urlsafe_b64encode(bytes(jobJson, 'utf-8')).decode('utf-8') + + url = base + "?job=" + jobBase64 + + if cache_key: + url += "&cache_key=" + cache_key + + signature = hmac.new(signing_secret.encode('utf-8'), url.encode('utf-8'), + hashlib.sha256).hexdigest() + + url += "&s=" + signature + + return url diff --git a/cloudconvert/task.py b/cloudconvert/task.py new file mode 100644 index 0000000..990a5e4 --- /dev/null +++ b/cloudconvert/task.py @@ -0,0 +1,88 @@ +from cloudconvert.resource import List, Find, Create, Delete, Wait, Show, Resource +from cloudconvert.cloudconvertrestclient import default_client +import cloudconvert.utils as util + + +class Upload(Resource): + + @classmethod + def upload(cls, file_name, task): + """Upload a resource e.g. + """ + if not (task.get('operation') == 'import/upload'): + raise Exception("The task operation is not import/upload") + + import os + if not os.path.exists(file_name): + raise Exception("Does not find the exact path of the file: {}".format(file_name)) + + form = task.get('result').get('form') + port_url = form.get('url') + params = form.get('parameters') + try: + file = open(file_name, 'rb') + + files = {'file': file} + + import requests + res = requests.request(method='POST', url=port_url, files=files, data=params) + file.close() + return True if res.status_code == 201 else False + + except Exception as e: + print("got exception while uploading file") + print(e) + + return False + + +class Cancel(Resource): + @classmethod + def cancel(cls, id): + """Cancel a resource for given Id e.g. task + Usage:: + >>> Task.cancel("4534d-34gsf-54cxv-9cxv") # return True or False + """ + api_client = default_client() + url = util.join_url(cls.path, str(id), "cancel") + api_resource = Resource() + new_attributes = api_client.post(url, {}, {}) + api_resource.error = None + api_resource.merge(new_attributes) + return api_resource.success() + + +class Retry(Resource): + @classmethod + def retry(cls, id): + """Retry a resource for given Id e.g. task + Usage:: + >>> Task.retry("4534d-34gsf-54cxv-9cxv") + """ + api_client = default_client() + + url = util.join_url(cls.path, str(id), "retry") + res = api_client.post(url) + try: + return res["data"] + except: + return res + + +class Task(List, Find, Create, Wait, Cancel, Retry, Show, Delete, Upload): + """Task class wrapping the REST v2/tasks endpoint. Enabling New Task Creation, Showing a task, Waiting for task, + Finding a task, Deleting a task, Cancelling a running task. + + Usage:: + >>> tasks = Task.all({"page": 5}) + >>> task = Task.find("") + >>> Task.create(name="import/url") + >>> Task.delete() # return True or False + >>> Task.cancel() # return True or False + """ + + path = "v2/tasks" + + +Task.convert_resources['tasks'] = Task +Task.convert_resources['task'] = Task diff --git a/cloudconvert/urlencoder.py b/cloudconvert/urlencoder.py deleted file mode 100644 index 7d1a5e1..0000000 --- a/cloudconvert/urlencoder.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -source: https://github.com/udemy/multidimensional_urlencode -should be changed to install requirement of "multidimensional_urlencode" as soon as https://github.com/uber/multidimensional_urlencode/pull/5 is merged - -""" - - -try: - from urllib.parse import urlencode as urllib_urlencode -except ImportError: - from urllib import urlencode as urllib_urlencode - - -def flatten(d): - """Return a dict as a list of lists. - >>> flatten({"a": "b"}) - [['a', 'b']] - >>> flatten({"a": [1, 2, 3]}) - [['a', [1, 2, 3]]] - >>> flatten({"a": {"b": "c"}}) - [['a', 'b', 'c']] - >>> flatten({"a": {"b": {"c": "e"}}}) - [['a', 'b', 'c', 'e']] - >>> sorted(flatten({"a": {"b": "c", "d": "e"}})) - [['a', 'b', 'c'], ['a', 'd', 'e']] - >>> sorted(flatten({"a": {"b": "c", "d": "e"}, "b": {"c": "d"}})) - [['a', 'b', 'c'], ['a', 'd', 'e'], ['b', 'c', 'd']] - """ - - if not isinstance(d, dict): - return [[d]] - - returned = [] - for key, value in list(d.items()): - # Each key, value is treated as a row. - nested = flatten(value) - for nest in nested: - current_row = [key] - current_row.extend(nest) - returned.append(current_row) - - return returned - - -def parametrize(params): - """Return list of params as params. - >>> parametrize(['a']) - 'a' - >>> parametrize(['a', 'b']) - 'a[b]' - >>> parametrize(['a', 'b', 'c']) - 'a[b][c]' - """ - returned = str(params[0]) - returned += "".join("[" + str(p) + "]" for p in params[1:]) - return returned - - -def urlencode(params): - """Urlencode a multidimensional dict.""" - - # Not doing duck typing here. Will make debugging easier. - if not isinstance(params, dict): - raise TypeError("Only dicts are supported.") - - params = flatten(params) - - url_params = {} - for param in params: - value = param.pop() - - name = parametrize(param) - if isinstance(value, (list, tuple)): - name += "[]" - - url_params[name] = value - - return urllib_urlencode(url_params, doseq=True) \ No newline at end of file diff --git a/cloudconvert/utils.py b/cloudconvert/utils.py new file mode 100644 index 0000000..cb536ec --- /dev/null +++ b/cloudconvert/utils.py @@ -0,0 +1,46 @@ +import re + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode + + +def join_url_params(url, params): + """Constructs percent-encoded query string from given parms dictionary + and appends to given url + Usage:: + >>> util.join_url_params("example.com/index.html", {"page-id": 2, "api_name": "cloud convert"}) + example.com/index.html?page-id=2&api_name=cloud-convert + """ + return url + "?" + urlencode(params) + + +def merge_dict(data, *override): + """ + Merges any number of dictionaries together, and returns a single dictionary + Usage:: + >>> util.merge_dict({"foo": "bar"}, {1: 2}, {"Cloud": "Convert"}) + {1: 2, 'foo': 'bar', 'Cloud': 'Convert'} + """ + result = {} + for current_dict in (data,) + override: + result.update(current_dict) + return result + + +def older_than_27(): + import sys + return True if sys.version_info[:2] < (2, 7) else False + + +def join_url(url, *paths): + """ + Joins individual URL strings together, and returns a single string. + Usage:: + >>> util.join_url("example.com", "index.html") + 'example.com/index.html' + """ + for path in paths: + url = re.sub(r'/?$', re.sub(r'^/?', '/', path), url) + return url diff --git a/cloudconvert/webhook.py b/cloudconvert/webhook.py new file mode 100644 index 0000000..4453e9c --- /dev/null +++ b/cloudconvert/webhook.py @@ -0,0 +1,16 @@ +import hmac +import hashlib + + +class Webhook(): + """Webhook class for verifying the webhook signature + + Usage:: + >>> Webhook.verify(payload_string, signature, signature_secret) # return True or False + """ + + @classmethod + def verify(cls, payload_string, signature, signature_secret): + generate_signature = hmac.new(signature_secret.encode('utf-8'), payload_string.encode('utf-8'), + hashlib.sha256).hexdigest() + return signature == generate_signature diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index e6675df..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -coverage==3.7.1 -nose==1.3.3 -yanc==0.2.4 -Sphinx==1.2.2 -coveralls==0.4.2 - diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3f50ac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests +urllib3 +requests-mock \ No newline at end of file diff --git a/setup.py b/setup.py index e527f6d..05b3c9d 100644 --- a/setup.py +++ b/setup.py @@ -1,36 +1,29 @@ +import setuptools +import os +with open(os.path.join(os.getcwd(), "README.md"), "r") as fh: + long_description = fh.read() -from __future__ import print_function - -from setuptools import setup - - -setup( - name='cloudconvert', - version='1.0.0', - url='https://github.com/cloudconvert/cloudconvert-python', - license='MIT', - author='Josias Montag', - tests_require=['nosetests'], - author_email='info@cloudconvert.com', - description='Official CloudConvert API wrapper', - packages=['cloudconvert'], +setuptools.setup( + name="cloudconvert", + version="2.1.0", + author="Josias Montag", + author_email="josias@montag.info", + description="Python REST API wrapper for cloud convert", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/cloudconvert/cloudconvert-python", + packages=setuptools.find_packages(exclude=["tests"]), + install_requires=[ + "requests", + "urllib3" + ], + tests_require=["requests-mock"], include_package_data=True, - platforms='any', - zip_safe=False, - keywords=["cloudconvert", "convert"], - install_requires = ['requests>=2.3.0'], classifiers=[ - "License :: OSI Approved :: BSD License", - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: System :: Archiving :: Packaging", - ], -) \ No newline at end of file + ], + zip_safe=False +) diff --git a/tests/input.png b/tests/input.png deleted file mode 100755 index 985d814..0000000 Binary files a/tests/input.png and /dev/null differ diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/input.pdf b/tests/integration/files/input.pdf similarity index 100% rename from tests/input.pdf rename to tests/integration/files/input.pdf diff --git a/tests/integration/files/input.png b/tests/integration/files/input.png new file mode 100644 index 0000000..d72c10f Binary files /dev/null and b/tests/integration/files/input.png differ diff --git a/tests/integration/out/.gitignore b/tests/integration/out/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/tests/integration/out/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/tests/integration/testJobs.py b/tests/integration/testJobs.py new file mode 100644 index 0000000..15cbef8 --- /dev/null +++ b/tests/integration/testJobs.py @@ -0,0 +1,92 @@ +################################################################### +## Test case for jobs ## +## ## +## How to run ? : ## +## $ python tests/integration/testJobs.py ## +################################################################### + +import sys +import os + +sys.path.append(os.getcwd()) + +import unittest +import cloudconvert +from cloudconvert.config import SANDBOX_API_KEY + + +class JobsTestCase(unittest.TestCase): + + def setUp(self): + """ + Test case setup method + :return: + """ + print("Setting up job test case") + self.cloudconvert = cloudconvert + + # setup the client with the provided API key by configuring + self.cloudconvert.configure(api_key=SANDBOX_API_KEY, sandbox=True) + + def testUploadAndDownloadFiles(self): + """ + Test case for uploading and downloading files + :return: + """ + print("Test case for uploading and downloading files...") + job = self.cloudconvert.Job.create(payload={ + 'tag': 'integration-test-upload-download', + 'tasks': { + 'import-it': { + 'operation': 'import/upload' + }, + 'export-it': { + 'input': 'import-it', + 'operation': 'export/url' + } + } + }) + + import_task = None + # fetch task with name "import-id" + for task in job["tasks"]: + task_name = task.get("name") + if task_name == "import-it": + import_task = task + + if task_name == "export-it": + export_task = task + + import_task_id = import_task.get("id") + export_task_id = export_task.get("id") + + # fetch the finished task + import_task = cloudconvert.Task.find(id=import_task_id) + + # do upload + uploaded = cloudconvert.Task.upload( + file_name=os.path.join(os.path.dirname(os.path.realpath(__file__)), "files/input.pdf"), task=import_task) + + if uploaded: + print("Uploaded file successfully..") + + # fetch the finished export task + exported_task = cloudconvert.Task.wait(id=export_task_id) + + # get exported url + exported_url = exported_task.get("result").get("files")[0].get("url") + fileName = exported_task.get("result").get("files")[0].get("filename") + + # now download the exported file + cloudconvert.download(url=exported_url, filename=os.path.join(os.path.dirname(os.path.realpath(__file__)), "out/" + fileName)) + + def tearDown(self): + """ + Teardown method + :return: + """ + print("Tearing down test case for job..") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/integration/testTasks.py b/tests/integration/testTasks.py new file mode 100644 index 0000000..f7a33f3 --- /dev/null +++ b/tests/integration/testTasks.py @@ -0,0 +1,59 @@ +################################################################### +## Test case for tasks ## +## ## +## How to run ? : ## +## $ python tests/integration/testTasks.py ## +################################################################### + +import sys +import os +sys.path.append(os.getcwd()) + +import unittest +import cloudconvert +from cloudconvert.config import SANDBOX_API_KEY + + +class TasksTestCase(unittest.TestCase): + + def setUp(self): + """ + Test case setup method + :return: + """ + print("Setting up task test case") + self.cloudconvert = cloudconvert + + # setup the client with the provided API key by configuring + self.cloudconvert.configure(api_key = SANDBOX_API_KEY, sandbox = True) + + def testImportUrlTask(self): + """ + Test case for uploading file + :return: + """ + print("Test case for 'import/url' file...") + new_task = { + "url": "https://github.com/cloudconvert/cloudconvert-php/raw/master/tests/Integration/files/input.pdf" + } + + task = cloudconvert.Task.create(operation="import/url", payload=new_task) + + # do wait for the task + wait_task = cloudconvert.Task.wait(id=task["id"]) + + # delete the task + deleted = cloudconvert.Task.delete(id=wait_task["id"]) + + print("task deleted with Id: {}".format(wait_task["id"]) if deleted else "unable to delete the task: {}".format(wait_task["id"])) + + def tearDown(self): + """ + Teardown method + :return: + """ + print("Tearing down test case for task..") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 8afc94f..0000000 --- a/tests/test_api.py +++ /dev/null @@ -1,55 +0,0 @@ - - -import unittest -import os - - -from cloudconvert.api import Api -from cloudconvert.process import Process -from cloudconvert.exceptions import ( - APIError, HTTPError, BadRequest, ConversionFailed, TemporaryUnavailable, InvalidResponse, InvalidParameterException -) - - -class testApi(unittest.TestCase): - - ## test helpers - - def setUp(self): - self.api = Api(api_key=os.environ.get('API_KEY')) - - def tearDown(self): - self.api = None - - def test_01_if_request_without_auth_works(self): - response = self.api.get("/conversiontypes", { - 'inputformat': 'pdf', - 'outputformat': 'jpg' - }, False) - self.assertIsNotNone(response) - - - def test_02_if_request_with_auth_works(self): - response = self.api.post("/process", { - 'inputformat': 'pdf', - 'outputformat': 'jpg' - }, True) - self.assertIsNotNone(response) - - - def test_03_if_process_creation_works(self): - process = self.api.createProcess({ - 'inputformat': 'pdf', - 'outputformat': 'jpg' - }) - self.assertIsInstance(process, Process) - - - def test_04_if_process_creation_with_invalid_format_throws_the_right_exception(self): - with self.assertRaises(BadRequest): - self.api.createProcess({ - 'inputformat': 'invalid', - 'outputformat': 'jpg' - }) - - diff --git a/tests/test_process.py b/tests/test_process.py deleted file mode 100644 index a089f10..0000000 --- a/tests/test_process.py +++ /dev/null @@ -1,143 +0,0 @@ - - -import unittest -import os - - -from cloudconvert.api import Api -from cloudconvert.process import Process -from cloudconvert.exceptions import ( - APIError, HTTPError, BadRequest, ConversionFailed, TemporaryUnavailable, InvalidResponse, InvalidParameterException -) - - -class testProcess(unittest.TestCase): - - ## test helpers - - def setUp(self): - self.api = Api(api_key=os.environ.get('API_KEY')) - - def tearDown(self): - self.api = None - - - - def test_01_if_process_with_input_download_works(self): - process = self.api.createProcess({ - 'inputformat': 'png', - 'outputformat': 'jpg' - }) - process.start({ - 'input': 'download', - 'outputformat': 'jpg', - 'wait': True, - 'file': 'https://cloudconvert.com/blog/wp-content/themes/cloudconvert/img/logo_96x60.png' - }) - self.assertEqual(process['step'],'finished') - self.assertEqual(process['output']['ext'],'jpg') - - ## cleanup - process.delete() - - - - def test_02_if_process_with_input_upload_works(self): - process = self.api.createProcess({ - 'inputformat': 'png', - 'outputformat': 'jpg' - }) - process.start({ - 'input': 'upload', - 'outputformat': 'jpg', - 'wait': True, - 'file': open('tests/input.png', 'rb') - }) - self.assertEqual(process['step'],'finished') - self.assertEqual(process['output']['ext'],'jpg') - - ## cleanup - process.delete() - - - - def test_03_if_process_with_input_upload_and_custom_options_works(self): - process = self.api.createProcess({ - 'inputformat': 'png', - 'outputformat': 'jpg' - }) - process.start({ - 'input': 'upload', - 'outputformat': 'jpg', - 'wait': True, - 'file': open('tests/input.png', 'rb'), - 'converteroptions': { - 'quality': 10 - } - }) - self.assertEqual(process['step'],'finished') - self.assertEqual(process['output']['ext'],'jpg') - self.assertEqual(int(process['converter']['options']['quality']),10) - - ## cleanup - process.delete() - - - - def test_04_if_download_of_output_file_works(self): - process = self.api.createProcess({ - 'inputformat': 'png', - 'outputformat': 'pdf' - }) - process.start({ - 'input': 'upload', - 'outputformat': 'pdf', - 'file': open('tests/input.png', 'rb') - }) - process.wait() - process.download("output.pdf") - self.assertTrue(os.path.isfile("output.pdf")) - - ## cleanup - os.remove("output.pdf") - process.delete() - - - - def test_05_if_download_of_multiple_output_file_works(self): - process = self.api.createProcess({ - 'inputformat': 'pdf', - 'outputformat': 'jpg' - }) - process.start({ - 'input': 'upload', - 'outputformat': 'jpg', - 'file': open('tests/input.pdf', 'rb'), - 'converteroptions': { - 'page_range': '1-2' - } - }) - process.wait() - process.downloadAll() - self.assertTrue(os.path.isfile("input-0.jpg")) - self.assertTrue(os.path.isfile("input-1.jpg")) - - ## cleanup - os.remove("input-0.jpg") - os.remove("input-1.jpg") - process.delete() - - - - def test_06_if_convert_shortcut_works(self): - process = self.api.convert({ - 'inputformat': 'png', - 'outputformat': 'jpg', - 'input': 'upload', - 'file': open('tests/input.png', 'rb') - }).wait() - self.assertEqual(process['step'],'finished') - self.assertEqual(process['output']['ext'],'jpg') - - ## cleanup - process.delete() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/responses/job.json b/tests/unit/responses/job.json new file mode 100644 index 0000000..1445410 --- /dev/null +++ b/tests/unit/responses/job.json @@ -0,0 +1,123 @@ +{ + "data": { + "id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "tag": "test-1234", + "status": "error", + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": "2019-05-30T10:53:05+00:00", + "ended_at": "2019-05-30T10:53:23+00:00", + "tasks": [ + { + "id": "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b", + "name": "export-1", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "error", + "code": "INPUT_TASK_FAILED", + "message": "Input task has failed", + "percent": 100, + "operation": "export\/url", + "payload": { + "operation": "export\/url", + "input": [ + "task-1" + ] + }, + "result": null, + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": null, + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "host_name": null, + "storage": null, + "depends_on_task_ids": [ + "6df0920a-7042-4e87-be52-f38a0a29a67e" + ], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + } + }, + { + "id": "6df0920a-7042-4e87-be52-f38a0a29a67e", + "name": "task-1", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "error", + "code": "INPUT_TASK_FAILED", + "message": "Input task has failed", + "percent": 100, + "operation": "convert", + "engine": null, + "engine_version": null, + "payload": { + "operation": "convert", + "input_format": "mp4", + "output_format": "mp4", + "engine": "ffmpeg", + "input": [ + "import-1" + ], + "video_codec": "x264", + "crf": 0, + "preset": "veryslow", + "profile": "baseline", + "width": 1920, + "height": 1080, + "audio_codec": "copy", + "audio_bitrate": 320, + "engine_version": "4.1.1" + }, + "result": null, + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": null, + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "host_name": null, + "storage": null, + "depends_on_task_ids": [ + "22be63c2-0e3f-4909-9c2a-2261dc540aba" + ], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/6df0920a-7042-4e87-be52-f38a0a29a67e" + } + }, + { + "id": "22be63c2-0e3f-4909-9c2a-2261dc540aba", + "name": "import-1", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "error", + "code": "SANDBOX_FILE_NOT_ALLOWED", + "message": "The file file.mp4 is not whitelisted for sandbox use", + "percent": 100, + "operation": "import\/url", + "payload": { + "operation": "import\/url", + "url": "https:\/\/some.url\/file.mp4", + "filename": "file.mp4" + }, + "result": { + "files": [ + { + "filename": "file.mp4", + "md5": "c03538f8edd84537190c264109fa2284" + } + ] + }, + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": "2019-05-30T10:53:05+00:00", + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "host_name": "leta", + "storage": null, + "depends_on_task_ids": [], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/22be63c2-0e3f-4909-9c2a-2261dc540aba" + } + } + ], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/jobs\/cd82535b-0614-4b23-bbba-b24ab0e892f7" + } + } +} diff --git a/tests/unit/responses/job_created.json b/tests/unit/responses/job_created.json new file mode 100644 index 0000000..9642bd6 --- /dev/null +++ b/tests/unit/responses/job_created.json @@ -0,0 +1 @@ +{"id": "5da371b0-0e43-41c4-94a1-1e04de2d2e29", "tag": null, "status": "waiting", "created_at": "2020-02-10T18:53:05+00:00", "started_at": null, "ended_at": null, "tasks": [{"id": "cb097382-cb18-4a52-bc0e-0f7dff3dc0d6", "name": "sandbox-task-import-file", "job_id": "5da371b0-0e43-41c4-94a1-1e04de2d2e29", "status": "waiting", "credits": null, "code": null, "message": null, "percent": 100, "operation": "import/url", "result": null, "created_at": "2020-02-10T18:53:05+00:00", "started_at": null, "ended_at": null, "retry_of_task_id": null, "copy_of_task_id": null, "user_id": 40100941, "priority": -10, "host_name": null, "storage": null, "depends_on_task_ids": [], "links": {"self": "https://api.cloudconvert.com/v2/tasks/cb097382-cb18-4a52-bc0e-0f7dff3dc0d6"}}], "links": {"self": "https://api.cloudconvert.com/v2/jobs/5da371b0-0e43-41c4-94a1-1e04de2d2e29"}} diff --git a/tests/unit/responses/job_export_urls.json b/tests/unit/responses/job_export_urls.json new file mode 100644 index 0000000..2dabbc2 --- /dev/null +++ b/tests/unit/responses/job_export_urls.json @@ -0,0 +1,67 @@ +{ + "data": { + "id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "tag": "test-1234", + "status": "error", + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": "2019-05-30T10:53:05+00:00", + "ended_at": "2019-05-30T10:53:23+00:00", + "tasks": [ + { + "id": "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b", + "name": "export-1", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "finished", + "percent": 100, + "operation": "export\/url", + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": null, + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "host_name": null, + "storage": null, + "result": { + "files": [ + { + "filename": "file.mp4", + "url": "https://storage.cloudconvert.com/file.mp4" + } + ] + }, + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + } + }, + { + "id": "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b", + "name": "export-2", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "finished", + "percent": 100, + "operation": "export\/url", + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": null, + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "host_name": null, + "storage": null, + "result": { + "files": [ + { + "filename": "file2.mp4", + "url": "https://storage.cloudconvert.com/file2.mp4" + } + ] + }, + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + } + } + ], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/jobs\/cd82535b-0614-4b23-bbba-b24ab0e892f7" + } + } +} diff --git a/tests/unit/responses/jobs.json b/tests/unit/responses/jobs.json new file mode 100644 index 0000000..3b64bdd --- /dev/null +++ b/tests/unit/responses/jobs.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "id": "bd7d06b4-60fb-472b-b3a3-9034b273df07", + "status": "waiting", + "created_at": "2019-05-13T19:52:21+00:00", + "started_at": null, + "ended_at": null, + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/jobs\/bd7d06b4-60fb-472b-b3a3-9034b273df07" + } + } + ], + "links": { + "first": "https:\/\/api.cloudconvert.com\/v2\/jobs?page=1", + "last": null, + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "path": "https:\/\/api.cloudconvert.com\/v2\/jobs", + "per_page": 100, + "to": 1 + } +} diff --git a/tests/unit/responses/retry.json b/tests/unit/responses/retry.json new file mode 100644 index 0000000..166a6b2 --- /dev/null +++ b/tests/unit/responses/retry.json @@ -0,0 +1 @@ +{"id": "e1dc9bfd-e038-4bf3-b10e-88ecbeaa0bc9", "job_id": null, "status": "waiting", "credits": null, "code": null, "message": null, "percent": 100, "operation": "import/url", "result": null, "created_at": "2020-02-10T16:55:25+00:00", "started_at": null, "ended_at": null, "retry_of_task_id": "66bd538e-1500-4e4b-b908-0e429b357e77", "copy_of_task_id": "66bd538e-1500-4e4b-b908-0e429b357e77", "user_id": 40100941, "priority": -10, "host_name": null, "storage": "ovh-lim", "depends_on_task_ids": [], "links": {"self": "https://api.cloudconvert.com/v2/tasks/e1dc9bfd-e038-4bf3-b10e-88ecbeaa0bc9"}} diff --git a/tests/unit/responses/task.json b/tests/unit/responses/task.json new file mode 100644 index 0000000..c235b8f --- /dev/null +++ b/tests/unit/responses/task.json @@ -0,0 +1,76 @@ +{ + "data": { + "id": "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b", + "name": "export-1", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "error", + "code": "INPUT_TASK_FAILED", + "message": "Input task has failed", + "percent": 100, + "operation": "export\/url", + "payload": { + "operation": "export\/url", + "input": [ + "task-1" + ] + }, + "result": null, + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": null, + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "retries": [], + "host_name": null, + "storage": null, + "depends_on_tasks": [ + { + "id": "6df0920a-7042-4e87-be52-f38a0a29a67e", + "name": "task-1", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "error", + "code": "INPUT_TASK_FAILED", + "message": "Input task has failed", + "percent": 100, + "operation": "convert", + "engine": null, + "engine_version": null, + "payload": { + "operation": "convert", + "input_format": "mp4", + "output_format": "mp4", + "engine": "ffmpeg", + "input": [ + "import-1" + ], + "video_codec": "x264", + "crf": 0, + "preset": "veryslow", + "profile": "baseline", + "width": 1920, + "height": 1080, + "audio_codec": "copy", + "audio_bitrate": 320, + "engine_version": "4.1.1" + }, + "result": null, + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": null, + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "host_name": null, + "storage": null, + "depends_on_task_ids": [ + "22be63c2-0e3f-4909-9c2a-2261dc540aba" + ], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/6df0920a-7042-4e87-be52-f38a0a29a67e" + } + } + ], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + } + } +} diff --git a/tests/unit/responses/task_created.json b/tests/unit/responses/task_created.json new file mode 100644 index 0000000..e1eda2a --- /dev/null +++ b/tests/unit/responses/task_created.json @@ -0,0 +1,27 @@ +{ + "data": { + "id": "2f901289-c9fe-4c89-9c4b-98be526bdfbf", + "job_id": null, + "status": "waiting", + "code": null, + "message": null, + "percent": 100, + "operation": "import\/url", + "payload": { + "name": "test", + "url": "http:\/\/invalid.url", + "filename": "test.file" + }, + "result": null, + "created_at": "2019-05-31T23:52:39+00:00", + "started_at": "2019-05-31T23:52:39+00:00", + "ended_at": "2019-05-31T23:53:26+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "retries": [], + "depends_on_tasks": [], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/2f901289-c9fe-4c89-9c4b-98be526bdfbf" + } + } +} diff --git a/tests/unit/responses/tasks.json b/tests/unit/responses/tasks.json new file mode 100644 index 0000000..c9ed3bc --- /dev/null +++ b/tests/unit/responses/tasks.json @@ -0,0 +1,46 @@ +{ + "data": [ + { + "id": "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b", + "name": "export-1", + "job_id": "cd82535b-0614-4b23-bbba-b24ab0e892f7", + "status": "error", + "code": "INPUT_TASK_FAILED", + "message": "Input task has failed", + "percent": 100, + "operation": "export\/url", + "payload": { + "operation": "export\/url", + "input": [ + "task-1" + ] + }, + "result": null, + "created_at": "2019-05-30T10:53:01+00:00", + "started_at": null, + "ended_at": "2019-05-30T10:53:23+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "retries": [], + "host_name": null, + "storage": null, + "depends_on_task_ids": [], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + } + } + ], + "links": { + "first": "https:\/\/api.cloudconvert.com\/v2\/tasks?page=1", + "last": null, + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "path": "https:\/\/api.cloudconvert.com\/v2\/tasks", + "per_page": 100, + "to": 1 + } +} diff --git a/tests/unit/responses/upload_task_created.json b/tests/unit/responses/upload_task_created.json new file mode 100644 index 0000000..1aa53bd --- /dev/null +++ b/tests/unit/responses/upload_task_created.json @@ -0,0 +1,33 @@ +{ + "data": { + "id": "2f901289-c9fe-4c89-9c4b-98be526bdfbf", + "job_id": null, + "status": "waiting", + "code": null, + "message": null, + "percent": 100, + "operation": "import\/upload", + "payload": null, + "result": { + "form": { + "url": "https://upload.sandbox.cloudconvert.com/storage.de1.cloud.ovh.net/v1/AUTH_b2cffe8f45324c2bba39e8db1aedb58f/cloudconvert-files-sandbox/8aefdb39-34c8-4c7a-9f2e-1751686d615e/?s=jNf7hn3zox1iZfZY6NirNA&e=1559588529", + "parameters": { + "expires": 1559588529, + "max_file_count": 1, + "max_file_size": 10000000000, + "signature": "79fda6c5ffbfaa857ae9a1430641cc68c5a72297" + } + } + }, + "created_at": "2019-05-31T23:52:39+00:00", + "started_at": "2019-05-31T23:52:39+00:00", + "ended_at": "2019-05-31T23:53:26+00:00", + "retry_of_task_id": null, + "copy_of_task_id": null, + "retries": [], + "depends_on_tasks": [], + "links": { + "self": "https:\/\/api.cloudconvert.com\/v2\/tasks\/2f901289-c9fe-4c89-9c4b-98be526bdfbf" + } + } +} diff --git a/tests/unit/testJob.py b/tests/unit/testJob.py new file mode 100644 index 0000000..e7a77ad --- /dev/null +++ b/tests/unit/testJob.py @@ -0,0 +1,140 @@ +################################################################### +## Test cases for Cloud Convert API jobs endpoints ## +## ## +## How to run ? : ## +## $ python tests/unit/testJob.py ## +################################################################### + +import sys +import os + +sys.path.append(os.getcwd()) + +import unittest +import cloudconvert +import requests_mock +import json +from cloudconvert.config import SANDBOX_API_KEY + + +class JobTestCase(unittest.TestCase): + + def setUp(self): + """ + Test case setup method + :return: + """ + print("Setting up Job test cases") + self.cloudconvert = cloudconvert + + # setup the client with the provided API key by configuring + self.cloudconvert.configure(api_key=SANDBOX_API_KEY, sandbox=True) + self.responses_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "responses") + + def testCreateJob(self): + """ + Create Job + :return: + """ + print("testcase for creating Job..") + + # create dict for new Job + job_with_single_task = { + "tasks": { + "sandbox-task-import-file": { + "operation": "import/url", + "url": "https://github.com/cloudconvert/cloudconvert-php/raw/master/tests/Integration/files/input.pdf" + } + } + } + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "job_created.json")) as f: + response_json = json.load(f) + + m.post("https://api.sandbox.cloudconvert.com/v2/jobs", json=response_json) + job = self.cloudconvert.Job.create(payload=job_with_single_task) + + self.assertEqual(first=job['id'], second="5da371b0-0e43-41c4-94a1-1e04de2d2e29") + print(m.called) + + def testWaitJob(self): + """ + Wait Job + :return: + """ + print("testcase for waiting job..") + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "job.json")) as f: + response_json = json.load(f) + + job_id = "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + m.get("https://sync.api.sandbox.cloudconvert.com/v2/jobs/{}".format(job_id), json=response_json) + + job = self.cloudconvert.Job.wait(id=job_id) + + self.assertEqual(first=job['id'], second="cd82535b-0614-4b23-bbba-b24ab0e892f7") + print(m.called) + + def testShowJob(self): + """ + Show Job + :return: + """ + print("testcase for show job..") + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "job.json")) as f: + response_json = json.load(f) + + job_id = "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + m.get("https://api.sandbox.cloudconvert.com/v2/jobs/{}".format(job_id), json=response_json) + + job = self.cloudconvert.Job.show(id=job_id) + + self.assertEqual(first=job['id'], second="cd82535b-0614-4b23-bbba-b24ab0e892f7") + print(m.called) + + def testListJob(self): + """ + List Jobs + :return: + """ + print("testcase for listing Jobs..") + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "jobs.json")) as f: + response_json = json.load(f) + + m.get("https://api.sandbox.cloudconvert.com/v2/jobs", json=response_json) + jobs = self.cloudconvert.Job.all() + + self.assertEqual(isinstance(jobs, list), True) + print(m.called) + + def testDeleteJob(self): + """ + Delete Job + :return: + """ + print("testcase for delete job..") + + with requests_mock.mock() as m: + job_id = "66681017-2e84-4956-991f-d6513f6a4e35" + m.delete("https://api.sandbox.cloudconvert.com/v2/jobs/{}".format(job_id), json={}) + + isDeleted = self.cloudconvert.Job.delete(id=job_id) + self.assertEqual(first=isDeleted, second=True) + print(m.called) + + def tearDown(self): + """ + Teardown method + :return: + """ + print("Tearing down job test cases..") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/testSignedUrl.py b/tests/unit/testSignedUrl.py new file mode 100644 index 0000000..ecd41cf --- /dev/null +++ b/tests/unit/testSignedUrl.py @@ -0,0 +1,71 @@ +################################################################### +## Test case for Signed URLs ## +## ## +## How to run ? : ## +## $ python testSignedUrl.py ## +################################################################### + +import sys +import os +sys.path.append(os.getcwd()) + +import unittest +import cloudconvert + + + +class TestSignedUrl(unittest.TestCase): + + def setUp(self): + """ + Test case setup method + :return: + """ + print("Setting up signed URL test case") + + def testVerifySignature(self): + """ + Test verify + :return: + """ + print("Testcase for creating signed URL..") + + # create dict for new Job + job = { + "tasks": { + "import-file": { + "operation": "import/url", + "url": "https://github.com/cloudconvert/cloudconvert-php/raw/master/tests/Integration/files/input.pdf" + }, + "export-file": { + "operation": "export/url", + "input": "import-file" + } + } + } + + base = "https://s.cloudconvert.com/b3d85428-584e-4639-bc11-76b7dee9c109" + signing_secret = "NT8dpJkttEyfSk3qlRgUJtvTkx64vhyX" + cache_key = "mykey" + + url = cloudconvert.SignedUrl.sign(base, signing_secret, job, cache_key) + + print(url) + + self.assertIn("https://s.cloudconvert.com/", url) + self.assertIn("?job=", url) + self.assertIn("&cache_key=mykey", url) + self.assertIn("&s=6dd147217a39534249a3cb418b357ba8cceacf74fc0db0d52630a07cac1ca268", url) + + + + def tearDown(self): + """ + Teardown method + :return: + """ + print("Tearing down test case for signed URL..") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/testTask.py b/tests/unit/testTask.py new file mode 100644 index 0000000..f7dc669 --- /dev/null +++ b/tests/unit/testTask.py @@ -0,0 +1,161 @@ +################################################################### +## Test cases for Cloud Convert API tasks endpoints ## +## ## +## How to run ? : ## +## $ python tests/unit/testTask.py ## +################################################################### + +import sys +import os +sys.path.append(os.getcwd()) + +import unittest +import requests_mock +import cloudconvert +import json +from cloudconvert.config import SANDBOX_API_KEY + + +class TaskTestCase(unittest.TestCase): + + def setUp(self): + """ + Test case setup method + :return: + """ + print("Setting up Task test cases") + self.cloudconvert = cloudconvert + + # setup the client with the provided API key by configuring + self.cloudconvert.configure(api_key = SANDBOX_API_KEY, sandbox = True) + self.responses_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "responses") + + def testCreateTask(self): + """ + Create Task + :return: + """ + print("testcase for creating task..") + + # create dict for new task + new_import_url_task = { + "url": "https://github.com/cloudconvert/cloudconvert-php/raw/master/tests/Integration/files/input.pdf" + } + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "task_created.json")) as f: + response_json = json.load(f) + + m.post("https://api.sandbox.cloudconvert.com/v2/import/url", json=response_json) + task = self.cloudconvert.Task.create(operation="import/url", payload=new_import_url_task) + + self.assertEqual(first=task['id'], second="2f901289-c9fe-4c89-9c4b-98be526bdfbf") + print(m.called) + + def testWaitTask(self): + """ + Wait Task + :return: + """ + print("testcase for waiting task..") + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "task.json")) as f: + response_json = json.load(f) + + task_id = "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + m.get("https://sync.api.sandbox.cloudconvert.com/v2/tasks/{}".format(task_id), json=response_json) + + task = self.cloudconvert.Task.wait(id=task_id) + + self.assertEqual(first=task['id'], second="4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b") + print(m.called) + + def testShowTask(self): + """ + Show Task + :return: + """ + print("testcase for show task..") + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "task.json")) as f: + response_json = json.load(f) + + task_id = "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + m.get("https://api.sandbox.cloudconvert.com/v2/tasks/{}".format(task_id), json=response_json) + + task = self.cloudconvert.Task.show(id=task_id) + + self.assertEqual(first=task['id'], second="4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b") + print(m.called) + + def testListTask(self): + """ + List Task + :return: + """ + print("testcase for listing tasks..") + + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "tasks.json")) as f: + response_json = json.load(f) + + m.get("https://api.sandbox.cloudconvert.com/v2/tasks", json=response_json) + tasks = self.cloudconvert.Task.all() + + self.assertEqual(isinstance(tasks, list), True) + print(m.called) + + def testRetryTask(self): + """ + Retry Task + :return: + """ + print("testcase for retrying a task") + with requests_mock.mock() as m: + with open("{}/{}".format(self.responses_path, "retry.json")) as f: + response_json = json.load(f) + + task_id = "66bd538e-1500-4e4b-b908-0e429b357e77" + m.post("https://api.sandbox.cloudconvert.com/v2/tasks/{}/retry".format(task_id), json=response_json) + tasks = self.cloudconvert.Task.retry(task_id) + + self.assertEqual(tasks["retry_of_task_id"], task_id) + print(m.called) + + def testDeleteTask(self): + """ + Delete Task + :return: + """ + print("testcase for delete task..") + + with requests_mock.mock() as m: + + task_id = "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" + m.delete("https://api.sandbox.cloudconvert.com/v2/tasks/{}".format(task_id), json={}) + + isDeleted = self.cloudconvert.Task.delete(id=task_id) + self.assertEqual(first=isDeleted, second=True) + print(m.called) + + def testDownloadOutput(self): + """ + Testcase to download output file + :return: + """ + res = cloudconvert.download(filename="path/to/save/file.ext", url="url/to/file/download") + print(res) + + def tearDown(self): + """ + Teardown method + :return: + """ + print("Tearing down task test cases..") + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/testWebhookSignature.py b/tests/unit/testWebhookSignature.py new file mode 100644 index 0000000..8bf40aa --- /dev/null +++ b/tests/unit/testWebhookSignature.py @@ -0,0 +1,48 @@ +################################################################### +## Test case for Webhook signature ## +## ## +## How to run ? : ## +## $ python testWebhookSignature.py ## +################################################################### + +import sys +import os +sys.path.append(os.getcwd()) + +import unittest +import cloudconvert + + +# Set webhook signature +WEBHOOK_SIGNATURE = "5c4c0691bce8a1a2af738b7073fe0627e792734813358c5f88a658819dd0a6d2" + + +class TestWebhookSignature(unittest.TestCase): + + def setUp(self): + """ + Test case setup method + :return: + """ + print("Setting up webhook signature test case") + + def testVerifySignature(self): + """ + Test verify + :return: + """ + print("Testcase for verifying signature..") + verified = cloudconvert.Webhook.verify("cloudconvert", WEBHOOK_SIGNATURE, "90sffs0d8fs0f9sf0") + + assert verified == True, "Signature did not match" + + def tearDown(self): + """ + Teardown method + :return: + """ + print("Tearing down test case for webhook signature..") + + +if __name__ == '__main__': + unittest.main()