PageRenderTime 64ms CodeModel.GetById 14ms app.highlight 41ms RepoModel.GetById 1ms app.codeStats 0ms

/gdata/client.py

http://radioappz.googlecode.com/
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