Add _media methods and support for resumable media download.

TBR: http://codereview.appspot.com/6295077/
diff --git a/apiclient/discovery.py b/apiclient/discovery.py
index 3e1964c..d5d693e 100644
--- a/apiclient/discovery.py
+++ b/apiclient/discovery.py
@@ -52,6 +52,7 @@
 from apiclient.http import MediaFileUpload
 from apiclient.http import MediaUpload
 from apiclient.model import JsonModel
+from apiclient.model import MediaModel
 from apiclient.model import RawModel
 from apiclient.schema import Schemas
 from email.mime.multipart import MIMEMultipart
@@ -499,7 +500,9 @@
 
       model = self._model
       # If there is no schema for the response then presume a binary blob.
-      if 'response' not in methodDesc:
+      if methodName.endswith('_media'):
+        model = MediaModel()
+      elif 'response' not in methodDesc:
         model = RawModel()
 
       headers = {}
@@ -618,8 +621,11 @@
         for (name, desc) in zip(enum, enumDesc):
           docs.append('      %s - %s\n' % (name, desc))
     if 'response' in methodDesc:
-      docs.append('\nReturns:\n  An object of the form\n\n    ')
-      docs.append(schema.prettyPrintSchema(methodDesc['response']))
+      if methodName.endswith('_media'):
+        docs.append('\nReturns:\n  The media object as a string.\n\n    ')
+      else:
+        docs.append('\nReturns:\n  An object of the form:\n\n    ')
+        docs.append(schema.prettyPrintSchema(methodDesc['response']))
 
     setattr(method, '__doc__', ''.join(docs))
     setattr(theclass, methodName, method)
@@ -680,6 +686,10 @@
   if 'methods' in resourceDesc:
     for methodName, methodDesc in resourceDesc['methods'].iteritems():
       createMethod(Resource, methodName, methodDesc, rootDesc)
+      # Add in _media methods. The functionality of the attached method will
+      # change when it sees that the method name ends in _media.
+      if methodDesc.get('supportsMediaDownload', False):
+        createMethod(Resource, methodName + '_media', methodDesc, rootDesc)
 
   # Add in nested resources
   if 'resources' in resourceDesc:
diff --git a/apiclient/http.py b/apiclient/http.py
index 022bdce..c3ef13b 100644
--- a/apiclient/http.py
+++ b/apiclient/http.py
@@ -76,6 +76,32 @@
       return 0.0
 
 
+class MediaDownloadProgress(object):
+  """Status of a resumable download."""
+
+  def __init__(self, resumable_progress, total_size):
+    """Constructor.
+
+    Args:
+      resumable_progress: int, bytes received so far.
+      total_size: int, total bytes in complete download.
+    """
+    self.resumable_progress = resumable_progress
+    self.total_size = total_size
+
+  def progress(self):
+    """Percent of download completed, as a float.
+
+    Returns:
+      the percentage complete as a float, returning 0.0 if the total size of
+      the download is unknown.
+    """
+    if self.total_size is not None:
+      return float(self.resumable_progress) / float(self.total_size)
+    else:
+      return 0.0
+
+
 class MediaUpload(object):
   """Describes a media object to upload.
 
@@ -268,7 +294,7 @@
     return self._fd.read(length)
 
   def to_json(self):
-    """Creating a JSON representation of an instance of Credentials.
+    """Creating a JSON representation of an instance of MediaFileUpload.
 
     Returns:
        string, a JSON representation of this instance, suitable to pass to
@@ -472,6 +498,86 @@
                                d['_resumable'])
 
 
+class MediaIoBaseDownload(object):
+  """"Download media resources.
+
+  Note that the Python file object is compatible with io.Base and can be used
+  with this class also.
+
+
+  Example:
+    request = service.objects().get_media(
+        bucket='a_bucket_id',
+        name='smiley.png')
+
+    fh = io.FileIO('image.png', mode='wb')
+    downloader = MediaIoBaseDownload(fh, request, chunksize=1024*1024)
+
+    done = False
+    while done is False:
+      status, done = downloader.next_chunk()
+      if status:
+        print "Download %d%%." % int(status.progress() * 100)
+    print "Download Complete!"
+  """
+
+  def __init__(self, fh, request, chunksize=DEFAULT_CHUNK_SIZE):
+    """Constructor.
+
+    Args:
+      fh: io.Base or file object, The stream in which to write the downloaded
+        bytes.
+      request: apiclient.http.HttpRequest, the media request to perform in
+        chunks.
+      chunksize: int, File will be downloaded in chunks of this many bytes.
+    """
+    self.fh_ = fh
+    self.request_ = request
+    self.uri_ = request.uri
+    self.chunksize_ = chunksize
+    self.progress_ = 0
+    self.total_size_ = None
+    self.done_ = False
+
+  def next_chunk(self):
+    """Get the next chunk of the download.
+
+    Returns:
+      (status, done): (MediaDownloadStatus, boolean)
+         The value of 'done' will be True when the media has been fully
+         downloaded.
+
+    Raises:
+      apiclient.errors.HttpError if the response was not a 2xx.
+      httplib2.Error if a transport error has occured.
+    """
+    headers = {
+        'range': 'bytes=%d-%d' % (
+            self.progress_, self.progress_ + self.chunksize_)
+        }
+    http = self.request_.http
+    http.follow_redirects = False
+
+    resp, content = http.request(self.uri_, headers=headers)
+    if resp.status in [301, 302, 303, 307, 308] and 'location' in resp:
+        self.uri_ = resp['location']
+        resp, content = http.request(self.uri_, headers=headers)
+    if resp.status in [200, 206]:
+      self.progress_ += len(content)
+      self.fh_.write(content)
+
+      if 'content-range' in resp:
+        content_range = resp['content-range']
+        length = content_range.rsplit('/', 1)[1]
+        self.total_size_ = int(length)
+
+      if self.progress_ == self.total_size_:
+        self.done_ = True
+      return MediaDownloadProgress(self.progress_, self.total_size_), self.done_
+    else:
+      raise HttpError(resp, content, self.uri_)
+
+
 class HttpRequest(object):
   """Encapsulates a single HTTP request."""
 
@@ -1219,6 +1325,7 @@
       iterable: iterable, a sequence of pairs of (headers, body)
     """
     self._iterable = iterable
+    self.follow_redirects = True
 
   def request(self, uri,
               method='GET',
diff --git a/apiclient/model.py b/apiclient/model.py
index aafd88b..0e31053 100644
--- a/apiclient/model.py
+++ b/apiclient/model.py
@@ -289,6 +289,25 @@
     return ''
 
 
+class MediaModel(JsonModel):
+  """Model class for requests that return Media.
+
+  Serializes and de-serializes between JSON and the Python
+  object representation of HTTP request, and returns the raw bytes
+  of the response body.
+  """
+  accept = '*/*'
+  content_type = 'application/json'
+  alt_param = 'media'
+
+  def deserialize(self, content):
+    return content
+
+  @property
+  def no_content_response(self):
+    return ''
+
+
 class ProtocolBufferModel(BaseModel):
   """Model class for protocol buffers.