/gdata/client.py

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