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