| #!/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.<locale>. 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, uri=None, **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. |
| uri: The uri to put to. If provided, this uri is PUT to rather than the |
| inferred uri from the entry's edit link. |
| |
| 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 |
| |
| if uri is None: |
| uri = entry.find_edit_link() |
| |
| return self.request(method='PUT', uri=uri, 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['category'] = ','.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 |