#!/usr/bin/env python # # Copyright (C) 2008, 2009 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # This module is used for version 2 of the Google Data APIs. """Provides a client to interact with Google Data API servers. This module is used for version 2 of the Google Data APIs. The primary class in this module is GDClient. GDClient: handles auth and CRUD operations when communicating with servers. GDataClient: deprecated client for version one services. Will be removed. """ __author__ = 'j.s@google.com (Jeff Scudder)' import re import atom.client import atom.core import atom.http_core import gdata.gauth import gdata.data class Error(Exception): pass class RequestError(Error): status = None reason = None body = None headers = None class RedirectError(RequestError): pass class CaptchaChallenge(RequestError): captcha_url = None captcha_token = None class ClientLoginTokenMissing(Error): pass class MissingOAuthParameters(Error): pass class ClientLoginFailed(RequestError): pass class UnableToUpgradeToken(RequestError): pass class Unauthorized(Error): pass class BadAuthenticationServiceURL(RedirectError): pass class BadAuthentication(RequestError): pass class NotModified(RequestError): pass class NotImplemented(RequestError): pass def error_from_response(message, http_response, error_class, response_body=None): """Creates a new exception and sets the HTTP information in the error. Args: message: str human readable message to be displayed if the exception is not caught. http_response: The response from the server, contains error information. error_class: The exception to be instantiated and populated with information from the http_response response_body: str (optional) specify if the response has already been read from the http_response object. """ if response_body is None: body = http_response.read() else: body = response_body error = error_class('%s: %i, %s' % (message, http_response.status, body)) error.status = http_response.status error.reason = http_response.reason error.body = body error.headers = atom.http_core.get_headers(http_response) return error def get_xml_version(version): """Determines which XML schema to use based on the client API version. Args: version: string which is converted to an int. The version string is in the form 'Major.Minor.x.y.z' and only the major version number is considered. If None is provided assume version 1. """ if version is None: return 1 return int(version.split('.')[0]) class GDClient(atom.client.AtomPubClient): """Communicates with Google Data servers to perform CRUD operations. This class is currently experimental and may change in backwards incompatible ways. This class exists to simplify the following three areas involved in using the Google Data APIs. CRUD Operations: The client provides a generic 'request' method for making HTTP requests. There are a number of convenience methods which are built on top of request, which include get_feed, get_entry, get_next, post, update, and delete. These methods contact the Google Data servers. Auth: Reading user-specific private data requires authorization from the user as do any changes to user data. An auth_token object can be passed into any of the HTTP requests to set the Authorization header in the request. You may also want to set the auth_token member to a an object which can use modify_request to set the Authorization header in the HTTP request. If you are authenticating using the email address and password, you can use the client_login method to obtain an auth token and set the auth_token member. If you are using browser redirects, specifically AuthSub, you will want to use gdata.gauth.AuthSubToken.from_url to obtain the token after the redirect, and you will probably want to updgrade this since use token to a multiple use (session) token using the upgrade_token method. API Versions: This client is multi-version capable and can be used with Google Data API version 1 and version 2. The version should be specified by setting the api_version member to a string, either '1' or '2'. """ # The gsessionid is used by Google Calendar to prevent redirects. __gsessionid = None api_version = None # Name of the Google Data service when making a ClientLogin request. auth_service = None # URL prefixes which should be requested for AuthSub and OAuth. auth_scopes = None def request(self, method=None, uri=None, auth_token=None, http_request=None, converter=None, desired_class=None, redirects_remaining=4, **kwargs): """Make an HTTP request to the server. See also documentation for atom.client.AtomPubClient.request. If a 302 redirect is sent from the server to the client, this client assumes that the redirect is in the form used by the Google Calendar API. The same request URI and method will be used as in the original request, but a gsessionid URL parameter will be added to the request URI with the value provided in the server's 302 redirect response. If the 302 redirect is not in the format specified by the Google Calendar API, a RedirectError will be raised containing the body of the server's response. The method calls the client's modify_request method to make any changes required by the client before the request is made. For example, a version 2 client could add a GData-Version: 2 header to the request in its modify_request method. Args: method: str The HTTP verb for this request, usually 'GET', 'POST', 'PUT', or 'DELETE' uri: atom.http_core.Uri, str, or unicode The URL being requested. auth_token: An object which sets the Authorization HTTP header in its modify_request method. Recommended classes include gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken among others. http_request: (optional) atom.http_core.HttpRequest converter: function which takes the body of the response as it's only argument and returns the desired object. desired_class: class descended from atom.core.XmlElement to which a successful response should be converted. If there is no converter function specified (converter=None) then the desired_class will be used in calling the atom.core.parse function. If neither the desired_class nor the converter is specified, an HTTP reponse object will be returned. redirects_remaining: (optional) int, if this number is 0 and the server sends a 302 redirect, the request method will raise an exception. This parameter is used in recursive request calls to avoid an infinite loop. Any additional arguments are passed through to atom.client.AtomPubClient.request. Returns: An HTTP response object (see atom.http_core.HttpResponse for a description of the object's interface) if no converter was specified and no desired_class was specified. If a converter function was provided, the results of calling the converter are returned. If no converter was specified but a desired_class was provided, the response body will be converted to the class using atom.core.parse. """ if isinstance(uri, (str, unicode)): uri = atom.http_core.Uri.parse_uri(uri) # Add the gsession ID to the URL to prevent further redirects. # TODO: If different sessions are using the same client, there will be a # multitude of redirects and session ID shuffling. # If the gsession ID is in the URL, adopt it as the standard location. if uri is not None and uri.query is not None and 'gsessionid' in uri.query: self.__gsessionid = uri.query['gsessionid'] # The gsession ID could also be in the HTTP request. elif (http_request is not None and http_request.uri is not None and http_request.uri.query is not None and 'gsessionid' in http_request.uri.query): self.__gsessionid = http_request.uri.query['gsessionid'] # If the gsession ID is stored in the client, and was not present in the # URI then add it to the URI. elif self.__gsessionid is not None: uri.query['gsessionid'] = self.__gsessionid # The AtomPubClient should call this class' modify_request before # performing the HTTP request. #http_request = self.modify_request(http_request) response = atom.client.AtomPubClient.request(self, method=method, uri=uri, auth_token=auth_token, http_request=http_request, **kwargs) # On success, convert the response body using the desired converter # function if present. if response is None: return None if response.status == 200 or response.status == 201: if converter is not None: return converter(response) elif desired_class is not None: if self.api_version is not None: return atom.core.parse(response.read(), desired_class, version=get_xml_version(self.api_version)) else: # No API version was specified, so allow parse to # use the default version. return atom.core.parse(response.read(), desired_class) else: return response # TODO: move the redirect logic into the Google Calendar client once it # exists since the redirects are only used in the calendar API. elif response.status == 302: if redirects_remaining > 0: location = (response.getheader('Location') or response.getheader('location')) if location is not None: m = re.compile('[\?\&]gsessionid=(\w*)').search(location) if m is not None: self.__gsessionid = m.group(1) # Make a recursive call with the gsession ID in the URI to follow # the redirect. return self.request(method=method, uri=uri, auth_token=auth_token, http_request=http_request, converter=converter, desired_class=desired_class, redirects_remaining=redirects_remaining-1, **kwargs) else: raise error_from_response('302 received without Location header', response, RedirectError) else: raise error_from_response('Too many redirects from server', response, RedirectError) elif response.status == 401: raise error_from_response('Unauthorized - Server responded with', response, Unauthorized) elif response.status == 304: raise error_from_response('Entry Not Modified - Server responded with', response, NotModified) elif response.status == 501: raise error_from_response( 'This API operation is not implemented. - Server responded with', response, NotImplemented) # If the server's response was not a 200, 201, 302, 304, 401, or 501, raise # an exception. else: raise error_from_response('Server responded with', response, RequestError) Request = request def request_client_login_token( self, email, password, source, service=None, account_type='HOSTED_OR_GOOGLE', auth_url=atom.http_core.Uri.parse_uri( 'https://www.google.com/accounts/ClientLogin'), captcha_token=None, captcha_response=None): service = service or self.auth_service # Set the target URL. http_request = atom.http_core.HttpRequest(uri=auth_url, method='POST') http_request.add_body_part( gdata.gauth.generate_client_login_request_body(email=email, password=password, service=service, source=source, account_type=account_type, captcha_token=captcha_token, captcha_response=captcha_response), 'application/x-www-form-urlencoded') # Use the underlying http_client to make the request. response = self.http_client.request(http_request) response_body = response.read() if response.status == 200: token_string = gdata.gauth.get_client_login_token_string(response_body) if token_string is not None: return gdata.gauth.ClientLoginToken(token_string) else: raise ClientLoginTokenMissing( 'Recieved a 200 response to client login request,' ' but no token was present. %s' % (response_body,)) elif response.status == 403: captcha_challenge = gdata.gauth.get_captcha_challenge(response_body) if captcha_challenge: challenge = CaptchaChallenge('CAPTCHA required') challenge.captcha_url = captcha_challenge['url'] challenge.captcha_token = captcha_challenge['token'] raise challenge elif response_body.splitlines()[0] == 'Error=BadAuthentication': raise BadAuthentication('Incorrect username or password') else: raise error_from_response('Server responded with a 403 code', response, RequestError, response_body) elif response.status == 302: # Google tries to redirect all bad URLs back to # http://www.google.. If a redirect # attempt is made, assume the user has supplied an incorrect # authentication URL raise error_from_response('Server responded with a redirect', response, BadAuthenticationServiceURL, response_body) else: raise error_from_response('Server responded to ClientLogin request', response, ClientLoginFailed, response_body) RequestClientLoginToken = request_client_login_token def client_login(self, email, password, source, service=None, account_type='HOSTED_OR_GOOGLE', auth_url=atom.http_core.Uri.parse_uri( 'https://www.google.com/accounts/ClientLogin'), captcha_token=None, captcha_response=None): """Performs an auth request using the user's email address and password. In order to modify user specific data and read user private data, your application must be authorized by the user. One way to demonstrage authorization is by including a Client Login token in the Authorization HTTP header of all requests. This method requests the Client Login token by sending the user's email address, password, the name of the application, and the service code for the service which will be accessed by the application. If the username and password are correct, the server will respond with the client login code and a new ClientLoginToken object will be set in the client's auth_token member. With the auth_token set, future requests from this client will include the Client Login token. For a list of service names, see http://code.google.com/apis/gdata/faq.html#clientlogin For more information on Client Login, see: http://code.google.com/apis/accounts/docs/AuthForInstalledApps.html Args: email: str The user's email address or username. password: str The password for the user's account. source: str The name of your application. This can be anything you like but should should give some indication of which app is making the request. service: str The service code for the service you would like to access. For example, 'cp' for contacts, 'cl' for calendar. For a full list see http://code.google.com/apis/gdata/faq.html#clientlogin If you are using a subclass of the gdata.client.GDClient, the service will usually be filled in for you so you do not need to specify it. For example see BloggerClient, SpreadsheetsClient, etc. account_type: str (optional) The type of account which is being authenticated. This can be either 'GOOGLE' for a Google Account, 'HOSTED' for a Google Apps Account, or the default 'HOSTED_OR_GOOGLE' which will select the Google Apps Account if the same email address is used for both a Google Account and a Google Apps Account. auth_url: str (optional) The URL to which the login request should be sent. captcha_token: str (optional) If a previous login attempt was reponded to with a CAPTCHA challenge, this is the token which identifies the challenge (from the CAPTCHA's URL). captcha_response: str (optional) If a previous login attempt was reponded to with a CAPTCHA challenge, this is the response text which was contained in the challenge. Returns: None Raises: A RequestError or one of its suclasses: BadAuthentication, BadAuthenticationServiceURL, ClientLoginFailed, ClientLoginTokenMissing, or CaptchaChallenge """ service = service or self.auth_service self.auth_token = self.request_client_login_token(email, password, source, service=service, account_type=account_type, auth_url=auth_url, captcha_token=captcha_token, captcha_response=captcha_response) ClientLogin = client_login def upgrade_token(self, token=None, url=atom.http_core.Uri.parse_uri( 'https://www.google.com/accounts/AuthSubSessionToken')): """Asks the Google auth server for a multi-use AuthSub token. For details on AuthSub, see: http://code.google.com/apis/accounts/docs/AuthSub.html Args: token: gdata.gauth.AuthSubToken or gdata.gauth.SecureAuthSubToken (optional) If no token is passed in, the client's auth_token member is used to request the new token. The token object will be modified to contain the new session token string. url: str or atom.http_core.Uri (optional) The URL to which the token upgrade request should be sent. Defaults to: https://www.google.com/accounts/AuthSubSessionToken Returns: The upgraded gdata.gauth.AuthSubToken object. """ # Default to using the auth_token member if no token is provided. if token is None: token = self.auth_token # We cannot upgrade a None token. if token is None: raise UnableToUpgradeToken('No token was provided.') if not isinstance(token, gdata.gauth.AuthSubToken): raise UnableToUpgradeToken( 'Cannot upgrade the token because it is not an AuthSubToken object.') http_request = atom.http_core.HttpRequest(uri=url, method='GET') token.modify_request(http_request) # Use the lower level HttpClient to make the request. response = self.http_client.request(http_request) if response.status == 200: token._upgrade_token(response.read()) return token else: raise UnableToUpgradeToken( 'Server responded to token upgrade request with %s: %s' % ( response.status, response.read())) UpgradeToken = upgrade_token def revoke_token(self, token=None, url=atom.http_core.Uri.parse_uri( 'https://www.google.com/accounts/AuthSubRevokeToken')): """Requests that the token be invalidated. This method can be used for both AuthSub and OAuth tokens (to invalidate a ClientLogin token, the user must change their password). Returns: True if the server responded with a 200. Raises: A RequestError if the server responds with a non-200 status. """ # Default to using the auth_token member if no token is provided. if token is None: token = self.auth_token http_request = atom.http_core.HttpRequest(uri=url, method='GET') token.modify_request(http_request) response = self.http_client.request(http_request) if response.status != 200: raise error_from_response('Server sent non-200 to revoke token', response, RequestError, response.read()) return True RevokeToken = revoke_token def get_oauth_token(self, scopes, next, consumer_key, consumer_secret=None, rsa_private_key=None, url=gdata.gauth.REQUEST_TOKEN_URL): """Obtains an OAuth request token to allow the user to authorize this app. Once this client has a request token, the user can authorize the request token by visiting the authorization URL in their browser. After being redirected back to this app at the 'next' URL, this app can then exchange the authorized request token for an access token. For more information see the documentation on Google Accounts with OAuth: http://code.google.com/apis/accounts/docs/OAuth.html#AuthProcess Args: scopes: list of strings or atom.http_core.Uri objects which specify the URL prefixes which this app will be accessing. For example, to access the Google Calendar API, you would want to use scopes: ['https://www.google.com/calendar/feeds/', 'http://www.google.com/calendar/feeds/'] next: str or atom.http_core.Uri object, The URL which the user's browser should be sent to after they authorize access to their data. This should be a URL in your application which will read the token information from the URL and upgrade the request token to an access token. consumer_key: str This is the identifier for this application which you should have received when you registered your application with Google to use OAuth. consumer_secret: str (optional) The shared secret between your app and Google which provides evidence that this request is coming from you application and not another app. If present, this libraries assumes you want to use an HMAC signature to verify requests. Keep this data a secret. rsa_private_key: str (optional) The RSA private key which is used to generate a digital signature which is checked by Google's server. If present, this library assumes that you want to use an RSA signature to verify requests. Keep this data a secret. url: The URL to which a request for a token should be made. The default is Google's OAuth request token provider. """ http_request = None if rsa_private_key is not None: http_request = gdata.gauth.generate_request_for_request_token( consumer_key, gdata.gauth.RSA_SHA1, scopes, rsa_key=rsa_private_key, auth_server_url=url, next=next) elif consumer_secret is not None: http_request = gdata.gauth.generate_request_for_request_token( consumer_key, gdata.gauth.HMAC_SHA1, scopes, consumer_secret=consumer_secret, auth_server_url=url, next=next) else: raise MissingOAuthParameters( 'To request an OAuth token, you must provide your consumer secret' ' or your private RSA key.') response = self.http_client.request(http_request) response_body = response.read() if response.status != 200: raise error_from_response('Unable to obtain OAuth request token', response, RequestError, response_body) if rsa_private_key is not None: return gdata.gauth.rsa_token_from_body(response_body, consumer_key, rsa_private_key, gdata.gauth.REQUEST_TOKEN) elif consumer_secret is not None: return gdata.gauth.hmac_token_from_body(response_body, consumer_key, consumer_secret, gdata.gauth.REQUEST_TOKEN) GetOAuthToken = get_oauth_token def get_access_token(self, request_token, url=gdata.gauth.ACCESS_TOKEN_URL): """Exchanges an authorized OAuth request token for an access token. Contacts the Google OAuth server to upgrade a previously authorized request token. Once the request token is upgraded to an access token, the access token may be used to access the user's data. For more details, see the Google Accounts OAuth documentation: http://code.google.com/apis/accounts/docs/OAuth.html#AccessToken Args: request_token: An OAuth token which has been authorized by the user. url: (optional) The URL to which the upgrade request should be sent. Defaults to: https://www.google.com/accounts/OAuthAuthorizeToken """ http_request = gdata.gauth.generate_request_for_access_token( request_token, auth_server_url=url) response = self.http_client.request(http_request) response_body = response.read() if response.status != 200: raise error_from_response( 'Unable to upgrade OAuth request token to access token', response, RequestError, response_body) return gdata.gauth.upgrade_to_access_token(request_token, response_body) GetAccessToken = get_access_token def modify_request(self, http_request): """Adds or changes request before making the HTTP request. This client will add the API version if it is specified. Subclasses may override this method to add their own request modifications before the request is made. """ http_request = atom.client.AtomPubClient.modify_request(self, http_request) if self.api_version is not None: http_request.headers['GData-Version'] = self.api_version return http_request ModifyRequest = modify_request def get_feed(self, uri, auth_token=None, converter=None, desired_class=gdata.data.GDFeed, **kwargs): return self.request(method='GET', uri=uri, auth_token=auth_token, converter=converter, desired_class=desired_class, **kwargs) GetFeed = get_feed def get_entry(self, uri, auth_token=None, converter=None, desired_class=gdata.data.GDEntry, etag=None, **kwargs): http_request = atom.http_core.HttpRequest() # Conditional retrieval if etag is not None: http_request.headers['If-None-Match'] = etag return self.request(method='GET', uri=uri, auth_token=auth_token, http_request=http_request, converter=converter, desired_class=desired_class, **kwargs) GetEntry = get_entry def get_next(self, feed, auth_token=None, converter=None, desired_class=None, **kwargs): """Fetches the next set of results from the feed. When requesting a feed, the number of entries returned is capped at a service specific default limit (often 25 entries). You can specify your own entry-count cap using the max-results URL query parameter. If there are more results than could fit under max-results, the feed will contain a next link. This method performs a GET against this next results URL. Returns: A new feed object containing the next set of entries in this feed. """ if converter is None and desired_class is None: desired_class = feed.__class__ return self.get_feed(feed.find_next_link(), auth_token=auth_token, converter=converter, desired_class=desired_class, **kwargs) GetNext = get_next # TODO: add a refresh method to re-fetch the entry/feed from the server # if it has been updated. def post(self, entry, uri, auth_token=None, converter=None, desired_class=None, **kwargs): if converter is None and desired_class is None: desired_class = entry.__class__ http_request = atom.http_core.HttpRequest() http_request.add_body_part( entry.to_string(get_xml_version(self.api_version)), 'application/atom+xml') return self.request(method='POST', uri=uri, auth_token=auth_token, http_request=http_request, converter=converter, desired_class=desired_class, **kwargs) Post = post def update(self, entry, auth_token=None, force=False, **kwargs): """Edits the entry on the server by sending the XML for this entry. Performs a PUT and converts the response to a new entry object with a matching class to the entry passed in. Args: entry: auth_token: force: boolean stating whether an update should be forced. Defaults to False. Normally, if a change has been made since the passed in entry was obtained, the server will not overwrite the entry since the changes were based on an obsolete version of the entry. Setting force to True will cause the update to silently overwrite whatever version is present. Returns: A new Entry object of a matching type to the entry which was passed in. """ http_request = atom.http_core.HttpRequest() http_request.add_body_part( entry.to_string(get_xml_version(self.api_version)), 'application/atom+xml') # Include the ETag in the request if present. if force: http_request.headers['If-Match'] = '*' elif hasattr(entry, 'etag') and entry.etag: http_request.headers['If-Match'] = entry.etag return self.request(method='PUT', uri=entry.find_edit_link(), auth_token=auth_token, http_request=http_request, desired_class=entry.__class__, **kwargs) Update = update def delete(self, entry_or_uri, auth_token=None, force=False, **kwargs): http_request = atom.http_core.HttpRequest() # Include the ETag in the request if present. if force: http_request.headers['If-Match'] = '*' elif hasattr(entry_or_uri, 'etag') and entry_or_uri.etag: http_request.headers['If-Match'] = entry_or_uri.etag # If the user passes in a URL, just delete directly, may not work as # the service might require an ETag. if isinstance(entry_or_uri, (str, unicode, atom.http_core.Uri)): return self.request(method='DELETE', uri=entry_or_uri, http_request=http_request, auth_token=auth_token, **kwargs) return self.request(method='DELETE', uri=entry_or_uri.find_edit_link(), http_request=http_request, auth_token=auth_token, **kwargs) Delete = delete #TODO: implement batch requests. #def batch(feed, uri, auth_token=None, converter=None, **kwargs): # pass # TODO: add a refresh method to request a conditional update to an entry # or feed. def _add_query_param(param_string, value, http_request): if value: http_request.uri.query[param_string] = value class Query(object): def __init__(self, text_query=None, categories=None, author=None, alt=None, updated_min=None, updated_max=None, pretty_print=False, published_min=None, published_max=None, start_index=None, max_results=None, strict=False): """Constructs a Google Data Query to filter feed contents serverside. Args: text_query: Full text search str (optional) categories: list of strings (optional). Each string is a required category. To include an 'or' query, put a | in the string between terms. For example, to find everything in the Fitz category and the Laurie or Jane category (Fitz and (Laurie or Jane)) you would set categories to ['Fitz', 'Laurie|Jane']. author: str (optional) The service returns entries where the author name and/or email address match your query string. alt: str (optional) for the Alternative representation type you'd like the feed in. If you don't specify an alt parameter, the service returns an Atom feed. This is equivalent to alt='atom'. alt='rss' returns an RSS 2.0 result feed. alt='json' returns a JSON representation of the feed. alt='json-in-script' Requests a response that wraps JSON in a script tag. alt='atom-in-script' Requests an Atom response that wraps an XML string in a script tag. alt='rss-in-script' Requests an RSS response that wraps an XML string in a script tag. updated_min: str (optional), RFC 3339 timestamp format, lower bounds. For example: 2005-08-09T10:57:00-08:00 updated_max: str (optional) updated time must be earlier than timestamp. pretty_print: boolean (optional) If True the server's XML response will be indented to make it more human readable. Defaults to False. published_min: str (optional), Similar to updated_min but for published time. published_max: str (optional), Similar to updated_max but for published time. start_index: int or str (optional) 1-based index of the first result to be retrieved. Note that this isn't a general cursoring mechanism. If you first send a query with ?start-index=1&max-results=10 and then send another query with ?start-index=11&max-results=10, the service cannot guarantee that the results are equivalent to ?start-index=1&max-results=20, because insertions and deletions could have taken place in between the two queries. max_results: int or str (optional) Maximum number of results to be retrieved. Each service has a default max (usually 25) which can vary from service to service. There is also a service-specific limit to the max_results you can fetch in a request. strict: boolean (optional) If True, the server will return an error if the server does not recognize any of the parameters in the request URL. Defaults to False. """ self.text_query = text_query self.categories = categories or [] self.author = author self.alt = alt self.updated_min = updated_min self.updated_max = updated_max self.pretty_print = pretty_print self.published_min = published_min self.published_max = published_max self.start_index = start_index self.max_results = max_results self.strict = strict def modify_request(self, http_request): _add_query_param('q', self.text_query, http_request) if self.categories: http_request.uri.query['categories'] = ','.join(self.categories) _add_query_param('author', self.author, http_request) _add_query_param('alt', self.alt, http_request) _add_query_param('updated-min', self.updated_min, http_request) _add_query_param('updated-max', self.updated_max, http_request) if self.pretty_print: http_request.uri.query['prettyprint'] = 'true' _add_query_param('published-min', self.published_min, http_request) _add_query_param('published-max', self.published_max, http_request) if self.start_index is not None: http_request.uri.query['start-index'] = str(self.start_index) if self.max_results is not None: http_request.uri.query['max-results'] = str(self.max_results) if self.strict: http_request.uri.query['strict'] = 'true' ModifyRequest = modify_request class GDQuery(atom.http_core.Uri): def _get_text_query(self): return self.query['q'] def _set_text_query(self, value): self.query['q'] = value text_query = property(_get_text_query, _set_text_query, doc='The q parameter for searching for an exact text match on content') class ResumableUploader(object): """Resumable upload helper for the Google Data protocol.""" DEFAULT_CHUNK_SIZE = 5242880 # 5MB def __init__(self, client, file_handle, content_type, total_file_size, chunk_size=None, desired_class=None): """Starts a resumable upload to a service that supports the protocol. Args: client: gdata.client.GDClient A Google Data API service. file_handle: object A file-like object containing the file to upload. content_type: str The mimetype of the file to upload. total_file_size: int The file's total size in bytes. chunk_size: int The size of each upload chunk. If None, the DEFAULT_CHUNK_SIZE will be used. desired_class: object (optional) The type of gdata.data.GDEntry to parse the completed entry as. This should be specific to the API. """ self.client = client self.file_handle = file_handle self.content_type = content_type self.total_file_size = total_file_size self.chunk_size = chunk_size or self.DEFAULT_CHUNK_SIZE self.desired_class = desired_class or gdata.data.GDEntry self.upload_uri = None # Send the entire file if the chunk size is less than fize's total size. if self.total_file_size <= self.chunk_size: self.chunk_size = total_file_size def _init_session(self, resumable_media_link, entry=None, headers=None, auth_token=None): """Starts a new resumable upload to a service that supports the protocol. The method makes a request to initiate a new upload session. The unique upload uri returned by the server (and set in this method) should be used to send upload chunks to the server. Args: resumable_media_link: str The full URL for the #resumable-create-media or #resumable-edit-media link for starting a resumable upload request or updating media using a resumable PUT. entry: A (optional) gdata.data.GDEntry containging metadata to create the upload from. headers: dict (optional) Additional headers to send in the initial request to create the resumable upload request. These headers will override any default headers sent in the request. For example: headers={'Slug': 'MyTitle'}. auth_token: (optional) An object which sets the Authorization HTTP header in its modify_request method. Recommended classes include gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken among others. Returns: The final Atom entry as created on the server. The entry will be parsed accoring to the class specified in self.desired_class. Raises: RequestError if the unique upload uri is not set or the server returns something other than an HTTP 308 when the upload is incomplete. """ http_request = atom.http_core.HttpRequest() # Send empty POST if Atom XML wasn't specified. if entry is None: http_request.add_body_part('', self.content_type, size=0) else: http_request.add_body_part(str(entry), 'application/atom+xml', size=len(str(entry))) http_request.headers['X-Upload-Content-Type'] = self.content_type http_request.headers['X-Upload-Content-Length'] = self.total_file_size if headers is not None: http_request.headers.update(headers) response = self.client.request(method='POST', uri=resumable_media_link, auth_token=auth_token, http_request=http_request) self.upload_uri = (response.getheader('location') or response.getheader('Location')) _InitSession = _init_session def upload_chunk(self, start_byte, content_bytes): """Uploads a byte range (chunk) to the resumable upload server. Args: start_byte: int The byte offset of the total file where the byte range passed in lives. content_bytes: str The file contents of this chunk. Returns: The final Atom entry created on the server. The entry object's type will be the class specified in self.desired_class. Raises: RequestError if the unique upload uri is not set or the server returns something other than an HTTP 308 when the upload is incomplete. """ if self.upload_uri is None: raise RequestError('Resumable upload request not initialized.') # Adjustment if last byte range is less than defined chunk size. chunk_size = self.chunk_size if len(content_bytes) <= chunk_size: chunk_size = len(content_bytes) http_request = atom.http_core.HttpRequest() http_request.add_body_part(content_bytes, self.content_type, size=len(content_bytes)) http_request.headers['Content-Range'] = ('bytes %s-%s/%s' % (start_byte, start_byte + chunk_size - 1, self.total_file_size)) try: response = self.client.request(method='POST', uri=self.upload_uri, http_request=http_request, desired_class=self.desired_class) return response except RequestError, error: if error.status == 308: return None else: raise error UploadChunk = upload_chunk def upload_file(self, resumable_media_link, entry=None, headers=None, auth_token=None): """Uploads an entire file in chunks using the resumable upload protocol. If you are interested in pausing an upload or controlling the chunking yourself, use the upload_chunk() method instead. Args: resumable_media_link: str The full URL for the #resumable-create-media for starting a resumable upload request. entry: A (optional) gdata.data.GDEntry containging metadata to create the upload from. headers: dict Additional headers to send in the initial request to create the resumable upload request. These headers will override any default headers sent in the request. For example: headers={'Slug': 'MyTitle'}. auth_token: (optional) An object which sets the Authorization HTTP header in its modify_request method. Recommended classes include gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken among others. Returns: The final Atom entry created on the server. The entry object's type will be the class specified in self.desired_class. Raises: RequestError if anything other than a HTTP 308 is returned when the request raises an exception. """ self._init_session(resumable_media_link, headers=headers, auth_token=auth_token, entry=entry) start_byte = 0 entry = None while not entry: entry = self.upload_chunk( start_byte, self.file_handle.read(self.chunk_size)) start_byte += self.chunk_size return entry UploadFile = upload_file def update_file(self, entry_or_resumable_edit_link, headers=None, force=False, auth_token=None): """Updates the contents of an existing file using the resumable protocol. If you are interested in pausing an upload or controlling the chunking yourself, use the upload_chunk() method instead. Args: entry_or_resumable_edit_link: object or string A gdata.data.GDEntry for the entry/file to update or the full uri of the link with rel #resumable-edit-media. headers: dict Additional headers to send in the initial request to create the resumable upload request. These headers will override any default headers sent in the request. For example: headers={'Slug': 'MyTitle'}. force boolean (optional) True to force an update and set the If-Match header to '*'. If False and entry_or_resumable_edit_link is a gdata.data.GDEntry object, its etag value is used. Otherwise this parameter should be set to True to force the update. auth_token: (optional) An object which sets the Authorization HTTP header in its modify_request method. Recommended classes include gdata.gauth.ClientLoginToken and gdata.gauth.AuthSubToken among others. Returns: The final Atom entry created on the server. The entry object's type will be the class specified in self.desired_class. Raises: RequestError if anything other than a HTTP 308 is returned when the request raises an exception. """ # Need to override the POST request for a resumable update (required). customer_headers = {'X-HTTP-Method-Override': 'PUT'} if headers is not None: customer_headers.update(headers) if isinstance(entry_or_resumable_edit_link, gdata.data.GDEntry): resumable_edit_link = entry_or_resumable_edit_link.find_url( 'http://schemas.google.com/g/2005#resumable-edit-media') customer_headers['If-Match'] = entry_or_resumable_edit_link.etag else: resumable_edit_link = entry_or_resumable_edit_link if force: customer_headers['If-Match'] = '*' return self.upload_file(resumable_edit_link, headers=customer_headers, auth_token=auth_token) UpdateFile = update_file def query_upload_status(self, uri=None): """Queries the current status of a resumable upload request. Args: uri: str (optional) A resumable upload uri to query and override the one that is set in this object. Returns: An integer representing the file position (byte) to resume the upload from or True if the upload is complete. Raises: RequestError if anything other than a HTTP 308 is returned when the request raises an exception. """ # Override object's unique upload uri. if uri is None: uri = self.upload_uri http_request = atom.http_core.HttpRequest() http_request.headers['Content-Length'] = '0' http_request.headers['Content-Range'] = 'bytes */%s' % self.total_file_size try: response = self.client.request( method='POST', uri=uri, http_request=http_request) if response.status == 201: return True else: raise error_from_response( '%s returned by server' % response.status, response, RequestError) except RequestError, error: if error.status == 308: for pair in error.headers: if pair[0].capitalize() == 'Range': return int(pair[1].split('-')[1]) + 1 else: raise error QueryUploadStatus = query_upload_status