From 866c87a58df313c2c7d70482426c765bd26a340d Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Sat, 15 Feb 2020 20:56:45 +0100 Subject: [PATCH 01/15] v2 initial commit --- .gitignore | 15 +- .travis.yml | 17 +- LICENCE | 21 ++ README.md | 131 ++++++++++ README.rst | 96 ------- cloudconvert/__init__.py | 25 +- cloudconvert/api.py | 191 -------------- cloudconvert/cloudconvertrestclient.py | 241 ++++++++++++++++++ cloudconvert/config.py | 10 + cloudconvert/environment_vars.py | 8 + cloudconvert/exceptions.py | 25 -- cloudconvert/exceptions/__init__.py | 0 cloudconvert/exceptions/exceptions.py | 98 +++++++ cloudconvert/job.py | 27 ++ cloudconvert/process.py | 137 ---------- cloudconvert/resource.py | 224 ++++++++++++++++ cloudconvert/task.py | 88 +++++++ cloudconvert/urlencoder.py | 78 ------ cloudconvert/utils.py | 46 ++++ cloudconvert/webhook.py | 16 ++ requirements-dev.txt | 9 +- setup.py | 53 ++-- tests/input.png | Bin 35024 -> 0 bytes tests/integration/__init__.py | 0 tests/{ => integration/files}/input.pdf | Bin tests/integration/files/input.png | Bin 0 -> 46937 bytes tests/integration/out/.gitignore | 2 + tests/integration/testJobs.py | 92 +++++++ tests/integration/testTasks.py | 59 +++++ tests/test_api.py | 55 ---- tests/test_process.py | 143 ----------- tests/unit/__init__.py | 0 tests/unit/responses/job.json | 123 +++++++++ tests/unit/responses/job_created.json | 1 + tests/unit/responses/job_export_urls.json | 67 +++++ tests/unit/responses/jobs.json | 27 ++ tests/unit/responses/retry.json | 1 + tests/unit/responses/task.json | 76 ++++++ tests/unit/responses/task_created.json | 27 ++ tests/unit/responses/tasks.json | 46 ++++ tests/unit/responses/upload_task_created.json | 33 +++ tests/unit/testJob.py | 140 ++++++++++ tests/unit/testTask.py | 161 ++++++++++++ tests/unit/testWebhookSignature.py | 48 ++++ 44 files changed, 1870 insertions(+), 787 deletions(-) create mode 100644 LICENCE create mode 100644 README.md delete mode 100644 README.rst delete mode 100644 cloudconvert/api.py create mode 100644 cloudconvert/cloudconvertrestclient.py create mode 100644 cloudconvert/config.py create mode 100644 cloudconvert/environment_vars.py delete mode 100644 cloudconvert/exceptions.py create mode 100644 cloudconvert/exceptions/__init__.py create mode 100644 cloudconvert/exceptions/exceptions.py create mode 100644 cloudconvert/job.py delete mode 100644 cloudconvert/process.py create mode 100644 cloudconvert/resource.py create mode 100644 cloudconvert/task.py delete mode 100644 cloudconvert/urlencoder.py create mode 100644 cloudconvert/utils.py create mode 100644 cloudconvert/webhook.py delete mode 100755 tests/input.png create mode 100644 tests/integration/__init__.py rename tests/{ => integration/files}/input.pdf (100%) create mode 100644 tests/integration/files/input.png create mode 100644 tests/integration/out/.gitignore create mode 100644 tests/integration/testJobs.py create mode 100644 tests/integration/testTasks.py delete mode 100644 tests/test_api.py delete mode 100644 tests/test_process.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/responses/job.json create mode 100644 tests/unit/responses/job_created.json create mode 100644 tests/unit/responses/job_export_urls.json create mode 100644 tests/unit/responses/jobs.json create mode 100644 tests/unit/responses/retry.json create mode 100644 tests/unit/responses/task.json create mode 100644 tests/unit/responses/task_created.json create mode 100644 tests/unit/responses/tasks.json create mode 100644 tests/unit/responses/upload_task_created.json create mode 100644 tests/unit/testJob.py create mode 100644 tests/unit/testTask.py create mode 100644 tests/unit/testWebhookSignature.py diff --git a/.gitignore b/.gitignore index d63cd22..1f76830 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,5 @@ -# 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 +*.egg \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index c7095a7..ad3985e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,11 @@ language: python python: - - "2.7" - - "3.2" - - "3.3" - - "3.4" - - "3.5" + - '3.7' + - '3.6' + - '3.5' + - '2.7' + install: - - pip install . - - pip install -r requirements-dev.txt -script: nosetests -sudo: false + - pip install -r requirements.txt + +script: python tests/unit/testTask.py || python tests/unit/testJob.py || python tests/unit/testWebhookSignature.py 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..30de25c --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +## cloudconvert-python + +This is the official Python SDK v2 for the [CloudConvert](https://cloudconvert.com/api/v2) _API v2_. +For API v1, please use [v1 branch](https://github.com/cloudconvert/cloudconvert-python/tree/v1) of this repository. + + +[![Build Status](https://travis-ci.com/cloudconvert/cloudconvert-python.svg?branch=master)](https://travis-ci.com/cloudconvert/cloudconvert-python) +## Installation + +``` + pip install cloudconvert +``` + +## Creating API Client + +``` + import cloudconvert + + cloudconvert.configure(api_key = 'API_KEY', sandbox = False) +``` + +Or set the environment variable `CLOUDCONVERT_API_KEY` and use: + +``` + import cloudconvert + + cloudconvert.default() +``` + +## Creating Jobs + +```js + 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. + +```js +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: + +```js +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. + +```js +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 +``` + +## Unit Tests + +``` +# Run Task tests +$ python tests/unit/testTask.py + +# Run Job tests +$ python tests/unit/testJob.py + +# Run Webhook tests +$ python tests/unit/testWebhookSignature.py + +``` + + +## Integration Tests +``` +# Run Integration test for task +$ python tests/integration/testTasks.py + +# Run Integration test for Job +$ python tests/integration/testJobs.py + +``` + + +## 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..c999d23 100644 --- a/cloudconvert/__init__.py +++ b/cloudconvert/__init__.py @@ -1,5 +1,20 @@ -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 + +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..03e11e3 --- /dev/null +++ b/cloudconvert/cloudconvertrestclient.py @@ -0,0 +1,241 @@ +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__ + +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()) + # 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 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 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 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..7b22ec0 --- /dev/null +++ b/cloudconvert/config.py @@ -0,0 +1,10 @@ +__version__ = "2.0.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" +} +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..6a38484 --- /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 + + +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 = 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 = 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 = 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 = default_client() + + url = util.join_url(cls.path, str(id), "wait") + res = api_client.get(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 = 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 = 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/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 index e6675df..a3f50ac 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,3 @@ -coverage==3.7.1 -nose==1.3.3 -yanc==0.2.4 -Sphinx==1.2.2 -coveralls==0.4.2 - +requests +urllib3 +requests-mock \ No newline at end of file diff --git a/setup.py b/setup.py index e527f6d..6396db4 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.0.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(), + 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", - ], + ], + zip_safe=False ) \ No newline at end of file diff --git a/tests/input.png b/tests/input.png deleted file mode 100755 index 985d814dc5868068c3c5bdf7f14c1d5fa126ef5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35024 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≦i`sW5Xm^Orc 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
  • 07RIuax1ZOk9>ofy5Fz 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 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 0000000000000000000000000000000000000000..d72c10fa264c019551eb6ee1694fea0f6fd1776a GIT binary patch literal 46937 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 Date: Sat, 15 Feb 2020 21:00:53 +0100 Subject: [PATCH 02/15] fix travis --- README.md | 2 +- requirements-dev.txt => requirements.txt | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename requirements-dev.txt => requirements.txt (100%) diff --git a/README.md b/README.md index 30de25c..62156be 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is the official Python SDK v2 for the [CloudConvert](https://cloudconvert.c For API v1, please use [v1 branch](https://github.com/cloudconvert/cloudconvert-python/tree/v1) of this repository. -[![Build Status](https://travis-ci.com/cloudconvert/cloudconvert-python.svg?branch=master)](https://travis-ci.com/cloudconvert/cloudconvert-python) +[![Build Status](https://travis-ci.org/cloudconvert/cloudconvert-python.svg?branch=master)](https://travis-ci.org/cloudconvert/cloudconvert-python) ## Installation ``` diff --git a/requirements-dev.txt b/requirements.txt similarity index 100% rename from requirements-dev.txt rename to requirements.txt From a31fc6a6a45e46cfe2ffbd88c33dd992075fd98f Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Mon, 19 Apr 2021 17:40:59 +0200 Subject: [PATCH 03/15] Create run-tests.yml --- .github/workflows/run-tests.yml | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/run-tests.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..0378e4f --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['2.7', '3.6', '3.7', '3.8'] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test Tasks + run: python tests/unit/testTask.py + - name: Test Jobs + run: python tests/unit/testJob.py + - name: Test Webhooks + run: python tests/unit/testWebhookSignature.py From a2b1a2d94d0bcab43379056c41c5efa1c82f16d2 Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Mon, 19 Apr 2021 17:45:19 +0200 Subject: [PATCH 04/15] Setup GitHub Actions --- .travis.yml | 11 ----------- README.md | 4 +++- 2 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ad3985e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: python -python: - - '3.7' - - '3.6' - - '3.5' - - '2.7' - -install: - - pip install -r requirements.txt - -script: python tests/unit/testTask.py || python tests/unit/testJob.py || python tests/unit/testWebhookSignature.py diff --git a/README.md b/README.md index 62156be..f41b6a0 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,10 @@ This is the official Python SDK v2 for the [CloudConvert](https://cloudconvert.com/api/v2) _API v2_. For API v1, please use [v1 branch](https://github.com/cloudconvert/cloudconvert-python/tree/v1) of this repository. +[![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) -[![Build Status](https://travis-ci.org/cloudconvert/cloudconvert-python.svg?branch=master)](https://travis-ci.org/cloudconvert/cloudconvert-python) ## Installation ``` From 991371d5f16709c22e8662daf57289aabd5c517d Mon Sep 17 00:00:00 2001 From: Kreus Amredes <67752638+Kreusada@users.noreply.github.com> Date: Wed, 1 Sep 2021 10:03:01 +0100 Subject: [PATCH 05/15] Update README.md --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f41b6a0..2e5901c 100644 --- a/README.md +++ b/README.md @@ -15,23 +15,23 @@ For API v1, please use [v1 branch](https://github.com/cloudconvert/cloudconvert- ## Creating API Client -``` - import cloudconvert - - cloudconvert.configure(api_key = 'API_KEY', sandbox = False) +```py + import cloudconvert + + cloudconvert.configure(api_key = 'API_KEY', sandbox = False) ``` Or set the environment variable `CLOUDCONVERT_API_KEY` and use: -``` - import cloudconvert - - cloudconvert.default() +```py + import cloudconvert + + cloudconvert.default() ``` ## Creating Jobs -```js +```py import cloudconvert cloudconvert.configure(api_key = 'API_KEY') @@ -61,7 +61,7 @@ Or set the environment variable `CLOUDCONVERT_API_KEY` and use: CloudConvert can generate public URLs for using `export/url` tasks. You can use these URLs to download output files. -```js +```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] @@ -73,7 +73,7 @@ print(res) 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: -```js +```py job = cloudconvert.Job.create(payload={ 'tasks': { 'upload-my-file': { @@ -93,7 +93,7 @@ res = cloudconvert.Task.find(id=upload_task_id) The node SDK allows to verify webhook requests received from CloudConvert. -```js +```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. From c7dfe1d22f5d94d5b1b62d79f2d6d1bd12f16839 Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Mon, 8 Nov 2021 12:04:02 +0100 Subject: [PATCH 06/15] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 2e5901c..c9bdeda 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ ## cloudconvert-python -This is the official Python SDK v2 for the [CloudConvert](https://cloudconvert.com/api/v2) _API v2_. -For API v1, please use [v1 branch](https://github.com/cloudconvert/cloudconvert-python/tree/v1) of this repository. +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) From b5f36cb6347b53103d7fe9304d6656817b14c567 Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Sun, 27 Mar 2022 11:47:58 +0200 Subject: [PATCH 07/15] signed URLs --- README.md | 90 ++++++++++++++++++++++++------------- cloudconvert/__init__.py | 1 + cloudconvert/signed_url.py | 29 ++++++++++++ tests/unit/testSignedUrl.py | 71 +++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 31 deletions(-) create mode 100644 cloudconvert/signed_url.py create mode 100644 tests/unit/testSignedUrl.py diff --git a/README.md b/README.md index c9bdeda..ac72ed2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## cloudconvert-python -This is the official Python SDK for the [CloudConvert](https://cloudconvert.com/api/v2) **API v2**. +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) @@ -17,7 +17,7 @@ This is the official Python SDK for the [CloudConvert](https://cloudconvert.com/ ```py import cloudconvert - cloudconvert.configure(api_key = 'API_KEY', sandbox = False) +cloudconvert.configure(api_key='API_KEY', sandbox=False) ``` Or set the environment variable `CLOUDCONVERT_API_KEY` and use: @@ -25,7 +25,7 @@ Or set the environment variable `CLOUDCONVERT_API_KEY` and use: ```py import cloudconvert - cloudconvert.default() +cloudconvert.default() ``` ## Creating Jobs @@ -33,26 +33,26 @@ Or set the environment variable `CLOUDCONVERT_API_KEY` and use: ```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' - } - } - }) +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' + } + } +}) ``` @@ -62,7 +62,7 @@ CloudConvert can generate public URLs for using `export/url` tasks. You can use ```py exported_url_task_id = "84e872fc-d823-4363-baab-eade2e05ee54" -res = cloudconvert.Task.wait(id=exported_url_task_id) # Wait for job completion +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) @@ -70,7 +70,8 @@ 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: +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={ @@ -88,16 +89,44 @@ 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. +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" + } + } +} -isValid = cloudconvert.Webhook.verify(payloadString, signature, signingSecret); # returns true or false +url = cloudconvert.SignedUrl.sign(base, signing_secret, job, cache_key); # returns the URL ``` ## Unit Tests @@ -114,8 +143,8 @@ $ python tests/unit/testWebhookSignature.py ``` - ## Integration Tests + ``` # Run Integration test for task $ python tests/integration/testTasks.py @@ -124,7 +153,6 @@ $ python tests/integration/testTasks.py $ python tests/integration/testJobs.py ``` - ## Resources diff --git a/cloudconvert/__init__.py b/cloudconvert/__init__.py index c999d23..5b63cd0 100644 --- a/cloudconvert/__init__.py +++ b/cloudconvert/__init__.py @@ -2,6 +2,7 @@ from cloudconvert.task import Task from cloudconvert.job import Job from cloudconvert.webhook import Webhook +from cloudconvert.signed_url import SignedUrl def configure(**config): """ 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/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() From 2668d64f080f478e68f753aa376f56d7a0e759f0 Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Sun, 27 Mar 2022 11:58:50 +0200 Subject: [PATCH 08/15] use new sync API endpoints for job/task wait() --- cloudconvert/cloudconvertrestclient.py | 15 ++++++++++++++- cloudconvert/config.py | 4 ++++ cloudconvert/resource.py | 4 ++-- tests/unit/testJob.py | 2 +- tests/unit/testTask.py | 2 +- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/cloudconvert/cloudconvertrestclient.py b/cloudconvert/cloudconvertrestclient.py index 03e11e3..3e32454 100644 --- a/cloudconvert/cloudconvertrestclient.py +++ b/cloudconvert/cloudconvertrestclient.py @@ -11,7 +11,7 @@ import cloudconvert.utils as util from cloudconvert.exceptions import exceptions -from cloudconvert.config import __version__, __endpoint_map__ +from cloudconvert.config import __version__, __endpoint_map__, __sync_endpoint_map__ log = logging.getLogger(__name__) @@ -40,6 +40,7 @@ def __init__(self, options=None, **kwargs): "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 @@ -57,6 +58,9 @@ def __init__(self, options=None, **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:: @@ -161,6 +165,15 @@ def get(self, action, headers=None): """ 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:: diff --git a/cloudconvert/config.py b/cloudconvert/config.py index 7b22ec0..149fc19 100644 --- a/cloudconvert/config.py +++ b/cloudconvert/config.py @@ -7,4 +7,8 @@ "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/resource.py b/cloudconvert/resource.py index 6a38484..18ea899 100644 --- a/cloudconvert/resource.py +++ b/cloudconvert/resource.py @@ -184,8 +184,8 @@ def wait(cls, id): """ api_client = default_client() - url = util.join_url(cls.path, str(id), "wait") - res = api_client.get(url) + url = util.join_url(cls.path, str(id)) + res = api_client.get_sync(url) try: return res["data"] except: diff --git a/tests/unit/testJob.py b/tests/unit/testJob.py index f919c27..e7a77ad 100644 --- a/tests/unit/testJob.py +++ b/tests/unit/testJob.py @@ -70,7 +70,7 @@ def testWaitJob(self): response_json = json.load(f) job_id = "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" - m.get("https://api.sandbox.cloudconvert.com/v2/jobs/{}/wait".format(job_id), json=response_json) + m.get("https://sync.api.sandbox.cloudconvert.com/v2/jobs/{}".format(job_id), json=response_json) job = self.cloudconvert.Job.wait(id=job_id) diff --git a/tests/unit/testTask.py b/tests/unit/testTask.py index f533a90..f7dc669 100644 --- a/tests/unit/testTask.py +++ b/tests/unit/testTask.py @@ -64,7 +64,7 @@ def testWaitTask(self): response_json = json.load(f) task_id = "4c80f1ae-5b3a-43d5-bb58-1a5c4eb4e46b" - m.get("https://api.sandbox.cloudconvert.com/v2/tasks/{}/wait".format(task_id), json=response_json) + m.get("https://sync.api.sandbox.cloudconvert.com/v2/tasks/{}".format(task_id), json=response_json) task = self.cloudconvert.Task.wait(id=task_id) From 0594e7cfe1ad9e5a42e39bfe48f51f9b7d19168f Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Sun, 27 Mar 2022 12:00:09 +0200 Subject: [PATCH 09/15] run tests on python 3.9 and 3.10 --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 0378e4f..5c4889e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - python-version: ['2.7', '3.6', '3.7', '3.8'] + python-version: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 From 4d87c4390ff31095405a6aa24f04904720572765 Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Wed, 30 Mar 2022 13:18:54 +0200 Subject: [PATCH 10/15] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6396db4..41c599b 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="cloudconvert", - version="2.0.0", + version="2.1.0", author="Josias Montag", author_email="josias@montag.info", description="Python REST API wrapper for cloud convert", From 2d5ca6ea038bb27c1b7b378d4a4982b28e7fd60e Mon Sep 17 00:00:00 2001 From: Bryce Willey Date: Sun, 22 May 2022 10:11:53 -0400 Subject: [PATCH 11/15] Use existing client in List, Find, etc. Instead of calling `default_client()` for each of those class methods, use a new method, `get_existing_client`, which gets if a user has setup a new api client with `configure`. Solves #13. --- .gitignore | 3 ++- cloudconvert/cloudconvertrestclient.py | 5 +++++ cloudconvert/resource.py | 14 +++++++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 1f76830..674aa7d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ dist/ build/ cloudconvert.egg-info/ -*.egg \ No newline at end of file +*.egg +**/__pycache__ diff --git a/cloudconvert/cloudconvertrestclient.py b/cloudconvert/cloudconvertrestclient.py index 3e32454..a3c719e 100644 --- a/cloudconvert/cloudconvertrestclient.py +++ b/cloudconvert/cloudconvertrestclient.py @@ -245,6 +245,11 @@ def default_client(): 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 diff --git a/cloudconvert/resource.py b/cloudconvert/resource.py index 18ea899..227c39e 100644 --- a/cloudconvert/resource.py +++ b/cloudconvert/resource.py @@ -1,7 +1,7 @@ import uuid import urllib import cloudconvert.utils as util -from cloudconvert.cloudconvertrestclient import default_client +from cloudconvert.cloudconvertrestclient import default_client, get_existing_client class Resource(object): @@ -114,7 +114,7 @@ def find(cls, id): Usage:: >>> job = Job.find("s9fsf9-s9f9sf9s-ggfgf9-fg9fg") """ - api_client = default_client() + api_client = get_existing_client() or default_client() url = util.join_url(cls.path, str(id)) res = api_client.get(url) @@ -134,7 +134,7 @@ def all(cls, params=None): Usage:: >>> tasks_list = tasks.all({'status': 'waiting'}) """ - api_client = default_client() + api_client = get_existing_client() or default_client() if params is None: url = cls.path @@ -165,7 +165,7 @@ def create(cls, operation=None, payload={}): >>> task.create(name=TASK_NAME) # return newly created task """ - api_client = default_client() + api_client = get_existing_client() or default_client() url = util.join_url('v2', operation or '') res = api_client.post(url, payload, headers={}) @@ -182,7 +182,7 @@ def wait(cls, id): Usage:: >>> job = job.wait("s9fsf9-s9f9sf9s-ggfgf9-fg9fg") """ - api_client = default_client() + api_client = get_existing_client() or default_client() url = util.join_url(cls.path, str(id)) res = api_client.get_sync(url) @@ -199,7 +199,7 @@ def show(cls, id): Usage:: >>> job = Job.show("s9fsf9-s9f9sf9s-ggfgf9-fg9fg") """ - api_client = default_client() + api_client = get_existing_client() or default_client() url = util.join_url(cls.path, str(id)) res = api_client.get(url) try: @@ -215,7 +215,7 @@ def delete(cls, id): Usage:: >>> Task.delete(TASK_ID) """ - api_client = default_client() + 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) From bb8807535a14133790d7baeb6e517e69ff5ce947 Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Tue, 17 Jan 2023 18:31:24 +0100 Subject: [PATCH 12/15] Update config.py --- cloudconvert/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudconvert/config.py b/cloudconvert/config.py index 149fc19..63dc907 100644 --- a/cloudconvert/config.py +++ b/cloudconvert/config.py @@ -1,4 +1,4 @@ -__version__ = "2.0.0" +__version__ = "2.1.0" __pypi_username__ = "" __pypi_packagename__ = "cloudconvert" __github_username__ = "cloudconvert" From 3ec8f28302c377a3a5c75c0ca9bac622efe9bc5d Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Tue, 17 Jan 2023 18:33:31 +0100 Subject: [PATCH 13/15] Update run-tests.yml --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5c4889e..4caadab 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - python-version: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['2.7', '3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 From 59672ad960194f100ccbe5602a6590a5a2de8ea5 Mon Sep 17 00:00:00 2001 From: Ebram Shehata Date: Thu, 14 Dec 2023 21:00:43 +0200 Subject: [PATCH 14/15] Exclude tests directory from being packaged --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 41c599b..05b3c9d 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/cloudconvert/cloudconvert-python", - packages=setuptools.find_packages(), + packages=setuptools.find_packages(exclude=["tests"]), install_requires=[ "requests", "urllib3" @@ -26,4 +26,4 @@ "Operating System :: OS Independent", ], zip_safe=False -) \ No newline at end of file +) From 3328aea3f2e64888439acde72642eb19efda2bd3 Mon Sep 17 00:00:00 2001 From: Josias Montag Date: Tue, 18 Mar 2025 11:38:34 +0100 Subject: [PATCH 15/15] fix tests and update Github actions --- .github/workflows/run-tests.yml | 20 +++++++------------- .vscode/settings.json | 13 +++++++++++++ README.md | 15 ++------------- tests/integration/testJobs.py | 2 +- 4 files changed, 23 insertions(+), 27 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4caadab..f244026 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,22 +13,16 @@ jobs: strategy: matrix: - python-version: ['2.7', '3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.12', '3.13'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' # caching pip dependencies - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Test Tasks - run: python tests/unit/testTask.py - - name: Test Jobs - run: python tests/unit/testJob.py - - name: Test Webhooks - run: python tests/unit/testWebhookSignature.py + run: pip install -r requirements.txt + - name: Run tests + run: python -m unittest -v 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/README.md b/README.md index ac72ed2..4c5835d 100644 --- a/README.md +++ b/README.md @@ -132,25 +132,14 @@ url = cloudconvert.SignedUrl.sign(base, signing_secret, job, cache_key); # retu ## Unit Tests ``` -# Run Task tests -$ python tests/unit/testTask.py - -# Run Job tests -$ python tests/unit/testJob.py - -# Run Webhook tests -$ python tests/unit/testWebhookSignature.py +python -m unittest discover -s tests/unit ``` ## Integration Tests ``` -# Run Integration test for task -$ python tests/integration/testTasks.py - -# Run Integration test for Job -$ python tests/integration/testJobs.py +python -m unittest discover -s tests/integration ``` diff --git a/tests/integration/testJobs.py b/tests/integration/testJobs.py index ecef1a6..15cbef8 100644 --- a/tests/integration/testJobs.py +++ b/tests/integration/testJobs.py @@ -78,7 +78,7 @@ def testUploadAndDownloadFiles(self): fileName = exported_task.get("result").get("files")[0].get("filename") # now download the exported file - cloudconvert.download(url=exported_url, filename="out/" + fileName) + cloudconvert.download(url=exported_url, filename=os.path.join(os.path.dirname(os.path.realpath(__file__)), "out/" + fileName)) def tearDown(self): """