1# Copyright 2014 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Client for discovery based APIs.
16
17A client library for Google's discovery based APIs.
18"""
19from __future__ import absolute_import
20import six
21from six.moves import zip
22
23__author__ = 'jcgregorio@google.com (Joe Gregorio)'
24__all__ = [
25    'build',
26    'build_from_document',
27    'fix_method_name',
28    'key2param',
29    ]
30
31from six import StringIO
32from six.moves.urllib.parse import urlencode, urlparse, urljoin, \
33  urlunparse, parse_qsl
34
35# Standard library imports
36import copy
37from email.generator import Generator
38from email.mime.multipart import MIMEMultipart
39from email.mime.nonmultipart import MIMENonMultipart
40import json
41import keyword
42import logging
43import mimetypes
44import os
45import re
46
47# Third-party imports
48import httplib2
49import uritemplate
50
51# Local imports
52from googleapiclient import mimeparse
53from googleapiclient.errors import HttpError
54from googleapiclient.errors import InvalidJsonError
55from googleapiclient.errors import MediaUploadSizeError
56from googleapiclient.errors import UnacceptableMimeTypeError
57from googleapiclient.errors import UnknownApiNameOrVersion
58from googleapiclient.errors import UnknownFileType
59from googleapiclient.http import HttpRequest
60from googleapiclient.http import MediaFileUpload
61from googleapiclient.http import MediaUpload
62from googleapiclient.model import JsonModel
63from googleapiclient.model import MediaModel
64from googleapiclient.model import RawModel
65from googleapiclient.schema import Schemas
66from oauth2client.client import GoogleCredentials
67from oauth2client.util import _add_query_parameter
68from oauth2client.util import positional
69
70
71# The client library requires a version of httplib2 that supports RETRIES.
72httplib2.RETRIES = 1
73
74logger = logging.getLogger(__name__)
75
76URITEMPLATE = re.compile('{[^}]*}')
77VARNAME = re.compile('[a-zA-Z0-9_-]+')
78DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
79                 '{api}/{apiVersion}/rest')
80DEFAULT_METHOD_DOC = 'A description of how to use this function'
81HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
82_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
83BODY_PARAMETER_DEFAULT_VALUE = {
84    'description': 'The request body.',
85    'type': 'object',
86    'required': True,
87}
88MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
89    'description': ('The filename of the media request body, or an instance '
90                    'of a MediaUpload object.'),
91    'type': 'string',
92    'required': False,
93}
94
95# Parameters accepted by the stack, but not visible via discovery.
96# TODO(dhermes): Remove 'userip' in 'v2'.
97STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
98STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
99
100# Library-specific reserved words beyond Python keywords.
101RESERVED_WORDS = frozenset(['body'])
102
103
104def fix_method_name(name):
105  """Fix method names to avoid reserved word conflicts.
106
107  Args:
108    name: string, method name.
109
110  Returns:
111    The name with a '_' prefixed if the name is a reserved word.
112  """
113  if keyword.iskeyword(name) or name in RESERVED_WORDS:
114    return name + '_'
115  else:
116    return name
117
118
119def key2param(key):
120  """Converts key names into parameter names.
121
122  For example, converting "max-results" -> "max_results"
123
124  Args:
125    key: string, the method key name.
126
127  Returns:
128    A safe method name based on the key name.
129  """
130  result = []
131  key = list(key)
132  if not key[0].isalpha():
133    result.append('x')
134  for c in key:
135    if c.isalnum():
136      result.append(c)
137    else:
138      result.append('_')
139
140  return ''.join(result)
141
142
143@positional(2)
144def build(serviceName,
145          version,
146          http=None,
147          discoveryServiceUrl=DISCOVERY_URI,
148          developerKey=None,
149          model=None,
150          requestBuilder=HttpRequest,
151          credentials=None):
152  """Construct a Resource for interacting with an API.
153
154  Construct a Resource object for interacting with an API. The serviceName and
155  version are the names from the Discovery service.
156
157  Args:
158    serviceName: string, name of the service.
159    version: string, the version of the service.
160    http: httplib2.Http, An instance of httplib2.Http or something that acts
161      like it that HTTP requests will be made through.
162    discoveryServiceUrl: string, a URI Template that points to the location of
163      the discovery service. It should have two parameters {api} and
164      {apiVersion} that when filled in produce an absolute URI to the discovery
165      document for that service.
166    developerKey: string, key obtained from
167      https://code.google.com/apis/console.
168    model: googleapiclient.Model, converts to and from the wire format.
169    requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
170      request.
171    credentials: oauth2client.Credentials, credentials to be used for
172      authentication.
173
174  Returns:
175    A Resource object with methods for interacting with the service.
176  """
177  params = {
178      'api': serviceName,
179      'apiVersion': version
180      }
181
182  if http is None:
183    http = httplib2.Http()
184
185  requested_url = uritemplate.expand(discoveryServiceUrl, params)
186
187  # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
188  # variable that contains the network address of the client sending the
189  # request. If it exists then add that to the request for the discovery
190  # document to avoid exceeding the quota on discovery requests.
191  if 'REMOTE_ADDR' in os.environ:
192    requested_url = _add_query_parameter(requested_url, 'userIp',
193                                         os.environ['REMOTE_ADDR'])
194  logger.info('URL being requested: GET %s' % requested_url)
195
196  resp, content = http.request(requested_url)
197
198  if resp.status == 404:
199    raise UnknownApiNameOrVersion("name: %s  version: %s" % (serviceName,
200                                                            version))
201  if resp.status >= 400:
202    raise HttpError(resp, content, uri=requested_url)
203
204  try:
205    content = content.decode('utf-8')
206  except AttributeError:
207    pass
208
209  try:
210    service = json.loads(content)
211  except ValueError as e:
212    logger.error('Failed to parse as JSON: ' + content)
213    raise InvalidJsonError()
214
215  return build_from_document(content, base=discoveryServiceUrl, http=http,
216      developerKey=developerKey, model=model, requestBuilder=requestBuilder,
217      credentials=credentials)
218
219
220@positional(1)
221def build_from_document(
222    service,
223    base=None,
224    future=None,
225    http=None,
226    developerKey=None,
227    model=None,
228    requestBuilder=HttpRequest,
229    credentials=None):
230  """Create a Resource for interacting with an API.
231
232  Same as `build()`, but constructs the Resource object from a discovery
233  document that is it given, as opposed to retrieving one over HTTP.
234
235  Args:
236    service: string or object, the JSON discovery document describing the API.
237      The value passed in may either be the JSON string or the deserialized
238      JSON.
239    base: string, base URI for all HTTP requests, usually the discovery URI.
240      This parameter is no longer used as rootUrl and servicePath are included
241      within the discovery document. (deprecated)
242    future: string, discovery document with future capabilities (deprecated).
243    http: httplib2.Http, An instance of httplib2.Http or something that acts
244      like it that HTTP requests will be made through.
245    developerKey: string, Key for controlling API usage, generated
246      from the API Console.
247    model: Model class instance that serializes and de-serializes requests and
248      responses.
249    requestBuilder: Takes an http request and packages it up to be executed.
250    credentials: object, credentials to be used for authentication.
251
252  Returns:
253    A Resource object with methods for interacting with the service.
254  """
255
256  # future is no longer used.
257  future = {}
258
259  if isinstance(service, six.string_types):
260    service = json.loads(service)
261  base = urljoin(service['rootUrl'], service['servicePath'])
262  schema = Schemas(service)
263
264  if credentials:
265    # If credentials were passed in, we could have two cases:
266    # 1. the scopes were specified, in which case the given credentials
267    #    are used for authorizing the http;
268    # 2. the scopes were not provided (meaning the Application Default
269    #    Credentials are to be used). In this case, the Application Default
270    #    Credentials are built and used instead of the original credentials.
271    #    If there are no scopes found (meaning the given service requires no
272    #    authentication), there is no authorization of the http.
273    if (isinstance(credentials, GoogleCredentials) and
274        credentials.create_scoped_required()):
275      scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
276      if scopes:
277        credentials = credentials.create_scoped(list(scopes.keys()))
278      else:
279        # No need to authorize the http object
280        # if the service does not require authentication.
281        credentials = None
282
283    if credentials:
284      http = credentials.authorize(http)
285
286  if model is None:
287    features = service.get('features', [])
288    model = JsonModel('dataWrapper' in features)
289  return Resource(http=http, baseUrl=base, model=model,
290                  developerKey=developerKey, requestBuilder=requestBuilder,
291                  resourceDesc=service, rootDesc=service, schema=schema)
292
293
294def _cast(value, schema_type):
295  """Convert value to a string based on JSON Schema type.
296
297  See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
298  JSON Schema.
299
300  Args:
301    value: any, the value to convert
302    schema_type: string, the type that value should be interpreted as
303
304  Returns:
305    A string representation of 'value' based on the schema_type.
306  """
307  if schema_type == 'string':
308    if type(value) == type('') or type(value) == type(u''):
309      return value
310    else:
311      return str(value)
312  elif schema_type == 'integer':
313    return str(int(value))
314  elif schema_type == 'number':
315    return str(float(value))
316  elif schema_type == 'boolean':
317    return str(bool(value)).lower()
318  else:
319    if type(value) == type('') or type(value) == type(u''):
320      return value
321    else:
322      return str(value)
323
324
325def _media_size_to_long(maxSize):
326  """Convert a string media size, such as 10GB or 3TB into an integer.
327
328  Args:
329    maxSize: string, size as a string, such as 2MB or 7GB.
330
331  Returns:
332    The size as an integer value.
333  """
334  if len(maxSize) < 2:
335    return 0
336  units = maxSize[-2:].upper()
337  bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
338  if bit_shift is not None:
339    return int(maxSize[:-2]) << bit_shift
340  else:
341    return int(maxSize)
342
343
344def _media_path_url_from_info(root_desc, path_url):
345  """Creates an absolute media path URL.
346
347  Constructed using the API root URI and service path from the discovery
348  document and the relative path for the API method.
349
350  Args:
351    root_desc: Dictionary; the entire original deserialized discovery document.
352    path_url: String; the relative URL for the API method. Relative to the API
353        root, which is specified in the discovery document.
354
355  Returns:
356    String; the absolute URI for media upload for the API method.
357  """
358  return '%(root)supload/%(service_path)s%(path)s' % {
359      'root': root_desc['rootUrl'],
360      'service_path': root_desc['servicePath'],
361      'path': path_url,
362  }
363
364
365def _fix_up_parameters(method_desc, root_desc, http_method):
366  """Updates parameters of an API method with values specific to this library.
367
368  Specifically, adds whatever global parameters are specified by the API to the
369  parameters for the individual method. Also adds parameters which don't
370  appear in the discovery document, but are available to all discovery based
371  APIs (these are listed in STACK_QUERY_PARAMETERS).
372
373  SIDE EFFECTS: This updates the parameters dictionary object in the method
374  description.
375
376  Args:
377    method_desc: Dictionary with metadata describing an API method. Value comes
378        from the dictionary of methods stored in the 'methods' key in the
379        deserialized discovery document.
380    root_desc: Dictionary; the entire original deserialized discovery document.
381    http_method: String; the HTTP method used to call the API method described
382        in method_desc.
383
384  Returns:
385    The updated Dictionary stored in the 'parameters' key of the method
386        description dictionary.
387  """
388  parameters = method_desc.setdefault('parameters', {})
389
390  # Add in the parameters common to all methods.
391  for name, description in six.iteritems(root_desc.get('parameters', {})):
392    parameters[name] = description
393
394  # Add in undocumented query parameters.
395  for name in STACK_QUERY_PARAMETERS:
396    parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
397
398  # Add 'body' (our own reserved word) to parameters if the method supports
399  # a request payload.
400  if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
401    body = BODY_PARAMETER_DEFAULT_VALUE.copy()
402    body.update(method_desc['request'])
403    parameters['body'] = body
404
405  return parameters
406
407
408def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
409  """Updates parameters of API by adding 'media_body' if supported by method.
410
411  SIDE EFFECTS: If the method supports media upload and has a required body,
412  sets body to be optional (required=False) instead. Also, if there is a
413  'mediaUpload' in the method description, adds 'media_upload' key to
414  parameters.
415
416  Args:
417    method_desc: Dictionary with metadata describing an API method. Value comes
418        from the dictionary of methods stored in the 'methods' key in the
419        deserialized discovery document.
420    root_desc: Dictionary; the entire original deserialized discovery document.
421    path_url: String; the relative URL for the API method. Relative to the API
422        root, which is specified in the discovery document.
423    parameters: A dictionary describing method parameters for method described
424        in method_desc.
425
426  Returns:
427    Triple (accept, max_size, media_path_url) where:
428      - accept is a list of strings representing what content types are
429        accepted for media upload. Defaults to empty list if not in the
430        discovery document.
431      - max_size is a long representing the max size in bytes allowed for a
432        media upload. Defaults to 0L if not in the discovery document.
433      - media_path_url is a String; the absolute URI for media upload for the
434        API method. Constructed using the API root URI and service path from
435        the discovery document and the relative path for the API method. If
436        media upload is not supported, this is None.
437  """
438  media_upload = method_desc.get('mediaUpload', {})
439  accept = media_upload.get('accept', [])
440  max_size = _media_size_to_long(media_upload.get('maxSize', ''))
441  media_path_url = None
442
443  if media_upload:
444    media_path_url = _media_path_url_from_info(root_desc, path_url)
445    parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
446    if 'body' in parameters:
447      parameters['body']['required'] = False
448
449  return accept, max_size, media_path_url
450
451
452def _fix_up_method_description(method_desc, root_desc):
453  """Updates a method description in a discovery document.
454
455  SIDE EFFECTS: Changes the parameters dictionary in the method description with
456  extra parameters which are used locally.
457
458  Args:
459    method_desc: Dictionary with metadata describing an API method. Value comes
460        from the dictionary of methods stored in the 'methods' key in the
461        deserialized discovery document.
462    root_desc: Dictionary; the entire original deserialized discovery document.
463
464  Returns:
465    Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
466    where:
467      - path_url is a String; the relative URL for the API method. Relative to
468        the API root, which is specified in the discovery document.
469      - http_method is a String; the HTTP method used to call the API method
470        described in the method description.
471      - method_id is a String; the name of the RPC method associated with the
472        API method, and is in the method description in the 'id' key.
473      - accept is a list of strings representing what content types are
474        accepted for media upload. Defaults to empty list if not in the
475        discovery document.
476      - max_size is a long representing the max size in bytes allowed for a
477        media upload. Defaults to 0L if not in the discovery document.
478      - media_path_url is a String; the absolute URI for media upload for the
479        API method. Constructed using the API root URI and service path from
480        the discovery document and the relative path for the API method. If
481        media upload is not supported, this is None.
482  """
483  path_url = method_desc['path']
484  http_method = method_desc['httpMethod']
485  method_id = method_desc['id']
486
487  parameters = _fix_up_parameters(method_desc, root_desc, http_method)
488  # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
489  # 'parameters' key and needs to know if there is a 'body' parameter because it
490  # also sets a 'media_body' parameter.
491  accept, max_size, media_path_url = _fix_up_media_upload(
492      method_desc, root_desc, path_url, parameters)
493
494  return path_url, http_method, method_id, accept, max_size, media_path_url
495
496
497def _urljoin(base, url):
498  """Custom urljoin replacement supporting : before / in url."""
499  # In general, it's unsafe to simply join base and url. However, for
500  # the case of discovery documents, we know:
501  #  * base will never contain params, query, or fragment
502  #  * url will never contain a scheme or net_loc.
503  # In general, this means we can safely join on /; we just need to
504  # ensure we end up with precisely one / joining base and url. The
505  # exception here is the case of media uploads, where url will be an
506  # absolute url.
507  if url.startswith('http://') or url.startswith('https://'):
508    return urljoin(base, url)
509  new_base = base if base.endswith('/') else base + '/'
510  new_url = url[1:] if url.startswith('/') else url
511  return new_base + new_url
512
513
514# TODO(dhermes): Convert this class to ResourceMethod and make it callable
515class ResourceMethodParameters(object):
516  """Represents the parameters associated with a method.
517
518  Attributes:
519    argmap: Map from method parameter name (string) to query parameter name
520        (string).
521    required_params: List of required parameters (represented by parameter
522        name as string).
523    repeated_params: List of repeated parameters (represented by parameter
524        name as string).
525    pattern_params: Map from method parameter name (string) to regular
526        expression (as a string). If the pattern is set for a parameter, the
527        value for that parameter must match the regular expression.
528    query_params: List of parameters (represented by parameter name as string)
529        that will be used in the query string.
530    path_params: Set of parameters (represented by parameter name as string)
531        that will be used in the base URL path.
532    param_types: Map from method parameter name (string) to parameter type. Type
533        can be any valid JSON schema type; valid values are 'any', 'array',
534        'boolean', 'integer', 'number', 'object', or 'string'. Reference:
535        http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
536    enum_params: Map from method parameter name (string) to list of strings,
537       where each list of strings is the list of acceptable enum values.
538  """
539
540  def __init__(self, method_desc):
541    """Constructor for ResourceMethodParameters.
542
543    Sets default values and defers to set_parameters to populate.
544
545    Args:
546      method_desc: Dictionary with metadata describing an API method. Value
547          comes from the dictionary of methods stored in the 'methods' key in
548          the deserialized discovery document.
549    """
550    self.argmap = {}
551    self.required_params = []
552    self.repeated_params = []
553    self.pattern_params = {}
554    self.query_params = []
555    # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
556    #                parsing is gotten rid of.
557    self.path_params = set()
558    self.param_types = {}
559    self.enum_params = {}
560
561    self.set_parameters(method_desc)
562
563  def set_parameters(self, method_desc):
564    """Populates maps and lists based on method description.
565
566    Iterates through each parameter for the method and parses the values from
567    the parameter dictionary.
568
569    Args:
570      method_desc: Dictionary with metadata describing an API method. Value
571          comes from the dictionary of methods stored in the 'methods' key in
572          the deserialized discovery document.
573    """
574    for arg, desc in six.iteritems(method_desc.get('parameters', {})):
575      param = key2param(arg)
576      self.argmap[param] = arg
577
578      if desc.get('pattern'):
579        self.pattern_params[param] = desc['pattern']
580      if desc.get('enum'):
581        self.enum_params[param] = desc['enum']
582      if desc.get('required'):
583        self.required_params.append(param)
584      if desc.get('repeated'):
585        self.repeated_params.append(param)
586      if desc.get('location') == 'query':
587        self.query_params.append(param)
588      if desc.get('location') == 'path':
589        self.path_params.add(param)
590      self.param_types[param] = desc.get('type', 'string')
591
592    # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
593    #                should have all path parameters already marked with
594    #                'location: path'.
595    for match in URITEMPLATE.finditer(method_desc['path']):
596      for namematch in VARNAME.finditer(match.group(0)):
597        name = key2param(namematch.group(0))
598        self.path_params.add(name)
599        if name in self.query_params:
600          self.query_params.remove(name)
601
602
603def createMethod(methodName, methodDesc, rootDesc, schema):
604  """Creates a method for attaching to a Resource.
605
606  Args:
607    methodName: string, name of the method to use.
608    methodDesc: object, fragment of deserialized discovery document that
609      describes the method.
610    rootDesc: object, the entire deserialized discovery document.
611    schema: object, mapping of schema names to schema descriptions.
612  """
613  methodName = fix_method_name(methodName)
614  (pathUrl, httpMethod, methodId, accept,
615   maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
616
617  parameters = ResourceMethodParameters(methodDesc)
618
619  def method(self, **kwargs):
620    # Don't bother with doc string, it will be over-written by createMethod.
621
622    for name in six.iterkeys(kwargs):
623      if name not in parameters.argmap:
624        raise TypeError('Got an unexpected keyword argument "%s"' % name)
625
626    # Remove args that have a value of None.
627    keys = list(kwargs.keys())
628    for name in keys:
629      if kwargs[name] is None:
630        del kwargs[name]
631
632    for name in parameters.required_params:
633      if name not in kwargs:
634        raise TypeError('Missing required parameter "%s"' % name)
635
636    for name, regex in six.iteritems(parameters.pattern_params):
637      if name in kwargs:
638        if isinstance(kwargs[name], six.string_types):
639          pvalues = [kwargs[name]]
640        else:
641          pvalues = kwargs[name]
642        for pvalue in pvalues:
643          if re.match(regex, pvalue) is None:
644            raise TypeError(
645                'Parameter "%s" value "%s" does not match the pattern "%s"' %
646                (name, pvalue, regex))
647
648    for name, enums in six.iteritems(parameters.enum_params):
649      if name in kwargs:
650        # We need to handle the case of a repeated enum
651        # name differently, since we want to handle both
652        # arg='value' and arg=['value1', 'value2']
653        if (name in parameters.repeated_params and
654            not isinstance(kwargs[name], six.string_types)):
655          values = kwargs[name]
656        else:
657          values = [kwargs[name]]
658        for value in values:
659          if value not in enums:
660            raise TypeError(
661                'Parameter "%s" value "%s" is not an allowed value in "%s"' %
662                (name, value, str(enums)))
663
664    actual_query_params = {}
665    actual_path_params = {}
666    for key, value in six.iteritems(kwargs):
667      to_type = parameters.param_types.get(key, 'string')
668      # For repeated parameters we cast each member of the list.
669      if key in parameters.repeated_params and type(value) == type([]):
670        cast_value = [_cast(x, to_type) for x in value]
671      else:
672        cast_value = _cast(value, to_type)
673      if key in parameters.query_params:
674        actual_query_params[parameters.argmap[key]] = cast_value
675      if key in parameters.path_params:
676        actual_path_params[parameters.argmap[key]] = cast_value
677    body_value = kwargs.get('body', None)
678    media_filename = kwargs.get('media_body', None)
679
680    if self._developerKey:
681      actual_query_params['key'] = self._developerKey
682
683    model = self._model
684    if methodName.endswith('_media'):
685      model = MediaModel()
686    elif 'response' not in methodDesc:
687      model = RawModel()
688
689    headers = {}
690    headers, params, query, body = model.request(headers,
691        actual_path_params, actual_query_params, body_value)
692
693    expanded_url = uritemplate.expand(pathUrl, params)
694    url = _urljoin(self._baseUrl, expanded_url + query)
695
696    resumable = None
697    multipart_boundary = ''
698
699    if media_filename:
700      # Ensure we end up with a valid MediaUpload object.
701      if isinstance(media_filename, six.string_types):
702        (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
703        if media_mime_type is None:
704          raise UnknownFileType(media_filename)
705        if not mimeparse.best_match([media_mime_type], ','.join(accept)):
706          raise UnacceptableMimeTypeError(media_mime_type)
707        media_upload = MediaFileUpload(media_filename,
708                                       mimetype=media_mime_type)
709      elif isinstance(media_filename, MediaUpload):
710        media_upload = media_filename
711      else:
712        raise TypeError('media_filename must be str or MediaUpload.')
713
714      # Check the maxSize
715      if media_upload.size() is not None and media_upload.size() > maxSize > 0:
716        raise MediaUploadSizeError("Media larger than: %s" % maxSize)
717
718      # Use the media path uri for media uploads
719      expanded_url = uritemplate.expand(mediaPathUrl, params)
720      url = _urljoin(self._baseUrl, expanded_url + query)
721      if media_upload.resumable():
722        url = _add_query_parameter(url, 'uploadType', 'resumable')
723
724      if media_upload.resumable():
725        # This is all we need to do for resumable, if the body exists it gets
726        # sent in the first request, otherwise an empty body is sent.
727        resumable = media_upload
728      else:
729        # A non-resumable upload
730        if body is None:
731          # This is a simple media upload
732          headers['content-type'] = media_upload.mimetype()
733          body = media_upload.getbytes(0, media_upload.size())
734          url = _add_query_parameter(url, 'uploadType', 'media')
735        else:
736          # This is a multipart/related upload.
737          msgRoot = MIMEMultipart('related')
738          # msgRoot should not write out it's own headers
739          setattr(msgRoot, '_write_headers', lambda self: None)
740
741          # attach the body as one part
742          msg = MIMENonMultipart(*headers['content-type'].split('/'))
743          msg.set_payload(body)
744          msgRoot.attach(msg)
745
746          # attach the media as the second part
747          msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
748          msg['Content-Transfer-Encoding'] = 'binary'
749
750          payload = media_upload.getbytes(0, media_upload.size())
751          msg.set_payload(payload)
752          msgRoot.attach(msg)
753          # encode the body: note that we can't use `as_string`, because
754          # it plays games with `From ` lines.
755          fp = StringIO()
756          g = Generator(fp, mangle_from_=False)
757          g.flatten(msgRoot, unixfrom=False)
758          body = fp.getvalue()
759
760          multipart_boundary = msgRoot.get_boundary()
761          headers['content-type'] = ('multipart/related; '
762                                     'boundary="%s"') % multipart_boundary
763          url = _add_query_parameter(url, 'uploadType', 'multipart')
764
765    logger.info('URL being requested: %s %s' % (httpMethod,url))
766    return self._requestBuilder(self._http,
767                                model.response,
768                                url,
769                                method=httpMethod,
770                                body=body,
771                                headers=headers,
772                                methodId=methodId,
773                                resumable=resumable)
774
775  docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
776  if len(parameters.argmap) > 0:
777    docs.append('Args:\n')
778
779  # Skip undocumented params and params common to all methods.
780  skip_parameters = list(rootDesc.get('parameters', {}).keys())
781  skip_parameters.extend(STACK_QUERY_PARAMETERS)
782
783  all_args = list(parameters.argmap.keys())
784  args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
785
786  # Move body to the front of the line.
787  if 'body' in all_args:
788    args_ordered.append('body')
789
790  for name in all_args:
791    if name not in args_ordered:
792      args_ordered.append(name)
793
794  for arg in args_ordered:
795    if arg in skip_parameters:
796      continue
797
798    repeated = ''
799    if arg in parameters.repeated_params:
800      repeated = ' (repeated)'
801    required = ''
802    if arg in parameters.required_params:
803      required = ' (required)'
804    paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
805    paramdoc = paramdesc.get('description', 'A parameter')
806    if '$ref' in paramdesc:
807      docs.append(
808          ('  %s: object, %s%s%s\n    The object takes the'
809          ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
810            schema.prettyPrintByName(paramdesc['$ref'])))
811    else:
812      paramtype = paramdesc.get('type', 'string')
813      docs.append('  %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
814                                          repeated))
815    enum = paramdesc.get('enum', [])
816    enumDesc = paramdesc.get('enumDescriptions', [])
817    if enum and enumDesc:
818      docs.append('    Allowed values\n')
819      for (name, desc) in zip(enum, enumDesc):
820        docs.append('      %s - %s\n' % (name, desc))
821  if 'response' in methodDesc:
822    if methodName.endswith('_media'):
823      docs.append('\nReturns:\n  The media object as a string.\n\n    ')
824    else:
825      docs.append('\nReturns:\n  An object of the form:\n\n    ')
826      docs.append(schema.prettyPrintSchema(methodDesc['response']))
827
828  setattr(method, '__doc__', ''.join(docs))
829  return (methodName, method)
830
831
832def createNextMethod(methodName):
833  """Creates any _next methods for attaching to a Resource.
834
835  The _next methods allow for easy iteration through list() responses.
836
837  Args:
838    methodName: string, name of the method to use.
839  """
840  methodName = fix_method_name(methodName)
841
842  def methodNext(self, previous_request, previous_response):
843    """Retrieves the next page of results.
844
845Args:
846  previous_request: The request for the previous page. (required)
847  previous_response: The response from the request for the previous page. (required)
848
849Returns:
850  A request object that you can call 'execute()' on to request the next
851  page. Returns None if there are no more items in the collection.
852    """
853    # Retrieve nextPageToken from previous_response
854    # Use as pageToken in previous_request to create new request.
855
856    if 'nextPageToken' not in previous_response:
857      return None
858
859    request = copy.copy(previous_request)
860
861    pageToken = previous_response['nextPageToken']
862    parsed = list(urlparse(request.uri))
863    q = parse_qsl(parsed[4])
864
865    # Find and remove old 'pageToken' value from URI
866    newq = [(key, value) for (key, value) in q if key != 'pageToken']
867    newq.append(('pageToken', pageToken))
868    parsed[4] = urlencode(newq)
869    uri = urlunparse(parsed)
870
871    request.uri = uri
872
873    logger.info('URL being requested: %s %s' % (methodName,uri))
874
875    return request
876
877  return (methodName, methodNext)
878
879
880class Resource(object):
881  """A class for interacting with a resource."""
882
883  def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
884               resourceDesc, rootDesc, schema):
885    """Build a Resource from the API description.
886
887    Args:
888      http: httplib2.Http, Object to make http requests with.
889      baseUrl: string, base URL for the API. All requests are relative to this
890          URI.
891      model: googleapiclient.Model, converts to and from the wire format.
892      requestBuilder: class or callable that instantiates an
893          googleapiclient.HttpRequest object.
894      developerKey: string, key obtained from
895          https://code.google.com/apis/console
896      resourceDesc: object, section of deserialized discovery document that
897          describes a resource. Note that the top level discovery document
898          is considered a resource.
899      rootDesc: object, the entire deserialized discovery document.
900      schema: object, mapping of schema names to schema descriptions.
901    """
902    self._dynamic_attrs = []
903
904    self._http = http
905    self._baseUrl = baseUrl
906    self._model = model
907    self._developerKey = developerKey
908    self._requestBuilder = requestBuilder
909    self._resourceDesc = resourceDesc
910    self._rootDesc = rootDesc
911    self._schema = schema
912
913    self._set_service_methods()
914
915  def _set_dynamic_attr(self, attr_name, value):
916    """Sets an instance attribute and tracks it in a list of dynamic attributes.
917
918    Args:
919      attr_name: string; The name of the attribute to be set
920      value: The value being set on the object and tracked in the dynamic cache.
921    """
922    self._dynamic_attrs.append(attr_name)
923    self.__dict__[attr_name] = value
924
925  def __getstate__(self):
926    """Trim the state down to something that can be pickled.
927
928    Uses the fact that the instance variable _dynamic_attrs holds attrs that
929    will be wiped and restored on pickle serialization.
930    """
931    state_dict = copy.copy(self.__dict__)
932    for dynamic_attr in self._dynamic_attrs:
933      del state_dict[dynamic_attr]
934    del state_dict['_dynamic_attrs']
935    return state_dict
936
937  def __setstate__(self, state):
938    """Reconstitute the state of the object from being pickled.
939
940    Uses the fact that the instance variable _dynamic_attrs holds attrs that
941    will be wiped and restored on pickle serialization.
942    """
943    self.__dict__.update(state)
944    self._dynamic_attrs = []
945    self._set_service_methods()
946
947  def _set_service_methods(self):
948    self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
949    self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
950    self._add_next_methods(self._resourceDesc, self._schema)
951
952  def _add_basic_methods(self, resourceDesc, rootDesc, schema):
953    # Add basic methods to Resource
954    if 'methods' in resourceDesc:
955      for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
956        fixedMethodName, method = createMethod(
957            methodName, methodDesc, rootDesc, schema)
958        self._set_dynamic_attr(fixedMethodName,
959                               method.__get__(self, self.__class__))
960        # Add in _media methods. The functionality of the attached method will
961        # change when it sees that the method name ends in _media.
962        if methodDesc.get('supportsMediaDownload', False):
963          fixedMethodName, method = createMethod(
964              methodName + '_media', methodDesc, rootDesc, schema)
965          self._set_dynamic_attr(fixedMethodName,
966                                 method.__get__(self, self.__class__))
967
968  def _add_nested_resources(self, resourceDesc, rootDesc, schema):
969    # Add in nested resources
970    if 'resources' in resourceDesc:
971
972      def createResourceMethod(methodName, methodDesc):
973        """Create a method on the Resource to access a nested Resource.
974
975        Args:
976          methodName: string, name of the method to use.
977          methodDesc: object, fragment of deserialized discovery document that
978            describes the method.
979        """
980        methodName = fix_method_name(methodName)
981
982        def methodResource(self):
983          return Resource(http=self._http, baseUrl=self._baseUrl,
984                          model=self._model, developerKey=self._developerKey,
985                          requestBuilder=self._requestBuilder,
986                          resourceDesc=methodDesc, rootDesc=rootDesc,
987                          schema=schema)
988
989        setattr(methodResource, '__doc__', 'A collection resource.')
990        setattr(methodResource, '__is_resource__', True)
991
992        return (methodName, methodResource)
993
994      for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
995        fixedMethodName, method = createResourceMethod(methodName, methodDesc)
996        self._set_dynamic_attr(fixedMethodName,
997                               method.__get__(self, self.__class__))
998
999  def _add_next_methods(self, resourceDesc, schema):
1000    # Add _next() methods
1001    # Look for response bodies in schema that contain nextPageToken, and methods
1002    # that take a pageToken parameter.
1003    if 'methods' in resourceDesc:
1004      for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
1005        if 'response' in methodDesc:
1006          responseSchema = methodDesc['response']
1007          if '$ref' in responseSchema:
1008            responseSchema = schema.get(responseSchema['$ref'])
1009          hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
1010                                                                   {})
1011          hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
1012          if hasNextPageToken and hasPageToken:
1013            fixedMethodName, method = createNextMethod(methodName + '_next')
1014            self._set_dynamic_attr(fixedMethodName,
1015                                   method.__get__(self, self.__class__))
1016