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