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