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