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