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