Merge lp:~leonardr/launchpadlib/retry-on-invalid-token into lp:launchpadlib
- retry-on-invalid-token
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Edwin Grubbs (community) | code | Approve | |
Benji York (community) | code* | Approve | |
Review via email: mp+43784@code.launchpad.net |
Commit message
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 RequestTokenAut
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 LaunchpadOAuthA
I got all the existing tests to work and added several new tests to clarify the behavior of Launchpad.
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.
Benji York (benji) wrote : | # |
Benji York (benji) wrote : | # |
Here's the rest of my review:
src/launchpadli
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_
cause their code to generate an error.
src/launchpadli
allow_
src/launchpadli
Since max_failed_attempts is ignored, maybe None would be a better default
value.
src/launchpadli
This note on the allow_access_levels parameter
This argument is used to construct the default `authorization_
so if you pass in your own `authorization_
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
authorizati
src/launchpadli
A line over 78 characters wide.
src/launchpadli
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.
- 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.
Leonard Richardson (leonardr) wrote : | # |
Here's an incremental diff: http://
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 LaunchpadOauthA
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://
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.
Edwin Grubbs (edwin-grubbs) wrote : | # |
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/launchpadl
>--- src/launchpadli
>+++ src/launchpadli
>@@ -124,6 +107,40 @@
> collection_of = 'distribution'
>
>
>+class LaunchpadOAuthA
>+ """Detects expired/invalid OAuth tokens and tries to get a new token."""
>+
>+ def __init__(self, launchpad, authorization_
>+ self.launchpad = launchpad
>+ self.authorizat
>+ super(Launchpad
>+
>+ def _bad_oauth_
>+ """Helper method to detect an error caused by a bad OAuth token."""
>+ return (response.status == 401 and
>+ (content.
>+ or content.
>+
>+ def _request(self, *args):
>+ response, content = super(
>+ LaunchpadOAuthA
>+ return self.retry_
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_
>+ """If the response indicates a bad token, get a new token and retry.
>+
>+ Otherwise, just return the response.
>+ """
>+ if (self._
>+ and self.authorizat
>+ # This access token is bad. Scrap it and create a new one.
>+ self.launchpad.
>+ self.authorizat
>+ # Retry the request with the new credentials.
>+ return self._request(
>+ return response, content
>+
>+
> class Launchpad(
> """Root Launchpad API class.
>
>=== added file 'src/launchpadl
>--- src/launchpadli
>+++ src/launchpadli
>+class TestAbilityToPa
>+ """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_
>+ """Make sure that launchpadlib can use the minimal data."""
>+ launchpad = self.launchpad_
>+ Response(200, SIMPLE_WADL),
>+ Response(200, SIMPLE_JSON))
>+
>+ def test_bad_
>+ """Show that bad WADL causes an exception."""
>+ sel...
- 122. By Leonard Richardson
-
Split out tests at Edwin's request.
Leonard Richardson (leonardr) wrote : | # |
> >+ def _request(self, *args):
> >+ response, content = super(
> >+ LaunchpadOAuthA
> >+ return self.retry_
>
>
>
> 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
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 |
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/launchpadli b/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/launchpadli b/credentials. py line 215:
The RequestTokenAut horizationEngin e interface has changed in what appear incompatible ways. Since this class is exported in
to be backward-
__all__, I assume it is a public interface that others may depend on. Is
this the case?
The same goes for AuthorizeReques tTokenWithBrows er and other subclasses.
src/launchpadli b/credentials. py line 231:
Mutable default arguments ("allow_ access_ levels= []") are a bug magnet. I
suggest using an empty tuple instead.
src/launchpadli b/credentials. py line 231:
Is there a reason why some of the __init__ parameters are documented but
others (like consumer_name) are not?
src/launchpadli b/credentials. py line 257:
Should it be an error to provide a value for allow_access_levels and not a access_ levels value to be discarded.
value for consumer_name because that will result in the caller-provided
allow_
src/launchpadli b/credentials. py line 264:
Similar to the above, should it be an error to provide a value for _name and value for consumer_name because that will result in
application
the caller-provided application_name value to be discarded.
src/launchpadli b/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/launchpadli b/credentials. py line 343:
Minor: The new store_credentia ls_locally( ) and credentials_ from_local_ store() method names might could be more credentials_ to_local_ store" (I have to
retrieve_
symmetric. Perhaps "store_
admit that I don't like that name much either).
src/launchpadli b/credentials. py line 389:
It isn't part of your changes, but the WAITING_FOR_USER definition is one
very long line.