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
1=== modified file 'src/launchpadlib/NEWS.txt'
2--- src/launchpadlib/NEWS.txt 2010-12-02 14:58:42 +0000
3+++ src/launchpadlib/NEWS.txt 2010-12-20 17:49:10 +0000
4@@ -2,9 +2,13 @@
5 NEWS for launchpadlib
6 =====================
7
8-1.8.1 (unreleased)
9+1.9.0 (Unreleased)
10 ==================
11
12+- When an authorization token expires or becomes invalid, attempt to
13+ acquire a new one, even in the middle of a session, rather than
14+ crashing.
15+
16 - The HTML generated by wadl-to-refhtml.xsl now validates.
17
18 1.8.0 (2010-11-15)
19
20=== modified file 'src/launchpadlib/credentials.py'
21--- src/launchpadlib/credentials.py 2010-10-28 20:48:12 +0000
22+++ src/launchpadlib/credentials.py 2010-12-20 17:49:10 +0000
23@@ -20,6 +20,7 @@
24 __all__ = [
25 'AccessToken',
26 'AnonymousAccessToken',
27+ 'AuthorizeRequestTokenWithBrowser',
28 'RequestTokenAuthorizationEngine',
29 'Consumer',
30 'Credentials',
31@@ -27,6 +28,7 @@
32
33 import base64
34 import cgi
35+from cStringIO import StringIO
36 import httplib2
37 import sys
38 import textwrap
39@@ -35,6 +37,7 @@
40 from urlparse import urljoin
41 import webbrowser
42
43+import keyring
44 import simplejson
45
46 from lazr.restfulclient.errors import HTTPError
47@@ -52,6 +55,8 @@
48 authorize_token_page = '+authorize-token'
49 access_token_poll_time = 1
50
51+EXPLOSIVE_ERRORS = (MemoryError, KeyboardInterrupt, SystemExit)
52+
53
54 class Credentials(OAuthAuthorizer):
55 """Standard credentials storage and usage class.
56@@ -66,6 +71,25 @@
57 URI_TOKEN_FORMAT = "uri"
58 DICT_TOKEN_FORMAT = "dict"
59
60+ def serialize(self):
61+ """Turn this object into a string.
62+
63+ This should probably be moved into OAuthAuthorizer.
64+ """
65+ sio = StringIO()
66+ self.save(sio)
67+ return sio.getvalue()
68+
69+ @classmethod
70+ def from_string(cls, value):
71+ """Create a `Credentials` object from a serialized string.
72+
73+ This should probably be moved into OAuthAuthorizer.
74+ """
75+ credentials = cls()
76+ credentials.load(StringIO(value))
77+ return credentials
78+
79 def get_request_token(self, context=None, web_root=uris.STAGING_WEB_ROOT,
80 token_format=URI_TOKEN_FORMAT):
81 """Request an OAuth token to Launchpad.
82@@ -189,45 +213,224 @@
83
84
85 class RequestTokenAuthorizationEngine(object):
86+ """The superclass of all request token authorizers.
87+
88+ This base class does not implement request token authorization,
89+ since that varies depending on how you want the end-user to
90+ authorize a request token. You'll need to subclass this class and
91+ implement `make_end_user_authorize_token`.
92+
93+ This class does implement a default strategy for storing
94+ authorized tokens in the GNOME keyring or KDE Wallet, but you can
95+ override this by implementing `store_credentials_locally` and
96+ `retrieve_credentials_from_local_store`.
97+ """
98
99 UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"
100
101- def __init__(self, web_root, consumer_name, request_token,
102- allow_access_levels=[]):
103- self.web_root = uris.lookup_web_root(web_root)
104- self.consumer_name = consumer_name
105- self.request_token = request_token
106- self.allow_access_levels = allow_access_levels
107-
108- def __call__(self):
109+ def __init__(self, service_root, application_name=None,
110+ consumer_name=None, credential_save_failed=None,
111+ allow_access_levels=None):
112+ """Base class initialization.
113+
114+ :param service_root: The root of the Launchpad instance being
115+ used.
116+
117+ :param application_name: The name of the application that
118+ wants to use launchpadlib. This is used in conjunction
119+ with a desktop-wide integration.
120+
121+ If you specify this argument, your values for
122+ consumer_name and allow_access_levels are ignored.
123+
124+ :param consumer_name: The OAuth consumer name, for an
125+ application that wants its own point of integration into
126+ Launchpad. In almost all cases, you want to specify
127+ application_name instead and do a desktop-wide
128+ integration. The exception is when you're integrating a
129+ third-party website into Launchpad.
130+
131+ :param credential_save_failed: A callback method to be invoked
132+ if the credentials cannot be stored locally.
133+
134+ You should not invoke this method yourself; instead, you
135+ should raise an exception in `store_credentials_locally()`.
136+
137+ :param allow_access_levels: A list of the Launchpad access
138+ levels to present to the user. ('READ_PUBLIC' and so on.)
139+ Your value for this argument will be ignored during a
140+ desktop-wide integration.
141+ :type allow_access_levels: A list of strings.
142+ """
143+ self.service_root = uris.lookup_service_root(service_root)
144+ self.web_root = uris.web_root_for_service_root(service_root)
145+
146+ if application_name is None and consumer_name is None:
147+ raise ValueError(
148+ "You must provide either application_name or consumer_name.")
149+
150+ if application_name is not None and consumer_name is not None:
151+ raise ValueError(
152+ "You must provide only one of application_name and "
153+ "consumer_name.")
154+
155+ if consumer_name is None:
156+ # System-wide integration. Create a system-wide consumer
157+ # and identify the application using a separate
158+ # application name.
159+ allow_access_levels = ["DESKTOP_INTEGRATION"]
160+ consumer = SystemWideConsumer(application_name)
161+ else:
162+ # Application-specific integration. Use the provided
163+ # consumer name to create a consumer automatically.
164+ consumer = Consumer(consumer_name)
165+ application_name = consumer_name
166+
167+ self.consumer = consumer
168+ self.application_name = application_name
169+
170+ self.allow_access_levels = allow_access_levels or []
171+ self.credential_save_failed = credential_save_failed
172+
173+ def authorization_url(self, request_token):
174+ """Return the authorization URL for a request token.
175+
176+ This is the URL the end-user must visit to authorize the
177+ token. How exactly does this happen? That depends on the
178+ subclass implementation.
179+ """
180+ page = "%s?oauth_token=%s" % (authorize_token_page, request_token)
181+ allow_permission = "&allow_permission="
182+ if len(self.allow_access_levels) > 0:
183+ page += (
184+ allow_permission
185+ + allow_permission.join(self.allow_access_levels))
186+ return urljoin(self.web_root, page)
187+
188+ def __call__(self, credentials):
189+ """Authorize a token and associate it with the given credentials.
190+
191+ The `credential_save_failed` callback will be invoked if
192+ there's a problem storing the credentials locally. It will not
193+ be invoked if there's a problem authorizing the credentials.
194+
195+ :param credentials: A `Credentials` object. If the end-user
196+ authorizes these credentials, this object will have its
197+ .access_token property set.
198+
199+ :return: If the credentials are successfully authorized, the
200+ return value is the `Credentials` object originally passed
201+ in. Otherwise the return value is None.
202+ """
203+ request_token_string = self.get_request_token(credentials)
204+ # Hand off control to the end-user.
205+ self.make_end_user_authorize_token(credentials, request_token_string)
206+ if credentials.access_token is None:
207+ # The end-user refused to authorize the application.
208+ return None
209+ try:
210+ self.store_credentials_locally(credentials)
211+ except EXPLOSIVE_ERRORS:
212+ raise
213+ except:
214+ if self.credential_save_failed is not None:
215+ self.credential_save_failed()
216+ return credentials
217+
218+ def get_request_token(self, credentials):
219+ """Get a new request token from the server.
220+
221+ :param return: The request token.
222+ """
223+ authorization_json = credentials.get_request_token(
224+ web_root=self.web_root,
225+ token_format=Credentials.DICT_TOKEN_FORMAT)
226+ return authorization_json['oauth_token']
227+
228+ def make_end_user_authorize_token(self, credentials, request_token):
229+ """Authorize the given request token using the given credentials.
230+
231+ Your subclass must implement this method: it has no default
232+ implementation.
233+
234+ Because an access token may expire or be revoked in the middle
235+ of a session, this method may be called at arbitrary points in
236+ a launchpadlib session, or even multiple times during a single
237+ session (with a different request token each time).
238+
239+ In most cases, however, this method will be called at the
240+ beginning of a launchpadlib session, or not at all.
241+ """
242 raise NotImplementedError()
243
244+ def store_credentials_locally(self, credentials):
245+ """Store newly-authorized credentials for later use.
246+
247+ By default, credentials are stored in the GNOME keyring or KDE
248+ Wallet. This is a good solution for desktop applications and
249+ local scripts, but if you are integrating a third-party
250+ website into Launchpad you'll need to implement something else
251+ (such as storing the credentials in a database).
252+ """
253+ keyring.set_password(
254+ 'launchpadlib',
255+ (credentials.consumer.key + '@' + self.service_root),
256+ credentials.serialize())
257+
258+ def retrieve_credentials_from_local_store(self):
259+ """Retrieve credentials from a local store.
260+
261+ This method is the inverse of `store_credentials_locally`. The
262+ default implementation looks for credentials stored in the
263+ GNOME keyring or KDE Wallet.
264+
265+ :return: A `Credentials` object if one is found in the local
266+ store, and None otherise.
267+ """
268+ credential_string = keyring.get_password(
269+ 'launchpadlib', self.consumer.key + '@' + self.service_root)
270+ if credential_string is not None:
271+ credentials = Credentials.from_string(credential_string)
272+ # The application name wasn't stored locally, because in a
273+ # desktop integration scenario, a single set of
274+ # credentials may be shared by many applications. We need
275+ # to set the application name for this specific instance
276+ # of the credentials.
277+ credentials.consumer.application_name = self.application_name
278+ return credentials
279+ return None
280+
281
282 class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):
283- """The simplest (and, right now, only) request token authorizer.
284+ """The simplest (and, right now, the only) request token authorizer.
285
286 This authorizer simply opens up the end-user's web browser to a
287 Launchpad URL and lets the end-user authorize the request token
288 themselves.
289-
290- :param max_failed_attempts: Unused by this token authorization
291- mechanism. Any value passed in will be ignored.
292 """
293
294 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..."
295
296- def __init__(self, web_root, consumer_name, request_token,
297- allow_access_levels=[], max_failed_attempts=None):
298- web_root = uris.lookup_web_root(web_root)
299- page = "+authorize-token?oauth_token=%s" % request_token
300- if len(allow_access_levels) > 0:
301- page += ("&allow_permission=" +
302- "&allow_permission=".join(allow_access_levels))
303- self.authorization_url = urljoin(web_root, page)
304+ def __init__(self, service_root, application_name, consumer_name=None,
305+ credential_save_failed=None, allow_access_levels=None):
306+ """Constructor.
307
308+ :param service_root: See `RequestTokenAuthorizationEngine`.
309+ :param application_name: See `RequestTokenAuthorizationEngine`.
310+ :param consumer_name: The value of this argument is
311+ ignored. If we have the capability to open the end-user's
312+ web browser, we must be running on the end-user's computer,
313+ so we should do a full desktop integration.
314+ :param credential_save_failed: See `RequestTokenAuthorizationEngine`.
315+ :param allow_access_levels: The value of this argument is
316+ ignored, for the same reason as consumer_name.
317+ """
318+ # It doesn't look like we're doing anything here, but we
319+ # are discarding the passed-in values for consumer_name and
320+ # allow_access_levels.
321 super(AuthorizeRequestTokenWithBrowser, self).__init__(
322- web_root, consumer_name, request_token,
323- allow_access_levels)
324+ service_root, application_name, None,
325+ credential_save_failed)
326
327 def output(self, message):
328 """Display a message.
329@@ -238,13 +441,17 @@
330 """
331 print message
332
333- def __call__(self, credentials, web_root):
334- webbrowser.open(self.authorization_url)
335- self.output(self.WAITING_FOR_USER % self.authorization_url)
336+ def make_end_user_authorize_token(self, credentials, request_token):
337+ """Have the end-user authorize the token in their browser."""
338+
339+ authorization_url = self.authorization_url(request_token)
340+ webbrowser.open(authorization_url)
341+ self.output(self.WAITING_FOR_USER % authorization_url)
342 while credentials.access_token is None:
343 time.sleep(access_token_poll_time)
344 try:
345- credentials.exchange_request_token_for_access_token(web_root)
346+ credentials.exchange_request_token_for_access_token(
347+ self.web_root)
348 break
349 except HTTPError, e:
350 if e.response.status == 403:
351@@ -259,6 +466,7 @@
352 print "Unexpected response from Launchpad:"
353 print e
354
355+
356 class TokenAuthorizationException(Exception):
357 pass
358
359
360=== modified file 'src/launchpadlib/launchpad.py'
361--- src/launchpadlib/launchpad.py 2010-11-01 19:48:27 +0000
362+++ src/launchpadlib/launchpad.py 2010-12-20 17:49:10 +0000
363@@ -25,16 +25,14 @@
364 import socket
365 import stat
366 import urlparse
367-from cStringIO import StringIO
368
369-import keyring
370-from lazr.uri import URI
371 from lazr.restfulclient.resource import (
372 CollectionWithKeyBasedLookup,
373 HostedFile,
374 ScalarValue,
375 ServiceRoot,
376 )
377+from lazr.restfulclient._browser import RestfulHttp
378 from launchpadlib.credentials import (
379 AccessToken,
380 AnonymousAccessToken,
381@@ -52,21 +50,6 @@
382 from launchpadlib.uris import EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT
383 OAUTH_REALM = 'https://api.launchpad.net'
384
385-EXPLOSIVE_ERRORS = (MemoryError, KeyboardInterrupt, SystemExit)
386-
387-def serialize_credentials(credentials):
388- # Get the credentials as a string.
389- sio = StringIO()
390- credentials.save(sio)
391- return sio.getvalue()
392-
393-
394-def unserialize_credentials(value):
395- # Get the credentials as a string.
396- credentials = Credentials()
397- credentials.load(StringIO(value))
398- return credentials
399-
400
401 class PersonSet(CollectionWithKeyBasedLookup):
402 """A custom subclass capable of person lookup by username."""
403@@ -124,6 +107,40 @@
404 collection_of = 'distribution'
405
406
407+class LaunchpadOAuthAwareHttp(RestfulHttp):
408+ """Detects expired/invalid OAuth tokens and tries to get a new token."""
409+
410+ def __init__(self, launchpad, authorization_engine, *args):
411+ self.launchpad = launchpad
412+ self.authorization_engine = authorization_engine
413+ super(LaunchpadOAuthAwareHttp, self).__init__(*args)
414+
415+ def _bad_oauth_token(self, response, content):
416+ """Helper method to detect an error caused by a bad OAuth token."""
417+ return (response.status == 401 and
418+ (content.startswith("Expired token")
419+ or content.startswith("Invalid token")))
420+
421+ def _request(self, *args):
422+ response, content = super(
423+ LaunchpadOAuthAwareHttp, self)._request(*args)
424+ return self.retry_on_bad_token(response, content, *args)
425+
426+ def retry_on_bad_token(self, response, content, *args):
427+ """If the response indicates a bad token, get a new token and retry.
428+
429+ Otherwise, just return the response.
430+ """
431+ if (self._bad_oauth_token(response, content)
432+ and self.authorization_engine is not None):
433+ # This access token is bad. Scrap it and create a new one.
434+ self.launchpad.credentials.access_token = None
435+ self.authorization_engine(self.launchpad.credentials)
436+ # Retry the request with the new credentials.
437+ return self._request(*args)
438+ return response, content
439+
440+
441 class Launchpad(ServiceRoot):
442 """Root Launchpad API class.
443
444@@ -142,13 +159,20 @@
445 }
446 RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES)
447
448- def __init__(self, credentials, service_root=uris.STAGING_SERVICE_ROOT,
449+ def __init__(self, credentials, authorization_engine,
450+ service_root=uris.STAGING_SERVICE_ROOT,
451 cache=None, timeout=None, proxy_info=None,
452 version=DEFAULT_VERSION):
453 """Root access to the Launchpad API.
454
455 :param credentials: The credentials used to access Launchpad.
456 :type credentials: `Credentials`
457+ :param authorization_engine: The object used to get end-user input
458+ for authorizing OAuth request tokens. Used when an OAuth
459+ access token expires or becomes invalid during a
460+ session, or is discovered to be invalid once launchpadlib
461+ starts up.
462+ :type authorization_engine: `RequestTokenAuthorizationEngine`
463 :param service_root: The URL to the root of the web service.
464 :type service_root: string
465 """
466@@ -162,15 +186,31 @@
467 "the version name from the root URI." % version)
468 raise ValueError(error)
469
470+ # We already have an access token, but it might expire or
471+ # become invalid during use. Store the authorization engine in
472+ # case we need to authorize a new token during use.
473+ self.authorization_engine = authorization_engine
474+
475 super(Launchpad, self).__init__(
476 credentials, service_root, cache, timeout, proxy_info, version)
477
478+ def httpFactory(self, credentials, cache, timeout, proxy_info):
479+ return LaunchpadOAuthAwareHttp(
480+ self, self.authorization_engine, credentials, cache, timeout,
481+ proxy_info)
482+
483+ @classmethod
484+ def authorization_engine_factory(cls, *args):
485+ return AuthorizeRequestTokenWithBrowser(*args)
486+
487 @classmethod
488 def login(cls, consumer_name, token_string, access_secret,
489 service_root=uris.STAGING_SERVICE_ROOT,
490 cache=None, timeout=None, proxy_info=None,
491+ authorization_engine=None, allow_access_levels=None,
492+ max_failed_attempts=None, credential_save_failed=None,
493 version=DEFAULT_VERSION):
494- """Convenience for setting up access credentials.
495+ """Convenience method for setting up access credentials.
496
497 When all three pieces of credential information (the consumer
498 name, the access token and the access secret) are available, this
499@@ -186,70 +226,80 @@
500 :type access_secret: string
501 :param service_root: The URL to the root of the web service.
502 :type service_root: string
503+ :param authorization_engine: See `Launchpad.__init__`. If you don't
504+ provide an authorization engine, a default engine will be
505+ constructed using your values for `service_root` and
506+ `credential_save_failed`.
507+ :param allow_access_levels: This argument is ignored, and only
508+ present to preserve backwards compatibility.
509+ :param max_failed_attempts: This argument is ignored, and only
510+ present to preserve backwards compatibility.
511 :return: The web service root
512 :rtype: `Launchpad`
513 """
514 access_token = AccessToken(token_string, access_secret)
515 credentials = Credentials(
516 consumer_name=consumer_name, access_token=access_token)
517- return cls(credentials, service_root, cache, timeout, proxy_info,
518- version)
519+ if authorization_engine is None:
520+ authorization_engine = cls.authorization_engine_factory(
521+ service_root, None, consumer_name, allow_access_levels,
522+ credential_save_failed)
523+ return cls(credentials, authorization_engine, service_root, cache,
524+ timeout, proxy_info, version)
525
526 @classmethod
527 def get_token_and_login(cls, consumer_name,
528 service_root=uris.STAGING_SERVICE_ROOT,
529 cache=None, timeout=None, proxy_info=None,
530- authorizer_class=AuthorizeRequestTokenWithBrowser,
531- allow_access_levels=[], max_failed_attempts=3,
532+ authorization_engine=None, allow_access_levels=[],
533+ max_failed_attempts=None,
534+ credential_save_failed=None,
535 version=DEFAULT_VERSION):
536 """Get credentials from Launchpad and log into the service root.
537
538- This is a convenience method which will open up the user's preferred
539- web browser and thus should not be used by most applications.
540- Applications should, instead, use Credentials.get_request_token() to
541- obtain the authorization URL and
542- Credentials.exchange_request_token_for_access_token() to obtain the
543- actual OAuth access token.
544-
545- This method will negotiate an OAuth access token with the service
546- provider, but to complete it we will need the user to log into
547- Launchpad and authorize us, so we'll open the authorization page in
548- a web browser and ask the user to come back here and tell us when they
549- finished the authorization process.
550+ This method is deprecated as of launchpadlib version 1.9.0. A
551+ launchpadlib application running on the end-user's computer
552+ should use `Launchpad.login_with()`. A launchpadlib
553+ application running on a web server, integrating Launchpad
554+ into some other website, should use
555+ `Credentials.get_request_token()` to obtain the authorization
556+ URL and
557+ `Credentials.exchange_request_token_for_access_token()` to
558+ obtain the actual OAuth access token. Then you can call
559+ `Launchpad.login()`.
560
561 :param consumer_name: Either a consumer name, as appropriate for
562 the `Consumer` constructor, or a premade Consumer object.
563 :type consumer_name: string
564 :param service_root: The URL to the root of the web service.
565 :type service_root: string
566+ :param authorization_engine: See `Launchpad.__init__`. If you don't
567+ provide an authorization engine, a default engine will be
568+ constructed using your values for `service_root` and
569+ `credential_save_failed`.
570+ :param allow_access_levels: This argument is ignored, and only
571+ present to preserve backwards compatibility.
572+ :param max_failed_attempts: This argument is ignored, and only
573+ present to preserve backwards compatibility.
574 :return: The web service root
575 :rtype: `Launchpad`
576 """
577 if isinstance(consumer_name, Consumer):
578- # Create the authorizer with no Consumer, then set the Consumer
579- # object directly.
580+ # Create the credentials with no Consumer, then set its .consumer
581+ # property directly.
582 credentials = Credentials(None)
583 credentials.consumer = consumer_name
584 else:
585- # Have the authorizer create the Consumer itself.
586- credentials = Credentials(consumer_name_or_consumer)
587- service_root = uris.lookup_service_root(service_root)
588- web_root_uri = URI(service_root)
589- web_root_uri.path = ""
590- web_root_uri.host = web_root_uri.host.replace("api.", "", 1)
591- web_root = str(web_root_uri.ensureSlash())
592- authorization_json = credentials.get_request_token(
593- web_root=web_root, token_format=Credentials.DICT_TOKEN_FORMAT)
594- authorizer = authorizer_class(
595- web_root, authorization_json['oauth_token_consumer'],
596- authorization_json['oauth_token'], allow_access_levels,
597- max_failed_attempts)
598- authorizer(credentials, web_root)
599- if credentials.access_token is None:
600- # The end-user refused to authorize the application.
601- return None
602- return cls(credentials, service_root, cache, timeout, proxy_info,
603- version)
604+ # Have the Credentials constructor create the Consumer
605+ # automatically.
606+ credentials = Credentials(consumer_name)
607+ if authorization_engine is None:
608+ authorization_engine = cls.authorization_engine_factory(
609+ service_root, None, consumer_name, allow_access_levels,
610+ credential_save_failed)
611+ credentials = authorization_engine(credentials)
612+ return cls(credentials, authorization_engine, service_root, cache,
613+ timeout, proxy_info, version)
614
615 @classmethod
616 def login_anonymously(
617@@ -261,32 +311,34 @@
618 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
619 token = AnonymousAccessToken()
620 credentials = Credentials(consumer_name, access_token=token)
621- return cls(credentials, service_root=service_root, cache=cache_path,
622- timeout=timeout, proxy_info=proxy_info, version=version)
623+ return cls(credentials, None, service_root=service_root,
624+ cache=cache_path, timeout=timeout, proxy_info=proxy_info,
625+ version=version)
626
627 @classmethod
628- def login_with(cls, application_name,
629+ def login_with(cls, application_name=None,
630 service_root=uris.STAGING_SERVICE_ROOT,
631 launchpadlib_dir=None, timeout=None, proxy_info=None,
632- authorizer_class=AuthorizeRequestTokenWithBrowser,
633- allow_access_levels=[], max_failed_attempts=3,
634- credentials_file=None, version=DEFAULT_VERSION,
635- consumer_name=None, credential_save_failed=None):
636+ authorization_engine=None, allow_access_levels=None,
637+ max_failed_attempts=None, credentials_file=None,
638+ version=DEFAULT_VERSION, consumer_name=None,
639+ credential_save_failed=None):
640 """Log in to Launchpad with possibly cached credentials.
641
642- This is a convenience method for either setting up new login
643- credentials, or re-using existing ones. When a login token is
644- generated using this method, the resulting credentials will be stored
645- in a system keyring if available or on disk if not.
646-
647- Subsequent calls to this method will load the credentials from one of
648- the aformentioned locations in the same priority order.
649-
650- `launchpadlib_dir` is also used for caching fetched objects. The cache
651- is per service root, and shared by all consumers.
652-
653- See `Launchpad.get_token_and_login()` for more information about
654- how new tokens are generated.
655+ Use this method to get a `Launchpad` object if you are writing
656+ a desktop application or a script. If the end-user has no
657+ cached Launchpad credential, their browser will open and
658+ they'll be asked to log in and authorize a desktop
659+ integration. The authorized Launchpad credential will be
660+ stored, so that the next time your program (or any other
661+ program run by that user on the same computer) needs a
662+ Launchpad credential, it will be retrieved from local storage.
663+
664+ By subclassing `RequestTokenAuthorizationEngine` and passing
665+ in an instance of the subclass as `authorization_engine`, you
666+ can change what happens when the end-user needs to authorize
667+ the Launchpad credential, and you can change how the
668+ authorized credential is stored and retrieved locally.
669
670 :param application_name: The application name. This is *not*
671 the OAuth consumer name. Unless a consumer_name is also
672@@ -294,89 +346,128 @@
673 consumer representing the end-user's computer as a whole.
674 :type application_name: string
675
676- :param credential_save_failed: a callback that is called if saving the
677- credentials in the auto-detected back-end (keyring or file) failed.
678-
679- :param consumer_name: The consumer name, as appropriate for the
680- `Consumer` constructor
681- :type consumer_name: string
682 :param service_root: The URL to the root of the web service.
683 :type service_root: string. Can either be the full URL to a service
684 or one of the short service names.
685- :param launchpadlib_dir: The directory where the cache and
686- credentials are stored.
687+
688+ :param launchpadlib_dir: The directory used to store cached
689+ data obtained from Launchpad. The cache is shared by all
690+ consumers, and each Launchpad service root has its own
691+ cache.
692 :type launchpadlib_dir: string
693+
694+ :param authorization_engine: A strategy for getting the end-user to
695+ authorize an OAuth request token, for exchanging the
696+ request token for an access token, and for storing the
697+ access token locally so that it can be reused.
698+ :type authorization_engine: `RequestTokenAuthorizationEngine`
699+
700+ :param credential_save_failed: a callback that is called upon
701+ a failure to save the credentials locally. This argument is
702+ used to construct the default `authorization_engine`, so if
703+ you pass in your own `authorization_engine` any value for
704+ this argument will be ignored.
705+ :type credential_save_failed: A callable
706+
707+ :param consumer_name: The consumer name, as appropriate for
708+ the `Consumer` constructor. You probably don't want to
709+ provide this, since providing it will prevent you from
710+ taking advantage of desktop-wide integration.
711+ :type consumer_name: string
712+
713 :param allow_access_levels: The acceptable access levels for
714- this application. This is ignored unless you specify a
715- consumer_name. All applications using the default
716- (desktop-wide) consumer name will ask for "desktop integration"
717- access, which gives read-write access to public and private data.
718+ this application.
719+
720+ This argument is used to construct the default
721+ `authorization_engine`, so if you pass in your own
722+ `authorization_engine` any value for this argument will be
723+ ignored. This argument will also be ignored unless you
724+ also specify `consumer_name`.
725+
726 :type allow_access_levels: list of strings
727+
728 :param credentials_file: ignored, only here for backward compatability
729 :type credentials_file: string
730
731- :param consumer_name: The OAuth consumer name. In most cases,
732- this is not necessary. You should only use this if you
733- don't want to take advantage of desktop-wide integration.
734- :type consumer_name: string
735-
736- :return: The web service root
737+ :return: A web service root authorized as the end-user.
738 :rtype: `Launchpad`
739
740 """
741- if consumer_name is None:
742- # System-wide integration. Create a system-wide consumer
743- # and identify the application using a separate
744- # application name.
745- allow_access_levels = ["DESKTOP_INTEGRATION"]
746- consumer = SystemWideConsumer(application_name)
747- consumer_name = consumer.key
748- else:
749- # Application-specific integration. Use the provided
750- # consumer name to create a consumer automatically.
751- consumer = consumer_name
752-
753 (service_root, launchpadlib_dir, cache_path,
754 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
755
756- credentials = keyring.get_password(
757- 'launchpadlib', consumer_name + '@' + service_root)
758- if credentials is not None:
759- credentials = unserialize_credentials(credentials)
760-
761- if credentials is not None:
762- # We loaded credentials from a file, but the application
763- # name wasn't stored in the file, because a single set of
764- # credentials may be shared by many applications. We need
765- # to set the application name for this specific instance
766- # of the credentials.
767- credentials.consumer.application_name = application_name
768+ if (application_name is None and consumer_name is None and
769+ authorization_engine is None):
770+ raise ValueError(
771+ "At least one of application_name, consumer_name, or "
772+ "authorization_engine must be provided.")
773+
774+ if authorization_engine is None:
775+ authorization_engine = cls.authorization_engine_factory(
776+ service_root, application_name, consumer_name,
777+ credential_save_failed, allow_access_levels)
778+ else:
779+ # An authorization engine was passed in, so we won't be
780+ # using any provided values for application_name,
781+ # consumer_name, or allow_access_levels. But at least make
782+ # sure we weren't given conflicting values, since that
783+ # makes the calling code look confusing.
784+ cls._assert_login_with_argument_consistency(
785+ "application_name", application_name,
786+ authorization_engine.application_name)
787+
788+ cls._assert_login_with_argument_consistency(
789+ "consumer_name", consumer_name,
790+ authorization_engine.consumer.key)
791+
792+ cls._assert_login_with_argument_consistency(
793+ "allow_access_levels", allow_access_levels,
794+ authorization_engine.allow_access_levels)
795+
796+ credentials = authorization_engine.retrieve_credentials_from_local_store()
797
798 if credentials is None:
799+ # Credentials were not found in the local store. Go
800+ # through the authorization process.
801 launchpad = cls.get_token_and_login(
802- consumer, service_root=service_root, cache=cache_path,
803- timeout=timeout, proxy_info=proxy_info,
804- authorizer_class=authorizer_class,
805- allow_access_levels=allow_access_levels,
806- max_failed_attempts=max_failed_attempts, version=version)
807- # New credentials were created, so save them for future use.
808- if launchpad is not None:
809- try:
810- keyring.set_password(
811- 'launchpadlib', consumer_name + '@' + service_root,
812- serialize_credentials(launchpad.credentials))
813- except EXPLOSIVE_ERRORS:
814- raise
815- except:
816- if credential_save_failed is not None:
817- credential_save_failed()
818+ authorization_engine.consumer, service_root=service_root,
819+ cache=cache_path, timeout=timeout, proxy_info=proxy_info,
820+ authorization_engine=authorization_engine,
821+ version=version)
822 else:
823+ # Credentials were found in the local store. Create a
824+ # Launchpad object.
825 launchpad = cls(
826- credentials, service_root=service_root, cache=cache_path,
827- timeout=timeout, proxy_info=proxy_info, version=version)
828+ credentials, authorization_engine, service_root=service_root,
829+ cache=cache_path, timeout=timeout, proxy_info=proxy_info,
830+ version=version)
831 return launchpad
832
833 @classmethod
834+ def _assert_login_with_argument_consistency(
835+ cls, argument_name, argument_value, authorization_engine_value):
836+ """Helper to find conflicting values passed into login_with.
837+
838+ Many of the arguments to login_with are used to build an
839+ authorization engine. If an authorization engine is passed in,
840+ many of the arguments become redundant. We'll allow redundant
841+ arguments through, but if a login_with argument *conflicts* with
842+ the argument in the provided authorization engine, we raise an
843+ error.
844+ """
845+ inconsistent_value_message = (
846+ "Inconsistent values given for %s: "
847+ "(%r passed in to login_with(), versus %r in authorization "
848+ "engine). You don't need to pass in %s to login_with() if you "
849+ "pass in an authorization engine, so just omit that argument.")
850+ if (argument_value is not None
851+ and argument_value != authorization_engine_value):
852+ raise ValueError(inconsistent_value_message % (
853+ argument_name, argument_value, authorization_engine_value,
854+ argument_name))
855+
856+
857+ @classmethod
858 def _get_paths(cls, service_root, launchpadlib_dir=None):
859 """Locate launchpadlib-related user paths and ensure they exist.
860
861
862=== modified file 'src/launchpadlib/testing/helpers.py'
863--- src/launchpadlib/testing/helpers.py 2010-10-20 13:45:30 +0000
864+++ src/launchpadlib/testing/helpers.py 2010-12-20 17:49:10 +0000
865@@ -21,6 +21,8 @@
866
867 __metaclass__ = type
868 __all__ = [
869+ 'NoNetworkAuthorizationEngine',
870+ 'NoNetworkLaunchpad',
871 'TestableLaunchpad',
872 'nopriv_read_nonprivate',
873 'salgado_read_nonprivate',
874@@ -31,8 +33,10 @@
875
876 from launchpadlib.launchpad import Launchpad
877 from launchpadlib.credentials import (
878+ AccessToken,
879 AuthorizeRequestTokenWithBrowser,
880 Credentials,
881+ RequestTokenAuthorizationEngine,
882 )
883
884
885@@ -47,6 +51,59 @@
886 version=version)
887
888
889+class NoNetworkAuthorizationEngine(RequestTokenAuthorizationEngine):
890+ """An authorization engine that doesn't open a web browser.
891+
892+ You can use this to test the creation of Launchpad objects and the
893+ storing of credentials. You can't use it to interact with the web
894+ service, since it only pretends to authorize its OAuth request tokens.
895+ """
896+ ACCESS_TOKEN_KEY = "access_key:84"
897+
898+ def __init__(self, *args, **kwargs):
899+ super(NoNetworkAuthorizationEngine, self).__init__(*args, **kwargs)
900+ # Set up some instrumentation.
901+ self.request_tokens_obtained = 0
902+ self.access_tokens_obtained = 0
903+
904+ def get_request_token(self, credentials):
905+ """Pretend to get a request token from the server.
906+
907+ We do this by simply returning a static token ID.
908+ """
909+ self.request_tokens_obtained += 1
910+ return "request_token:42"
911+
912+ def make_end_user_authorize_token(self, credentials, request_token):
913+ """Pretend to exchange a request token for an access token.
914+
915+ We do this by simply setting the access_token property.
916+ """
917+ credentials.access_token = AccessToken(
918+ self.ACCESS_TOKEN_KEY, 'access_secret:168')
919+ self.access_tokens_obtained += 1
920+
921+
922+class NoNetworkLaunchpad(Launchpad):
923+ """A Launchpad instance for tests with no network access.
924+
925+ It's only useful for making sure that certain methods were called.
926+ It can't be used to interact with the API.
927+ """
928+
929+ def __init__(self, credentials, authorization_engine, service_root,
930+ cache, timeout, proxy_info, version):
931+ self.credentials = credentials
932+ self.authorization_engine = authorization_engine
933+ self.passed_in_args = dict(
934+ service_root=service_root, cache=cache, timeout=timeout,
935+ proxy_info=proxy_info, version=version)
936+
937+ @classmethod
938+ def authorization_engine_factory(cls, *args):
939+ return NoNetworkAuthorizationEngine(*args)
940+
941+
942 class KnownTokens:
943 """Known access token/secret combinations."""
944
945
946=== added file 'src/launchpadlib/tests/test_http.py'
947--- src/launchpadlib/tests/test_http.py 1970-01-01 00:00:00 +0000
948+++ src/launchpadlib/tests/test_http.py 2010-12-20 17:49:10 +0000
949@@ -0,0 +1,228 @@
950+# Copyright 2010 Canonical Ltd.
951+
952+# This file is part of launchpadlib.
953+#
954+# launchpadlib is free software: you can redistribute it and/or modify it
955+# under the terms of the GNU Lesser General Public License as published by the
956+# Free Software Foundation, version 3 of the License.
957+#
958+# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
959+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
960+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
961+# for more details.
962+#
963+# You should have received a copy of the GNU Lesser General Public License
964+# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
965+
966+"""Tests for the LaunchpadOAuthAwareHTTP class."""
967+
968+from collections import deque
969+import unittest
970+
971+from simplejson import dumps, JSONDecodeError
972+
973+from launchpadlib.errors import Unauthorized
974+from launchpadlib.launchpad import (
975+ Launchpad,
976+ LaunchpadOAuthAwareHttp,
977+ )
978+from launchpadlib.testing.helpers import (
979+ NoNetworkAuthorizationEngine,
980+ NoNetworkLaunchpad,
981+ )
982+
983+
984+# The simplest WADL that looks like a representation of the service root.
985+SIMPLE_WADL = '''<?xml version="1.0"?>
986+<application xmlns="http://research.sun.com/wadl/2006/10">
987+ <resources base="http://www.example.com/">
988+ <resource path="" type="#service-root"/>
989+ </resources>
990+
991+ <resource_type id="service-root">
992+ <method name="GET" id="service-root-get">
993+ <response>
994+ <representation href="#service-root-json"/>
995+ </response>
996+ </method>
997+ </resource_type>
998+
999+ <representation id="service-root-json" mediaType="application/json"/>
1000+</application>
1001+'''
1002+
1003+# The simplest JSON that looks like a representation of the service root.
1004+SIMPLE_JSON = dumps({})
1005+
1006+
1007+class Response:
1008+ """A fake HTTP response object."""
1009+ def __init__(self, status, content):
1010+ self.status = status
1011+ self.content = content
1012+
1013+
1014+class SimulatedResponsesHttp(LaunchpadOAuthAwareHttp):
1015+ """Responds to HTTP requests by shifting responses off a stack."""
1016+
1017+ def __init__(self, responses, *args):
1018+ """Constructor.
1019+
1020+ :param responses: A list of HttpResponse objects to use
1021+ in response to requests.
1022+ """
1023+ self.sent_responses = []
1024+ self.unsent_responses = responses
1025+ super(SimulatedResponsesHttp, self).__init__(*args)
1026+
1027+ def _request(self, *args):
1028+ response = self.unsent_responses.popleft()
1029+ self.sent_responses.append(response)
1030+ return self.retry_on_bad_token(response, response.content)
1031+
1032+
1033+class SimulatedResponsesLaunchpad(Launchpad):
1034+
1035+ # Every Http object generated by this class will return these
1036+ # responses, in order.
1037+ responses = []
1038+
1039+ def httpFactory(self, *args):
1040+ return SimulatedResponsesHttp(
1041+ deque(self.responses), self, self.authorization_engine, *args)
1042+
1043+
1044+class SimulatedResponsesTestCase(unittest.TestCase):
1045+ """Test cases that give fake responses to launchpad's HTTP requests."""
1046+
1047+ def setUp(self):
1048+ """Clear out the list of simulated responses."""
1049+ SimulatedResponsesLaunchpad.responses = []
1050+ self.engine = NoNetworkAuthorizationEngine(
1051+ 'http://api.example.com/', 'application name')
1052+
1053+ def launchpad_with_responses(self, *responses):
1054+ """Use simulated HTTP responses to get a Launchpad object.
1055+
1056+ The given Response objects will be sent, in order, in response
1057+ to launchpadlib's requests.
1058+
1059+ :param responses: Some number of Response objects.
1060+ :return: The Launchpad object, assuming that errors in the
1061+ simulated requests didn't prevent one from being created.
1062+ """
1063+ SimulatedResponsesLaunchpad.responses = responses
1064+ return SimulatedResponsesLaunchpad.login_with(
1065+ 'application name', authorization_engine=self.engine)
1066+
1067+
1068+class TestAbilityToParseData(SimulatedResponsesTestCase):
1069+ """Test launchpadlib's ability to handle the sample data.
1070+
1071+ To create a Launchpad object, two HTTP requests must succeed and
1072+ return usable data: the requests for the WADL and JSON
1073+ representations of the service root. This test shows that the
1074+ minimal data in SIMPLE_WADL and SIMPLE_JSON is good enough to
1075+ create a Launchpad object.
1076+ """
1077+
1078+ def test_minimal_data(self):
1079+ """Make sure that launchpadlib can use the minimal data."""
1080+ launchpad = self.launchpad_with_responses(
1081+ Response(200, SIMPLE_WADL),
1082+ Response(200, SIMPLE_JSON))
1083+
1084+ def test_bad_wadl(self):
1085+ """Show that bad WADL causes an exception."""
1086+ self.assertRaises(
1087+ SyntaxError, self.launchpad_with_responses,
1088+ Response(200, "This is not WADL."),
1089+ Response(200, SIMPLE_JSON))
1090+
1091+ def test_bad_json(self):
1092+ """Show that bad JSON causes an exception."""
1093+ self.assertRaises(
1094+ JSONDecodeError, self.launchpad_with_responses,
1095+ Response(200, SIMPLE_WADL),
1096+ Response(200, "This is not JSON."))
1097+
1098+
1099+class TestTokenFailureDuringRequest(SimulatedResponsesTestCase):
1100+ """Test access token failures during a request.
1101+
1102+ launchpadlib makes two HTTP requests on startup, to get the WADL
1103+ and JSON representations of the service root. If Launchpad
1104+ receives a 401 error during this process, it will acquire a fresh
1105+ access token and try again.
1106+ """
1107+
1108+ def test_good_token(self):
1109+ """If our token is good, we never get another one."""
1110+ SimulatedResponsesLaunchpad.responses = [
1111+ Response(200, SIMPLE_WADL),
1112+ Response(200, SIMPLE_JSON)]
1113+
1114+ self.assertEquals(self.engine.access_tokens_obtained, 0)
1115+ launchpad = SimulatedResponsesLaunchpad.login_with(
1116+ 'application name', authorization_engine=self.engine)
1117+ self.assertEquals(self.engine.access_tokens_obtained, 0)
1118+
1119+ def test_bad_token(self):
1120+ """If our token is bad, we get another one."""
1121+ SimulatedResponsesLaunchpad.responses = [
1122+ Response(401, "Invalid token."),
1123+ Response(200, SIMPLE_WADL),
1124+ Response(200, SIMPLE_JSON)]
1125+
1126+ self.assertEquals(self.engine.access_tokens_obtained, 0)
1127+ launchpad = SimulatedResponsesLaunchpad.login_with(
1128+ 'application name', authorization_engine=self.engine)
1129+ self.assertEquals(self.engine.access_tokens_obtained, 1)
1130+
1131+ def test_expired_token(self):
1132+ """If our token is expired, we get another one."""
1133+
1134+ SimulatedResponsesLaunchpad.responses = [
1135+ Response(401, "Expired token."),
1136+ Response(200, SIMPLE_WADL),
1137+ Response(200, SIMPLE_JSON)]
1138+
1139+ self.assertEquals(self.engine.access_tokens_obtained, 0)
1140+ launchpad = SimulatedResponsesLaunchpad.login_with(
1141+ 'application name', authorization_engine=self.engine)
1142+ self.assertEquals(self.engine.access_tokens_obtained, 1)
1143+
1144+ def test_delayed_error(self):
1145+ """We get another token no matter when the error happens."""
1146+ SimulatedResponsesLaunchpad.responses = [
1147+ Response(200, SIMPLE_WADL),
1148+ Response(401, "Expired token."),
1149+ Response(200, SIMPLE_JSON)]
1150+
1151+ self.assertEquals(self.engine.access_tokens_obtained, 0)
1152+ launchpad = SimulatedResponsesLaunchpad.login_with(
1153+ 'application name', authorization_engine=self.engine)
1154+ self.assertEquals(self.engine.access_tokens_obtained, 1)
1155+
1156+ def test_many_errors(self):
1157+ """We'll keep getting new tokens as long as tokens are the problem."""
1158+ SimulatedResponsesLaunchpad.responses = [
1159+ Response(401, "Invalid token."),
1160+ Response(200, SIMPLE_WADL),
1161+ Response(401, "Expired token."),
1162+ Response(401, "Invalid token."),
1163+ Response(200, SIMPLE_JSON)]
1164+ self.assertEquals(self.engine.access_tokens_obtained, 0)
1165+ launchpad = SimulatedResponsesLaunchpad.login_with(
1166+ 'application name', authorization_engine=self.engine)
1167+ self.assertEquals(self.engine.access_tokens_obtained, 3)
1168+
1169+ def test_other_unauthorized(self):
1170+ """If the token is not at fault, a 401 error raises an exception."""
1171+
1172+ SimulatedResponsesLaunchpad.responses = [
1173+ Response(401, "Some other error.")]
1174+
1175+ self.assertRaises(
1176+ Unauthorized, SimulatedResponsesLaunchpad.login_with,
1177+ 'application name', authorization_engine=self.engine)
1178
1179=== modified file 'src/launchpadlib/tests/test_launchpad.py'
1180--- src/launchpadlib/tests/test_launchpad.py 2010-11-01 20:18:48 +0000
1181+++ src/launchpadlib/tests/test_launchpad.py 2010-12-20 17:49:10 +0000
1182@@ -31,44 +31,19 @@
1183
1184 from launchpadlib.credentials import (
1185 AccessToken,
1186- AuthorizeRequestTokenWithBrowser,
1187 Credentials,
1188- SystemWideConsumer,
1189 )
1190-from launchpadlib.launchpad import Launchpad
1191+
1192 from launchpadlib import uris
1193 import launchpadlib.launchpad
1194-
1195-class NoNetworkLaunchpad(Launchpad):
1196- """A Launchpad instance for tests with no network access.
1197-
1198- It's only useful for making sure that certain methods were called.
1199- It can't be used to interact with the API.
1200- """
1201-
1202- consumer_name = None
1203- passed_in_kwargs = None
1204- credentials = None
1205- get_token_and_login_called = False
1206-
1207- def __init__(self, credentials, **kw):
1208- self.credentials = credentials
1209- self.passed_in_kwargs = kw
1210-
1211- @classmethod
1212- def get_token_and_login(cls, consumer, **kw):
1213- """Create fake credentials and record that we were called."""
1214- credentials = Credentials(
1215- consumer.key, consumer_secret='consumer_secret:42',
1216- access_token=AccessToken('access_key:84', 'access_secret:168'),
1217- application_name=consumer.application_name)
1218- launchpad = cls(credentials, **kw)
1219-
1220- launchpad.get_token_and_login_called = True
1221- launchpad.consumer = consumer
1222- launchpad.passed_in_kwargs = kw
1223- return launchpad
1224-
1225+from launchpadlib.launchpad import Launchpad
1226+from launchpadlib.testing.helpers import (
1227+ NoNetworkAuthorizationEngine,
1228+ NoNetworkLaunchpad,
1229+ )
1230+
1231+# A dummy service root for use in tests
1232+SERVICE_ROOT = "http://api.example.com/"
1233
1234 class TestResourceTypeClasses(unittest.TestCase):
1235 """launchpadlib must know about restfulclient's resource types."""
1236@@ -137,7 +112,7 @@
1237 version = "version-foo"
1238 root = uris.service_roots['staging'] + version
1239 try:
1240- Launchpad(None, root, version=version)
1241+ Launchpad(None, None, service_root=root, version=version)
1242 except ValueError, e:
1243 self.assertTrue(str(e).startswith(
1244 "It looks like you're using a service root that incorporates "
1245@@ -149,13 +124,15 @@
1246 # Make sure the problematic URL is caught even if it has a
1247 # slash on the end.
1248 root += '/'
1249- self.assertRaises(ValueError, Launchpad, None, root, version=version)
1250+ self.assertRaises(ValueError, Launchpad, None, None,
1251+ service_root=root, version=version)
1252
1253 # Test that the default version has the same problem
1254 # when no explicit version is specified
1255 default_version = NoNetworkLaunchpad.DEFAULT_VERSION
1256 root = uris.service_roots['staging'] + default_version + '/'
1257- self.assertRaises(ValueError, Launchpad, None, root)
1258+ self.assertRaises(ValueError, Launchpad, None, None,
1259+ service_root=root)
1260
1261
1262 class InMemoryKeyring:
1263@@ -171,6 +148,32 @@
1264 return self.data.get((service, username))
1265
1266
1267+class TestRequestTokenAuthorizationEngine(unittest.TestCase):
1268+ """Tests for the RequestTokenAuthorizationEngine class."""
1269+
1270+ def test_app_must_be_identified(self):
1271+ self.assertRaises(
1272+ ValueError, NoNetworkAuthorizationEngine, SERVICE_ROOT)
1273+
1274+ def test_application_name_identifies_app(self):
1275+ NoNetworkAuthorizationEngine(SERVICE_ROOT, application_name='name')
1276+
1277+ def test_consumer_name_identifies_app(self):
1278+ NoNetworkAuthorizationEngine(SERVICE_ROOT, consumer_name='name')
1279+
1280+ def test_conflicting_app_identification(self):
1281+ # You can't specify both application_name and consumer_name.
1282+ self.assertRaises(
1283+ ValueError, NoNetworkAuthorizationEngine,
1284+ SERVICE_ROOT, application_name='name1', consumer_name='name2')
1285+
1286+ # This holds true even if you specify the same value for
1287+ # both. They're not the same thing.
1288+ self.assertRaises(
1289+ ValueError, NoNetworkAuthorizationEngine,
1290+ SERVICE_ROOT, application_name='name', consumer_name='name')
1291+
1292+
1293 class TestLaunchpadLoginWith(unittest.TestCase):
1294 """Tests for Launchpad.login_with()."""
1295
1296@@ -178,21 +181,33 @@
1297 # For these tests we want to disable retrieving or storing credentials
1298 # in a system-provided keyring and use a dummy keyring implementation
1299 # instaed.
1300- self._saved_keyring = launchpadlib.launchpad.keyring
1301- launchpadlib.launchpad.keyring = InMemoryKeyring()
1302+ self._saved_keyring = launchpadlib.credentials.keyring
1303+ launchpadlib.credentials.keyring = InMemoryKeyring()
1304 self.temp_dir = tempfile.mkdtemp()
1305
1306 def tearDown(self):
1307 # Restore the gnomekeyring module that we disabled in setUp.
1308- launchpadlib.launchpad.keyring = self._saved_keyring
1309+ launchpadlib.credentials.keyring = self._saved_keyring
1310 shutil.rmtree(self.temp_dir)
1311
1312+ def test_max_failed_attempts_accepted(self):
1313+ # You can pass in a value for the 'max_failed_attempts'
1314+ # argument, even though that argument doesn't do anything.
1315+ launchpad = NoNetworkLaunchpad.login_with(
1316+ 'not important', max_failed_attempts=5)
1317+
1318+ def test_credentials_file_accepted(self):
1319+ # You can pass in a value for the 'credentials_file'
1320+ # argument, even though that argument doesn't do anything.
1321+ launchpad = NoNetworkLaunchpad.login_with(
1322+ 'not important', credentials_file='foo')
1323+
1324 def test_dirs_created(self):
1325 # The path we pass into login_with() is the directory where
1326 # cache and credentials for all service roots are stored.
1327 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
1328 launchpad = NoNetworkLaunchpad.login_with(
1329- 'not important', service_root='http://api.example.com/',
1330+ 'not important', service_root=SERVICE_ROOT,
1331 launchpadlib_dir=launchpadlib_dir)
1332 # The 'launchpadlib' dir got created.
1333 self.assertTrue(os.path.isdir(launchpadlib_dir))
1334@@ -219,7 +234,7 @@
1335 mode = stat.S_IMODE(statinfo.st_mode)
1336 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
1337 launchpad = NoNetworkLaunchpad.login_with(
1338- 'not important', service_root='http://api.example.com/',
1339+ 'not important', service_root=SERVICE_ROOT,
1340 launchpadlib_dir=launchpadlib_dir)
1341 # Verify the mode has been changed to 0700
1342 statinfo = os.stat(launchpadlib_dir)
1343@@ -229,7 +244,7 @@
1344 def test_dirs_created_are_secure(self):
1345 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
1346 launchpad = NoNetworkLaunchpad.login_with(
1347- 'not important', service_root='http://api.example.com/',
1348+ 'not important', service_root=SERVICE_ROOT,
1349 launchpadlib_dir=launchpadlib_dir)
1350 self.assertTrue(os.path.isdir(launchpadlib_dir))
1351 # Verify the mode is safe
1352@@ -243,18 +258,18 @@
1353 # credentials will be cached to disk.
1354 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
1355 launchpad = NoNetworkLaunchpad.login_with(
1356- 'not important', service_root='http://api.example.com/',
1357+ 'not important', service_root=SERVICE_ROOT,
1358 launchpadlib_dir=launchpadlib_dir, version="foo")
1359- self.assertEquals(launchpad.passed_in_kwargs['version'], 'foo')
1360+ self.assertEquals(launchpad.passed_in_args['version'], 'foo')
1361
1362 # Now execute the same test a second time. This time, the
1363 # credentials are loaded from disk and a different code path
1364 # is executed. We want to make sure this code path propagates
1365 # the 'version' argument.
1366 launchpad = NoNetworkLaunchpad.login_with(
1367- 'not important', service_root='http://api.example.com/',
1368+ 'not important', service_root=SERVICE_ROOT,
1369 launchpadlib_dir=launchpadlib_dir, version="bar")
1370- self.assertEquals(launchpad.passed_in_kwargs['version'], 'bar')
1371+ self.assertEquals(launchpad.passed_in_args['version'], 'bar')
1372
1373 def test_application_name_is_propagated(self):
1374 # Create a Launchpad instance for a given application name.
1375@@ -263,7 +278,7 @@
1376 # single system-wide credential.
1377 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
1378 launchpad = NoNetworkLaunchpad.login_with(
1379- 'very important', service_root='http://api.example.com/',
1380+ 'very important', service_root=SERVICE_ROOT,
1381 launchpadlib_dir=launchpadlib_dir)
1382 self.assertEquals(
1383 launchpad.credentials.consumer.application_name, 'very important')
1384@@ -274,36 +289,112 @@
1385 # the application name, instead of picking an empty one from
1386 # disk.
1387 launchpad = NoNetworkLaunchpad.login_with(
1388- 'very important', service_root='http://api.example.com/',
1389+ 'very important', service_root=SERVICE_ROOT,
1390 launchpadlib_dir=launchpadlib_dir)
1391 self.assertEquals(
1392 launchpad.credentials.consumer.application_name, 'very important')
1393
1394- def test_no_credentials_calls_get_token_and_login(self):
1395- # If no credentials are found, get_token_and_login() is called.
1396- service_root = 'http://api.example.com/'
1397+ def test_authorization_engine_is_propagated(self):
1398+ # You can pass in a custom authorization engine, which will be
1399+ # used to get a request token and exchange it for an access
1400+ # token.
1401+ engine = NoNetworkAuthorizationEngine(
1402+ SERVICE_ROOT, 'application name')
1403+ launchpad = NoNetworkLaunchpad.login_with(
1404+ authorization_engine=engine)
1405+ self.assertEquals(engine.request_tokens_obtained, 1)
1406+ self.assertEquals(engine.access_tokens_obtained, 1)
1407+
1408+ def test_login_with_must_identify_application(self):
1409+ # If you call login_with without identifying your application
1410+ # you'll get an error.
1411+ self.assertRaises(ValueError, NoNetworkLaunchpad.login_with)
1412+
1413+ def test_application_name_identifies_app(self):
1414+ NoNetworkLaunchpad.login_with(application_name="name")
1415+
1416+ def test_consumer_name_identifies_app(self):
1417+ NoNetworkLaunchpad.login_with(consumer_name="name")
1418+
1419+ def test_inconsistent_application_name_rejected(self):
1420+ """Catch an attempt to specify inconsistent application_names."""
1421+ engine = NoNetworkAuthorizationEngine(
1422+ SERVICE_ROOT, 'application name1')
1423+ self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
1424+ "application name2",
1425+ authorization_engine=engine)
1426+
1427+ def test_inconsistent_consumer_name_rejected(self):
1428+ """Catch an attempt to specify inconsistent application_names."""
1429+ engine = NoNetworkAuthorizationEngine(
1430+ SERVICE_ROOT, None, consumer_name="consumer_name1")
1431+
1432+ self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
1433+ "consumer_name2",
1434+ authorization_engine=engine)
1435+
1436+ def test_inconsistent_allow_access_levels_rejected(self):
1437+ """Catch an attempt to specify inconsistent allow_access_levels."""
1438+ engine = NoNetworkAuthorizationEngine(
1439+ SERVICE_ROOT, consumer_name="consumer",
1440+ allow_access_levels=['FOO'])
1441+
1442+ self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
1443+ None, consumer_name="consumer",
1444+ allow_access_levels=['BAR'],
1445+ authorization_engine=engine)
1446+
1447+ def test_non_desktop_integration(self):
1448+ # When doing a non-desktop integration, you must specify a
1449+ # consumer_name. You can pass a list of allowable access
1450+ # levels into login_with().
1451+ launchpad = NoNetworkLaunchpad.login_with(
1452+ consumer_name="consumer", allow_access_levels=['FOO'])
1453+ self.assertEquals(launchpad.credentials.consumer.key, "consumer")
1454+ self.assertEquals(launchpad.credentials.consumer.application_name,
1455+ None)
1456+ self.assertEquals(launchpad.authorization_engine.allow_access_levels,
1457+ ['FOO'])
1458+
1459+ def test_desktop_integration_doesnt_happen_without_consumer_name(self):
1460+ # The only way to do a non-desktop integration is to specify a
1461+ # consumer_name. If you specify application_name instead, your
1462+ # value for allow_access_levels is ignored, and a desktop
1463+ # integration is performed.
1464+ launchpad = NoNetworkLaunchpad.login_with(
1465+ 'application name', allow_access_levels=['FOO'])
1466+ self.assertEquals(launchpad.authorization_engine.allow_access_levels,
1467+ ['DESKTOP_INTEGRATION'])
1468+
1469+ def test_no_credentials_creates_new_credential(self):
1470+ # If no credentials are found, a desktop-wide credential is created.
1471 timeout = object()
1472 proxy_info = object()
1473 launchpad = NoNetworkLaunchpad.login_with(
1474 'app name', launchpadlib_dir=self.temp_dir,
1475- service_root=service_root, timeout=timeout, proxy_info=proxy_info)
1476- self.assertEqual(launchpad.consumer.application_name, 'app name')
1477+ service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info)
1478+ # Here's the new credential.
1479+ self.assertEqual(launchpad.credentials.access_token.key,
1480+ NoNetworkAuthorizationEngine.ACCESS_TOKEN_KEY)
1481+ self.assertEqual(launchpad.credentials.consumer.application_name,
1482+ 'app name')
1483+ self.assertEquals(launchpad.authorization_engine.allow_access_levels,
1484+ ['DESKTOP_INTEGRATION'])
1485+ # The expected arguments were passed in to the Launchpad
1486+ # constructor.
1487 expected_arguments = dict(
1488- allow_access_levels=['DESKTOP_INTEGRATION'],
1489- authorizer_class=AuthorizeRequestTokenWithBrowser,
1490- max_failed_attempts=3,
1491- service_root=service_root,
1492- timeout=timeout,
1493- proxy_info=proxy_info,
1494+ service_root=SERVICE_ROOT,
1495 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'),
1496+ timeout=timeout,
1497+ proxy_info=proxy_info,
1498 version=NoNetworkLaunchpad.DEFAULT_VERSION)
1499- self.assertEqual(launchpad.passed_in_kwargs, expected_arguments)
1500+ self.assertEqual(launchpad.passed_in_args, expected_arguments)
1501
1502 def test_anonymous_login(self):
1503 """Test the anonymous login helper function."""
1504 launchpad = NoNetworkLaunchpad.login_anonymously(
1505 'anonymous access', launchpadlib_dir=self.temp_dir,
1506- service_root='http://api.example.com/')
1507+ service_root=SERVICE_ROOT)
1508 self.assertEqual(launchpad.credentials.access_token.key, '')
1509 self.assertEqual(launchpad.credentials.access_token.secret, '')
1510
1511@@ -325,22 +416,21 @@
1512 access_token=AccessToken('access_key:84', 'access_secret:168'))
1513 credentials.save_to_path(credentials_file_path)
1514
1515- service_root = 'http://api.example.com/'
1516 timeout = object()
1517 proxy_info = object()
1518 version = "foo"
1519 launchpad = NoNetworkLaunchpad.login_with(
1520 'app name', launchpadlib_dir=self.temp_dir,
1521- service_root=service_root, timeout=timeout, proxy_info=proxy_info,
1522+ service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info,
1523 version=version)
1524 expected_arguments = dict(
1525- service_root=service_root,
1526+ service_root=SERVICE_ROOT,
1527 timeout=timeout,
1528 proxy_info=proxy_info,
1529 version=version,
1530 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'))
1531 for key, expected in expected_arguments.items():
1532- actual = launchpad.passed_in_kwargs[key]
1533+ actual = launchpad.passed_in_args[key]
1534 self.assertEqual(actual, expected)
1535
1536 def test_None_launchpadlib_dir(self):
1537@@ -349,11 +439,11 @@
1538 old_home = os.environ['HOME']
1539 os.environ['HOME'] = self.temp_dir
1540 launchpad = NoNetworkLaunchpad.login_with(
1541- 'app name', service_root='http://api.example.com/')
1542+ 'app name', service_root=SERVICE_ROOT)
1543 # Reset the environment to the old value.
1544 os.environ['HOME'] = old_home
1545
1546- cache_dir = launchpad.passed_in_kwargs['cache']
1547+ cache_dir = launchpad.passed_in_args['cache']
1548 launchpadlib_dir = os.path.abspath(
1549 os.path.join(cache_dir, '..', '..'))
1550 self.assertEqual(
1551@@ -365,14 +455,14 @@
1552 # A short service name is converted to the full service root URL.
1553 launchpad = NoNetworkLaunchpad.login_with('app name', 'staging')
1554 self.assertEqual(
1555- launchpad.passed_in_kwargs['service_root'],
1556+ launchpad.passed_in_args['service_root'],
1557 'https://api.staging.launchpad.net/')
1558
1559 # A full URL as the service name is left alone.
1560 launchpad = NoNetworkLaunchpad.login_with(
1561 'app name', uris.service_roots['staging'])
1562 self.assertEqual(
1563- launchpad.passed_in_kwargs['service_root'],
1564+ launchpad.passed_in_args['service_root'],
1565 uris.service_roots['staging'])
1566
1567 # A short service name that does not match one of the
1568@@ -402,10 +492,10 @@
1569
1570 @contextmanager
1571 def fake_keyring(fake):
1572- original_keyring = launchpadlib.launchpad.keyring
1573- launchpadlib.launchpad.keyring = fake
1574+ original_keyring = launchpadlib.credentials.keyring
1575+ launchpadlib.credentials.keyring = fake
1576 yield
1577- launchpadlib.launchpad.keyring = original_keyring
1578+ launchpadlib.credentials.keyring = original_keyring
1579
1580
1581 class TestCredenitialSaveFailedCallback(unittest.TestCase):
1582@@ -434,9 +524,10 @@
1583 callback_called.append(None)
1584
1585 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
1586+ service_root = "http://api.example.com/"
1587 with fake_keyring(BadSaveKeyring()):
1588 NoNetworkLaunchpad.login_with(
1589- 'not important', service_root='http://api.example.com/',
1590+ 'not important', service_root=service_root,
1591 launchpadlib_dir=launchpadlib_dir,
1592 credential_save_failed=callback)
1593 self.assertEquals(len(callback_called), 1)
1594
1595=== modified file 'src/launchpadlib/uris.py'
1596--- src/launchpadlib/uris.py 2010-10-27 21:22:29 +0000
1597+++ src/launchpadlib/uris.py 2010-12-20 17:49:10 +0000
1598@@ -24,9 +24,11 @@
1599 __all__ = [
1600 'lookup_service_root',
1601 'lookup_web_root',
1602+ 'web_root_for_service_root',
1603 ]
1604
1605 from urlparse import urlparse
1606+from lazr.uri import URI
1607
1608 LPNET_SERVICE_ROOT = 'https://api.launchpad.net/'
1609 EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/'
1610@@ -101,3 +103,15 @@
1611 """
1612 return _dereference_alias(web_root, web_roots)
1613
1614+
1615+def web_root_for_service_root(service_root):
1616+ """Turn a service root URL into a web root URL.
1617+
1618+ This is done heuristically, not with a lookup.
1619+ """
1620+ service_root = lookup_service_root(service_root)
1621+ web_root_uri = URI(service_root)
1622+ web_root_uri.path = ""
1623+ web_root_uri.host = web_root_uri.host.replace("api.", "", 1)
1624+ web_root = str(web_root_uri.ensureSlash())
1625+ return web_root

Subscribers

People subscribed via source and target branches