Merge lp:~leonardr/launchpadlib/retry-on-invalid-token into lp:launchpadlib

Proposed by Leonard Richardson
Status: Merged
Merged at revision: 102
Proposed branch: lp:~leonardr/launchpadlib/retry-on-invalid-token
Merge into: lp:launchpadlib
Diff against target: 1625 lines (+931/-238)
7 files modified
src/launchpadlib/NEWS.txt (+5/-1)
src/launchpadlib/credentials.py (+234/-26)
src/launchpadlib/launchpad.py (+228/-137)
src/launchpadlib/testing/helpers.py (+57/-0)
src/launchpadlib/tests/test_http.py (+228/-0)
src/launchpadlib/tests/test_launchpad.py (+165/-74)
src/launchpadlib/uris.py (+14/-0)
To merge this branch: bzr merge lp:~leonardr/launchpadlib/retry-on-invalid-token
Reviewer Review Type Date Requested Status
Edwin Grubbs (community) code Approve
Benji York (community) code* Approve
Review via email: mp+43784@code.launchpad.net

Description of the change

This branch moves almost all of the code having to do with OAuth tokens--getting the request token, exchanging it for an access token, storing it in the keyring and retrieving it from the keyring--into the RequestTokenAuthorizationEngine. Previously that class only the code to get the end-user to authorize the request token.

Why did I do this? Because of bug 670886. Up to this point, we've been assuming that once you exchange a request token for an access token, or retrieve an access token from local store, you can make HTTP requests to the web service and never have to think about tokens anymore. But that's not true. Since you last used that cached token, it might have expired, or you might have cancelled it. Worse, it might expire thirty minutes into your launchpadlib session. When that happens, launchpadlib simply crashes.

To fix this bug, I had to take the mechanism used to get new access tokens and make it portable, Make it so all the information about an access token--the application name, the allowable access levels, whether to store the access token in the keyring or somewhere else, whether to authorize the request token using a web browser open or some other mechanism--was all kept in a single object, which could be stored with the Launchpad instance and brought out of retirement if Launchpad ever found itself using an expired or invalid token.

The main non-refactoring code is the new LaunchpadOAuthAwareRestfulHttp class, which detects certain kinds of 401 failures from Launchpad and takes the opportunity to obtain a new access token. I talked with developer Martin Owens about this and he's fine with this behavior so long as he has the opportunity to plug in his own authorization engine.

I got all the existing tests to work and added several new tests to clarify the behavior of Launchpad.login_with(), which ignores different arguments in different circumstances. For instance, consumer_name overrides application_name, and authorization_engine overrides both of them plus credential_save_failed and allow_access_levels.

Problems with the branch, which I'm working on:

1. I haven't checked that the launchpadlib integration tests within Launchpad still work.

2. I don't have a test for the new behavior itself. We can't test Launchpad's HTTP behavior in general--that has to be done manually or within Launchpad--but I'm thinking of writing a fake Http subclass that just gives out 401 errors. That should be enough to write a test.

3. The code that checks the 401 is a little brittle: it looks at the entity-body to distinguish between a 401 caused by a bad token and a 401 caused by accessing data you don't have access to. We might decide to change or localize this one day. I'd like to do a Launchpad branch that puts this information in an X- HTTP header, and do a follow-up launchpadlib branch that looks at the HTTP header instead of the entity-body.

(I'm not sure whether "private data" gives you a 401 or 403. If that's a 403, it should make sense to get a new token every time a signed request yields a 401. That would simplify the code.)

Since this is a rather large branch I'm happy to write a guide to the code--just let me know.

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

I've read through the entire diff and feel like I have a solid
understanding of the changes being made. The architectural
transformation not only accomplishes the desired effect, but makes the
responsibilities of the code better organized.

I have a few questions about particular bits of the code below. I have
run out of time today so I'm posting what I have and will finish first
thing tomorrow.

src/launchpadlib/credentials.py line 29:

    I was surprised to see "import" and "from...import" lines intermixed (I
    expected to see "from" first and then "import"). I don't see anything in
    the style guide about them though, so this may be the preferred
    style.

src/launchpadlib/credentials.py line 215:

    The RequestTokenAuthorizationEngine interface has changed in what appear
    to be backward-incompatible ways. Since this class is exported in
    __all__, I assume it is a public interface that others may depend on. Is
    this the case?

    The same goes for AuthorizeRequestTokenWithBrowser and other subclasses.

src/launchpadlib/credentials.py line 231:

    Mutable default arguments ("allow_access_levels=[]") are a bug magnet. I
    suggest using an empty tuple instead.

src/launchpadlib/credentials.py line 231:

    Is there a reason why some of the __init__ parameters are documented but
    others (like consumer_name) are not?

src/launchpadlib/credentials.py line 257:

    Should it be an error to provide a value for allow_access_levels and not a
    value for consumer_name because that will result in the caller-provided
    allow_access_levels value to be discarded.

src/launchpadlib/credentials.py line 264:

    Similar to the above, should it be an error to provide a value for
    application_name and value for consumer_name because that will result in
    the caller-provided application_name value to be discarded.

src/launchpadlib/credentials.py line 259:

    The "consumer_name = consumer.key" line seems to be dead code, the value
    of consumer_name isn't used after that line.

