/gdata/client.py
Python | 1126 lines | 937 code | 55 blank | 134 comment | 67 complexity | 156edadb75d5ce6a692d2bcff7ad81d7 MD5 | raw file
1#!/usr/bin/env python 2# 3# Copyright (C) 2008, 2009 Google Inc. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17 18# This module is used for version 2 of the Google Data APIs. 19 20 21"""Provides a client to interact with Google Data API servers. 22 23This module is used for version 2 of the Google Data APIs. The primary class 24in this module is GDClient. 25 26 GDClient: handles auth and CRUD operations when communicating with servers. 27 GDataClient: deprecated client for version one services. Will be removed. 28""" 29 30 31__author__ = 'j.s@google.com (Jeff Scudder)' 32 33 34import re 35import atom.client 36import atom.core 37import atom.http_core 38import gdata.gauth 39import gdata.data 40 41 42class Error(Exception): 43 pass 44 45 46class RequestError(Error): 47 status = None 48 reason = None 49 body = None 50 headers = None 51 52 53class RedirectError(RequestError): 54 pass 55 56 57class CaptchaChallenge(RequestError): 58 captcha_url = None 59 captcha_token = None 60 61 62class ClientLoginTokenMissing(Error): 63 pass 64 65 66class MissingOAuthParameters(Error): 67 pass 68 69 70class ClientLoginFailed(RequestError): 71 pass 72 73 74class UnableToUpgradeToken(RequestError): 75 pass 76 77 78class Unauthorized(Error): 79 pass 80 81 82class BadAuthenticationServiceURL(RedirectError): 83 pass 84 85 86class BadAuthentication(RequestError): 87 pass 88 89 90class NotModified(RequestError): 91 pass 92 93class NotImplemented(RequestError): 94 pass 95 96 97def error_from_response(message, http_response, error_class, 98 response_body=None): 99 100 """Creates a new exception and sets the HTTP information in the error. 101 102 Args: 103 message: str human readable message to be displayed if the exception is 104 not caught. 105 http_response: The response from the server, contains error information. 106 error_class: The exception to be instantiated and populated with 107 information from the http_response 108 response_body: str (optional) specify if the response has already been read 109 from the http_response object. 110 """ 111 if response_body is None: 112 body = http_response.read() 113 else: 114 body = response_body 115 error = error_class('%s: %i, %s' % (message, http_response.status, body)) 116 error.status = http_response.status 117 error.reason = http_response.reason 118 error.body = body 119 error.headers = atom.http_core.get_headers(http_response) 120 return error 121 122 123def get_xml_version(version): 124 """Determines which XML schema to use based on the client API version. 125 126 Args: 127 version: string which is converted to an int. The version string is in 128 the form 'Major.Minor.x.y.z' and only the major version number 129 is considered. If None is provided assume version 1. 130 """ 131 if version is None: 132 return 1 133 return int(version.split('.')[0]) 134 135 136class GDClient(atom.client.AtomPubClient): 137 """Communicates with Google Data servers to perform CRUD operations. 138 139 This class is currently experimental and may change in backwards 140 incompatible ways. 141 142 This class exists to simplify the following three areas involved in using 143 the Google Data APIs. 144 145 CRUD Operations: 146 147 The client provides a generic 'request' method for making HTTP requests. 148 There are a number of convenience methods which are built on top of 149 request, which include get_feed, get_entry, get_next, post, update, and 150 delete. These methods contact the Google Data servers. 151 152 Auth: 153 154 Reading user-specific private data requires authorization from the user as 155 do any changes to user data. An auth_token object can be passed into any 156 of the HTTP requests to set the Authorization header in the request. 157 158 You may also want to set the auth_token member to a an object which can 159 use modify_request to set the Authorization header in the HTTP request. 160 161 If you are authenticating using the email address and password, you can 162 use the client_login method to obtain an auth token and set the 163 auth_token member. 164 165 If you are using browser redirects, specifically AuthSub, you will want 166 to use gdata.gauth.AuthSubToken.from_url to obtain the token after the 167 redirect, and you will probably want to updgrade this since use token 168 to a multiple use (session) token using the upgrade_token method. 169 170 API Versions: 171 172 This client is multi-version capable and can be used with Google Data API 173 version 1 and version 2. The version should be specified by setting the 174 api_version member to a string, either '1' or '2'. 175 """ 176 177 # The gsessionid is used by Google Calendar to prevent redirects. 178 __gsessionid = None 179 api_version = None 180 # Name of the Google Data service when making a ClientLogin request. 181 auth_service = None 182 # URL prefixes which should be requested for AuthSub and OAuth. 183 auth_scopes = None 184 185 def request(self, method=None, uri=None, auth_token=None, 186 http_request=None, converter=None, desired_class=None, 187 redirects_remaining=4, **kwargs): 188 """Make an HTTP request to the server. 189 190 See also documentation for atom.client.AtomPubClient.request. 191 192 If a 302 redirect is sent from the server to the client, this client 193 assumes that the redirect is in the form used by the Google Calendar API. 194 The same request URI and method will be used as in the original request, 195 but a gsessionid URL parameter will be added to the request URI with 196 the value provided in the server's 302 redirect response. If the 302 197 redirect is not in the format specified by the Google Calendar API, a 198 RedirectError will be raised containing the body of the server's 199 response. 200 201 The method calls the client's modify_request method to make any changes 202 required by the client before the request is made. For example, a 203 version 2 client could add a GData-Version: 2 header to the request in 204 its modify_request method. 205 206 Args: 207 method: str The HTTP verb for this request, usually 'GET', 'POST', 208 'PUT', or 'DELETE' 209 uri: atom.http_core.Uri, str, or unicode The URL being requested. 210 auth_token: An object which sets the Authorization HTTP header in its 211 modify_request method. Recommended classes include 212 gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken 213 among others. 214 http_request: (optional) atom.http_core.HttpRequest 215 converter: function which takes the body of the response as it's only 216 argument and returns the desired object. 217 desired_class: class descended from atom.core.XmlElement to which a 218 successful response should be converted. If there is no 219 converter function specified (converter=None) then the 220 desired_class will be used in calling the 221 atom.core.parse function. If neither 222 the desired_class nor the converter is specified, an 223 HTTP reponse object will be returned. 224 redirects_remaining: (optional) int, if this number is 0 and the 225 server sends a 302 redirect, the request method 226 will raise an exception. This parameter is used in 227 recursive request calls to avoid an infinite loop. 228 229 Any additional arguments are passed through to 230 atom.client.AtomPubClient.request. 231 232 Returns: 233 An HTTP response object (see atom.http_core.HttpResponse for a 234 description of the object's interface) if no converter was 235 specified and no desired_class was specified. If a converter function 236 was provided, the results of calling the converter are returned. If no 237 converter was specified but a desired_class was provided, the response 238 body will be converted to the class using 239 atom.core.parse. 240 """ 241 if isinstance(uri, (str, unicode)): 242 uri = atom.http_core.Uri.parse_uri(uri) 243 244 # Add the gsession ID to the URL to prevent further redirects. 245 # TODO: If different sessions are using the same client, there will be a 246 # multitude of redirects and session ID shuffling. 247 # If the gsession ID is in the URL, adopt it as the standard location. 248 if uri is not None and uri.query is not None and 'gsessionid' in uri.query: 249 self.__gsessionid = uri.query['gsessionid'] 250 # The gsession ID could also be in the HTTP request. 251 elif (http_request is not None and http_request.uri is not None 252 and http_request.uri.query is not None 253 and 'gsessionid' in http_request.uri.query): 254 self.__gsessionid = http_request.uri.query['gsessionid'] 255 # If the gsession ID is stored in the client, and was not present in the 256 # URI then add it to the URI. 257 elif self.__gsessionid is not None: 258 uri.query['gsessionid'] = self.__gsessionid 259 260 # The AtomPubClient should call this class' modify_request before 261 # performing the HTTP request. 262 #http_request = self.modify_request(http_request) 263 264 response = atom.client.AtomPubClient.request(self, method=method, 265 uri=uri, auth_token=auth_token, http_request=http_request, **kwargs) 266 # On success, convert the response body using the desired converter 267 # function if present. 268 if response is None: 269 return None 270 if response.status == 200 or response.status == 201: 271 if converter is not None: 272 return converter(response) 273 elif desired_class is not None: 274 if self.api_version is not None: 275 return atom.core.parse(response.read(), desired_class, 276 version=get_xml_version(self.api_version)) 277 else: 278 # No API version was specified, so allow parse to 279 # use the default version. 280 return atom.core.parse(response.read(), desired_class) 281 else: 282 return response 283 # TODO: move the redirect logic into the Google Calendar client once it 284 # exists since the redirects are only used in the calendar API. 285 elif response.status == 302: 286 if redirects_remaining > 0: 287 location = (response.getheader('Location') 288 or response.getheader('location')) 289 if location is not None: 290 m = re.compile('[\?\&]gsessionid=(\w*)').search(location) 291 if m is not None: 292 self.__gsessionid = m.group(1) 293 # Make a recursive call with the gsession ID in the URI to follow 294 # the redirect. 295 return self.request(method=method, uri=uri, auth_token=auth_token, 296 http_request=http_request, converter=converter, 297 desired_class=desired_class, 298 redirects_remaining=redirects_remaining-1, 299 **kwargs) 300 else: 301 raise error_from_response('302 received without Location header', 302 response, RedirectError) 303 else: 304 raise error_from_response('Too many redirects from server', 305 response, RedirectError) 306 elif response.status == 401: 307 raise error_from_response('Unauthorized - Server responded with', 308 response, Unauthorized) 309 elif response.status == 304: 310 raise error_from_response('Entry Not Modified - Server responded with', 311 response, NotModified) 312 elif response.status == 501: 313 raise error_from_response( 314 'This API operation is not implemented. - Server responded with', 315 response, NotImplemented) 316 # If the server's response was not a 200, 201, 302, 304, 401, or 501, raise 317 # an exception. 318 else: 319 raise error_from_response('Server responded with', response, 320 RequestError) 321 322 Request = request 323 324 def request_client_login_token( 325 self, email, password, source, service=None, 326 account_type='HOSTED_OR_GOOGLE', 327 auth_url=atom.http_core.Uri.parse_uri( 328 'https://www.google.com/accounts/ClientLogin'), 329 captcha_token=None, captcha_response=None): 330 service = service or self.auth_service 331 # Set the target URL. 332 http_request = atom.http_core.HttpRequest(uri=auth_url, method='POST') 333 http_request.add_body_part( 334 gdata.gauth.generate_client_login_request_body(email=email, 335 password=password, service=service, source=source, 336 account_type=account_type, captcha_token=captcha_token, 337 captcha_response=captcha_response), 338 'application/x-www-form-urlencoded') 339 340 # Use the underlying http_client to make the request. 341 response = self.http_client.request(http_request) 342 343 response_body = response.read() 344 if response.status == 200: 345 token_string = gdata.gauth.get_client_login_token_string(response_body) 346 if token_string is not None: 347 return gdata.gauth.ClientLoginToken(token_string) 348 else: 349 raise ClientLoginTokenMissing( 350 'Recieved a 200 response to client login request,' 351 ' but no token was present. %s' % (response_body,)) 352 elif response.status == 403: 353 captcha_challenge = gdata.gauth.get_captcha_challenge(response_body) 354 if captcha_challenge: 355 challenge = CaptchaChallenge('CAPTCHA required') 356 challenge.captcha_url = captcha_challenge['url'] 357 challenge.captcha_token = captcha_challenge['token'] 358 raise challenge 359 elif response_body.splitlines()[0] == 'Error=BadAuthentication': 360 raise BadAuthentication('Incorrect username or password') 361 else: 362 raise error_from_response('Server responded with a 403 code', 363 response, RequestError, response_body) 364 elif response.status == 302: 365 # Google tries to redirect all bad URLs back to 366 # http://www.google.<locale>. If a redirect 367 # attempt is made, assume the user has supplied an incorrect 368 # authentication URL 369 raise error_from_response('Server responded with a redirect', 370 response, BadAuthenticationServiceURL, 371 response_body) 372 else: 373 raise error_from_response('Server responded to ClientLogin request', 374 response, ClientLoginFailed, response_body) 375 376 RequestClientLoginToken = request_client_login_token 377 378 def client_login(self, email, password, source, service=None, 379 account_type='HOSTED_OR_GOOGLE', 380 auth_url=atom.http_core.Uri.parse_uri( 381 'https://www.google.com/accounts/ClientLogin'), 382 captcha_token=None, captcha_response=None): 383 """Performs an auth request using the user's email address and password. 384 385 In order to modify user specific data and read user private data, your 386 application must be authorized by the user. One way to demonstrage 387 authorization is by including a Client Login token in the Authorization 388 HTTP header of all requests. This method requests the Client Login token 389 by sending the user's email address, password, the name of the 390 application, and the service code for the service which will be accessed 391 by the application. If the username and password are correct, the server 392 will respond with the client login code and a new ClientLoginToken 393 object will be set in the client's auth_token member. With the auth_token 394 set, future requests from this client will include the Client Login 395 token. 396 397 For a list of service names, see 398 http://code.google.com/apis/gdata/faq.html#clientlogin 399 For more information on Client Login, see: 400 http://code.google.com/apis/accounts/docs/AuthForInstalledApps.html 401 402 Args: 403 email: str The user's email address or username. 404 password: str The password for the user's account. 405 source: str The name of your application. This can be anything you 406 like but should should give some indication of which app is 407 making the request. 408 service: str The service code for the service you would like to access. 409 For example, 'cp' for contacts, 'cl' for calendar. For a full 410 list see 411 http://code.google.com/apis/gdata/faq.html#clientlogin 412 If you are using a subclass of the gdata.client.GDClient, the 413 service will usually be filled in for you so you do not need 414 to specify it. For example see BloggerClient, 415 SpreadsheetsClient, etc. 416 account_type: str (optional) The type of account which is being 417 authenticated. This can be either 'GOOGLE' for a Google 418 Account, 'HOSTED' for a Google Apps Account, or the 419 default 'HOSTED_OR_GOOGLE' which will select the Google 420 Apps Account if the same email address is used for both 421 a Google Account and a Google Apps Account. 422 auth_url: str (optional) The URL to which the login request should be 423 sent. 424 captcha_token: str (optional) If a previous login attempt was reponded 425 to with a CAPTCHA challenge, this is the token which 426 identifies the challenge (from the CAPTCHA's URL). 427 captcha_response: str (optional) If a previous login attempt was 428 reponded to with a CAPTCHA challenge, this is the 429 response text which was contained in the challenge. 430 431 Returns: 432 None 433 434 Raises: 435 A RequestError or one of its suclasses: BadAuthentication, 436 BadAuthenticationServiceURL, ClientLoginFailed, 437 ClientLoginTokenMissing, or CaptchaChallenge 438 """ 439 service = service or self.auth_service 440 self.auth_token = self.request_client_login_token(email, password, 441 source, service=service, account_type=account_type, auth_url=auth_url, 442 captcha_token=captcha_token, captcha_response=captcha_response) 443 444 ClientLogin = client_login 445 446 def upgrade_token(self, token=None, url=atom.http_core.Uri.parse_uri( 447 'https://www.google.com/accounts/AuthSubSessionToken')): 448 """Asks the Google auth server for a multi-use AuthSub token. 449 450 For details on AuthSub, see: 451 http://code.google.com/apis/accounts/docs/AuthSub.html 452 453 Args: 454 token: gdata.gauth.AuthSubToken or gdata.gauth.SecureAuthSubToken 455 (optional) If no token is passed in, the client's auth_token member 456 is used to request the new token. The token object will be modified 457 to contain the new session token string. 458 url: str or atom.http_core.Uri (optional) The URL to which the token 459 upgrade request should be sent. Defaults to: 460 https://www.google.com/accounts/AuthSubSessionToken 461 462 Returns: 463 The upgraded gdata.gauth.AuthSubToken object. 464 """ 465 # Default to using the auth_token member if no token is provided. 466 if token is None: 467 token = self.auth_token 468 # We cannot upgrade a None token. 469 if token is None: 470 raise UnableToUpgradeToken('No token was provided.') 471 if not isinstance(token, gdata.gauth.AuthSubToken): 472 raise UnableToUpgradeToken( 473 'Cannot upgrade the token because it is not an AuthSubToken object.') 474 http_request = atom.http_core.HttpRequest(uri=url, method='GET') 475 token.modify_request(http_request) 476 # Use the lower level HttpClient to make the request. 477 response = self.http_client.request(http_request) 478 if response.status == 200: 479 token._upgrade_token(response.read()) 480 return token 481 else: 482 raise UnableToUpgradeToken( 483 'Server responded to token upgrade request with %s: %s' % ( 484 response.status, response.read())) 485 486 UpgradeToken = upgrade_token 487 488 def revoke_token(self, token=None, url=atom.http_core.Uri.parse_uri( 489 'https://www.google.com/accounts/AuthSubRevokeToken')): 490 """Requests that the token be invalidated. 491 492 This method can be used for both AuthSub and OAuth tokens (to invalidate 493 a ClientLogin token, the user must change their password). 494 495 Returns: 496 True if the server responded with a 200. 497 498 Raises: 499 A RequestError if the server responds with a non-200 status. 500 """ 501 # Default to using the auth_token member if no token is provided. 502 if token is None: 503 token = self.auth_token 504 505 http_request = atom.http_core.HttpRequest(uri=url, method='GET') 506 token.modify_request(http_request) 507 response = self.http_client.request(http_request) 508 if response.status != 200: 509 raise error_from_response('Server sent non-200 to revoke token', 510 response, RequestError, response_body) 511 512 return True 513 514 RevokeToken = revoke_token 515 516 def get_oauth_token(self, scopes, next, consumer_key, consumer_secret=None, 517 rsa_private_key=None, 518 url=gdata.gauth.REQUEST_TOKEN_URL): 519 """Obtains an OAuth request token to allow the user to authorize this app. 520 521 Once this client has a request token, the user can authorize the request 522 token by visiting the authorization URL in their browser. After being 523 redirected back to this app at the 'next' URL, this app can then exchange 524 the authorized request token for an access token. 525 526 For more information see the documentation on Google Accounts with OAuth: 527 http://code.google.com/apis/accounts/docs/OAuth.html#AuthProcess 528 529 Args: 530 scopes: list of strings or atom.http_core.Uri objects which specify the 531 URL prefixes which this app will be accessing. For example, to access 532 the Google Calendar API, you would want to use scopes: 533 ['https://www.google.com/calendar/feeds/', 534 'http://www.google.com/calendar/feeds/'] 535 next: str or atom.http_core.Uri object, The URL which the user's browser 536 should be sent to after they authorize access to their data. This 537 should be a URL in your application which will read the token 538 information from the URL and upgrade the request token to an access 539 token. 540 consumer_key: str This is the identifier for this application which you 541 should have received when you registered your application with Google 542 to use OAuth. 543 consumer_secret: str (optional) The shared secret between your app and 544 Google which provides evidence that this request is coming from you 545 application and not another app. If present, this libraries assumes 546 you want to use an HMAC signature to verify requests. Keep this data 547 a secret. 548 rsa_private_key: str (optional) The RSA private key which is used to 549 generate a digital signature which is checked by Google's server. If 550 present, this library assumes that you want to use an RSA signature 551 to verify requests. Keep this data a secret. 552 url: The URL to which a request for a token should be made. The default 553 is Google's OAuth request token provider. 554 """ 555 http_request = None 556 if rsa_private_key is not None: 557 http_request = gdata.gauth.generate_request_for_request_token( 558 consumer_key, gdata.gauth.RSA_SHA1, scopes, 559 rsa_key=rsa_private_key, auth_server_url=url, next=next) 560 elif consumer_secret is not None: 561 http_request = gdata.gauth.generate_request_for_request_token( 562 consumer_key, gdata.gauth.HMAC_SHA1, scopes, 563 consumer_secret=consumer_secret, auth_server_url=url, next=next) 564 else: 565 raise MissingOAuthParameters( 566 'To request an OAuth token, you must provide your consumer secret' 567 ' or your private RSA key.') 568 569 response = self.http_client.request(http_request) 570 response_body = response.read() 571 572 if response.status != 200: 573 raise error_from_response('Unable to obtain OAuth request token', 574 response, RequestError, response_body) 575 576 if rsa_private_key is not None: 577 return gdata.gauth.rsa_token_from_body(response_body, consumer_key, 578 rsa_private_key, 579 gdata.gauth.REQUEST_TOKEN) 580 elif consumer_secret is not None: 581 return gdata.gauth.hmac_token_from_body(response_body, consumer_key, 582 consumer_secret, 583 gdata.gauth.REQUEST_TOKEN) 584 585 GetOAuthToken = get_oauth_token 586 587 def get_access_token(self, request_token, 588 url=gdata.gauth.ACCESS_TOKEN_URL): 589 """Exchanges an authorized OAuth request token for an access token. 590 591 Contacts the Google OAuth server to upgrade a previously authorized 592 request token. Once the request token is upgraded to an access token, 593 the access token may be used to access the user's data. 594 595 For more details, see the Google Accounts OAuth documentation: 596 http://code.google.com/apis/accounts/docs/OAuth.html#AccessToken 597 598 Args: 599 request_token: An OAuth token which has been authorized by the user. 600 url: (optional) The URL to which the upgrade request should be sent. 601 Defaults to: https://www.google.com/accounts/OAuthAuthorizeToken 602 """ 603 http_request = gdata.gauth.generate_request_for_access_token( 604 request_token, auth_server_url=url) 605 response = self.http_client.request(http_request) 606 response_body = response.read() 607 if response.status != 200: 608 raise error_from_response( 609 'Unable to upgrade OAuth request token to access token', 610 response, RequestError, response_body) 611 612 return gdata.gauth.upgrade_to_access_token(request_token, response_body) 613 614 GetAccessToken = get_access_token 615 616 def modify_request(self, http_request): 617 """Adds or changes request before making the HTTP request. 618 619 This client will add the API version if it is specified. 620 Subclasses may override this method to add their own request 621 modifications before the request is made. 622 """ 623 http_request = atom.client.AtomPubClient.modify_request(self, 624 http_request) 625 if self.api_version is not None: 626 http_request.headers['GData-Version'] = self.api_version 627 return http_request 628 629 ModifyRequest = modify_request 630 631 def get_feed(self, uri, auth_token=None, converter=None, 632 desired_class=gdata.data.GDFeed, **kwargs): 633 return self.request(method='GET', uri=uri, auth_token=auth_token, 634 converter=converter, desired_class=desired_class, 635 **kwargs) 636 637 GetFeed = get_feed 638 639 def get_entry(self, uri, auth_token=None, converter=None, 640 desired_class=gdata.data.GDEntry, etag=None, **kwargs): 641 http_request = atom.http_core.HttpRequest() 642 # Conditional retrieval 643 if etag is not None: 644 http_request.headers['If-None-Match'] = etag 645 return self.request(method='GET', uri=uri, auth_token=auth_token, 646 http_request=http_request, converter=converter, 647 desired_class=desired_class, **kwargs) 648 649 GetEntry = get_entry 650 651 def get_next(self, feed, auth_token=None, converter=None, 652 desired_class=None, **kwargs): 653 """Fetches the next set of results from the feed. 654 655 When requesting a feed, the number of entries returned is capped at a 656 service specific default limit (often 25 entries). You can specify your 657 own entry-count cap using the max-results URL query parameter. If there 658 are more results than could fit under max-results, the feed will contain 659 a next link. This method performs a GET against this next results URL. 660 661 Returns: 662 A new feed object containing the next set of entries in this feed. 663 """ 664 if converter is None and desired_class is None: 665 desired_class = feed.__class__ 666 return self.get_feed(feed.find_next_link(), auth_token=auth_token, 667 converter=converter, desired_class=desired_class, 668 **kwargs) 669 670 GetNext = get_next 671 672 # TODO: add a refresh method to re-fetch the entry/feed from the server 673 # if it has been updated. 674 675 def post(self, entry, uri, auth_token=None, converter=None, 676 desired_class=None, **kwargs): 677 if converter is None and desired_class is None: 678 desired_class = entry.__class__ 679 http_request = atom.http_core.HttpRequest() 680 http_request.add_body_part( 681 entry.to_string(get_xml_version(self.api_version)), 682 'application/atom+xml') 683 return self.request(method='POST', uri=uri, auth_token=auth_token, 684 http_request=http_request, converter=converter, 685 desired_class=desired_class, **kwargs) 686 687 Post = post 688 689 def update(self, entry, auth_token=None, force=False, **kwargs): 690 """Edits the entry on the server by sending the XML for this entry. 691 692 Performs a PUT and converts the response to a new entry object with a 693 matching class to the entry passed in. 694 695 Args: 696 entry: 697 auth_token: 698 force: boolean stating whether an update should be forced. Defaults to 699 False. Normally, if a change has been made since the passed in 700 entry was obtained, the server will not overwrite the entry since 701 the changes were based on an obsolete version of the entry. 702 Setting force to True will cause the update to silently 703 overwrite whatever version is present. 704 705 Returns: 706 A new Entry object of a matching type to the entry which was passed in. 707 """ 708 http_request = atom.http_core.HttpRequest() 709 http_request.add_body_part( 710 entry.to_string(get_xml_version(self.api_version)), 711 'application/atom+xml') 712 # Include the ETag in the request if present. 713 if force: 714 http_request.headers['If-Match'] = '*' 715 elif hasattr(entry, 'etag') and entry.etag: 716 http_request.headers['If-Match'] = entry.etag 717 718 return self.request(method='PUT', uri=entry.find_edit_link(), 719 auth_token=auth_token, http_request=http_request, 720 desired_class=entry.__class__, **kwargs) 721 722 Update = update 723 724 def delete(self, entry_or_uri, auth_token=None, force=False, **kwargs): 725 http_request = atom.http_core.HttpRequest() 726 727 # Include the ETag in the request if present. 728 if force: 729 http_request.headers['If-Match'] = '*' 730 elif hasattr(entry_or_uri, 'etag') and entry_or_uri.etag: 731 http_request.headers['If-Match'] = entry_or_uri.etag 732 733 # If the user passes in a URL, just delete directly, may not work as 734 # the service might require an ETag. 735 if isinstance(entry_or_uri, (str, unicode, atom.http_core.Uri)): 736 return self.request(method='DELETE', uri=entry_or_uri, 737 http_request=http_request, auth_token=auth_token, 738 **kwargs) 739 740 return self.request(method='DELETE', uri=entry_or_uri.find_edit_link(), 741 http_request=http_request, auth_token=auth_token, 742 **kwargs) 743 744 Delete = delete 745 746 #TODO: implement batch requests. 747 #def batch(feed, uri, auth_token=None, converter=None, **kwargs): 748 # pass 749 750 # TODO: add a refresh method to request a conditional update to an entry 751 # or feed. 752 753 754def _add_query_param(param_string, value, http_request): 755 if value: 756 http_request.uri.query[param_string] = value 757 758 759class Query(object): 760 761 def __init__(self, text_query=None, categories=None, author=None, alt=None, 762 updated_min=None, updated_max=None, pretty_print=False, 763 published_min=None, published_max=None, start_index=None, 764 max_results=None, strict=False): 765 """Constructs a Google Data Query to filter feed contents serverside. 766 767 Args: 768 text_query: Full text search str (optional) 769 categories: list of strings (optional). Each string is a required 770 category. To include an 'or' query, put a | in the string between 771 terms. For example, to find everything in the Fitz category and 772 the Laurie or Jane category (Fitz and (Laurie or Jane)) you would 773 set categories to ['Fitz', 'Laurie|Jane']. 774 author: str (optional) The service returns entries where the author 775 name and/or email address match your query string. 776 alt: str (optional) for the Alternative representation type you'd like 777 the feed in. If you don't specify an alt parameter, the service 778 returns an Atom feed. This is equivalent to alt='atom'. 779 alt='rss' returns an RSS 2.0 result feed. 780 alt='json' returns a JSON representation of the feed. 781 alt='json-in-script' Requests a response that wraps JSON in a script 782 tag. 783 alt='atom-in-script' Requests an Atom response that wraps an XML 784 string in a script tag. 785 alt='rss-in-script' Requests an RSS response that wraps an XML 786 string in a script tag. 787 updated_min: str (optional), RFC 3339 timestamp format, lower bounds. 788 For example: 2005-08-09T10:57:00-08:00 789 updated_max: str (optional) updated time must be earlier than timestamp. 790 pretty_print: boolean (optional) If True the server's XML response will 791 be indented to make it more human readable. Defaults to False. 792 published_min: str (optional), Similar to updated_min but for published 793 time. 794 published_max: str (optional), Similar to updated_max but for published 795 time. 796 start_index: int or str (optional) 1-based index of the first result to 797 be retrieved. Note that this isn't a general cursoring mechanism. 798 If you first send a query with ?start-index=1&max-results=10 and 799 then send another query with ?start-index=11&max-results=10, the 800 service cannot guarantee that the results are equivalent to 801 ?start-index=1&max-results=20, because insertions and deletions 802 could have taken place in between the two queries. 803 max_results: int or str (optional) Maximum number of results to be 804 retrieved. Each service has a default max (usually 25) which can 805 vary from service to service. There is also a service-specific 806 limit to the max_results you can fetch in a request. 807 strict: boolean (optional) If True, the server will return an error if 808 the server does not recognize any of the parameters in the request 809 URL. Defaults to False. 810 """ 811 self.text_query = text_query 812 self.categories = categories or [] 813 self.author = author 814 self.alt = alt 815 self.updated_min = updated_min 816 self.updated_max = updated_max 817 self.pretty_print = pretty_print 818 self.published_min = published_min 819 self.published_max = published_max 820 self.start_index = start_index 821 self.max_results = max_results 822 self.strict = strict 823 824 def modify_request(self, http_request): 825 _add_query_param('q', self.text_query, http_request) 826 if self.categories: 827 http_request.uri.query['categories'] = ','.join(self.categories) 828 _add_query_param('author', self.author, http_request) 829 _add_query_param('alt', self.alt, http_request) 830 _add_query_param('updated-min', self.updated_min, http_request) 831 _add_query_param('updated-max', self.updated_max, http_request) 832 if self.pretty_print: 833 http_request.uri.query['prettyprint'] = 'true' 834 _add_query_param('published-min', self.published_min, http_request) 835 _add_query_param('published-max', self.published_max, http_request) 836 if self.start_index is not None: 837 http_request.uri.query['start-index'] = str(self.start_index) 838 if self.max_results is not None: 839 http_request.uri.query['max-results'] = str(self.max_results) 840 if self.strict: 841 http_request.uri.query['strict'] = 'true' 842 843 844 ModifyRequest = modify_request 845 846 847class GDQuery(atom.http_core.Uri): 848 849 def _get_text_query(self): 850 return self.query['q'] 851 852 def _set_text_query(self, value): 853 self.query['q'] = value 854 855 text_query = property(_get_text_query, _set_text_query, 856 doc='The q parameter for searching for an exact text match on content') 857 858 859class ResumableUploader(object): 860 """Resumable upload helper for the Google Data protocol.""" 861 862 DEFAULT_CHUNK_SIZE = 5242880 # 5MB 863 864 def __init__(self, client, file_handle, content_type, total_file_size, 865 chunk_size=None, desired_class=None): 866 """Starts a resumable upload to a service that supports the protocol. 867 868 Args: 869 client: gdata.client.GDClient A Google Data API service. 870 file_handle: object A file-like object containing the file to upload. 871 content_type: str The mimetype of the file to upload. 872 total_file_size: int The file's total size in bytes. 873 chunk_size: int The size of each upload chunk. If None, the 874 DEFAULT_CHUNK_SIZE will be used. 875 desired_class: object (optional) The type of gdata.data.GDEntry to parse 876 the completed entry as. This should be specific to the API. 877 """ 878 self.client = client 879 self.file_handle = file_handle 880 self.content_type = content_type 881 self.total_file_size = total_file_size 882 self.chunk_size = chunk_size or self.DEFAULT_CHUNK_SIZE 883 self.desired_class = desired_class or gdata.data.GDEntry 884 self.upload_uri = None 885 886 # Send the entire file if the chunk size is less than fize's total size. 887 if self.total_file_size <= self.chunk_size: 888 self.chunk_size = total_file_size 889 890 def _init_session(self, resumable_media_link, entry=None, headers=None, 891 auth_token=None): 892 """Starts a new resumable upload to a service that supports the protocol. 893 894 The method makes a request to initiate a new upload session. The unique 895 upload uri returned by the server (and set in this method) should be used 896 to send upload chunks to the server. 897 898 Args: 899 resumable_media_link: str The full URL for the #resumable-create-media or 900 #resumable-edit-media link for starting a resumable upload request or 901 updating media using a resumable PUT. 902 entry: A (optional) gdata.data.GDEntry containging metadata to create the 903 upload from. 904 headers: dict (optional) Additional headers to send in the initial request 905 to create the resumable upload request. These headers will override 906 any default headers sent in the request. For example: 907 headers={'Slug': 'MyTitle'}. 908 auth_token: (optional) An object which sets the Authorization HTTP header 909 in its modify_request method. Recommended classes include 910 gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken 911 among others. 912 913 Returns: 914 The final Atom entry as created on the server. The entry will be 915 parsed accoring to the class specified in self.desired_class. 916 917 Raises: 918 RequestError if the unique upload uri is not set or the 919 server returns something other than an HTTP 308 when the upload is 920 incomplete. 921 """ 922 http_request = atom.http_core.HttpRequest() 923 924 # Send empty POST if Atom XML wasn't specified. 925 if entry is None: 926 http_request.add_body_part('', self.content_type, size=0) 927 else: 928 http_request.add_body_part(str(entry), 'application/atom+xml', 929 size=len(str(entry))) 930 http_request.headers['X-Upload-Content-Type'] = self.content_type 931 http_request.headers['X-Upload-Content-Length'] = self.total_file_size 932 933 if headers is not None: 934 http_request.headers.update(headers) 935 936 response = self.client.request(method='POST', 937 uri=resumable_media_link, 938 auth_token=auth_token, 939 http_request=http_request) 940 941 self.upload_uri = (response.getheader('location') or 942 response.getheader('Location')) 943 944 _InitSession = _init_session 945 946 def upload_chunk(self, start_byte, content_bytes): 947 """Uploads a byte range (chunk) to the resumable upload server. 948 949 Args: 950 start_byte: int The byte offset of the total file where the byte range 951 passed in lives. 952 content_bytes: str The file contents of this chunk. 953 954 Returns: 955 The final Atom entry created on the server. The entry object's type will 956 be the class specified in self.desired_class. 957 958 Raises: 959 RequestError if the unique upload uri is not set or the 960 server returns something other than an HTTP 308 when the upload is 961 incomplete. 962 """ 963 if self.upload_uri is None: 964 raise RequestError('Resumable upload request not initialized.') 965 966 # Adjustment if last byte range is less than defined chunk size. 967 chunk_size = self.chunk_size 968 if len(content_bytes) <= chunk_size: 969 chunk_size = len(content_bytes) 970 971 http_request = atom.http_core.HttpRequest() 972 http_request.add_body_part(content_bytes, self.content_type, 973 size=len(content_bytes)) 974 http_request.headers['Content-Range'] = ('bytes %s-%s/%s' 975 % (start_byte, 976 start_byte + chunk_size - 1, 977 self.total_file_size)) 978 979 try: 980 response = self.client.request(method='POST', uri=self.upload_uri, 981 http_request=http_request, 982 desired_class=self.desired_class) 983 return response 984 except RequestError, error: 985 if error.status == 308: 986 return None 987 else: 988 raise error 989 990 UploadChunk = upload_chunk 991 992 def upload_file(self, resumable_media_link, entry=None, headers=None, 993 auth_token=None): 994 """Uploads an entire file in chunks using the resumable upload protocol. 995 996 If you are interested in pausing an upload or controlling the chunking 997 yourself, use the upload_chunk() method instead. 998 999 Args: 1000 resumable_media_link: str The full URL for the #resumable-create-media for 1001 starting a resumable upload request. 1002 entry: A (optional) gdata.data.GDEntry containging metadata to create the 1003 upload from. 1004 headers: dict Additional headers to send in the initial request to create 1005 the resumable upload request. These headers will override any default 1006 headers sent in the request. For example: headers={'Slug': 'MyTitle'}. 1007 auth_token: (optional) An object which sets the Authorization HTTP header 1008 in its modify_request method. Recommended classes include 1009 gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken 1010 among others. 1011 1012 Returns: 1013 The final Atom entry created on the server. The entry object's type will 1014 be the class specified in self.desired_class. 1015 1016 Raises: 1017 RequestError if anything other than a HTTP 308 is returned 1018 when the request raises an exception. 1019 """ 1020 self._init_session(resumable_media_link, headers=headers, 1021 auth_token=auth_token, entry=entry) 1022 1023 start_byte = 0 1024 entry = None 1025 1026 while not entry: 1027 entry = self.upload_chunk( 1028 start_byte, self.file_handle.read(self.chunk_size)) 1029 start_byte += self.chunk_size 1030 1031 return entry 1032 1033 UploadFile = upload_file 1034 1035 def update_file(self, entry_or_resumable_edit_link, headers=None, force=False, 1036 auth_token=None): 1037 """Updates the contents of an existing file using the resumable protocol. 1038 1039 If you are interested in pausing an upload or controlling the chunking 1040 yourself, use the upload_chunk() method instead. 1041 1042 Args: 1043 entry_or_resumable_edit_link: object or string A gdata.data.GDEntry for 1044 the entry/file to update or the full uri of the link with rel 1045 #resumable-edit-media. 1046 headers: dict Additional headers to send in the initial request to create 1047 the resumable upload request. These headers will override any default 1048 headers sent in the request. For example: headers={'Slug': 'MyTitle'}. 1049 force boolean (optional) True to force an update and set the If-Match 1050 header to '*'. If False and entry_or_resumable_edit_link is a 1051 gdata.data.GDEntry object, its etag value is used. Otherwise this 1052 parameter should be set to True to force the update. 1053 auth_token: (optional) An object which sets the Authorization HTTP header 1054 in its modify_request method. Recommended classes include 1055 gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken 1056 among others. 1057 1058 Returns: 1059 The final Atom entry created on the server. The entry object's type will 1060 be the class specified in self.desired_class. 1061 1062 Raises: 1063 RequestError if anything other than a HTTP 308 is returned 1064 when the request raises an exception. 1065 """ 1066 # Need to override the POST request for a resumable update (required). 1067 customer_headers = {'X-HTTP-Method-Override': 'PUT'} 1068 1069 if headers is not None: 1070 customer_headers.update(headers) 1071 1072 if isinstance(entry_or_resumable_edit_link, gdata.data.GDEntry): 1073 resumable_edit_link = entry_or_resumable_edit_link.find_url( 1074 'http://schemas.google.com/g/2005#resumable-edit-media') 1075 customer_headers['If-Match'] = entry_or_resumable_edit_link.etag 1076 else: 1077 resumable_edit_link = entry_or_resumable_edit_link 1078 1079 if force: 1080 customer_headers['If-Match'] = '*' 1081 1082 return self.upload_file(resumable_edit_link, headers=customer_headers, 1083 auth_token=auth_token) 1084 1085 UpdateFile = update_file 1086 1087 def query_upload_status(self, uri=None): 1088 """Queries the current status of a resumable upload request. 1089 1090 Args: 1091 uri: str (optional) A resumable upload uri to query and override the one 1092 that is set in this object. 1093 1094 Returns: 1095 An integer representing the file position (byte) to resume the upload from 1096 or True if the upload is complete. 1097 1098 Raises: 1099 RequestError if anything other than a HTTP 308 is returned 1100 when the request raises an exception. 1101 """ 1102 # Override object's unique upload uri. 1103 if uri is None: 1104 uri = self.upload_uri 1105 1106 http_request = atom.http_core.HttpRequest() 1107 http_request.headers['Content-Length'] = '0' 1108 http_request.headers['Content-Range'] = 'bytes */%s' % self.total_file_size 1109 1110 try: 1111 response = self.client.request( 1112 method='POST', uri=uri, http_request=http_request) 1113 if response.status == 201: 1114 return True 1115 else: 1116 raise error_from_response( 1117 '%s returned by server' % response.status, response, RequestError) 1118 except RequestError, error: 1119 if error.status == 308: 1120 for pair in error.headers: 1121 if pair[0].capitalize() == 'Range': 1122 return int(pair[1].split('-')[1]) + 1 1123 else: 1124 raise error 1125 1126 QueryUploadStatus = query_upload_status