Skip to content
2 changes: 1 addition & 1 deletion convertapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .exceptions import *
from .client import Client
from .upload_io import UploadIO
from .api import convert, user
from .api import convert, user, async_convert, async_poll

# configuration

Expand Down
15 changes: 15 additions & 0 deletions convertapi/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import convertapi

from .task import Task
from .result import Result
from .exceptions import AsyncConversionInProgress

def convert(to_format, params, from_format = None, timeout = None):
task = Task(from_format, to_format, params, timeout = timeout)
return task.run()


def user(timeout = None):
return convertapi.client.get('user', timeout = timeout)


def async_convert(to_format, params, from_format = None, timeout = None):
task = Task(from_format, to_format, params, timeout = timeout, is_async = True)
return task.run()


def async_poll(job_id):
response = convertapi.client.get('async/job/%s' % job_id)
if not response:
raise AsyncConversionInProgress
return Result(response)
3 changes: 3 additions & 0 deletions convertapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ def handle_response(self, r):
except ValueError:
raise e

if r.content == b'':
return None

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to solve a problem mentioned in issue #21: while the conversion is in progress, it seems the response from the server is a 202 Accepted with a blank response.

In the tests, the very first request after the async conversion has a status code 200 and blank content. The second request would have a status 202, as well as the subsequent requests until a 200 response with the expected result.

That's why I'm not testing for a 202 status code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice this would raise a JSONDecodeError for any get request that doesn't have a JSON response, and I don't want to fail with an AsyncConversionInProgress error for any request. This way, I'm returning None, and only on the async_poll function I will check for the empty response and raise the exception accordingly.

return r.json()

def url(self, path):
Expand Down
4 changes: 4 additions & 0 deletions convertapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ def __init__(self, result):
def __str__(self):
message = "%s Code: %s. %s" % (self.message, self.code, self.invalid_parameters)
return message.strip()


class AsyncConversionInProgress(BaseError):
pass
6 changes: 4 additions & 2 deletions convertapi/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
DEFAULT_URL_FORMAT = 'web'

class Task:
def __init__(self, from_format, to_format, params, timeout = None):
def __init__(self, from_format, to_format, params, timeout = None, is_async = False):
self.from_format = from_format
self.to_format = to_format
self.params = params
self.timeout = timeout or convertapi.conversion_timeout
self.is_async = is_async

self.default_params = {
'Timeout': self.timeout,
Expand All @@ -21,7 +22,8 @@ def run(self):
params = self.__normalize_params()
from_format = self.from_format or self.__detect_format()
timeout = self.timeout + convertapi.conversion_timeout_delta if self.timeout else None
path = "convert/%s/to/%s" % (from_format, self.to_format)
base_path = 'convert' if not self.is_async else 'async/convert'
path = "%s/%s/to/%s" % (base_path, from_format, self.to_format)

if 'converter' in params:
path += "/converter/%s" % (params['converter'])
Expand Down
47 changes: 47 additions & 0 deletions tests/test_convertapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import io
import tempfile
import time
import requests

from . import utils
Expand Down Expand Up @@ -74,3 +75,49 @@ def test_api_error(self):
def test_user_info(self):
user_info = convertapi.user()
assert user_info['Active']


class TestAsyncConvertapi(utils.TestCase):
retry_sleep_base_timeout = 0.1

def setUp(self):
convertapi.api_secret = os.environ['CONVERT_API_SECRET']
convertapi.max_parallel_uploads = 10

def test_async_conversion_and_polling(self):
convert_result = convertapi.async_convert('pdf', { 'File': 'examples/files/test.docx' })
assert convert_result.response['JobId']

poll_result = self.get_poll_result(convert_result.response['JobId'])
assert poll_result.save_files(tempfile.gettempdir())
assert poll_result.conversion_cost > 0

def test_polling_of_invalid_job_id(self):
fake_job_id = 'rwvd6vtq58eurfwv5zw5h2ci2pgno1pn'
try:
convertapi.async_poll(fake_job_id)
except requests.exceptions.HTTPError:
pass
else:
raise AssertionError

def test_polling_too_fast_and_getting_202_accepted(self):
convert_result = convertapi.async_convert('pdf', { 'File': 'examples/files/test.docx' })
try:
convertapi.async_poll(convert_result.response['JobId'])
except convertapi.AsyncConversionInProgress:
pass
else:
raise AssertionError

def get_poll_result(self, job_id, retry_count=5):
try:
result = convertapi.async_poll(job_id)
except convertapi.AsyncConversionInProgress as error:
if retry_count > 0:
time.sleep((1 + 0.1) ** (5 - retry_count) - 1)
return self.get_poll_result(job_id, retry_count=retry_count - 1)
else:
raise error
else:
return result