src/launchpadlib/credentials.py line 343:

    Minor: The new store_credentials_locally() and
    retrieve_credentials_from_local_store() method names might could be more
    symmetric. Perhaps "store_credentials_to_local_store" (I have to
    admit that I don't like that name much either).

src/launchpadlib/credentials.py line 389:

    It isn't part of your changes, but the WAITING_FOR_USER definition is one
    very long line.

Revision history for this message
Benji York (benji) wrote :

Here's the rest of my review:

src/launchpadlib/launchpad.py line 156:

    I know people most often use .login_with() to create instances, do they
    ever use the constructor?

    If so and they are passing service_root as a positional argument, then
    insertion of the authorization_engine parameter before service_root will
    cause their code to generate an error.

src/launchpadlib/launchpad.py line 204:

    allow_access_levels has a mutable default.

src/launchpadlib/launchpad.py line 204:

    Since max_failed_attempts is ignored, maybe None would be a better default
    value.

src/launchpadlib/launchpad.py line 374:

    This note on the allow_access_levels parameter

        This argument is used to construct the default `authorization_engine`,
        so if you pass in your own `authorization_engine` any value for this
        argument will be ignored. This argument will also be ignored unless
        you also specify `consumer_name`.

    ...is sufficiently complex that I think programmatic enforcement of the
    given rules is warranted. Unfortunately for backward compatibility
    reasons I suspect we con only enforce the first rule (passing
    authorization_engine makes allow_access_levels irrelevant).

src/launchpadlib/launchpad.py line 375:

    A line over 78 characters wide.

src/launchpadlib/launchpad.py line 396:

    A line over 78 characters wide.

None of these are major, and I'm sure they'll all be addressed forthwith (even if some don't spur changes), so approving.

Since I'm doing mentored reviews, Edwin will have to review my review before final approval. I'll let him know to look at this review once you're response is in.

review: Approve (code*)
114. By Leonard Richardson

Added tests that require simulating HTTP responses.

115. By Leonard Richardson

Basic tests of the mock-HTTP-response code itself.

116. By Leonard Richardson

Track how many access tokens were obtained, not whether some have been obtained.

Revision history for this message
Leonard Richardson (leonardr) wrote :

Here's an incremental diff: http://pastebin.ubuntu.com/544534/

I've written a framework that lets me send fake HTTP responses to launchpadlib, allowing me to finally do a (sort of) end-to-end test of login_with(), as well as demonstrate what happens if Launchpad suddenly starts sending 401 responses to launchpadlib's requests. I had to do a tiny bit of refactoring of the LaunchpadOauthAwareHttp._request method, and I moved several helper classes from test_launchpad into testing/helpers.py (because they're now used by more than one test module), but almost all of this code is new tests.

Revision history for this message
Benji York (benji) wrote :

On Thu, Dec 16, 2010 at 1:17 PM, Leonard Richardson
<email address hidden> wrote:
> Here's an incremental diff: http://pastebin.ubuntu.com/544534/

I don't see any problems introduced in the diff.
--
Benji York

117. By Leonard Richardson

Response to feedback, focusing on docstrings and default arguments.

118. By Leonard Richardson

Added checks for incompatible information passed into login_with. Made application_name optional, but started enforcing the precense of *some* way of identifying the application.

119. By Leonard Richardson

Application name and consumer name are not the same thing, so don't allow the creation of an authorization engine that specifies the same value for both.

120. By Leonard Richardson

Minor cleanup.

121. By Leonard Richardson

Merge with trunk.

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (7.1 KiB)

Hi Leonard,

This is a nice branch, and I think benji did an excellent review,
however, since the branch is so big, I found a few more items to improve.

-Edwin

>=== modified file 'src/launchpadlib/launchpad.py'
>--- src/launchpadlib/launchpad.py 2010-11-01 19:48:27 +0000
>+++ src/launchpadlib/launchpad.py 2010-12-20 15:26:37 +0000
>@@ -124,6 +107,40 @@
> collection_of = 'distribution'
>
>
>+class LaunchpadOAuthAwareHttp(RestfulHttp):
>+ """Detects expired/invalid OAuth tokens and tries to get a new token."""
>+
>+ def __init__(self, launchpad, authorization_engine, *args):
>+ self.launchpad = launchpad
>+ self.authorization_engine = authorization_engine
>+ super(LaunchpadOAuthAwareHttp, self).__init__(*args)
>+
>+ def _bad_oauth_token(self, response, content):
>+ """Helper method to detect an error caused by a bad OAuth token."""
>+ return (response.status == 401 and
>+ (content.startswith("Expired token")
>+ or content.startswith("Invalid token")))
>+
>+ def _request(self, *args):
>+ response, content = super(
>+ LaunchpadOAuthAwareHttp, self)._request(*args)
>+ return self.retry_on_bad_token(response, content, *args)

If there is a bug that causes continual 401 errors, it will
eventually reach the max recursion depth and give a ginormous traceback.
It seems like each request should only need to be retried once or twice
before raising an exception.

>+
>+ def retry_on_bad_token(self, response, content, *args):
>+ """If the response indicates a bad token, get a new token and retry.
>+
>+ Otherwise, just return the response.
>+ """
>+ if (self._bad_oauth_token(response, content)
>+ and self.authorization_engine is not None):
>+ # This access token is bad. Scrap it and create a new one.
>+ self.launchpad.credentials.access_token = None
>+ self.authorization_engine(self.launchpad.credentials)
>+ # Retry the request with the new credentials.
>+ return self._request(*args)
>+ return response, content
>+
>+
> class Launchpad(ServiceRoot):
> """Root Launchpad API class.
>
>=== added file 'src/launchpadlib/tests/test_http.py'
>--- src/launchpadlib/tests/test_http.py 1970-01-01 00:00:00 +0000
>+++ src/launchpadlib/tests/test_http.py 2010-12-20 15:26:37 +0000
>+class TestAbilityToParseData(SimulatedResponsesTestCase):
>+ """Test launchpadlib's ability to handle the sample data.
>+
>+ To create a Launchpad object, two HTTP requests must succeed and
>+ return usable data: the requests for the WADL and JSON
>+ representations of the service root. This test shows that the
>+ minimal data in SIMPLE_WADL and SIMPLE_JSON is good enough to
>+ create a Launchpad object.
>+ """
>+
>+ def test_minimal_data(self):
>+ """Make sure that launchpadlib can use the minimal data."""
>+ launchpad = self.launchpad_with_responses(
>+ Response(200, SIMPLE_WADL),
>+ Response(200, SIMPLE_JSON))
>+
>+ def test_bad_data(self):
>+ """Show that bad WADL causes an exception."""
>+ sel...

Read more...

review: Approve (code)
122. By Leonard Richardson

Split out tests at Edwin's request.

Revision history for this message
Leonard Richardson (leonardr) wrote :

> >+ def _request(self, *args):
> >+ response, content = super(
> >+ LaunchpadOAuthAwareHttp, self)._request(*args)
> >+ return self.retry_on_bad_token(response, content, *args)
>
>
>
> If there is a bug that causes continual 401 errors, it will
> eventually reach the max recursion depth and give a ginormous traceback.
> It seems like each request should only need to be retried once or twice
> before raising an exception.

Long before this happens, the user will get tired of browser opens and hit Ctrl-C. I looked into making this change, but I couldn't find a way to do it since I can't really add arguments to _request (it's an httplib2 method). Given the low impact of the change, I'm going to leave it out. I've made the other changes you suggest.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/launchpadlib/NEWS.txt'
--- src/launchpadlib/NEWS.txt 2010-12-02 14:58:42 +0000
+++ src/launchpadlib/NEWS.txt 2010-12-20 17:49:10 +0000
@@ -2,9 +2,13 @@
2NEWS for launchpadlib2NEWS for launchpadlib
3=====================3=====================
44
51.8.1 (unreleased)51.9.0 (Unreleased)
6==================6==================
77
8- When an authorization token expires or becomes invalid, attempt to
9 acquire a new one, even in the middle of a session, rather than
10 crashing.
11
8- The HTML generated by wadl-to-refhtml.xsl now validates.12- The HTML generated by wadl-to-refhtml.xsl now validates.
913
101.8.0 (2010-11-15)141.8.0 (2010-11-15)
1115
=== modified file 'src/launchpadlib/credentials.py'
--- src/launchpadlib/credentials.py 2010-10-28 20:48:12 +0000
+++ src/launchpadlib/credentials.py 2010-12-20 17:49:10 +0000
@@ -20,6 +20,7 @@
20__all__ = [20__all__ = [
21 'AccessToken',21 'AccessToken',
22 'AnonymousAccessToken',22 'AnonymousAccessToken',
23 'AuthorizeRequestTokenWithBrowser',
23 'RequestTokenAuthorizationEngine',24 'RequestTokenAuthorizationEngine',
24 'Consumer',25 'Consumer',
25 'Credentials',26 'Credentials',
@@ -27,6 +28,7 @@
2728
28import base6429import base64
29import cgi30import cgi
31from cStringIO import StringIO
30import httplib232import httplib2
31import sys33import sys
32import textwrap34import textwrap
@@ -35,6 +37,7 @@
35from urlparse import urljoin37from urlparse import urljoin
36import webbrowser38import webbrowser
3739
40import keyring
38import simplejson41import simplejson
3942
40from lazr.restfulclient.errors import HTTPError43from lazr.restfulclient.errors import HTTPError
@@ -52,6 +55,8 @@
52authorize_token_page = '+authorize-token'55authorize_token_page = '+authorize-token'
53access_token_poll_time = 156access_token_poll_time = 1
5457
58EXPLOSIVE_ERRORS = (MemoryError, KeyboardInterrupt, SystemExit)
59
5560
56class Credentials(OAuthAuthorizer):61class Credentials(OAuthAuthorizer):
57 """Standard credentials storage and usage class.62 """Standard credentials storage and usage class.
@@ -66,6 +71,25 @@
66 URI_TOKEN_FORMAT = "uri"71 URI_TOKEN_FORMAT = "uri"
67 DICT_TOKEN_FORMAT = "dict"72 DICT_TOKEN_FORMAT = "dict"
6873
74 def serialize(self):
75 """Turn this object into a string.
76
77 This should probably be moved into OAuthAuthorizer.
78 """
79 sio = StringIO()
80 self.save(sio)
81 return sio.getvalue()
82
83 @classmethod
84 def from_string(cls, value):
85 """Create a `Credentials` object from a serialized string.
86
87 This should probably be moved into OAuthAuthorizer.
88 """
89 credentials = cls()
90 credentials.load(StringIO(value))
91 return credentials
92
69 def get_request_token(self, context=None, web_root=uris.STAGING_WEB_ROOT,93 def get_request_token(self, context=None, web_root=uris.STAGING_WEB_ROOT,
70 token_format=URI_TOKEN_FORMAT):94 token_format=URI_TOKEN_FORMAT):
71 """Request an OAuth token to Launchpad.95 """Request an OAuth token to Launchpad.
@@ -189,45 +213,224 @@
189213
190214
191class RequestTokenAuthorizationEngine(object):215class RequestTokenAuthorizationEngine(object):
216 """The superclass of all request token authorizers.
217
218 This base class does not implement request token authorization,
219 since that varies depending on how you want the end-user to
220 authorize a request token. You'll need to subclass this class and
221 implement `make_end_user_authorize_token`.
222
223 This class does implement a default strategy for storing
224 authorized tokens in the GNOME keyring or KDE Wallet, but you can
225 override this by implementing `store_credentials_locally` and
226 `retrieve_credentials_from_local_store`.
227 """
192228
193 UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"229 UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"
194230
195 def __init__(self, web_root, consumer_name, request_token,231 def __init__(self, service_root, application_name=None,
196 allow_access_levels=[]):232 consumer_name=None, credential_save_failed=None,
197 self.web_root = uris.lookup_web_root(web_root)233 allow_access_levels=None):
198 self.consumer_name = consumer_name234 """Base class initialization.
199 self.request_token = request_token235
200 self.allow_access_levels = allow_access_levels236 :param service_root: The root of the Launchpad instance being
201237 used.
202 def __call__(self):238
239 :param application_name: The name of the application that
240 wants to use launchpadlib. This is used in conjunction
241 with a desktop-wide integration.
242
243 If you specify this argument, your values for
244 consumer_name and allow_access_levels are ignored.
245
246 :param consumer_name: The OAuth consumer name, for an
247 application that wants its own point of integration into
248 Launchpad. In almost all cases, you want to specify
249 application_name instead and do a desktop-wide
250 integration. The exception is when you're integrating a
251 third-party website into Launchpad.
252
253 :param credential_save_failed: A callback method to be invoked
254 if the credentials cannot be stored locally.
255
256 You should not invoke this method yourself; instead, you
257 should raise an exception in `store_credentials_locally()`.
258
259 :param allow_access_levels: A list of the Launchpad access
260 levels to present to the user. ('READ_PUBLIC' and so on.)
261 Your value for this argument will be ignored during a
262 desktop-wide integration.
263 :type allow_access_levels: A list of strings.
264 """
265 self.service_root = uris.lookup_service_root(service_root)
266 self.web_root = uris.web_root_for_service_root(service_root)
267
268 if application_name is None and consumer_name is None:
269 raise ValueError(
270 "You must provide either application_name or consumer_name.")
271
272 if application_name is not None and consumer_name is not None:
273 raise ValueError(
274 "You must provide only one of application_name and "
275 "consumer_name.")
276
277 if consumer_name is None:
278 # System-wide integration. Create a system-wide consumer
279 # and identify the application using a separate
280 # application name.
281 allow_access_levels = ["DESKTOP_INTEGRATION"]
282 consumer = SystemWideConsumer(application_name)
283 else:
284 # Application-specific integration. Use the provided
285 # consumer name to create a consumer automatically.
286 consumer = Consumer(consumer_name)
287 application_name = consumer_name
288
289 self.consumer = consumer
290 self.application_name = application_name
291
292 self.allow_access_levels = allow_access_levels or []
293 self.credential_save_failed = credential_save_failed
294
295 def authorization_url(self, request_token):
296 """Return the authorization URL for a request token.
297
298 This is the URL the end-user must visit to authorize the
299 token. How exactly does this happen? That depends on the
300 subclass implementation.
301 """
302 page = "%s?oauth_token=%s" % (authorize_token_page, request_token)
303 allow_permission = "&allow_permission="
304 if len(self.allow_access_levels) > 0:
305 page += (
306 allow_permission
307 + allow_permission.join(self.allow_access_levels))
308 return urljoin(self.web_root, page)
309
310 def __call__(self, credentials):
311 """Authorize a token and associate it with the given credentials.
312
313 The `credential_save_failed` callback will be invoked if
314 there's a problem storing the credentials locally. It will not
315 be invoked if there's a problem authorizing the credentials.
316
317 :param credentials: A `Credentials` object. If the end-user
318 authorizes these credentials, this object will have its
319 .access_token property set.
320
321 :return: If the credentials are successfully authorized, the
322 return value is the `Credentials` object originally passed
323 in. Otherwise the return value is None.
324 """
325 request_token_string = self.get_request_token(credentials)
326 # Hand off control to the end-user.
327 self.make_end_user_authorize_token(credentials, request_token_string)
328 if credentials.access_token is None:
329 # The end-user refused to authorize the application.
330 return None
331 try:
332 self.store_credentials_locally(credentials)
333 except EXPLOSIVE_ERRORS:
334 raise
335 except:
336 if self.credential_save_failed is not None:
337 self.credential_save_failed()
338 return credentials
339
340 def get_request_token(self, credentials):
341 """Get a new request token from the server.
342
343 :param return: The request token.
344 """
345 authorization_json = credentials.get_request_token(
346 web_root=self.web_root,
347 token_format=Credentials.DICT_TOKEN_FORMAT)
348 return authorization_json['oauth_token']
349
350 def make_end_user_authorize_token(self, credentials, request_token):
351 """Authorize the given request token using the given credentials.
352
353 Your subclass must implement this method: it has no default
354 implementation.
355
356 Because an access token may expire or be revoked in the middle
357 of a session, this method may be called at arbitrary points in
358 a launchpadlib session, or even multiple times during a single
359 session (with a different request token each time).
360
361 In most cases, however, this method will be called at the
362 beginning of a launchpadlib session, or not at all.
363 """
203 raise NotImplementedError()364 raise NotImplementedError()
204365
366 def store_credentials_locally(self, credentials):
367 """Store newly-authorized credentials for later use.
368
369 By default, credentials are stored in the GNOME keyring or KDE
370 Wallet. This is a good solution for desktop applications and
371 local scripts, but if you are integrating a third-party
372 website into Launchpad you'll need to implement something else
373 (such as storing the credentials in a database).
374 """
375 keyring.set_password(
376 'launchpadlib',
377 (credentials.consumer.key + '@' + self.service_root),
378 credentials.serialize())
379
380 def retrieve_credentials_from_local_store(self):
381 """Retrieve credentials from a local store.
382
383 This method is the inverse of `store_credentials_locally`. The
384 default implementation looks for credentials stored in the
385 GNOME keyring or KDE Wallet.
386
387 :return: A `Credentials` object if one is found in the local
388 store, and None otherise.
389 """
390 credential_string = keyring.get_password(
391 'launchpadlib', self.consumer.key + '@' + self.service_root)
392 if credential_string is not None:
393 credentials = Credentials.from_string(credential_string)
394 # The application name wasn't stored locally, because in a
395 # desktop integration scenario, a single set of
396 # credentials may be shared by many applications. We need
397 # to set the application name for this specific instance
398 # of the credentials.
399 credentials.consumer.application_name = self.application_name
400 return credentials
401 return None
402
205403
206class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):404class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):
207 """The simplest (and, right now, only) request token authorizer.405 """The simplest (and, right now, the only) request token authorizer.
208406
209 This authorizer simply opens up the end-user's web browser to a407 This authorizer simply opens up the end-user's web browser to a
210 Launchpad URL and lets the end-user authorize the request token408 Launchpad URL and lets the end-user authorize the request token
211 themselves.409 themselves.
212
213 :param max_failed_attempts: Unused by this token authorization
214 mechanism. Any value passed in will be ignored.
215 """410 """
216411
217 WAITING_FOR_USER = "The authorization page:\n (%s)\nshould be opening in your browser. Use your browser to authorize\nthis program to access Launchpad on your behalf. \n\nWaiting to hear from Launchpad about your decision..."412 WAITING_FOR_USER = "The authorization page:\n (%s)\nshould be opening in your browser. Use your browser to authorize\nthis program to access Launchpad on your behalf. \n\nWaiting to hear from Launchpad about your decision..."
218413
219 def __init__(self, web_root, consumer_name, request_token,414 def __init__(self, service_root, application_name, consumer_name=None,
220 allow_access_levels=[], max_failed_attempts=None):415 credential_save_failed=None, allow_access_levels=None):
221 web_root = uris.lookup_web_root(web_root)416 """Constructor.
222 page = "+authorize-token?oauth_token=%s" % request_token
223 if len(allow_access_levels) > 0:
224 page += ("&allow_permission=" +
225 "&allow_permission=".join(allow_access_levels))
226 self.authorization_url = urljoin(web_root, page)
227417
418 :param service_root: See `RequestTokenAuthorizationEngine`.
419 :param application_name: See `RequestTokenAuthorizationEngine`.
420 :param consumer_name: The value of this argument is
421 ignored. If we have the capability to open the end-user's
422 web browser, we must be running on the end-user's computer,
423 so we should do a full desktop integration.
424 :param credential_save_failed: See `RequestTokenAuthorizationEngine`.
425 :param allow_access_levels: The value of this argument is
426 ignored, for the same reason as consumer_name.
427 """
428 # It doesn't look like we're doing anything here, but we
429 # are discarding the passed-in values for consumer_name and
430 # allow_access_levels.
228 super(AuthorizeRequestTokenWithBrowser, self).__init__(431 super(AuthorizeRequestTokenWithBrowser, self).__init__(
229 web_root, consumer_name, request_token,432 service_root, application_name, None,
230 allow_access_levels)433 credential_save_failed)
231434
232 def output(self, message):435 def output(self, message):
233 """Display a message.436 """Display a message.
@@ -238,13 +441,17 @@
238 """441 """
239 print message442 print message
240443
241 def __call__(self, credentials, web_root):444 def make_end_user_authorize_token(self, credentials, request_token):
242 webbrowser.open(self.authorization_url)445 """Have the end-user authorize the token in their browser."""
243 self.output(self.WAITING_FOR_USER % self.authorization_url)446
447 authorization_url = self.authorization_url(request_token)
448 webbrowser.open(authorization_url)
449 self.output(self.WAITING_FOR_USER % authorization_url)
244 while credentials.access_token is None:450 while credentials.access_token is None:
245 time.sleep(access_token_poll_time)451 time.sleep(access_token_poll_time)
246 try:452 try:
247 credentials.exchange_request_token_for_access_token(web_root)453 credentials.exchange_request_token_for_access_token(
454 self.web_root)
248 break455 break
249 except HTTPError, e:456 except HTTPError, e:
250 if e.response.status == 403:457 if e.response.status == 403:
@@ -259,6 +466,7 @@
259 print "Unexpected response from Launchpad:"466 print "Unexpected response from Launchpad:"
260 print e467 print e
261468
469
262class TokenAuthorizationException(Exception):470class TokenAuthorizationException(Exception):
263 pass471 pass
264472
265473
=== modified file 'src/launchpadlib/launchpad.py'
--- src/launchpadlib/launchpad.py 2010-11-01 19:48:27 +0000
+++ src/launchpadlib/launchpad.py 2010-12-20 17:49:10 +0000
@@ -25,16 +25,14 @@
25import socket25import socket
26import stat26import stat
27import urlparse27import urlparse
28from cStringIO import StringIO
2928
30import keyring
31from lazr.uri import URI
32from lazr.restfulclient.resource import (29from lazr.restfulclient.resource import (
33 CollectionWithKeyBasedLookup,30 CollectionWithKeyBasedLookup,
34 HostedFile,31 HostedFile,
35 ScalarValue,32 ScalarValue,
36 ServiceRoot,33 ServiceRoot,
37 )34 )
35from lazr.restfulclient._browser import RestfulHttp
38from launchpadlib.credentials import (36from launchpadlib.credentials import (
39 AccessToken,37 AccessToken,
40 AnonymousAccessToken,38 AnonymousAccessToken,
@@ -52,21 +50,6 @@
52from launchpadlib.uris import EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT50from launchpadlib.uris import EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT
53OAUTH_REALM = 'https://api.launchpad.net'51OAUTH_REALM = 'https://api.launchpad.net'
5452
55EXPLOSIVE_ERRORS = (MemoryError, KeyboardInterrupt, SystemExit)
56
57def serialize_credentials(credentials):
58 # Get the credentials as a string.
59 sio = StringIO()
60 credentials.save(sio)
61 return sio.getvalue()
62
63
64def unserialize_credentials(value):
65 # Get the credentials as a string.
66 credentials = Credentials()
67 credentials.load(StringIO(value))
68 return credentials
69
7053
71class PersonSet(CollectionWithKeyBasedLookup):54class PersonSet(CollectionWithKeyBasedLookup):
72 """A custom subclass capable of person lookup by username."""55 """A custom subclass capable of person lookup by username."""
@@ -124,6 +107,40 @@
124 collection_of = 'distribution'107 collection_of = 'distribution'
125108
126109
110class LaunchpadOAuthAwareHttp(RestfulHttp):
111 """Detects expired/invalid OAuth tokens and tries to get a new token."""
112
113 def __init__(self, launchpad, authorization_engine, *args):
114 self.launchpad = launchpad
115 self.authorization_engine = authorization_engine
116 super(LaunchpadOAuthAwareHttp, self).__init__(*args)
117
118 def _bad_oauth_token(self, response, content):
119 """Helper method to detect an error caused by a bad OAuth token."""
120 return (response.status == 401 and
121 (content.startswith("Expired token")
122 or content.startswith("Invalid token")))
123
124 def _request(self, *args):
125 response, content = super(
126 LaunchpadOAuthAwareHttp, self)._request(*args)
127 return self.retry_on_bad_token(response, content, *args)
128
129 def retry_on_bad_token(self, response, content, *args):
130 """If the response indicates a bad token, get a new token and retry.
131
132 Otherwise, just return the response.
133 """
134 if (self._bad_oauth_token(response, content)
135 and self.authorization_engine is not None):
136 # This access token is bad. Scrap it and create a new one.
137 self.launchpad.credentials.access_token = None
138 self.authorization_engine(self.launchpad.credentials)
139 # Retry the request with the new credentials.
140 return self._request(*args)
141 return response, content
142
143
127class Launchpad(ServiceRoot):144class Launchpad(ServiceRoot):
128 """Root Launchpad API class.145 """Root Launchpad API class.
129146
@@ -142,13 +159,20 @@
142 }159 }
143 RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES)160 RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES)
144161
145 def __init__(self, credentials, service_root=uris.STAGING_SERVICE_ROOT,162 def __init__(self, credentials, authorization_engine,
163 service_root=uris.STAGING_SERVICE_ROOT,
146 cache=None, timeout=None, proxy_info=None,164 cache=None, timeout=None, proxy_info=None,
147 version=DEFAULT_VERSION):165 version=DEFAULT_VERSION):
148 """Root access to the Launchpad API.166 """Root access to the Launchpad API.
149167
150 :param credentials: The credentials used to access Launchpad.168 :param credentials: The credentials used to access Launchpad.
151 :type credentials: `Credentials`169 :type credentials: `Credentials`
170 :param authorization_engine: The object used to get end-user input
171 for authorizing OAuth request tokens. Used when an OAuth
172 access token expires or becomes invalid during a
173 session, or is discovered to be invalid once launchpadlib
174 starts up.
175 :type authorization_engine: `RequestTokenAuthorizationEngine`
152 :param service_root: The URL to the root of the web service.176 :param service_root: The URL to the root of the web service.
153 :type service_root: string177 :type service_root: string
154 """178 """
@@ -162,15 +186,31 @@
162 "the version name from the root URI." % version)186 "the version name from the root URI." % version)
163 raise ValueError(error)187 raise ValueError(error)
164188
189 # We already have an access token, but it might expire or
190 # become invalid during use. Store the authorization engine in
191 # case we need to authorize a new token during use.
192 self.authorization_engine = authorization_engine
193
165 super(Launchpad, self).__init__(194 super(Launchpad, self).__init__(
166 credentials, service_root, cache, timeout, proxy_info, version)195 credentials, service_root, cache, timeout, proxy_info, version)
167196
197 def httpFactory(self, credentials, cache, timeout, proxy_info):
198 return LaunchpadOAuthAwareHttp(
199 self, self.authorization_engine, credentials, cache, timeout,
200 proxy_info)
201
202 @classmethod
203 def authorization_engine_factory(cls, *args):
204 return AuthorizeRequestTokenWithBrowser(*args)
205
168 @classmethod206 @classmethod
169 def login(cls, consumer_name, token_string, access_secret,207 def login(cls, consumer_name, token_string, access_secret,
170 service_root=uris.STAGING_SERVICE_ROOT,208 service_root=uris.STAGING_SERVICE_ROOT,
171 cache=None, timeout=None, proxy_info=None,209 cache=None, timeout=None, proxy_info=None,
210 authorization_engine=None, allow_access_levels=None,
211 max_failed_attempts=None, credential_save_failed=None,
172 version=DEFAULT_VERSION):212 version=DEFAULT_VERSION):
173 """Convenience for setting up access credentials.213 """Convenience method for setting up access credentials.
174214
175 When all three pieces of credential information (the consumer215 When all three pieces of credential information (the consumer
176 name, the access token and the access secret) are available, this216 name, the access token and the access secret) are available, this
@@ -186,70 +226,80 @@
186 :type access_secret: string226 :type access_secret: string
187 :param service_root: The URL to the root of the web service.227 :param service_root: The URL to the root of the web service.
188 :type service_root: string228 :type service_root: string
229 :param authorization_engine: See `Launchpad.__init__`. If you don't
230 provide an authorization engine, a default engine will be
231 constructed using your values for `service_root` and
232 `credential_save_failed`.
233 :param allow_access_levels: This argument is ignored, and only
234 present to preserve backwards compatibility.
235 :param max_failed_attempts: This argument is ignored, and only
236 present to preserve backwards compatibility.
189 :return: The web service root237 :return: The web service root
190 :rtype: `Launchpad`238 :rtype: `Launchpad`
191 """239 """
192 access_token = AccessToken(token_string, access_secret)240 access_token = AccessToken(token_string, access_secret)
193 credentials = Credentials(241 credentials = Credentials(
194 consumer_name=consumer_name, access_token=access_token)242 consumer_name=consumer_name, access_token=access_token)
195 return cls(credentials, service_root, cache, timeout, proxy_info,243 if authorization_engine is None:
196 version)244 authorization_engine = cls.authorization_engine_factory(
245 service_root, None, consumer_name, allow_access_levels,
246 credential_save_failed)
247 return cls(credentials, authorization_engine, service_root, cache,
248 timeout, proxy_info, version)
197249
198 @classmethod250 @classmethod
199 def get_token_and_login(cls, consumer_name,251 def get_token_and_login(cls, consumer_name,
200 service_root=uris.STAGING_SERVICE_ROOT,252 service_root=uris.STAGING_SERVICE_ROOT,
201 cache=None, timeout=None, proxy_info=None,253 cache=None, timeout=None, proxy_info=None,
202 authorizer_class=AuthorizeRequestTokenWithBrowser,254 authorization_engine=None, allow_access_levels=[],
203 allow_access_levels=[], max_failed_attempts=3,255 max_failed_attempts=None,
256 credential_save_failed=None,
204 version=DEFAULT_VERSION):257 version=DEFAULT_VERSION):
205 """Get credentials from Launchpad and log into the service root.258 """Get credentials from Launchpad and log into the service root.
206259
207 This is a convenience method which will open up the user's preferred260 This method is deprecated as of launchpadlib version 1.9.0. A
208 web browser and thus should not be used by most applications.261 launchpadlib application running on the end-user's computer
209 Applications should, instead, use Credentials.get_request_token() to262 should use `Launchpad.login_with()`. A launchpadlib
210 obtain the authorization URL and263 application running on a web server, integrating Launchpad
211 Credentials.exchange_request_token_for_access_token() to obtain the264 into some other website, should use
212 actual OAuth access token.265 `Credentials.get_request_token()` to obtain the authorization
213266 URL and
214 This method will negotiate an OAuth access token with the service267 `Credentials.exchange_request_token_for_access_token()` to
215 provider, but to complete it we will need the user to log into268 obtain the actual OAuth access token. Then you can call
216 Launchpad and authorize us, so we'll open the authorization page in269 `Launchpad.login()`.
217 a web browser and ask the user to come back here and tell us when they
218 finished the authorization process.
219270
220 :param consumer_name: Either a consumer name, as appropriate for271 :param consumer_name: Either a consumer name, as appropriate for
221 the `Consumer` constructor, or a premade Consumer object.272 the `Consumer` constructor, or a premade Consumer object.
222 :type consumer_name: string273 :type consumer_name: string
223 :param service_root: The URL to the root of the web service.274 :param service_root: The URL to the root of the web service.
224 :type service_root: string275 :type service_root: string
276 :param authorization_engine: See `Launchpad.__init__`. If you don't
277 provide an authorization engine, a default engine will be
278 constructed using your values for `service_root` and
279 `credential_save_failed`.
280 :param allow_access_levels: This argument is ignored, and only
281 present to preserve backwards compatibility.
282 :param max_failed_attempts: This argument is ignored, and only
283 present to preserve backwards compatibility.
225 :return: The web service root284 :return: The web service root
226 :rtype: `Launchpad`285 :rtype: `Launchpad`
227 """286 """
228 if isinstance(consumer_name, Consumer):287 if isinstance(consumer_name, Consumer):
229 # Create the authorizer with no Consumer, then set the Consumer288 # Create the credentials with no Consumer, then set its .consumer
230 # object directly.289 # property directly.
231 credentials = Credentials(None)290 credentials = Credentials(None)
232 credentials.consumer = consumer_name291 credentials.consumer = consumer_name
233 else:292 else:
234 # Have the authorizer create the Consumer itself.293 # Have the Credentials constructor create the Consumer
235 credentials = Credentials(consumer_name_or_consumer)294 # automatically.
236 service_root = uris.lookup_service_root(service_root)295 credentials = Credentials(consumer_name)
237 web_root_uri = URI(service_root)296 if authorization_engine is None:
238 web_root_uri.path = ""297 authorization_engine = cls.authorization_engine_factory(
239 web_root_uri.host = web_root_uri.host.replace("api.", "", 1)298 service_root, None, consumer_name, allow_access_levels,
240 web_root = str(web_root_uri.ensureSlash())299 credential_save_failed)
241 authorization_json = credentials.get_request_token(300 credentials = authorization_engine(credentials)
242 web_root=web_root, token_format=Credentials.DICT_TOKEN_FORMAT)301 return cls(credentials, authorization_engine, service_root, cache,
243 authorizer = authorizer_class(302 timeout, proxy_info, version)
244 web_root, authorization_json['oauth_token_consumer'],
245 authorization_json['oauth_token'], allow_access_levels,
246 max_failed_attempts)
247 authorizer(credentials, web_root)
248 if credentials.access_token is None:
249 # The end-user refused to authorize the application.
250 return None
251 return cls(credentials, service_root, cache, timeout, proxy_info,
252 version)
253303
254 @classmethod304 @classmethod
255 def login_anonymously(305 def login_anonymously(
@@ -261,32 +311,34 @@
261 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)311 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
262 token = AnonymousAccessToken()312 token = AnonymousAccessToken()
263 credentials = Credentials(consumer_name, access_token=token)313 credentials = Credentials(consumer_name, access_token=token)
264 return cls(credentials, service_root=service_root, cache=cache_path,314 return cls(credentials, None, service_root=service_root,
265 timeout=timeout, proxy_info=proxy_info, version=version)315 cache=cache_path, timeout=timeout, proxy_info=proxy_info,
316 version=version)
266317
267 @classmethod318 @classmethod
268 def login_with(cls, application_name,319 def login_with(cls, application_name=None,
269 service_root=uris.STAGING_SERVICE_ROOT,320 service_root=uris.STAGING_SERVICE_ROOT,
270 launchpadlib_dir=None, timeout=None, proxy_info=None,321 launchpadlib_dir=None, timeout=None, proxy_info=None,
271 authorizer_class=AuthorizeRequestTokenWithBrowser,322 authorization_engine=None, allow_access_levels=None,
272 allow_access_levels=[], max_failed_attempts=3,323 max_failed_attempts=None, credentials_file=None,
273 credentials_file=None, version=DEFAULT_VERSION,324 version=DEFAULT_VERSION, consumer_name=None,
274 consumer_name=None, credential_save_failed=None):325 credential_save_failed=None):
275 """Log in to Launchpad with possibly cached credentials.326 """Log in to Launchpad with possibly cached credentials.
276327
277 This is a convenience method for either setting up new login328 Use this method to get a `Launchpad` object if you are writing
278 credentials, or re-using existing ones. When a login token is329 a desktop application or a script. If the end-user has no
279 generated using this method, the resulting credentials will be stored330 cached Launchpad credential, their browser will open and
280 in a system keyring if available or on disk if not.331 they'll be asked to log in and authorize a desktop
281332 integration. The authorized Launchpad credential will be
282 Subsequent calls to this method will load the credentials from one of333 stored, so that the next time your program (or any other
283 the aformentioned locations in the same priority order.334 program run by that user on the same computer) needs a
284335 Launchpad credential, it will be retrieved from local storage.
285 `launchpadlib_dir` is also used for caching fetched objects. The cache336
286 is per service root, and shared by all consumers.337 By subclassing `RequestTokenAuthorizationEngine` and passing
287338 in an instance of the subclass as `authorization_engine`, you
288 See `Launchpad.get_token_and_login()` for more information about339 can change what happens when the end-user needs to authorize
289 how new tokens are generated.340 the Launchpad credential, and you can change how the
341 authorized credential is stored and retrieved locally.
290342
291 :param application_name: The application name. This is *not*343 :param application_name: The application name. This is *not*
292 the OAuth consumer name. Unless a consumer_name is also344 the OAuth consumer name. Unless a consumer_name is also
@@ -294,89 +346,128 @@
294 consumer representing the end-user's computer as a whole.346 consumer representing the end-user's computer as a whole.
295 :type application_name: string347 :type application_name: string
296348
297 :param credential_save_failed: a callback that is called if saving the
298 credentials in the auto-detected back-end (keyring or file) failed.
299
300 :param consumer_name: The consumer name, as appropriate for the
301 `Consumer` constructor
302 :type consumer_name: string
303 :param service_root: The URL to the root of the web service.349 :param service_root: The URL to the root of the web service.
304 :type service_root: string. Can either be the full URL to a service350 :type service_root: string. Can either be the full URL to a service
305 or one of the short service names.351 or one of the short service names.
306 :param launchpadlib_dir: The directory where the cache and352
307 credentials are stored.353 :param launchpadlib_dir: The directory used to store cached
354 data obtained from Launchpad. The cache is shared by all
355 consumers, and each Launchpad service root has its own
356 cache.
308 :type launchpadlib_dir: string357 :type launchpadlib_dir: string
358
359 :param authorization_engine: A strategy for getting the end-user to
360 authorize an OAuth request token, for exchanging the
361 request token for an access token, and for storing the
362 access token locally so that it can be reused.
363 :type authorization_engine: `RequestTokenAuthorizationEngine`
364
365 :param credential_save_failed: a callback that is called upon
366 a failure to save the credentials locally. This argument is
367 used to construct the default `authorization_engine`, so if
368 you pass in your own `authorization_engine` any value for
369 this argument will be ignored.
370 :type credential_save_failed: A callable
371
372 :param consumer_name: The consumer name, as appropriate for
373 the `Consumer` constructor. You probably don't want to
374 provide this, since providing it will prevent you from
375 taking advantage of desktop-wide integration.
376 :type consumer_name: string
377
309 :param allow_access_levels: The acceptable access levels for378 :param allow_access_levels: The acceptable access levels for
310 this application. This is ignored unless you specify a379 this application.
311 consumer_name. All applications using the default380
312 (desktop-wide) consumer name will ask for "desktop integration"381 This argument is used to construct the default
313 access, which gives read-write access to public and private data.382 `authorization_engine`, so if you pass in your own
383 `authorization_engine` any value for this argument will be
384 ignored. This argument will also be ignored unless you
385 also specify `consumer_name`.
386
314 :type allow_access_levels: list of strings387 :type allow_access_levels: list of strings
388
315 :param credentials_file: ignored, only here for backward compatability389 :param credentials_file: ignored, only here for backward compatability
316 :type credentials_file: string390 :type credentials_file: string
317391
318 :param consumer_name: The OAuth consumer name. In most cases,392 :return: A web service root authorized as the end-user.
319 this is not necessary. You should only use this if you
320 don't want to take advantage of desktop-wide integration.
321 :type consumer_name: string
322
323 :return: The web service root
324 :rtype: `Launchpad`393 :rtype: `Launchpad`
325394
326 """395 """
327 if consumer_name is None:
328 # System-wide integration. Create a system-wide consumer
329 # and identify the application using a separate
330 # application name.
331 allow_access_levels = ["DESKTOP_INTEGRATION"]
332 consumer = SystemWideConsumer(application_name)
333 consumer_name = consumer.key
334 else:
335 # Application-specific integration. Use the provided
336 # consumer name to create a consumer automatically.
337 consumer = consumer_name
338
339 (service_root, launchpadlib_dir, cache_path,396 (service_root, launchpadlib_dir, cache_path,
340 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)397 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
341398
342 credentials = keyring.get_password(399 if (application_name is None and consumer_name is None and
343 'launchpadlib', consumer_name + '@' + service_root)400 authorization_engine is None):
344 if credentials is not None:401 raise ValueError(
345 credentials = unserialize_credentials(credentials)402 "At least one of application_name, consumer_name, or "
346403 "authorization_engine must be provided.")
347 if credentials is not None:404
348 # We loaded credentials from a file, but the application405 if authorization_engine is None:
349 # name wasn't stored in the file, because a single set of406 authorization_engine = cls.authorization_engine_factory(
350 # credentials may be shared by many applications. We need407 service_root, application_name, consumer_name,
351 # to set the application name for this specific instance408 credential_save_failed, allow_access_levels)
352 # of the credentials.409 else:
353 credentials.consumer.application_name = application_name410 # An authorization engine was passed in, so we won't be
411 # using any provided values for application_name,
412 # consumer_name, or allow_access_levels. But at least make
413 # sure we weren't given conflicting values, since that
414 # makes the calling code look confusing.
415 cls._assert_login_with_argument_consistency(
416 "application_name", application_name,
417 authorization_engine.application_name)
418
419 cls._assert_login_with_argument_consistency(
420 "consumer_name", consumer_name,
421 authorization_engine.consumer.key)
422
423 cls._assert_login_with_argument_consistency(
424 "allow_access_levels", allow_access_levels,
425 authorization_engine.allow_access_levels)
426
427 credentials = authorization_engine.retrieve_credentials_from_local_store()
354428
355 if credentials is None:429 if credentials is None:
430 # Credentials were not found in the local store. Go
431 # through the authorization process.
356 launchpad = cls.get_token_and_login(432 launchpad = cls.get_token_and_login(
357 consumer, service_root=service_root, cache=cache_path,433 authorization_engine.consumer, service_root=service_root,
358 timeout=timeout, proxy_info=proxy_info,434 cache=cache_path, timeout=timeout, proxy_info=proxy_info,
359 authorizer_class=authorizer_class,435 authorization_engine=authorization_engine,
360 allow_access_levels=allow_access_levels,436 version=version)
361 max_failed_attempts=max_failed_attempts, version=version)
362 # New credentials were created, so save them for future use.
363 if launchpad is not None:
364 try:
365 keyring.set_password(
366 'launchpadlib', consumer_name + '@' + service_root,
367 serialize_credentials(launchpad.credentials))
368 except EXPLOSIVE_ERRORS:
369 raise
370 except:
371 if credential_save_failed is not None:
372 credential_save_failed()
373 else:437 else:
438 # Credentials were found in the local store. Create a
439 # Launchpad object.
374 launchpad = cls(440 launchpad = cls(
375 credentials, service_root=service_root, cache=cache_path,441 credentials, authorization_engine, service_root=service_root,
376 timeout=timeout, proxy_info=proxy_info, version=version)442 cache=cache_path, timeout=timeout, proxy_info=proxy_info,
443 version=version)
377 return launchpad444 return launchpad
378445
379 @classmethod446 @classmethod
447 def _assert_login_with_argument_consistency(
448 cls, argument_name, argument_value, authorization_engine_value):
449 """Helper to find conflicting values passed into login_with.
450
451 Many of the arguments to login_with are used to build an
452 authorization engine. If an authorization engine is passed in,
453 many of the arguments become redundant. We'll allow redundant
454 arguments through, but if a login_with argument *conflicts* with
455 the argument in the provided authorization engine, we raise an
456 error.
457 """
458 inconsistent_value_message = (
459 "Inconsistent values given for %s: "
460 "(%r passed in to login_with(), versus %r in authorization "
461 "engine). You don't need to pass in %s to login_with() if you "
462 "pass in an authorization engine, so just omit that argument.")
463 if (argument_value is not None
464 and argument_value != authorization_engine_value):
465 raise ValueError(inconsistent_value_message % (
466 argument_name, argument_value, authorization_engine_value,
467 argument_name))
468
469
470 @classmethod
380 def _get_paths(cls, service_root, launchpadlib_dir=None):471 def _get_paths(cls, service_root, launchpadlib_dir=None):
381 """Locate launchpadlib-related user paths and ensure they exist.472 """Locate launchpadlib-related user paths and ensure they exist.
382473
383474
=== modified file 'src/launchpadlib/testing/helpers.py'
--- src/launchpadlib/testing/helpers.py 2010-10-20 13:45:30 +0000
+++ src/launchpadlib/testing/helpers.py 2010-12-20 17:49:10 +0000
@@ -21,6 +21,8 @@
2121
22__metaclass__ = type22__metaclass__ = type
23__all__ = [23__all__ = [
24 'NoNetworkAuthorizationEngine',
25 'NoNetworkLaunchpad',
24 'TestableLaunchpad',26 'TestableLaunchpad',
25 'nopriv_read_nonprivate',27 'nopriv_read_nonprivate',
26 'salgado_read_nonprivate',28 'salgado_read_nonprivate',
@@ -31,8 +33,10 @@
3133
32from launchpadlib.launchpad import Launchpad34from launchpadlib.launchpad import Launchpad
33from launchpadlib.credentials import (35from launchpadlib.credentials import (
36 AccessToken,
34 AuthorizeRequestTokenWithBrowser,37 AuthorizeRequestTokenWithBrowser,
35 Credentials,38 Credentials,
39 RequestTokenAuthorizationEngine,
36 )40 )
3741
3842
@@ -47,6 +51,59 @@
47 version=version)51 version=version)
4852
4953
54class NoNetworkAuthorizationEngine(RequestTokenAuthorizationEngine):
55 """An authorization engine that doesn't open a web browser.
56
57 You can use this to test the creation of Launchpad objects and the
58 storing of credentials. You can't use it to interact with the web
59 service, since it only pretends to authorize its OAuth request tokens.
60 """
61 ACCESS_TOKEN_KEY = "access_key:84"
62
63 def __init__(self, *args, **kwargs):
64 super(NoNetworkAuthorizationEngine, self).__init__(*args, **kwargs)
65 # Set up some instrumentation.
66 self.request_tokens_obtained = 0
67 self.access_tokens_obtained = 0
68
69 def get_request_token(self, credentials):
70 """Pretend to get a request token from the server.
71
72 We do this by simply returning a static token ID.
73 """
74 self.request_tokens_obtained += 1
75 return "request_token:42"
76
77 def make_end_user_authorize_token(self, credentials, request_token):
78 """Pretend to exchange a request token for an access token.
79
80 We do this by simply setting the access_token property.
81 """
82 credentials.access_token = AccessToken(
83 self.ACCESS_TOKEN_KEY, 'access_secret:168')
84 self.access_tokens_obtained += 1
85
86
87class NoNetworkLaunchpad(Launchpad):
88 """A Launchpad instance for tests with no network access.
89
90 It's only useful for making sure that certain methods were called.
91 It can't be used to interact with the API.
92 """
93
94 def __init__(self, credentials, authorization_engine, service_root,
95 cache, timeout, proxy_info, version):
96 self.credentials = credentials
97 self.authorization_engine = authorization_engine
98 self.passed_in_args = dict(
99 service_root=service_root, cache=cache, timeout=timeout,
100 proxy_info=proxy_info, version=version)
101
102 @classmethod
103 def authorization_engine_factory(cls, *args):
104 return NoNetworkAuthorizationEngine(*args)
105
106
50class KnownTokens:107class KnownTokens:
51 """Known access token/secret combinations."""108 """Known access token/secret combinations."""
52109
53110
=== added file 'src/launchpadlib/tests/test_http.py'
--- src/launchpadlib/tests/test_http.py 1970-01-01 00:00:00 +0000
+++ src/launchpadlib/tests/test_http.py 2010-12-20 17:49:10 +0000
@@ -0,0 +1,228 @@
1# Copyright 2010 Canonical Ltd.
2
3# This file is part of launchpadlib.
4#
5# launchpadlib is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by the
7# Free Software Foundation, version 3 of the License.
8#
9# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
12# for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the LaunchpadOAuthAwareHTTP class."""
18
19from collections import deque
20import unittest
21
22from simplejson import dumps, JSONDecodeError
23
24from launchpadlib.errors import Unauthorized
25from launchpadlib.launchpad import (
26 Launchpad,
27 LaunchpadOAuthAwareHttp,
28 )
29from launchpadlib.testing.helpers import (
30 NoNetworkAuthorizationEngine,
31 NoNetworkLaunchpad,
32 )
33
34
35# The simplest WADL that looks like a representation of the service root.
36SIMPLE_WADL = '''<?xml version="1.0"?>
37<application xmlns="http://research.sun.com/wadl/2006/10">
38 <resources base="http://www.example.com/">
39 <resource path="" type="#service-root"/>
40 </resources>
41
42 <resource_type id="service-root">
43 <method name="GET" id="service-root-get">
44 <response>
45 <representation href="#service-root-json"/>
46 </response>
47 </method>
48 </resource_type>
49
50 <representation id="service-root-json" mediaType="application/json"/>
51</application>
52'''
53
54# The simplest JSON that looks like a representation of the service root.
55SIMPLE_JSON = dumps({})
56
57
58class Response:
59 """A fake HTTP response object."""
60 def __init__(self, status, content):
61 self.status = status
62 self.content = content
63
64
65class SimulatedResponsesHttp(LaunchpadOAuthAwareHttp):
66 """Responds to HTTP requests by shifting responses off a stack."""
67
68 def __init__(self, responses, *args):
69 """Constructor.
70
71 :param responses: A list of HttpResponse objects to use
72 in response to requests.
73 """
74 self.sent_responses = []
75 self.unsent_responses = responses
76 super(SimulatedResponsesHttp, self).__init__(*args)
77
78 def _request(self, *args):
79 response = self.unsent_responses.popleft()
80 self.sent_responses.append(response)
81 return self.retry_on_bad_token(response, response.content)
82
83
84class SimulatedResponsesLaunchpad(Launchpad):
85
86 # Every Http object generated by this class will return these
87 # responses, in order.
88 responses = []
89
90 def httpFactory(self, *args):
91 return SimulatedResponsesHttp(
92 deque(self.responses), self, self.authorization_engine, *args)
93
94
95class SimulatedResponsesTestCase(unittest.TestCase):
96 """Test cases that give fake responses to launchpad's HTTP requests."""
97
98 def setUp(self):
99 """Clear out the list of simulated responses."""
100 SimulatedResponsesLaunchpad.responses = []
101 self.engine = NoNetworkAuthorizationEngine(
102 'http://api.example.com/', 'application name')
103
104 def launchpad_with_responses(self, *responses):
105 """Use simulated HTTP responses to get a Launchpad object.
106
107 The given Response objects will be sent, in order, in response
108 to launchpadlib's requests.
109
110 :param responses: Some number of Response objects.
111 :return: The Launchpad object, assuming that errors in the
112 simulated requests didn't prevent one from being created.
113 """
114 SimulatedResponsesLaunchpad.responses = responses
115 return SimulatedResponsesLaunchpad.login_with(
116 'application name', authorization_engine=self.engine)
117
118
119class TestAbilityToParseData(SimulatedResponsesTestCase):
120 """Test launchpadlib's ability to handle the sample data.
121
122 To create a Launchpad object, two HTTP requests must succeed and
123 return usable data: the requests for the WADL and JSON
124 representations of the service root. This test shows that the
125 minimal data in SIMPLE_WADL and SIMPLE_JSON is good enough to
126 create a Launchpad object.
127 """
128
129 def test_minimal_data(self):
130 """Make sure that launchpadlib can use the minimal data."""
131 launchpad = self.launchpad_with_responses(
132 Response(200, SIMPLE_WADL),
133 Response(200, SIMPLE_JSON))
134
135 def test_bad_wadl(self):
136 """Show that bad WADL causes an exception."""
137 self.assertRaises(
138 SyntaxError, self.launchpad_with_responses,
139 Response(200, "This is not WADL."),
140 Response(200, SIMPLE_JSON))
141
142 def test_bad_json(self):
143 """Show that bad JSON causes an exception."""
144 self.assertRaises(
145 JSONDecodeError, self.launchpad_with_responses,
146 Response(200, SIMPLE_WADL),
147 Response(200, "This is not JSON."))
148
149
150class TestTokenFailureDuringRequest(SimulatedResponsesTestCase):
151 """Test access token failures during a request.
152
153 launchpadlib makes two HTTP requests on startup, to get the WADL
154 and JSON representations of the service root. If Launchpad
155 receives a 401 error during this process, it will acquire a fresh
156 access token and try again.
157 """
158
159 def test_good_token(self):
160 """If our token is good, we never get another one."""
161 SimulatedResponsesLaunchpad.responses = [
162 Response(200, SIMPLE_WADL),
163 Response(200, SIMPLE_JSON)]
164
165 self.assertEquals(self.engine.access_tokens_obtained, 0)
166 launchpad = SimulatedResponsesLaunchpad.login_with(
167 'application name', authorization_engine=self.engine)
168 self.assertEquals(self.engine.access_tokens_obtained, 0)
169
170 def test_bad_token(self):
171 """If our token is bad, we get another one."""
172 SimulatedResponsesLaunchpad.responses = [
173 Response(401, "Invalid token."),
174 Response(200, SIMPLE_WADL),
175 Response(200, SIMPLE_JSON)]
176
177 self.assertEquals(self.engine.access_tokens_obtained, 0)
178 launchpad = SimulatedResponsesLaunchpad.login_with(
179 'application name', authorization_engine=self.engine)
180 self.assertEquals(self.engine.access_tokens_obtained, 1)
181
182 def test_expired_token(self):
183 """If our token is expired, we get another one."""
184
185 SimulatedResponsesLaunchpad.responses = [
186 Response(401, "Expired token."),
187 Response(200, SIMPLE_WADL),
188 Response(200, SIMPLE_JSON)]
189
190 self.assertEquals(self.engine.access_tokens_obtained, 0)
191 launchpad = SimulatedResponsesLaunchpad.login_with(
192 'application name', authorization_engine=self.engine)
193 self.assertEquals(self.engine.access_tokens_obtained, 1)
194
195 def test_delayed_error(self):
196 """We get another token no matter when the error happens."""
197 SimulatedResponsesLaunchpad.responses = [
198 Response(200, SIMPLE_WADL),
199 Response(401, "Expired token."),
200 Response(200, SIMPLE_JSON)]
201
202 self.assertEquals(self.engine.access_tokens_obtained, 0)
203 launchpad = SimulatedResponsesLaunchpad.login_with(
204 'application name', authorization_engine=self.engine)
205 self.assertEquals(self.engine.access_tokens_obtained, 1)
206
207 def test_many_errors(self):
208 """We'll keep getting new tokens as long as tokens are the problem."""
209 SimulatedResponsesLaunchpad.responses = [
210 Response(401, "Invalid token."),
211 Response(200, SIMPLE_WADL),
212 Response(401, "Expired token."),
213 Response(401, "Invalid token."),
214 Response(200, SIMPLE_JSON)]
215 self.assertEquals(self.engine.access_tokens_obtained, 0)
216 launchpad = SimulatedResponsesLaunchpad.login_with(
217 'application name', authorization_engine=self.engine)
218 self.assertEquals(self.engine.access_tokens_obtained, 3)
219
220 def test_other_unauthorized(self):
221 """If the token is not at fault, a 401 error raises an exception."""
222
223 SimulatedResponsesLaunchpad.responses = [
224 Response(401, "Some other error.")]
225
226 self.assertRaises(
227 Unauthorized, SimulatedResponsesLaunchpad.login_with,
228 'application name', authorization_engine=self.engine)
0229
=== modified file 'src/launchpadlib/tests/test_launchpad.py'
--- src/launchpadlib/tests/test_launchpad.py 2010-11-01 20:18:48 +0000
+++ src/launchpadlib/tests/test_launchpad.py 2010-12-20 17:49:10 +0000
@@ -31,44 +31,19 @@
3131
32from launchpadlib.credentials import (32from launchpadlib.credentials import (
33 AccessToken,33 AccessToken,
34 AuthorizeRequestTokenWithBrowser,
35 Credentials,34 Credentials,
36 SystemWideConsumer,
37 )35 )
38from launchpadlib.launchpad import Launchpad36
39from launchpadlib import uris37from launchpadlib import uris
40import launchpadlib.launchpad38import launchpadlib.launchpad
4139from launchpadlib.launchpad import Launchpad
42class NoNetworkLaunchpad(Launchpad):40from launchpadlib.testing.helpers import (
43 """A Launchpad instance for tests with no network access.41 NoNetworkAuthorizationEngine,
4442 NoNetworkLaunchpad,
45 It's only useful for making sure that certain methods were called.43 )
46 It can't be used to interact with the API.44
47 """45# A dummy service root for use in tests
4846SERVICE_ROOT = "http://api.example.com/"
49 consumer_name = None
50 passed_in_kwargs = None
51 credentials = None
52 get_token_and_login_called = False
53
54 def __init__(self, credentials, **kw):
55 self.credentials = credentials
56 self.passed_in_kwargs = kw
57
58 @classmethod
59 def get_token_and_login(cls, consumer, **kw):
60 """Create fake credentials and record that we were called."""
61 credentials = Credentials(
62 consumer.key, consumer_secret='consumer_secret:42',
63 access_token=AccessToken('access_key:84', 'access_secret:168'),
64 application_name=consumer.application_name)
65 launchpad = cls(credentials, **kw)
66
67 launchpad.get_token_and_login_called = True
68 launchpad.consumer = consumer
69 launchpad.passed_in_kwargs = kw
70 return launchpad
71
7247
73class TestResourceTypeClasses(unittest.TestCase):48class TestResourceTypeClasses(unittest.TestCase):
74 """launchpadlib must know about restfulclient's resource types."""49 """launchpadlib must know about restfulclient's resource types."""
@@ -137,7 +112,7 @@
137 version = "version-foo"112 version = "version-foo"
138 root = uris.service_roots['staging'] + version113 root = uris.service_roots['staging'] + version
139 try:114 try:
140 Launchpad(None, root, version=version)115 Launchpad(None, None, service_root=root, version=version)
141 except ValueError, e:116 except ValueError, e:
142 self.assertTrue(str(e).startswith(117 self.assertTrue(str(e).startswith(
143 "It looks like you're using a service root that incorporates "118 "It looks like you're using a service root that incorporates "
@@ -149,13 +124,15 @@
149 # Make sure the problematic URL is caught even if it has a124 # Make sure the problematic URL is caught even if it has a
150 # slash on the end.125 # slash on the end.
151 root += '/'126 root += '/'
152 self.assertRaises(ValueError, Launchpad, None, root, version=version)127 self.assertRaises(ValueError, Launchpad, None, None,
128 service_root=root, version=version)
153129
154 # Test that the default version has the same problem130 # Test that the default version has the same problem
155 # when no explicit version is specified131 # when no explicit version is specified
156 default_version = NoNetworkLaunchpad.DEFAULT_VERSION132 default_version = NoNetworkLaunchpad.DEFAULT_VERSION
157 root = uris.service_roots['staging'] + default_version + '/'133 root = uris.service_roots['staging'] + default_version + '/'
158 self.assertRaises(ValueError, Launchpad, None, root)134 self.assertRaises(ValueError, Launchpad, None, None,
135 service_root=root)
159136
160137
161class InMemoryKeyring:138class InMemoryKeyring:
@@ -171,6 +148,32 @@
171 return self.data.get((service, username))148 return self.data.get((service, username))
172149
173150
151class TestRequestTokenAuthorizationEngine(unittest.TestCase):
152 """Tests for the RequestTokenAuthorizationEngine class."""
153
154 def test_app_must_be_identified(self):
155 self.assertRaises(
156 ValueError, NoNetworkAuthorizationEngine, SERVICE_ROOT)
157
158 def test_application_name_identifies_app(self):
159 NoNetworkAuthorizationEngine(SERVICE_ROOT, application_name='name')
160
161 def test_consumer_name_identifies_app(self):
162 NoNetworkAuthorizationEngine(SERVICE_ROOT, consumer_name='name')
163
164 def test_conflicting_app_identification(self):
165 # You can't specify both application_name and consumer_name.
166 self.assertRaises(
167 ValueError, NoNetworkAuthorizationEngine,
168 SERVICE_ROOT, application_name='name1', consumer_name='name2')
169
170 # This holds true even if you specify the same value for
171 # both. They're not the same thing.
172 self.assertRaises(
173 ValueError, NoNetworkAuthorizationEngine,
174 SERVICE_ROOT, application_name='name', consumer_name='name')
175
176
174class TestLaunchpadLoginWith(unittest.TestCase):177class TestLaunchpadLoginWith(unittest.TestCase):
175 """Tests for Launchpad.login_with()."""178 """Tests for Launchpad.login_with()."""
176179
@@ -178,21 +181,33 @@
178 # For these tests we want to disable retrieving or storing credentials181 # For these tests we want to disable retrieving or storing credentials
179 # in a system-provided keyring and use a dummy keyring implementation182 # in a system-provided keyring and use a dummy keyring implementation
180 # instaed.183 # instaed.
181 self._saved_keyring = launchpadlib.launchpad.keyring184 self._saved_keyring = launchpadlib.credentials.keyring
182 launchpadlib.launchpad.keyring = InMemoryKeyring()185 launchpadlib.credentials.keyring = InMemoryKeyring()
183 self.temp_dir = tempfile.mkdtemp()186 self.temp_dir = tempfile.mkdtemp()
184187
185 def tearDown(self):188 def tearDown(self):
186 # Restore the gnomekeyring module that we disabled in setUp.189 # Restore the gnomekeyring module that we disabled in setUp.
187 launchpadlib.launchpad.keyring = self._saved_keyring190 launchpadlib.credentials.keyring = self._saved_keyring
188 shutil.rmtree(self.temp_dir)191 shutil.rmtree(self.temp_dir)
189192
193 def test_max_failed_attempts_accepted(self):
194 # You can pass in a value for the 'max_failed_attempts'
195 # argument, even though that argument doesn't do anything.
196 launchpad = NoNetworkLaunchpad.login_with(
197 'not important', max_failed_attempts=5)
198
199 def test_credentials_file_accepted(self):
200 # You can pass in a value for the 'credentials_file'
201 # argument, even though that argument doesn't do anything.
202 launchpad = NoNetworkLaunchpad.login_with(
203 'not important', credentials_file='foo')
204
190 def test_dirs_created(self):205 def test_dirs_created(self):
191 # The path we pass into login_with() is the directory where206 # The path we pass into login_with() is the directory where
192 # cache and credentials for all service roots are stored.207 # cache and credentials for all service roots are stored.
193 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')208 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
194 launchpad = NoNetworkLaunchpad.login_with(209 launchpad = NoNetworkLaunchpad.login_with(
195 'not important', service_root='http://api.example.com/',210 'not important', service_root=SERVICE_ROOT,
196 launchpadlib_dir=launchpadlib_dir)211 launchpadlib_dir=launchpadlib_dir)
197 # The 'launchpadlib' dir got created.212 # The 'launchpadlib' dir got created.
198 self.assertTrue(os.path.isdir(launchpadlib_dir))213 self.assertTrue(os.path.isdir(launchpadlib_dir))
@@ -219,7 +234,7 @@
219 mode = stat.S_IMODE(statinfo.st_mode)234 mode = stat.S_IMODE(statinfo.st_mode)
220 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)235 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
221 launchpad = NoNetworkLaunchpad.login_with(236 launchpad = NoNetworkLaunchpad.login_with(
222 'not important', service_root='http://api.example.com/',237 'not important', service_root=SERVICE_ROOT,
223 launchpadlib_dir=launchpadlib_dir)238 launchpadlib_dir=launchpadlib_dir)
224 # Verify the mode has been changed to 0700239 # Verify the mode has been changed to 0700
225 statinfo = os.stat(launchpadlib_dir)240 statinfo = os.stat(launchpadlib_dir)
@@ -229,7 +244,7 @@
229 def test_dirs_created_are_secure(self):244 def test_dirs_created_are_secure(self):
230 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')245 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
231 launchpad = NoNetworkLaunchpad.login_with(246 launchpad = NoNetworkLaunchpad.login_with(
232 'not important', service_root='http://api.example.com/',247 'not important', service_root=SERVICE_ROOT,
233 launchpadlib_dir=launchpadlib_dir)248 launchpadlib_dir=launchpadlib_dir)
234 self.assertTrue(os.path.isdir(launchpadlib_dir))249 self.assertTrue(os.path.isdir(launchpadlib_dir))
235 # Verify the mode is safe250 # Verify the mode is safe
@@ -243,18 +258,18 @@
243 # credentials will be cached to disk.258 # credentials will be cached to disk.
244 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')259 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
245 launchpad = NoNetworkLaunchpad.login_with(260 launchpad = NoNetworkLaunchpad.login_with(
246 'not important', service_root='http://api.example.com/',261 'not important', service_root=SERVICE_ROOT,
247 launchpadlib_dir=launchpadlib_dir, version="foo")262 launchpadlib_dir=launchpadlib_dir, version="foo")
248 self.assertEquals(launchpad.passed_in_kwargs['version'], 'foo')263 self.assertEquals(launchpad.passed_in_args['version'], 'foo')
249264
250 # Now execute the same test a second time. This time, the265 # Now execute the same test a second time. This time, the
251 # credentials are loaded from disk and a different code path266 # credentials are loaded from disk and a different code path
252 # is executed. We want to make sure this code path propagates267 # is executed. We want to make sure this code path propagates
253 # the 'version' argument.268 # the 'version' argument.
254 launchpad = NoNetworkLaunchpad.login_with(269 launchpad = NoNetworkLaunchpad.login_with(
255 'not important', service_root='http://api.example.com/',270 'not important', service_root=SERVICE_ROOT,
256 launchpadlib_dir=launchpadlib_dir, version="bar")271 launchpadlib_dir=launchpadlib_dir, version="bar")
257 self.assertEquals(launchpad.passed_in_kwargs['version'], 'bar')272 self.assertEquals(launchpad.passed_in_args['version'], 'bar')
258273
259 def test_application_name_is_propagated(self):274 def test_application_name_is_propagated(self):
260 # Create a Launchpad instance for a given application name.275 # Create a Launchpad instance for a given application name.
@@ -263,7 +278,7 @@
263 # single system-wide credential.278 # single system-wide credential.
264 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')279 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
265 launchpad = NoNetworkLaunchpad.login_with(280 launchpad = NoNetworkLaunchpad.login_with(
266 'very important', service_root='http://api.example.com/',281 'very important', service_root=SERVICE_ROOT,
267 launchpadlib_dir=launchpadlib_dir)282 launchpadlib_dir=launchpadlib_dir)
268 self.assertEquals(283 self.assertEquals(
269 launchpad.credentials.consumer.application_name, 'very important')284 launchpad.credentials.consumer.application_name, 'very important')
@@ -274,36 +289,112 @@
274 # the application name, instead of picking an empty one from289 # the application name, instead of picking an empty one from
275 # disk.290 # disk.
276 launchpad = NoNetworkLaunchpad.login_with(291 launchpad = NoNetworkLaunchpad.login_with(
277 'very important', service_root='http://api.example.com/',292 'very important', service_root=SERVICE_ROOT,
278 launchpadlib_dir=launchpadlib_dir)293 launchpadlib_dir=launchpadlib_dir)
279 self.assertEquals(294 self.assertEquals(
280 launchpad.credentials.consumer.application_name, 'very important')295 launchpad.credentials.consumer.application_name, 'very important')
281296
282 def test_no_credentials_calls_get_token_and_login(self):297 def test_authorization_engine_is_propagated(self):
283 # If no credentials are found, get_token_and_login() is called.298 # You can pass in a custom authorization engine, which will be
284 service_root = 'http://api.example.com/'299 # used to get a request token and exchange it for an access
300 # token.
301 engine = NoNetworkAuthorizationEngine(
302 SERVICE_ROOT, 'application name')
303 launchpad = NoNetworkLaunchpad.login_with(
304 authorization_engine=engine)
305 self.assertEquals(engine.request_tokens_obtained, 1)
306 self.assertEquals(engine.access_tokens_obtained, 1)
307
308 def test_login_with_must_identify_application(self):
309 # If you call login_with without identifying your application
310 # you'll get an error.
311 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with)
312
313 def test_application_name_identifies_app(self):
314 NoNetworkLaunchpad.login_with(application_name="name")
315
316 def test_consumer_name_identifies_app(self):
317 NoNetworkLaunchpad.login_with(consumer_name="name")
318
319 def test_inconsistent_application_name_rejected(self):
320 """Catch an attempt to specify inconsistent application_names."""
321 engine = NoNetworkAuthorizationEngine(
322 SERVICE_ROOT, 'application name1')
323 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
324 "application name2",
325 authorization_engine=engine)
326
327 def test_inconsistent_consumer_name_rejected(self):
328 """Catch an attempt to specify inconsistent application_names."""
329 engine = NoNetworkAuthorizationEngine(
330 SERVICE_ROOT, None, consumer_name="consumer_name1")
331
332 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
333 "consumer_name2",
334 authorization_engine=engine)
335
336 def test_inconsistent_allow_access_levels_rejected(self):
337 """Catch an attempt to specify inconsistent allow_access_levels."""
338 engine = NoNetworkAuthorizationEngine(
339 SERVICE_ROOT, consumer_name="consumer",
340 allow_access_levels=['FOO'])
341
342 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
343 None, consumer_name="consumer",
344 allow_access_levels=['BAR'],
345 authorization_engine=engine)
346
347 def test_non_desktop_integration(self):
348 # When doing a non-desktop integration, you must specify a
349 # consumer_name. You can pass a list of allowable access
350 # levels into login_with().
351 launchpad = NoNetworkLaunchpad.login_with(
352 consumer_name="consumer", allow_access_levels=['FOO'])
353 self.assertEquals(launchpad.credentials.consumer.key, "consumer")
354 self.assertEquals(launchpad.credentials.consumer.application_name,
355 None)
356 self.assertEquals(launchpad.authorization_engine.allow_access_levels,
357 ['FOO'])
358
359 def test_desktop_integration_doesnt_happen_without_consumer_name(self):
360 # The only way to do a non-desktop integration is to specify a
361 # consumer_name. If you specify application_name instead, your
362 # value for allow_access_levels is ignored, and a desktop
363 # integration is performed.
364 launchpad = NoNetworkLaunchpad.login_with(
365 'application name', allow_access_levels=['FOO'])
366 self.assertEquals(launchpad.authorization_engine.allow_access_levels,
367 ['DESKTOP_INTEGRATION'])
368
369 def test_no_credentials_creates_new_credential(self):
370 # If no credentials are found, a desktop-wide credential is created.
285 timeout = object()371 timeout = object()
286 proxy_info = object()372 proxy_info = object()
287 launchpad = NoNetworkLaunchpad.login_with(373 launchpad = NoNetworkLaunchpad.login_with(
288 'app name', launchpadlib_dir=self.temp_dir,374 'app name', launchpadlib_dir=self.temp_dir,
289 service_root=service_root, timeout=timeout, proxy_info=proxy_info)375 service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info)
290 self.assertEqual(launchpad.consumer.application_name, 'app name')376 # Here's the new credential.
377 self.assertEqual(launchpad.credentials.access_token.key,
378 NoNetworkAuthorizationEngine.ACCESS_TOKEN_KEY)
379 self.assertEqual(launchpad.credentials.consumer.application_name,
380 'app name')
381 self.assertEquals(launchpad.authorization_engine.allow_access_levels,
382 ['DESKTOP_INTEGRATION'])
383 # The expected arguments were passed in to the Launchpad
384 # constructor.
291 expected_arguments = dict(385 expected_arguments = dict(
292 allow_access_levels=['DESKTOP_INTEGRATION'],386 service_root=SERVICE_ROOT,
293 authorizer_class=AuthorizeRequestTokenWithBrowser,
294 max_failed_attempts=3,
295 service_root=service_root,
296 timeout=timeout,
297 proxy_info=proxy_info,
298 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'),387 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'),
388 timeout=timeout,
389 proxy_info=proxy_info,
299 version=NoNetworkLaunchpad.DEFAULT_VERSION)390 version=NoNetworkLaunchpad.DEFAULT_VERSION)
300 self.assertEqual(launchpad.passed_in_kwargs, expected_arguments)391 self.assertEqual(launchpad.passed_in_args, expected_arguments)
301392
302 def test_anonymous_login(self):393 def test_anonymous_login(self):
303 """Test the anonymous login helper function."""394 """Test the anonymous login helper function."""
304 launchpad = NoNetworkLaunchpad.login_anonymously(395 launchpad = NoNetworkLaunchpad.login_anonymously(
305 'anonymous access', launchpadlib_dir=self.temp_dir,396 'anonymous access', launchpadlib_dir=self.temp_dir,
306 service_root='http://api.example.com/')397 service_root=SERVICE_ROOT)
307 self.assertEqual(launchpad.credentials.access_token.key, '')398 self.assertEqual(launchpad.credentials.access_token.key, '')
308 self.assertEqual(launchpad.credentials.access_token.secret, '')399 self.assertEqual(launchpad.credentials.access_token.secret, '')
309400
@@ -325,22 +416,21 @@
325 access_token=AccessToken('access_key:84', 'access_secret:168'))416 access_token=AccessToken('access_key:84', 'access_secret:168'))
326 credentials.save_to_path(credentials_file_path)417 credentials.save_to_path(credentials_file_path)
327418
328 service_root = 'http://api.example.com/'
329 timeout = object()419 timeout = object()
330 proxy_info = object()420 proxy_info = object()
331 version = "foo"421 version = "foo"
332 launchpad = NoNetworkLaunchpad.login_with(422 launchpad = NoNetworkLaunchpad.login_with(
333 'app name', launchpadlib_dir=self.temp_dir,423 'app name', launchpadlib_dir=self.temp_dir,
334 service_root=service_root, timeout=timeout, proxy_info=proxy_info,424 service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info,
335 version=version)425 version=version)
336 expected_arguments = dict(426 expected_arguments = dict(
337 service_root=service_root,427 service_root=SERVICE_ROOT,
338 timeout=timeout,428 timeout=timeout,
339 proxy_info=proxy_info,429 proxy_info=proxy_info,
340 version=version,430 version=version,
341 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'))431 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'))
342 for key, expected in expected_arguments.items():432 for key, expected in expected_arguments.items():
343 actual = launchpad.passed_in_kwargs[key]433 actual = launchpad.passed_in_args[key]
344 self.assertEqual(actual, expected)434 self.assertEqual(actual, expected)
345435
346 def test_None_launchpadlib_dir(self):436 def test_None_launchpadlib_dir(self):
@@ -349,11 +439,11 @@
349 old_home = os.environ['HOME']439 old_home = os.environ['HOME']
350 os.environ['HOME'] = self.temp_dir440 os.environ['HOME'] = self.temp_dir
351 launchpad = NoNetworkLaunchpad.login_with(441 launchpad = NoNetworkLaunchpad.login_with(
352 'app name', service_root='http://api.example.com/')442 'app name', service_root=SERVICE_ROOT)
353 # Reset the environment to the old value.443 # Reset the environment to the old value.
354 os.environ['HOME'] = old_home444 os.environ['HOME'] = old_home
355445
356 cache_dir = launchpad.passed_in_kwargs['cache']446 cache_dir = launchpad.passed_in_args['cache']
357 launchpadlib_dir = os.path.abspath(447 launchpadlib_dir = os.path.abspath(
358 os.path.join(cache_dir, '..', '..'))448 os.path.join(cache_dir, '..', '..'))
359 self.assertEqual(449 self.assertEqual(
@@ -365,14 +455,14 @@
365 # A short service name is converted to the full service root URL.455 # A short service name is converted to the full service root URL.
366 launchpad = NoNetworkLaunchpad.login_with('app name', 'staging')456 launchpad = NoNetworkLaunchpad.login_with('app name', 'staging')
367 self.assertEqual(457 self.assertEqual(
368 launchpad.passed_in_kwargs['service_root'],458 launchpad.passed_in_args['service_root'],
369 'https://api.staging.launchpad.net/')459 'https://api.staging.launchpad.net/')
370460
371 # A full URL as the service name is left alone.461 # A full URL as the service name is left alone.
372 launchpad = NoNetworkLaunchpad.login_with(462 launchpad = NoNetworkLaunchpad.login_with(
373 'app name', uris.service_roots['staging'])463 'app name', uris.service_roots['staging'])
374 self.assertEqual(464 self.assertEqual(
375 launchpad.passed_in_kwargs['service_root'],465 launchpad.passed_in_args['service_root'],
376 uris.service_roots['staging'])466 uris.service_roots['staging'])
377467
378 # A short service name that does not match one of the468 # A short service name that does not match one of the
@@ -402,10 +492,10 @@
402492
403@contextmanager493@contextmanager
404def fake_keyring(fake):494def fake_keyring(fake):
405 original_keyring = launchpadlib.launchpad.keyring495 original_keyring = launchpadlib.credentials.keyring
406 launchpadlib.launchpad.keyring = fake496 launchpadlib.credentials.keyring = fake
407 yield497 yield
408 launchpadlib.launchpad.keyring = original_keyring498 launchpadlib.credentials.keyring = original_keyring
409499
410500
411class TestCredenitialSaveFailedCallback(unittest.TestCase):501class TestCredenitialSaveFailedCallback(unittest.TestCase):
@@ -434,9 +524,10 @@
434 callback_called.append(None)524 callback_called.append(None)
435525
436 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')526 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
527 service_root = "http://api.example.com/"
437 with fake_keyring(BadSaveKeyring()):528 with fake_keyring(BadSaveKeyring()):
438 NoNetworkLaunchpad.login_with(529 NoNetworkLaunchpad.login_with(
439 'not important', service_root='http://api.example.com/',530 'not important', service_root=service_root,
440 launchpadlib_dir=launchpadlib_dir,531 launchpadlib_dir=launchpadlib_dir,
441 credential_save_failed=callback)532 credential_save_failed=callback)
442 self.assertEquals(len(callback_called), 1)533 self.assertEquals(len(callback_called), 1)
443534
=== modified file 'src/launchpadlib/uris.py'
--- src/launchpadlib/uris.py 2010-10-27 21:22:29 +0000
+++ src/launchpadlib/uris.py 2010-12-20 17:49:10 +0000
@@ -24,9 +24,11 @@
24__all__ = [24__all__ = [
25 'lookup_service_root',25 'lookup_service_root',
26 'lookup_web_root',26 'lookup_web_root',
27 'web_root_for_service_root',
27 ]28 ]
2829
29from urlparse import urlparse30from urlparse import urlparse
31from lazr.uri import URI
3032
31LPNET_SERVICE_ROOT = 'https://api.launchpad.net/'33LPNET_SERVICE_ROOT = 'https://api.launchpad.net/'
32EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/'34EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/'
@@ -101,3 +103,15 @@
101 """103 """
102 return _dereference_alias(web_root, web_roots)104 return _dereference_alias(web_root, web_roots)
103105
106
107def web_root_for_service_root(service_root):
108 """Turn a service root URL into a web root URL.
109
110 This is done heuristically, not with a lookup.
111 """
112 service_root = lookup_service_root(service_root)
113 web_root_uri = URI(service_root)
114 web_root_uri.path = ""
115 web_root_uri.host = web_root_uri.host.replace("api.", "", 1)
116 web_root = str(web_root_uri.ensureSlash())
117 return web_root

Subscribers

People subscribed via source and target branches