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