blob: e01386c3c26b6c11bfaec42cd497274adeeee987 [file] [log] [blame]
John Asmuth864311d2014-04-24 15:46:08 -04001# Copyright (C) 2010 Google Inc.
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"""
19
20__author__ = 'jcgregorio@google.com (Joe Gregorio)'
21__all__ = [
22 'build',
23 'build_from_document',
24 'fix_method_name',
25 'key2param',
26 ]
27
28
29# Standard library imports
30import copy
31from email.mime.multipart import MIMEMultipart
32from email.mime.nonmultipart import MIMENonMultipart
33import keyword
34import logging
35import mimetypes
36import os
37import re
38import urllib
39import urlparse
40
41try:
42 from urlparse import parse_qsl
43except ImportError:
44 from cgi import parse_qsl
45
46# Third-party imports
47import httplib2
48import mimeparse
49import uritemplate
50
51# Local imports
52from googleapiclient.errors import HttpError
53from googleapiclient.errors import InvalidJsonError
54from googleapiclient.errors import MediaUploadSizeError
55from googleapiclient.errors import UnacceptableMimeTypeError
56from googleapiclient.errors import UnknownApiNameOrVersion
57from googleapiclient.errors import UnknownFileType
58from googleapiclient.http import HttpRequest
59from googleapiclient.http import MediaFileUpload
60from googleapiclient.http import MediaUpload
61from googleapiclient.model import JsonModel
62from googleapiclient.model import MediaModel
63from googleapiclient.model import RawModel
64from googleapiclient.schema import Schemas
65from oauth2client.anyjson import simplejson
Craig Citroae83efb2014-06-06 09:45:57 -070066from oauth2client.client import GoogleCredentials
John Asmuth864311d2014-04-24 15:46:08 -040067from 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,
Orest Bolohane92c9002014-05-30 11:15:43 -0700150 requestBuilder=HttpRequest,
151 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400152 """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.
Orest Bolohane92c9002014-05-30 11:15:43 -0700171 credentials: oauth2client.Credentials, credentials to be used for
172 authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400173
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'])
Eric Gjertsen87553e42014-05-13 15:49:50 -0400194 logger.info('URL being requested: GET %s' % requested_url)
John Asmuth864311d2014-04-24 15:46:08 -0400195
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 service = simplejson.loads(content)
206 except ValueError, e:
207 logger.error('Failed to parse as JSON: ' + content)
208 raise InvalidJsonError()
209
210 return build_from_document(content, base=discoveryServiceUrl, http=http,
Orest Bolohane92c9002014-05-30 11:15:43 -0700211 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
212 credentials=credentials)
John Asmuth864311d2014-04-24 15:46:08 -0400213
214
215@positional(1)
216def build_from_document(
217 service,
218 base=None,
219 future=None,
220 http=None,
221 developerKey=None,
222 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700223 requestBuilder=HttpRequest,
224 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400225 """Create a Resource for interacting with an API.
226
227 Same as `build()`, but constructs the Resource object from a discovery
228 document that is it given, as opposed to retrieving one over HTTP.
229
230 Args:
231 service: string or object, the JSON discovery document describing the API.
232 The value passed in may either be the JSON string or the deserialized
233 JSON.
234 base: string, base URI for all HTTP requests, usually the discovery URI.
235 This parameter is no longer used as rootUrl and servicePath are included
236 within the discovery document. (deprecated)
237 future: string, discovery document with future capabilities (deprecated).
238 http: httplib2.Http, An instance of httplib2.Http or something that acts
239 like it that HTTP requests will be made through.
240 developerKey: string, Key for controlling API usage, generated
241 from the API Console.
242 model: Model class instance that serializes and de-serializes requests and
243 responses.
244 requestBuilder: Takes an http request and packages it up to be executed.
Orest Bolohane92c9002014-05-30 11:15:43 -0700245 credentials: object, credentials to be used for authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400246
247 Returns:
248 A Resource object with methods for interacting with the service.
249 """
250
251 # future is no longer used.
252 future = {}
253
254 if isinstance(service, basestring):
255 service = simplejson.loads(service)
256 base = urlparse.urljoin(service['rootUrl'], service['servicePath'])
257 schema = Schemas(service)
258
Orest Bolohane92c9002014-05-30 11:15:43 -0700259 if credentials:
260 # If credentials were passed in, we could have two cases:
261 # 1. the scopes were specified, in which case the given credentials
262 # are used for authorizing the http;
oresticaaff4e1f2014-07-08 11:28:45 -0700263 # 2. the scopes were not provided (meaning the Application Default
264 # Credentials are to be used). In this case, the Application Default
265 # Credentials are built and used instead of the original credentials.
266 # If there are no scopes found (meaning the given service requires no
267 # authentication), there is no authorization of the http.
Craig Citroae83efb2014-06-06 09:45:57 -0700268 if (isinstance(credentials, GoogleCredentials) and
269 credentials.create_scoped_required()):
Orest Bolohane92c9002014-05-30 11:15:43 -0700270 scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
271 if scopes:
272 credentials = credentials.create_scoped(scopes.keys())
273 else:
274 # No need to authorize the http object
275 # if the service does not require authentication.
276 credentials = None
277
278 if credentials:
279 http = credentials.authorize(http)
280
John Asmuth864311d2014-04-24 15:46:08 -0400281 if model is None:
282 features = service.get('features', [])
283 model = JsonModel('dataWrapper' in features)
284 return Resource(http=http, baseUrl=base, model=model,
285 developerKey=developerKey, requestBuilder=requestBuilder,
286 resourceDesc=service, rootDesc=service, schema=schema)
287
288
289def _cast(value, schema_type):
290 """Convert value to a string based on JSON Schema type.
291
292 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
293 JSON Schema.
294
295 Args:
296 value: any, the value to convert
297 schema_type: string, the type that value should be interpreted as
298
299 Returns:
300 A string representation of 'value' based on the schema_type.
301 """
302 if schema_type == 'string':
303 if type(value) == type('') or type(value) == type(u''):
304 return value
305 else:
306 return str(value)
307 elif schema_type == 'integer':
308 return str(int(value))
309 elif schema_type == 'number':
310 return str(float(value))
311 elif schema_type == 'boolean':
312 return str(bool(value)).lower()
313 else:
314 if type(value) == type('') or type(value) == type(u''):
315 return value
316 else:
317 return str(value)
318
319
320def _media_size_to_long(maxSize):
321 """Convert a string media size, such as 10GB or 3TB into an integer.
322
323 Args:
324 maxSize: string, size as a string, such as 2MB or 7GB.
325
326 Returns:
327 The size as an integer value.
328 """
329 if len(maxSize) < 2:
330 return 0L
331 units = maxSize[-2:].upper()
332 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
333 if bit_shift is not None:
334 return long(maxSize[:-2]) << bit_shift
335 else:
336 return long(maxSize)
337
338
339def _media_path_url_from_info(root_desc, path_url):
340 """Creates an absolute media path URL.
341
342 Constructed using the API root URI and service path from the discovery
343 document and the relative path for the API method.
344
345 Args:
346 root_desc: Dictionary; the entire original deserialized discovery document.
347 path_url: String; the relative URL for the API method. Relative to the API
348 root, which is specified in the discovery document.
349
350 Returns:
351 String; the absolute URI for media upload for the API method.
352 """
353 return '%(root)supload/%(service_path)s%(path)s' % {
354 'root': root_desc['rootUrl'],
355 'service_path': root_desc['servicePath'],
356 'path': path_url,
357 }
358
359
360def _fix_up_parameters(method_desc, root_desc, http_method):
361 """Updates parameters of an API method with values specific to this library.
362
363 Specifically, adds whatever global parameters are specified by the API to the
364 parameters for the individual method. Also adds parameters which don't
365 appear in the discovery document, but are available to all discovery based
366 APIs (these are listed in STACK_QUERY_PARAMETERS).
367
368 SIDE EFFECTS: This updates the parameters dictionary object in the method
369 description.
370
371 Args:
372 method_desc: Dictionary with metadata describing an API method. Value comes
373 from the dictionary of methods stored in the 'methods' key in the
374 deserialized discovery document.
375 root_desc: Dictionary; the entire original deserialized discovery document.
376 http_method: String; the HTTP method used to call the API method described
377 in method_desc.
378
379 Returns:
380 The updated Dictionary stored in the 'parameters' key of the method
381 description dictionary.
382 """
383 parameters = method_desc.setdefault('parameters', {})
384
385 # Add in the parameters common to all methods.
386 for name, description in root_desc.get('parameters', {}).iteritems():
387 parameters[name] = description
388
389 # Add in undocumented query parameters.
390 for name in STACK_QUERY_PARAMETERS:
391 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
392
393 # Add 'body' (our own reserved word) to parameters if the method supports
394 # a request payload.
395 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
396 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
397 body.update(method_desc['request'])
398 parameters['body'] = body
399
400 return parameters
401
402
403def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
404 """Updates parameters of API by adding 'media_body' if supported by method.
405
406 SIDE EFFECTS: If the method supports media upload and has a required body,
407 sets body to be optional (required=False) instead. Also, if there is a
408 'mediaUpload' in the method description, adds 'media_upload' key to
409 parameters.
410
411 Args:
412 method_desc: Dictionary with metadata describing an API method. Value comes
413 from the dictionary of methods stored in the 'methods' key in the
414 deserialized discovery document.
415 root_desc: Dictionary; the entire original deserialized discovery document.
416 path_url: String; the relative URL for the API method. Relative to the API
417 root, which is specified in the discovery document.
418 parameters: A dictionary describing method parameters for method described
419 in method_desc.
420
421 Returns:
422 Triple (accept, max_size, media_path_url) where:
423 - accept is a list of strings representing what content types are
424 accepted for media upload. Defaults to empty list if not in the
425 discovery document.
426 - max_size is a long representing the max size in bytes allowed for a
427 media upload. Defaults to 0L if not in the discovery document.
428 - media_path_url is a String; the absolute URI for media upload for the
429 API method. Constructed using the API root URI and service path from
430 the discovery document and the relative path for the API method. If
431 media upload is not supported, this is None.
432 """
433 media_upload = method_desc.get('mediaUpload', {})
434 accept = media_upload.get('accept', [])
435 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
436 media_path_url = None
437
438 if media_upload:
439 media_path_url = _media_path_url_from_info(root_desc, path_url)
440 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
441 if 'body' in parameters:
442 parameters['body']['required'] = False
443
444 return accept, max_size, media_path_url
445
446
447def _fix_up_method_description(method_desc, root_desc):
448 """Updates a method description in a discovery document.
449
450 SIDE EFFECTS: Changes the parameters dictionary in the method description with
451 extra parameters which are used locally.
452
453 Args:
454 method_desc: Dictionary with metadata describing an API method. Value comes
455 from the dictionary of methods stored in the 'methods' key in the
456 deserialized discovery document.
457 root_desc: Dictionary; the entire original deserialized discovery document.
458
459 Returns:
460 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
461 where:
462 - path_url is a String; the relative URL for the API method. Relative to
463 the API root, which is specified in the discovery document.
464 - http_method is a String; the HTTP method used to call the API method
465 described in the method description.
466 - method_id is a String; the name of the RPC method associated with the
467 API method, and is in the method description in the 'id' key.
468 - accept is a list of strings representing what content types are
469 accepted for media upload. Defaults to empty list if not in the
470 discovery document.
471 - max_size is a long representing the max size in bytes allowed for a
472 media upload. Defaults to 0L if not in the discovery document.
473 - media_path_url is a String; the absolute URI for media upload for the
474 API method. Constructed using the API root URI and service path from
475 the discovery document and the relative path for the API method. If
476 media upload is not supported, this is None.
477 """
478 path_url = method_desc['path']
479 http_method = method_desc['httpMethod']
480 method_id = method_desc['id']
481
482 parameters = _fix_up_parameters(method_desc, root_desc, http_method)
483 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
484 # 'parameters' key and needs to know if there is a 'body' parameter because it
485 # also sets a 'media_body' parameter.
486 accept, max_size, media_path_url = _fix_up_media_upload(
487 method_desc, root_desc, path_url, parameters)
488
489 return path_url, http_method, method_id, accept, max_size, media_path_url
490
491
492# TODO(dhermes): Convert this class to ResourceMethod and make it callable
493class ResourceMethodParameters(object):
494 """Represents the parameters associated with a method.
495
496 Attributes:
497 argmap: Map from method parameter name (string) to query parameter name
498 (string).
499 required_params: List of required parameters (represented by parameter
500 name as string).
501 repeated_params: List of repeated parameters (represented by parameter
502 name as string).
503 pattern_params: Map from method parameter name (string) to regular
504 expression (as a string). If the pattern is set for a parameter, the
505 value for that parameter must match the regular expression.
506 query_params: List of parameters (represented by parameter name as string)
507 that will be used in the query string.
508 path_params: Set of parameters (represented by parameter name as string)
509 that will be used in the base URL path.
510 param_types: Map from method parameter name (string) to parameter type. Type
511 can be any valid JSON schema type; valid values are 'any', 'array',
512 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
513 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
514 enum_params: Map from method parameter name (string) to list of strings,
515 where each list of strings is the list of acceptable enum values.
516 """
517
518 def __init__(self, method_desc):
519 """Constructor for ResourceMethodParameters.
520
521 Sets default values and defers to set_parameters to populate.
522
523 Args:
524 method_desc: Dictionary with metadata describing an API method. Value
525 comes from the dictionary of methods stored in the 'methods' key in
526 the deserialized discovery document.
527 """
528 self.argmap = {}
529 self.required_params = []
530 self.repeated_params = []
531 self.pattern_params = {}
532 self.query_params = []
533 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
534 # parsing is gotten rid of.
535 self.path_params = set()
536 self.param_types = {}
537 self.enum_params = {}
538
539 self.set_parameters(method_desc)
540
541 def set_parameters(self, method_desc):
542 """Populates maps and lists based on method description.
543
544 Iterates through each parameter for the method and parses the values from
545 the parameter dictionary.
546
547 Args:
548 method_desc: Dictionary with metadata describing an API method. Value
549 comes from the dictionary of methods stored in the 'methods' key in
550 the deserialized discovery document.
551 """
552 for arg, desc in method_desc.get('parameters', {}).iteritems():
553 param = key2param(arg)
554 self.argmap[param] = arg
555
556 if desc.get('pattern'):
557 self.pattern_params[param] = desc['pattern']
558 if desc.get('enum'):
559 self.enum_params[param] = desc['enum']
560 if desc.get('required'):
561 self.required_params.append(param)
562 if desc.get('repeated'):
563 self.repeated_params.append(param)
564 if desc.get('location') == 'query':
565 self.query_params.append(param)
566 if desc.get('location') == 'path':
567 self.path_params.add(param)
568 self.param_types[param] = desc.get('type', 'string')
569
570 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
571 # should have all path parameters already marked with
572 # 'location: path'.
573 for match in URITEMPLATE.finditer(method_desc['path']):
574 for namematch in VARNAME.finditer(match.group(0)):
575 name = key2param(namematch.group(0))
576 self.path_params.add(name)
577 if name in self.query_params:
578 self.query_params.remove(name)
579
580
581def createMethod(methodName, methodDesc, rootDesc, schema):
582 """Creates a method for attaching to a Resource.
583
584 Args:
585 methodName: string, name of the method to use.
586 methodDesc: object, fragment of deserialized discovery document that
587 describes the method.
588 rootDesc: object, the entire deserialized discovery document.
589 schema: object, mapping of schema names to schema descriptions.
590 """
591 methodName = fix_method_name(methodName)
592 (pathUrl, httpMethod, methodId, accept,
593 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
594
595 parameters = ResourceMethodParameters(methodDesc)
596
597 def method(self, **kwargs):
598 # Don't bother with doc string, it will be over-written by createMethod.
599
600 for name in kwargs.iterkeys():
601 if name not in parameters.argmap:
602 raise TypeError('Got an unexpected keyword argument "%s"' % name)
603
604 # Remove args that have a value of None.
605 keys = kwargs.keys()
606 for name in keys:
607 if kwargs[name] is None:
608 del kwargs[name]
609
610 for name in parameters.required_params:
611 if name not in kwargs:
612 raise TypeError('Missing required parameter "%s"' % name)
613
614 for name, regex in parameters.pattern_params.iteritems():
615 if name in kwargs:
616 if isinstance(kwargs[name], basestring):
617 pvalues = [kwargs[name]]
618 else:
619 pvalues = kwargs[name]
620 for pvalue in pvalues:
621 if re.match(regex, pvalue) is None:
622 raise TypeError(
623 'Parameter "%s" value "%s" does not match the pattern "%s"' %
624 (name, pvalue, regex))
625
626 for name, enums in parameters.enum_params.iteritems():
627 if name in kwargs:
628 # We need to handle the case of a repeated enum
629 # name differently, since we want to handle both
630 # arg='value' and arg=['value1', 'value2']
631 if (name in parameters.repeated_params and
632 not isinstance(kwargs[name], basestring)):
633 values = kwargs[name]
634 else:
635 values = [kwargs[name]]
636 for value in values:
637 if value not in enums:
638 raise TypeError(
639 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
640 (name, value, str(enums)))
641
642 actual_query_params = {}
643 actual_path_params = {}
644 for key, value in kwargs.iteritems():
645 to_type = parameters.param_types.get(key, 'string')
646 # For repeated parameters we cast each member of the list.
647 if key in parameters.repeated_params and type(value) == type([]):
648 cast_value = [_cast(x, to_type) for x in value]
649 else:
650 cast_value = _cast(value, to_type)
651 if key in parameters.query_params:
652 actual_query_params[parameters.argmap[key]] = cast_value
653 if key in parameters.path_params:
654 actual_path_params[parameters.argmap[key]] = cast_value
655 body_value = kwargs.get('body', None)
656 media_filename = kwargs.get('media_body', None)
657
658 if self._developerKey:
659 actual_query_params['key'] = self._developerKey
660
661 model = self._model
662 if methodName.endswith('_media'):
663 model = MediaModel()
664 elif 'response' not in methodDesc:
665 model = RawModel()
666
667 headers = {}
668 headers, params, query, body = model.request(headers,
669 actual_path_params, actual_query_params, body_value)
670
671 expanded_url = uritemplate.expand(pathUrl, params)
672 url = urlparse.urljoin(self._baseUrl, expanded_url + query)
673
674 resumable = None
675 multipart_boundary = ''
676
677 if media_filename:
678 # Ensure we end up with a valid MediaUpload object.
679 if isinstance(media_filename, basestring):
680 (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
681 if media_mime_type is None:
682 raise UnknownFileType(media_filename)
683 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
684 raise UnacceptableMimeTypeError(media_mime_type)
685 media_upload = MediaFileUpload(media_filename,
686 mimetype=media_mime_type)
687 elif isinstance(media_filename, MediaUpload):
688 media_upload = media_filename
689 else:
690 raise TypeError('media_filename must be str or MediaUpload.')
691
692 # Check the maxSize
693 if maxSize > 0 and media_upload.size() > maxSize:
694 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
695
696 # Use the media path uri for media uploads
697 expanded_url = uritemplate.expand(mediaPathUrl, params)
698 url = urlparse.urljoin(self._baseUrl, expanded_url + query)
699 if media_upload.resumable():
700 url = _add_query_parameter(url, 'uploadType', 'resumable')
701
702 if media_upload.resumable():
703 # This is all we need to do for resumable, if the body exists it gets
704 # sent in the first request, otherwise an empty body is sent.
705 resumable = media_upload
706 else:
707 # A non-resumable upload
708 if body is None:
709 # This is a simple media upload
710 headers['content-type'] = media_upload.mimetype()
711 body = media_upload.getbytes(0, media_upload.size())
712 url = _add_query_parameter(url, 'uploadType', 'media')
713 else:
714 # This is a multipart/related upload.
715 msgRoot = MIMEMultipart('related')
716 # msgRoot should not write out it's own headers
717 setattr(msgRoot, '_write_headers', lambda self: None)
718
719 # attach the body as one part
720 msg = MIMENonMultipart(*headers['content-type'].split('/'))
721 msg.set_payload(body)
722 msgRoot.attach(msg)
723
724 # attach the media as the second part
725 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
726 msg['Content-Transfer-Encoding'] = 'binary'
727
728 payload = media_upload.getbytes(0, media_upload.size())
729 msg.set_payload(payload)
730 msgRoot.attach(msg)
731 body = msgRoot.as_string()
732
733 multipart_boundary = msgRoot.get_boundary()
734 headers['content-type'] = ('multipart/related; '
735 'boundary="%s"') % multipart_boundary
736 url = _add_query_parameter(url, 'uploadType', 'multipart')
737
Eric Gjertsen87553e42014-05-13 15:49:50 -0400738 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400739 return self._requestBuilder(self._http,
740 model.response,
741 url,
742 method=httpMethod,
743 body=body,
744 headers=headers,
745 methodId=methodId,
746 resumable=resumable)
747
748 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
749 if len(parameters.argmap) > 0:
750 docs.append('Args:\n')
751
752 # Skip undocumented params and params common to all methods.
753 skip_parameters = rootDesc.get('parameters', {}).keys()
754 skip_parameters.extend(STACK_QUERY_PARAMETERS)
755
756 all_args = parameters.argmap.keys()
757 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
758
759 # Move body to the front of the line.
760 if 'body' in all_args:
761 args_ordered.append('body')
762
763 for name in all_args:
764 if name not in args_ordered:
765 args_ordered.append(name)
766
767 for arg in args_ordered:
768 if arg in skip_parameters:
769 continue
770
771 repeated = ''
772 if arg in parameters.repeated_params:
773 repeated = ' (repeated)'
774 required = ''
775 if arg in parameters.required_params:
776 required = ' (required)'
777 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
778 paramdoc = paramdesc.get('description', 'A parameter')
779 if '$ref' in paramdesc:
780 docs.append(
781 (' %s: object, %s%s%s\n The object takes the'
782 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
783 schema.prettyPrintByName(paramdesc['$ref'])))
784 else:
785 paramtype = paramdesc.get('type', 'string')
786 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
787 repeated))
788 enum = paramdesc.get('enum', [])
789 enumDesc = paramdesc.get('enumDescriptions', [])
790 if enum and enumDesc:
791 docs.append(' Allowed values\n')
792 for (name, desc) in zip(enum, enumDesc):
793 docs.append(' %s - %s\n' % (name, desc))
794 if 'response' in methodDesc:
795 if methodName.endswith('_media'):
796 docs.append('\nReturns:\n The media object as a string.\n\n ')
797 else:
798 docs.append('\nReturns:\n An object of the form:\n\n ')
799 docs.append(schema.prettyPrintSchema(methodDesc['response']))
800
801 setattr(method, '__doc__', ''.join(docs))
802 return (methodName, method)
803
804
805def createNextMethod(methodName):
806 """Creates any _next methods for attaching to a Resource.
807
808 The _next methods allow for easy iteration through list() responses.
809
810 Args:
811 methodName: string, name of the method to use.
812 """
813 methodName = fix_method_name(methodName)
814
815 def methodNext(self, previous_request, previous_response):
816 """Retrieves the next page of results.
817
818Args:
819 previous_request: The request for the previous page. (required)
820 previous_response: The response from the request for the previous page. (required)
821
822Returns:
823 A request object that you can call 'execute()' on to request the next
824 page. Returns None if there are no more items in the collection.
825 """
826 # Retrieve nextPageToken from previous_response
827 # Use as pageToken in previous_request to create new request.
828
829 if 'nextPageToken' not in previous_response:
830 return None
831
832 request = copy.copy(previous_request)
833
834 pageToken = previous_response['nextPageToken']
835 parsed = list(urlparse.urlparse(request.uri))
836 q = parse_qsl(parsed[4])
837
838 # Find and remove old 'pageToken' value from URI
839 newq = [(key, value) for (key, value) in q if key != 'pageToken']
840 newq.append(('pageToken', pageToken))
841 parsed[4] = urllib.urlencode(newq)
842 uri = urlparse.urlunparse(parsed)
843
844 request.uri = uri
845
Eric Gjertsen87553e42014-05-13 15:49:50 -0400846 logger.info('URL being requested: %s %s' % (methodName,uri))
John Asmuth864311d2014-04-24 15:46:08 -0400847
848 return request
849
850 return (methodName, methodNext)
851
852
853class Resource(object):
854 """A class for interacting with a resource."""
855
856 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
857 resourceDesc, rootDesc, schema):
858 """Build a Resource from the API description.
859
860 Args:
861 http: httplib2.Http, Object to make http requests with.
862 baseUrl: string, base URL for the API. All requests are relative to this
863 URI.
864 model: googleapiclient.Model, converts to and from the wire format.
865 requestBuilder: class or callable that instantiates an
866 googleapiclient.HttpRequest object.
867 developerKey: string, key obtained from
868 https://code.google.com/apis/console
869 resourceDesc: object, section of deserialized discovery document that
870 describes a resource. Note that the top level discovery document
871 is considered a resource.
872 rootDesc: object, the entire deserialized discovery document.
873 schema: object, mapping of schema names to schema descriptions.
874 """
875 self._dynamic_attrs = []
876
877 self._http = http
878 self._baseUrl = baseUrl
879 self._model = model
880 self._developerKey = developerKey
881 self._requestBuilder = requestBuilder
882 self._resourceDesc = resourceDesc
883 self._rootDesc = rootDesc
884 self._schema = schema
885
886 self._set_service_methods()
887
888 def _set_dynamic_attr(self, attr_name, value):
889 """Sets an instance attribute and tracks it in a list of dynamic attributes.
890
891 Args:
892 attr_name: string; The name of the attribute to be set
893 value: The value being set on the object and tracked in the dynamic cache.
894 """
895 self._dynamic_attrs.append(attr_name)
896 self.__dict__[attr_name] = value
897
898 def __getstate__(self):
899 """Trim the state down to something that can be pickled.
900
901 Uses the fact that the instance variable _dynamic_attrs holds attrs that
902 will be wiped and restored on pickle serialization.
903 """
904 state_dict = copy.copy(self.__dict__)
905 for dynamic_attr in self._dynamic_attrs:
906 del state_dict[dynamic_attr]
907 del state_dict['_dynamic_attrs']
908 return state_dict
909
910 def __setstate__(self, state):
911 """Reconstitute the state of the object from being pickled.
912
913 Uses the fact that the instance variable _dynamic_attrs holds attrs that
914 will be wiped and restored on pickle serialization.
915 """
916 self.__dict__.update(state)
917 self._dynamic_attrs = []
918 self._set_service_methods()
919
920 def _set_service_methods(self):
921 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
922 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
923 self._add_next_methods(self._resourceDesc, self._schema)
924
925 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
926 # Add basic methods to Resource
927 if 'methods' in resourceDesc:
928 for methodName, methodDesc in resourceDesc['methods'].iteritems():
929 fixedMethodName, method = createMethod(
930 methodName, methodDesc, rootDesc, schema)
931 self._set_dynamic_attr(fixedMethodName,
932 method.__get__(self, self.__class__))
933 # Add in _media methods. The functionality of the attached method will
934 # change when it sees that the method name ends in _media.
935 if methodDesc.get('supportsMediaDownload', False):
936 fixedMethodName, method = createMethod(
937 methodName + '_media', methodDesc, rootDesc, schema)
938 self._set_dynamic_attr(fixedMethodName,
939 method.__get__(self, self.__class__))
940
941 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
942 # Add in nested resources
943 if 'resources' in resourceDesc:
944
945 def createResourceMethod(methodName, methodDesc):
946 """Create a method on the Resource to access a nested Resource.
947
948 Args:
949 methodName: string, name of the method to use.
950 methodDesc: object, fragment of deserialized discovery document that
951 describes the method.
952 """
953 methodName = fix_method_name(methodName)
954
955 def methodResource(self):
956 return Resource(http=self._http, baseUrl=self._baseUrl,
957 model=self._model, developerKey=self._developerKey,
958 requestBuilder=self._requestBuilder,
959 resourceDesc=methodDesc, rootDesc=rootDesc,
960 schema=schema)
961
962 setattr(methodResource, '__doc__', 'A collection resource.')
963 setattr(methodResource, '__is_resource__', True)
964
965 return (methodName, methodResource)
966
967 for methodName, methodDesc in resourceDesc['resources'].iteritems():
968 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
969 self._set_dynamic_attr(fixedMethodName,
970 method.__get__(self, self.__class__))
971
972 def _add_next_methods(self, resourceDesc, schema):
973 # Add _next() methods
974 # Look for response bodies in schema that contain nextPageToken, and methods
975 # that take a pageToken parameter.
976 if 'methods' in resourceDesc:
977 for methodName, methodDesc in resourceDesc['methods'].iteritems():
978 if 'response' in methodDesc:
979 responseSchema = methodDesc['response']
980 if '$ref' in responseSchema:
981 responseSchema = schema.get(responseSchema['$ref'])
982 hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
983 {})
984 hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
985 if hasNextPageToken and hasPageToken:
986 fixedMethodName, method = createNextMethod(methodName + '_next')
987 self._set_dynamic_attr(fixedMethodName,
988 method.__get__(self, self.__class__))