Add support for resumable upload.
Reviewed in http://codereview.appspot.com/5417051/.
diff --git a/apiclient/discovery.py b/apiclient/discovery.py
index 2a53b0d..9e5b230 100644
--- a/apiclient/discovery.py
+++ b/apiclient/discovery.py
@@ -26,6 +26,7 @@
import httplib2
import logging
import os
+import random
import re
import uritemplate
import urllib
@@ -48,6 +49,8 @@
from errors import UnknownApiNameOrVersion
from errors import UnknownLinkType
from http import HttpRequest
+from http import MediaUpload
+from http import MediaFileUpload
from model import JsonModel
URITEMPLATE = re.compile('{[^}]*}')
@@ -325,6 +328,7 @@
if 'mediaUpload' in methodDesc:
mediaUpload = methodDesc['mediaUpload']
mediaPathUrl = mediaUpload['protocols']['simple']['path']
+ mediaResumablePathUrl = mediaUpload['protocols']['resumable']['path']
accept = mediaUpload['accept']
maxSize = _media_size_to_long(mediaUpload.get('maxSize', ''))
@@ -440,28 +444,46 @@
expanded_url = uritemplate.expand(pathUrl, params)
url = urlparse.urljoin(self._baseUrl, expanded_url + query)
+ resumable = None
+ multipart_boundary = ''
+
if media_filename:
- (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
- if media_mime_type is None:
- raise UnknownFileType(media_filename)
- if not mimeparse.best_match([media_mime_type], ','.join(accept)):
- raise UnacceptableMimeTypeError(media_mime_type)
+ # Convert a simple filename into a MediaUpload object.
+ if isinstance(media_filename, basestring):
+ (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
+ if media_mime_type is None:
+ raise UnknownFileType(media_filename)
+ if not mimeparse.best_match([media_mime_type], ','.join(accept)):
+ raise UnacceptableMimeTypeError(media_mime_type)
+ media_upload = MediaFileUpload(media_filename, media_mime_type)
+ elif isinstance(media_filename, MediaUpload):
+ media_upload = media_filename
+ else:
+ raise TypeError(
+ 'media_filename must be str or MediaUpload. Got %s' % type(media_upload))
+
+ if media_upload.resumable():
+ resumable = media_upload
# Check the maxSize
- if maxSize > 0 and os.path.getsize(media_filename) > maxSize:
- raise MediaUploadSizeError(media_filename)
+ if maxSize > 0 and media_upload.size() > maxSize:
+ raise MediaUploadSizeError("Media larger than: %s" % maxSize)
# Use the media path uri for media uploads
- expanded_url = uritemplate.expand(mediaPathUrl, params)
+ if media_upload.resumable():
+ expanded_url = uritemplate.expand(mediaResumablePathUrl, params)
+ else:
+ expanded_url = uritemplate.expand(mediaPathUrl, params)
url = urlparse.urljoin(self._baseUrl, expanded_url + query)
if body is None:
- headers['content-type'] = media_mime_type
- # make the body the contents of the file
- f = file(media_filename, 'rb')
- body = f.read()
- f.close()
+ # This is a simple media upload
+ headers['content-type'] = media_upload.mimetype()
+ expanded_url = uritemplate.expand(mediaResumablePathUrl, params)
+ if not media_upload.resumable():
+ body = media_upload.getbytes(0, media_upload.size())
else:
+ # This is a multipart/related upload.
msgRoot = MIMEMultipart('related')
# msgRoot should not write out it's own headers
setattr(msgRoot, '_write_headers', lambda self: None)
@@ -472,19 +494,51 @@
msgRoot.attach(msg)
# attach the media as the second part
- msg = MIMENonMultipart(*media_mime_type.split('/'))
+ msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
msg['Content-Transfer-Encoding'] = 'binary'
- f = file(media_filename, 'rb')
- msg.set_payload(f.read())
- f.close()
- msgRoot.attach(msg)
+ if media_upload.resumable():
+ # This is a multipart resumable upload, where a multipart payload
+ # looks like this:
+ #
+ # --===============1678050750164843052==
+ # Content-Type: application/json
+ # MIME-Version: 1.0
+ #
+ # {'foo': 'bar'}
+ # --===============1678050750164843052==
+ # Content-Type: image/png
+ # MIME-Version: 1.0
+ # Content-Transfer-Encoding: binary
+ #
+ # <BINARY STUFF>
+ # --===============1678050750164843052==--
+ #
+ # In the case of resumable multipart media uploads, the <BINARY
+ # STUFF> is large and will be spread across multiple PUTs. What we
+ # do here is compose the multipart message with a random payload in
+ # place of <BINARY STUFF> and then split the resulting content into
+ # two pieces, text before <BINARY STUFF> and text after <BINARY
+ # STUFF>. The text after <BINARY STUFF> is the multipart boundary.
+ # In apiclient.http the HttpRequest will send the text before
+ # <BINARY STUFF>, then send the actual binary media in chunks, and
+ # then will send the multipart delimeter.
- body = msgRoot.as_string()
+ payload = hex(random.getrandbits(300))
+ msg.set_payload(payload)
+ msgRoot.attach(msg)
+ body = msgRoot.as_string()
+ body, _ = body.split(payload)
+ resumable = media_upload
+ else:
+ payload = media_upload.getbytes(0, media_upload.size())
+ msg.set_payload(payload)
+ msgRoot.attach(msg)
+ body = msgRoot.as_string()
- # must appear after the call to as_string() to get the right boundary
+ multipart_boundary = msgRoot.get_boundary()
headers['content-type'] = ('multipart/related; '
- 'boundary="%s"') % msgRoot.get_boundary()
+ 'boundary="%s"') % multipart_boundary
logging.info('URL being requested: %s' % url)
return self._requestBuilder(self._http,
@@ -493,7 +547,8 @@
method=httpMethod,
body=body,
headers=headers,
- methodId=methodId)
+ methodId=methodId,
+ resumable=resumable)
docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
if len(argmap) > 0:
diff --git a/apiclient/errors.py b/apiclient/errors.py
index ff0c154..30a48e8 100644
--- a/apiclient/errors.py
+++ b/apiclient/errors.py
@@ -85,6 +85,11 @@
pass
+class ResumableUploadError(Error):
+ """Error occured during resumable upload."""
+ pass
+
+
class UnexpectedMethodError(Error):
"""Exception raised by RequestMockBuilder on unexpected calls."""
diff --git a/apiclient/http.py b/apiclient/http.py
index d2a3a2f..0b45a44 100644
--- a/apiclient/http.py
+++ b/apiclient/http.py
@@ -25,16 +25,187 @@
'set_user_agent', 'tunnel_patch'
]
+import copy
import httplib2
import os
+import mimeparse
+import mimetypes
from model import JsonModel
from errors import HttpError
+from errors import ResumableUploadError
from errors import UnexpectedBodyError
from errors import UnexpectedMethodError
from anyjson import simplejson
+class MediaUploadProgress(object):
+ """Status of a resumable upload."""
+
+ def __init__(self, resumable_progress, total_size):
+ """Constructor.
+
+ Args:
+ resumable_progress: int, bytes sent so far.
+ total_size: int, total bytes in complete upload.
+ """
+ self.resumable_progress = resumable_progress
+ self.total_size = total_size
+
+ def progress(self):
+ """Percent of upload completed, as a float."""
+ return float(self.resumable_progress)/float(self.total_size)
+
+
+class MediaUpload(object):
+ """Describes a media object to upload.
+
+ Base class that defines the interface of MediaUpload subclasses.
+ """
+
+ def getbytes(self, begin, end):
+ raise NotImplementedError()
+
+ def size(self):
+ raise NotImplementedError()
+
+ def chunksize(self):
+ raise NotImplementedError()
+
+ def mimetype(self):
+ return 'application/octet-stream'
+
+ def resumable(self):
+ return False
+
+ def _to_json(self, strip=None):
+ """Utility function for creating a JSON representation of a MediaUpload.
+
+ Args:
+ strip: array, An array of names of members to not include in the JSON.
+
+ Returns:
+ string, a JSON representation of this instance, suitable to pass to
+ from_json().
+ """
+ t = type(self)
+ d = copy.copy(self.__dict__)
+ if strip is not None:
+ for member in strip:
+ del d[member]
+ d['_class'] = t.__name__
+ d['_module'] = t.__module__
+ return simplejson.dumps(d)
+
+ def to_json(self):
+ """Create a JSON representation of an instance of MediaUpload.
+
+ Returns:
+ string, a JSON representation of this instance, suitable to pass to
+ from_json().
+ """
+ return self._to_json()
+
+ @classmethod
+ def new_from_json(cls, s):
+ """Utility class method to instantiate a MediaUpload subclass from a JSON
+ representation produced by to_json().
+
+ Args:
+ s: string, JSON from to_json().
+
+ Returns:
+ An instance of the subclass of MediaUpload that was serialized with
+ to_json().
+ """
+ data = simplejson.loads(s)
+ # Find and call the right classmethod from_json() to restore the object.
+ module = data['_module']
+ m = __import__(module, fromlist=module.split('.')[:-1])
+ kls = getattr(m, data['_class'])
+ from_json = getattr(kls, 'from_json')
+ return from_json(s)
+
+class MediaFileUpload(MediaUpload):
+ """A MediaUpload for a file.
+
+ Construct a MediaFileUpload and pass as the media_body parameter of the
+ method. For example, if we had a service that allowed uploading images:
+
+
+ media = MediaFileUpload('smiley.png', mimetype='image/png', chunksize=1000,
+ resumable=True)
+ service.objects().insert(
+ bucket=buckets['items'][0]['id'],
+ name='smiley.png',
+ media_body=media).execute()
+ """
+
+ def __init__(self, filename, mimetype=None, chunksize=10000, resumable=False):
+ """Constructor.
+
+ Args:
+ filename: string, Name of the file.
+ mimetype: string, Mime-type of the file. If None then a mime-type will be
+ guessed from the file extension.
+ chunksize: int, File will be uploaded in chunks of this many bytes. Only
+ used if resumable=True.
+ resumable: bool, True if this is a resumable upload. False means upload in
+ a single request.
+ """
+ self._filename = filename
+ self._size = os.path.getsize(filename)
+ self._fd = None
+ if mimetype is None:
+ (mimetype, encoding) = mimetypes.guess_type(filename)
+ self._mimetype = mimetype
+ self._chunksize = chunksize
+ self._resumable = resumable
+
+ def mimetype(self):
+ return self._mimetype
+
+ def size(self):
+ return self._size
+
+ def chunksize(self):
+ return self._chunksize
+
+ def resumable(self):
+ return self._resumable
+
+ def getbytes(self, begin, length):
+ """Get bytes from the media.
+
+ Args:
+ begin: int, offset from beginning of file.
+ length: int, number of bytes to read, starting at begin.
+
+ Returns:
+ A string of bytes read. May be shorted than length if EOF was reached
+ first.
+ """
+ if self._fd is None:
+ self._fd = open(self._filename, 'rb')
+ self._fd.seek(begin)
+ return self._fd.read(length)
+
+ def to_json(self):
+ """Creating a JSON representation of an instance of Credentials.
+
+ Returns:
+ string, a JSON representation of this instance, suitable to pass to
+ from_json().
+ """
+ return self._to_json(['_fd'])
+
+ @staticmethod
+ def from_json(s):
+ d = simplejson.loads(s)
+ return MediaFileUpload(
+ d['_filename'], d['_mimetype'], d['_chunksize'], d['_resumable'])
+
+
class HttpRequest(object):
"""Encapsulates a single HTTP request.
"""
@@ -43,7 +214,8 @@
method='GET',
body=None,
headers=None,
- methodId=None):
+ methodId=None,
+ resumable=None):
"""Constructor for an HttpRequest.
Args:
@@ -53,16 +225,39 @@
on an error.
uri: string, the absolute URI to send the request to
method: string, the HTTP method to use
- body: string, the request body of the HTTP request
+ body: string, the request body of the HTTP request,
headers: dict, the HTTP request headers
methodId: string, a unique identifier for the API method being called.
+ resumable: MediaUpload, None if this is not a resumbale request.
"""
self.uri = uri
self.method = method
self.body = body
self.headers = headers or {}
+ self.methodId = methodId
self.http = http
self.postproc = postproc
+ self.resumable = resumable
+
+ major, minor, params = mimeparse.parse_mime_type(
+ headers.get('content-type', 'application/json'))
+ self.multipart_boundary = params.get('boundary', '').strip('"')
+
+ # If this was a multipart resumable, the size of the non-media part.
+ self.multipart_size = 0
+
+ # The resumable URI to send chunks to.
+ self.resumable_uri = None
+
+ # The bytes that have been uploaded.
+ self.resumable_progress = 0
+
+ if resumable is not None:
+ if self.body is not None:
+ self.multipart_size = len(self.body)
+ else:
+ self.multipart_size = 0
+ self.total_size = self.resumable.size() + self.multipart_size + len(self.multipart_boundary)
def execute(self, http=None):
"""Execute the request.
@@ -81,14 +276,118 @@
"""
if http is None:
http = self.http
- resp, content = http.request(self.uri, self.method,
- body=self.body,
- headers=self.headers)
+ if self.resumable:
+ body = None
+ while body is None:
+ _, body = self.next_chunk(http)
+ return body
+ else:
+ resp, content = http.request(self.uri, self.method,
+ body=self.body,
+ headers=self.headers)
- if resp.status >= 300:
- raise HttpError(resp, content, self.uri)
+ if resp.status >= 300:
+ raise HttpError(resp, content, self.uri)
return self.postproc(resp, content)
+ def next_chunk(self, http=None):
+ """Execute the next step of a resumable upload.
+
+ Can only be used if the method being executed supports media uploads and the
+ MediaUpload object passed in was flagged as using resumable upload.
+
+ Example:
+
+ media = MediaFileUpload('smiley.png', mimetype='image/png', chunksize=1000,
+ resumable=True)
+ request = service.objects().insert(
+ bucket=buckets['items'][0]['id'],
+ name='smiley.png',
+ media_body=media)
+
+ response = None
+ while response is None:
+ status, response = request.next_chunk()
+ if status:
+ print "Upload %d%% complete." % int(status.progress() * 100)
+
+
+ Returns:
+ (status, body): (ResumableMediaStatus, object)
+ The body will be None until the resumable media is fully uploaded.
+ """
+ if http is None:
+ http = self.http
+
+ if self.resumable_uri is None:
+ start_headers = copy.copy(self.headers)
+ start_headers['X-Upload-Content-Type'] = self.resumable.mimetype()
+ start_headers['X-Upload-Content-Length'] = str(self.resumable.size())
+ start_headers['Content-Length'] = '0'
+ resp, content = http.request(self.uri, self.method,
+ body="",
+ headers=start_headers)
+ if resp.status == 200 and 'location' in resp:
+ self.resumable_uri = resp['location']
+ else:
+ raise ResumableUploadError("Failed to retrieve starting URI.")
+ if self.body:
+ begin = 0
+ data = self.body
+ else:
+ begin = self.resumable_progress - self.multipart_size
+ data = self.resumable.getbytes(begin, self.resumable.chunksize())
+
+ # Tack on the multipart/related boundary if we are at the end of the file.
+ if begin + self.resumable.chunksize() >= self.resumable.size():
+ data += self.multipart_boundary
+ headers = {
+ 'Content-Range': 'bytes %d-%d/%d' % (
+ self.resumable_progress, self.resumable_progress + len(data) - 1,
+ self.total_size),
+ }
+ resp, content = http.request(self.resumable_uri, 'PUT',
+ body=data,
+ headers=headers)
+ if resp.status in [200, 201]:
+ return None, self.postproc(resp, content)
+ # A "308 Resume Incomplete" indicates we are not done.
+ elif resp.status == 308:
+ self.resumable_progress = int(resp['range'].split('-')[1]) + 1
+ if self.resumable_progress >= self.multipart_size:
+ self.body = None
+ if 'location' in resp:
+ self.resumable_uri = resp['location']
+ else:
+ raise HttpError(resp, content, self.uri)
+
+ return MediaUploadProgress(self.resumable_progress, self.total_size), None
+
+ def to_json(self):
+ """Returns a JSON representation of the HttpRequest."""
+ d = copy.copy(self.__dict__)
+ if d['resumable'] is not None:
+ d['resumable'] = self.resumable.to_json()
+ del d['http']
+ del d['postproc']
+ return simplejson.dumps(d)
+
+ @staticmethod
+ def from_json(s, http, postproc):
+ """Returns an HttpRequest populated with info from a JSON object."""
+ d = simplejson.loads(s)
+ if d['resumable'] is not None:
+ d['resumable'] = MediaUpload.new_from_json(d['resumable'])
+ return HttpRequest(
+ http,
+ postproc,
+ uri = d['uri'],
+ method= d['method'],
+ body=d['body'],
+ headers=d['headers'],
+ methodId=d['methodId'],
+ resumable=d['resumable'])
+
class HttpRequestMock(object):
"""Mock of HttpRequest.
@@ -166,7 +465,7 @@
self.check_unexpected = check_unexpected
def __call__(self, http, postproc, uri, method='GET', body=None,
- headers=None, methodId=None):
+ headers=None, methodId=None, resumable=None):
"""Implements the callable interface that discovery.build() expects
of requestBuilder, which is to build an object compatible with
HttpRequest.execute(). See that method for the description of the
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 606796e..2995633 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -34,13 +34,16 @@
from cgi import parse_qs
from apiclient.discovery import build, build_from_document, key2param
-from apiclient.http import HttpMock
-from apiclient.http import tunnel_patch
-from apiclient.http import HttpMockSequence
from apiclient.errors import HttpError
from apiclient.errors import InvalidJsonError
from apiclient.errors import MediaUploadSizeError
+from apiclient.errors import ResumableUploadError
from apiclient.errors import UnacceptableMimeTypeError
+from apiclient.http import HttpMock
+from apiclient.http import HttpMockSequence
+from apiclient.http import MediaFileUpload
+from apiclient.http import MediaUploadProgress
+from apiclient.http import tunnel_patch
DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
@@ -317,6 +320,169 @@
request = zoo.animals().insert(body={})
self.assertTrue(request.headers['content-type'], 'application/json')
+ def test_resumable_multipart_media_good_upload(self):
+ self.http = HttpMock(datafile('zoo.json'), {'status': '200'})
+ zoo = build('zoo', 'v1', self.http)
+
+ media_upload = MediaFileUpload(datafile('small.png'), resumable=True)
+ request = zoo.animals().insert(media_body=media_upload, body={})
+ self.assertTrue(request.headers['content-type'].startswith(
+ 'multipart/related'))
+ self.assertEquals('--==', request.body[0:4])
+ self.assertEquals(media_upload, request.resumable)
+
+ self.assertEquals('image/png', request.resumable.mimetype())
+
+ self.assertTrue(len(request.multipart_boundary) > 0)
+ self.assertNotEquals(request.body, None)
+ self.assertEquals(request.resumable_uri, None)
+
+ http = HttpMockSequence([
+ ({'status': '200',
+ 'location': 'http://upload.example.com'}, ''),
+ ({'status': '308',
+ 'location': 'http://upload.example.com/2',
+ 'range': '0-12'}, ''),
+ ({'status': '308',
+ 'location': 'http://upload.example.com/3',
+ 'range': '0-%d' % (request.total_size - 2)}, ''),
+ ({'status': '200'}, '{"foo": "bar"}'),
+ ])
+
+ status, body = request.next_chunk(http)
+ self.assertEquals(None, body)
+ self.assertTrue(isinstance(status, MediaUploadProgress))
+ self.assertEquals(13, status.resumable_progress)
+
+ # request.body is not None because the server only acknowledged 12 bytes,
+ # which is less than the size of the body, so we need to send it again.
+ self.assertNotEquals(request.body, None)
+
+ # Two requests should have been made and the resumable_uri should have been
+ # updated for each one.
+ self.assertEquals(request.resumable_uri, 'http://upload.example.com/2')
+
+ self.assertEquals(media_upload, request.resumable)
+ self.assertEquals(13, request.resumable_progress)
+
+ status, body = request.next_chunk(http)
+ self.assertEquals(request.resumable_uri, 'http://upload.example.com/3')
+ self.assertEquals(request.total_size-1, request.resumable_progress)
+ self.assertEquals(request.body, None)
+
+ # Final call to next_chunk should complete the upload.
+ status, body = request.next_chunk(http)
+ self.assertEquals(body, {"foo": "bar"})
+ self.assertEquals(status, None)
+
+
+ def test_resumable_media_good_upload(self):
+ """Not a multipart upload."""
+ self.http = HttpMock(datafile('zoo.json'), {'status': '200'})
+ zoo = build('zoo', 'v1', self.http)
+
+ media_upload = MediaFileUpload(datafile('small.png'), resumable=True)
+ request = zoo.animals().insert(media_body=media_upload, body=None)
+ self.assertTrue(request.headers['content-type'].startswith(
+ 'image/png'))
+ self.assertEquals(media_upload, request.resumable)
+
+ self.assertEquals('image/png', request.resumable.mimetype())
+
+ self.assertEquals(request.multipart_boundary, '')
+ self.assertEquals(request.body, None)
+ self.assertEquals(request.resumable_uri, None)
+
+ http = HttpMockSequence([
+ ({'status': '200',
+ 'location': 'http://upload.example.com'}, ''),
+ ({'status': '308',
+ 'location': 'http://upload.example.com/2',
+ 'range': '0-12'}, ''),
+ ({'status': '308',
+ 'location': 'http://upload.example.com/3',
+ 'range': '0-%d' % (request.total_size - 2)}, ''),
+ ({'status': '200'}, '{"foo": "bar"}'),
+ ])
+
+ status, body = request.next_chunk(http)
+ self.assertEquals(None, body)
+ self.assertTrue(isinstance(status, MediaUploadProgress))
+ self.assertEquals(13, status.resumable_progress)
+
+ # Two requests should have been made and the resumable_uri should have been
+ # updated for each one.
+ self.assertEquals(request.resumable_uri, 'http://upload.example.com/2')
+
+ self.assertEquals(media_upload, request.resumable)
+ self.assertEquals(13, request.resumable_progress)
+
+ status, body = request.next_chunk(http)
+ self.assertEquals(request.resumable_uri, 'http://upload.example.com/3')
+ self.assertEquals(request.total_size-1, request.resumable_progress)
+ self.assertEquals(request.body, None)
+
+ # Final call to next_chunk should complete the upload.
+ status, body = request.next_chunk(http)
+ self.assertEquals(body, {"foo": "bar"})
+ self.assertEquals(status, None)
+
+
+ def test_resumable_media_good_upload_from_execute(self):
+ """Not a multipart upload."""
+ self.http = HttpMock(datafile('zoo.json'), {'status': '200'})
+ zoo = build('zoo', 'v1', self.http)
+
+ media_upload = MediaFileUpload(datafile('small.png'), resumable=True)
+ request = zoo.animals().insert(media_body=media_upload, body=None)
+
+ http = HttpMockSequence([
+ ({'status': '200',
+ 'location': 'http://upload.example.com'}, ''),
+ ({'status': '308',
+ 'location': 'http://upload.example.com/2',
+ 'range': '0-12'}, ''),
+ ({'status': '308',
+ 'location': 'http://upload.example.com/3',
+ 'range': '0-%d' % (request.total_size - 2)}, ''),
+ ({'status': '200'}, '{"foo": "bar"}'),
+ ])
+
+ body = request.execute(http)
+ self.assertEquals(body, {"foo": "bar"})
+
+ def test_resumable_media_fail_unknown_response_code_first_request(self):
+ """Not a multipart upload."""
+ self.http = HttpMock(datafile('zoo.json'), {'status': '200'})
+ zoo = build('zoo', 'v1', self.http)
+
+ media_upload = MediaFileUpload(datafile('small.png'), resumable=True)
+ request = zoo.animals().insert(media_body=media_upload, body=None)
+
+ http = HttpMockSequence([
+ ({'status': '400',
+ 'location': 'http://upload.example.com'}, ''),
+ ])
+
+ self.assertRaises(ResumableUploadError, request.execute, http)
+
+ def test_resumable_media_fail_unknown_response_code_subsequent_request(self):
+ """Not a multipart upload."""
+ self.http = HttpMock(datafile('zoo.json'), {'status': '200'})
+ zoo = build('zoo', 'v1', self.http)
+
+ media_upload = MediaFileUpload(datafile('small.png'), resumable=True)
+ request = zoo.animals().insert(media_body=media_upload, body=None)
+
+ http = HttpMockSequence([
+ ({'status': '200',
+ 'location': 'http://upload.example.com'}, ''),
+ ({'status': '400'}, ''),
+ ])
+
+ self.assertRaises(HttpError, request.execute, http)
+
+
class Next(unittest.TestCase):
def test_next_successful_none_on_no_next_page_token(self):
diff --git a/tests/test_http.py b/tests/test_http.py
index bba63d8..09d6eb8 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -23,12 +23,22 @@
# Do not remove the httplib2 import
import httplib2
+import os
import unittest
from apiclient.http import set_user_agent
from apiclient.http import HttpMockSequence
+from apiclient.http import HttpRequest
+from apiclient.http import MediaUpload
+from apiclient.http import MediaFileUpload
+DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
+
+
+def datafile(filename):
+ return os.path.join(DATA_DIR, filename)
+
class TestUserAgent(unittest.TestCase):
def test_set_user_agent(self):
@@ -50,5 +60,54 @@
resp, content = http.request("http://example.com")
self.assertEqual(content['user-agent'], 'my_app/5.5 my_library/0.1')
+ def test_media_file_upload_to_from_json(self):
+ upload = MediaFileUpload(
+ datafile('small.png'), chunksize=500, resumable=True)
+ self.assertEquals('image/png', upload.mimetype())
+ self.assertEquals(190, upload.size())
+ self.assertEquals(True, upload.resumable())
+ self.assertEquals(500, upload.chunksize())
+ self.assertEquals('PNG', upload.getbytes(1, 3))
+
+ json = upload.to_json()
+ new_upload = MediaUpload.new_from_json(json)
+
+ self.assertEquals('image/png', new_upload.mimetype())
+ self.assertEquals(190, new_upload.size())
+ self.assertEquals(True, new_upload.resumable())
+ self.assertEquals(500, new_upload.chunksize())
+ self.assertEquals('PNG', new_upload.getbytes(1, 3))
+
+ def test_http_request_to_from_json(self):
+
+ def _postproc(*kwargs):
+ pass
+
+ http = httplib2.Http()
+ media_upload = MediaFileUpload(
+ datafile('small.png'), chunksize=500, resumable=True)
+ req = HttpRequest(
+ http,
+ _postproc,
+ 'http://example.com',
+ method='POST',
+ body='{}',
+ headers={'content-type': 'multipart/related; boundary="---flubber"'},
+ methodId='foo',
+ resumable=media_upload)
+
+ json = req.to_json()
+ new_req = HttpRequest.from_json(json, http, _postproc)
+
+ self.assertEquals(new_req.headers,
+ {'content-type':
+ 'multipart/related; boundary="---flubber"'})
+ self.assertEquals(new_req.uri, 'http://example.com')
+ self.assertEquals(new_req.body, '{}')
+ self.assertEquals(new_req.http, http)
+ self.assertEquals(new_req.resumable.to_json(), media_upload.to_json())
+ self.assertEquals(new_req.multipart_boundary, '---flubber')
+
+
if __name__ == '__main__':
unittest.main()