Merge lp:~leonardr/launchpadlib/launchpad-integration into lp:launchpadlib
- launchpad-integration
- Merge into trunk
Proposed by
Leonard Richardson
Status: | Merged |
---|---|
Merged at revision: | 103 |
Proposed branch: | lp:~leonardr/launchpadlib/launchpad-integration |
Merge into: | lp:launchpadlib |
Diff against target: |
1408 lines (+663/-260) 9 files modified
src/launchpadlib/NEWS.txt (+7/-0) src/launchpadlib/__init__.py (+1/-1) src/launchpadlib/credentials.py (+139/-67) src/launchpadlib/docs/introduction.txt (+0/-11) src/launchpadlib/launchpad.py (+200/-95) src/launchpadlib/testing/helpers.py (+77/-20) src/launchpadlib/tests/test_credential_store.py (+138/-0) src/launchpadlib/tests/test_http.py (+3/-2) src/launchpadlib/tests/test_launchpad.py (+98/-64) |
To merge this branch: | bzr merge lp:~leonardr/launchpadlib/launchpad-integration |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Māris Fogels (community) | Approve | ||
Review via email: mp+45176@code.launchpad.net |
Commit message
Description of the change
This branch makes some minor changes to launchpadlib that I made while integrating my branch https:/
* Bumped the version number.
* Removed a doctest of login(), since the method is deprecated (and it now has a unit test).
* Made the TestableLaunchpad class provide default values for credential_store and authorization_
* Changed the KnownTokens convenience class to pass in a Credentials object into the TestableLaunchpad constructor, rather than using the now-deprecated login() method.
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote : | # |
Good catch. I've changed the behavior to raise the exception if credential_
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-20 12:41:52 +0000 |
3 | +++ src/launchpadlib/NEWS.txt 2011-01-04 21:27:13 +0000 |
4 | @@ -11,6 +11,13 @@ |
5 | |
6 | - The HTML generated by wadl-to-refhtml.xsl now validates. |
7 | |
8 | +- Most of the helper login methods have been deprecated. There are now |
9 | + only two helper methods: |
10 | + |
11 | + * Launchpad.login_anonymously, for anonymous credential-free access. |
12 | + * Launchpad.login_with, for programs that need a credential. |
13 | + |
14 | + |
15 | 1.8.0 (2010-11-15) |
16 | ================== |
17 | |
18 | |
19 | === modified file 'src/launchpadlib/__init__.py' |
20 | --- src/launchpadlib/__init__.py 2010-12-02 14:59:10 +0000 |
21 | +++ src/launchpadlib/__init__.py 2011-01-04 21:27:13 +0000 |
22 | @@ -14,4 +14,4 @@ |
23 | # You should have received a copy of the GNU Lesser General Public License |
24 | # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
25 | |
26 | -__version__ = '1.8.1' |
27 | +__version__ = '1.9.0' |
28 | |
29 | === modified file 'src/launchpadlib/credentials.py' |
30 | --- src/launchpadlib/credentials.py 2010-12-16 22:33:31 +0000 |
31 | +++ src/launchpadlib/credentials.py 2011-01-04 21:27:13 +0000 |
32 | @@ -21,19 +21,19 @@ |
33 | 'AccessToken', |
34 | 'AnonymousAccessToken', |
35 | 'AuthorizeRequestTokenWithBrowser', |
36 | + 'CredentialStore', |
37 | 'RequestTokenAuthorizationEngine', |
38 | 'Consumer', |
39 | 'Credentials', |
40 | ] |
41 | |
42 | -import base64 |
43 | import cgi |
44 | from cStringIO import StringIO |
45 | import httplib2 |
46 | -import sys |
47 | -import textwrap |
48 | +import os |
49 | +import stat |
50 | import time |
51 | -from urllib import urlencode, quote |
52 | +from urllib import urlencode |
53 | from urlparse import urljoin |
54 | import webbrowser |
55 | |
56 | @@ -212,6 +212,122 @@ |
57 | super(AnonymousAccessToken, self).__init__('','') |
58 | |
59 | |
60 | +class CredentialStore(object): |
61 | + """Store OAuth credentials locally. |
62 | + |
63 | + This is a generic superclass. To implement a specific way of |
64 | + storing credentials locally you'll need to subclass this class, |
65 | + and implement `do_save` and `do_load`. |
66 | + """ |
67 | + |
68 | + def __init__(self, credential_save_failed=None): |
69 | + """Constructor. |
70 | + |
71 | + :param credential_save_failed: A callback to be invoked if the |
72 | + save to local storage fails. You should never invoke this |
73 | + callback yourself! Instead, you should raise an exception |
74 | + from do_save(). |
75 | + """ |
76 | + self.credential_save_failed = credential_save_failed |
77 | + |
78 | + def save(self, credentials, unique_consumer_id): |
79 | + """Save the credentials and invoke the callback on failure.""" |
80 | + try: |
81 | + self.do_save(credentials, unique_consumer_id) |
82 | + except EXPLOSIVE_ERRORS: |
83 | + raise |
84 | + except: |
85 | + if self.credential_save_failed is not None: |
86 | + self.credential_save_failed() |
87 | + return credentials |
88 | + |
89 | + def do_save(self, credentials, unique_consumer_id): |
90 | + """Store newly-authorized credentials locally for later use. |
91 | + |
92 | + :param credentials: A Credentials object to save. |
93 | + :param unique_consumer_id: A string uniquely identifying an |
94 | + OAuth consumer on a Launchpad instance. |
95 | + """ |
96 | + raise NotImplementedError() |
97 | + |
98 | + def load(self, unique_key): |
99 | + """Retrieve credentials from a local store. |
100 | + |
101 | + This method is the inverse of `save`. |
102 | + |
103 | + There's no special behavior in this method--it just calls |
104 | + `do_load`. There _is_ special behavior in `save`, and this |
105 | + way, developers can remember to implement `do_save` and |
106 | + `do_load`, not `do_save` and `load`. |
107 | + |
108 | + :param unique_key: A string uniquely identifying an OAuth consumer |
109 | + on a Launchpad instance. |
110 | + |
111 | + :return: A `Credentials` object if one is found in the local |
112 | + store, and None otherise. |
113 | + """ |
114 | + return self.do_load(unique_key) |
115 | + |
116 | + def do_load(self, unique_key): |
117 | + """Retrieve credentials from a local store. |
118 | + |
119 | + This method is the inverse of `do_save`. |
120 | + |
121 | + :param unique_key: A string uniquely identifying an OAuth consumer |
122 | + on a Launchpad instance. |
123 | + |
124 | + :return: A `Credentials` object if one is found in the local |
125 | + store, and None otherise. |
126 | + """ |
127 | + raise NotImplementedError() |
128 | + |
129 | + |
130 | +class KeyringCredentialStore(CredentialStore): |
131 | + """Store credentials in the GNOME keyring or KDE wallet. |
132 | + |
133 | + This is a good solution for desktop applications and interactive |
134 | + scripts. It doesn't work for non-interactive scripts, or for |
135 | + integrating third-party websites into Launchpad. |
136 | + """ |
137 | + |
138 | + def do_save(self, credentials, unique_key): |
139 | + """Store newly-authorized credentials in the keyring.""" |
140 | + keyring.set_password( |
141 | + 'launchpadlib', unique_key, credentials.serialize()) |
142 | + |
143 | + def do_load(self, unique_key): |
144 | + """Retrieve credentials from the keyring.""" |
145 | + credential_string = keyring.get_password( |
146 | + 'launchpadlib', unique_key) |
147 | + if credential_string is not None: |
148 | + return Credentials.from_string(credential_string) |
149 | + return None |
150 | + |
151 | + |
152 | +class UnencryptedFileCredentialStore(CredentialStore): |
153 | + """Store credentials unencrypted in a file on disk. |
154 | + |
155 | + This is a good solution for scripts that need to run without any |
156 | + user interaction. |
157 | + """ |
158 | + |
159 | + def __init__(self, filename, credential_save_failed=None): |
160 | + super(UnencryptedFileCredentialStore, self).__init__( |
161 | + credential_save_failed) |
162 | + self.filename = filename |
163 | + |
164 | + def do_save(self, credentials, unique_key): |
165 | + """Save the credentials to disk.""" |
166 | + credentials.save_to_path(self.filename) |
167 | + |
168 | + def do_load(self, unique_key): |
169 | + """Load the credentials from disk.""" |
170 | + if (os.path.exists(self.filename) |
171 | + and not os.stat(self.filename)[stat.ST_SIZE] == 0): |
172 | + return Credentials.load_from_path(self.filename) |
173 | + return None |
174 | + |
175 | + |
176 | class RequestTokenAuthorizationEngine(object): |
177 | """The superclass of all request token authorizers. |
178 | |
179 | @@ -219,18 +335,12 @@ |
180 | since that varies depending on how you want the end-user to |
181 | authorize a request token. You'll need to subclass this class and |
182 | implement `make_end_user_authorize_token`. |
183 | - |
184 | - This class does implement a default strategy for storing |
185 | - authorized tokens in the GNOME keyring or KDE Wallet, but you can |
186 | - override this by implementing `store_credentials_locally` and |
187 | - `retrieve_credentials_from_local_store`. |
188 | """ |
189 | |
190 | UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED" |
191 | |
192 | def __init__(self, service_root, application_name=None, |
193 | - consumer_name=None, credential_save_failed=None, |
194 | - allow_access_levels=None): |
195 | + consumer_name=None, allow_access_levels=None): |
196 | """Base class initialization. |
197 | |
198 | :param service_root: The root of the Launchpad instance being |
199 | @@ -250,12 +360,6 @@ |
200 | integration. The exception is when you're integrating a |
201 | third-party website into Launchpad. |
202 | |
203 | - :param credential_save_failed: A callback method to be invoked |
204 | - if the credentials cannot be stored locally. |
205 | - |
206 | - You should not invoke this method yourself; instead, you |
207 | - should raise an exception in `store_credentials_locally()`. |
208 | - |
209 | :param allow_access_levels: A list of the Launchpad access |
210 | levels to present to the user. ('READ_PUBLIC' and so on.) |
211 | Your value for this argument will be ignored during a |
212 | @@ -272,7 +376,8 @@ |
213 | if application_name is not None and consumer_name is not None: |
214 | raise ValueError( |
215 | "You must provide only one of application_name and " |
216 | - "consumer_name.") |
217 | + "consumer_name. (You provided %r and %r.)" % ( |
218 | + application_name, consumer_name)) |
219 | |
220 | if consumer_name is None: |
221 | # System-wide integration. Create a system-wide consumer |
222 | @@ -290,7 +395,11 @@ |
223 | self.application_name = application_name |
224 | |
225 | self.allow_access_levels = allow_access_levels or [] |
226 | - self.credential_save_failed = credential_save_failed |
227 | + |
228 | + @property |
229 | + def unique_consumer_id(self): |
230 | + """Return a string identifying this consumer on this host.""" |
231 | + return self.consumer.key + '@' + self.service_root |
232 | |
233 | def authorization_url(self, request_token): |
234 | """Return the authorization URL for a request token. |
235 | @@ -307,17 +416,22 @@ |
236 | + allow_permission.join(self.allow_access_levels)) |
237 | return urljoin(self.web_root, page) |
238 | |
239 | - def __call__(self, credentials): |
240 | + def __call__(self, credentials, credential_store): |
241 | """Authorize a token and associate it with the given credentials. |
242 | |
243 | - The `credential_save_failed` callback will be invoked if |
244 | - there's a problem storing the credentials locally. It will not |
245 | - be invoked if there's a problem authorizing the credentials. |
246 | + If the credential store runs into a problem storing the |
247 | + credential locally, the `credential_save_failed` callback will |
248 | + be invoked. The callback will not be invoked if there's a |
249 | + problem authorizing the credentials. |
250 | |
251 | :param credentials: A `Credentials` object. If the end-user |
252 | authorizes these credentials, this object will have its |
253 | .access_token property set. |
254 | |
255 | + :param credential_store: A `CredentialStore` object. If the |
256 | + end-user authorizes the credentials, they will be |
257 | + persisted locally using this object. |
258 | + |
259 | :return: If the credentials are successfully authorized, the |
260 | return value is the `Credentials` object originally passed |
261 | in. Otherwise the return value is None. |
262 | @@ -328,13 +442,8 @@ |
263 | if credentials.access_token is None: |
264 | # The end-user refused to authorize the application. |
265 | return None |
266 | - try: |
267 | - self.store_credentials_locally(credentials) |
268 | - except EXPLOSIVE_ERRORS: |
269 | - raise |
270 | - except: |
271 | - if self.credential_save_failed is not None: |
272 | - self.credential_save_failed() |
273 | + # save() invokes the callback on failure. |
274 | + credential_store.save(credentials, self.unique_consumer_id) |
275 | return credentials |
276 | |
277 | def get_request_token(self, credentials): |
278 | @@ -363,43 +472,6 @@ |
279 | """ |
280 | raise NotImplementedError() |
281 | |
282 | - def store_credentials_locally(self, credentials): |
283 | - """Store newly-authorized credentials for later use. |
284 | - |
285 | - By default, credentials are stored in the GNOME keyring or KDE |
286 | - Wallet. This is a good solution for desktop applications and |
287 | - local scripts, but if you are integrating a third-party |
288 | - website into Launchpad you'll need to implement something else |
289 | - (such as storing the credentials in a database). |
290 | - """ |
291 | - keyring.set_password( |
292 | - 'launchpadlib', |
293 | - (credentials.consumer.key + '@' + self.service_root), |
294 | - credentials.serialize()) |
295 | - |
296 | - def retrieve_credentials_from_local_store(self): |
297 | - """Retrieve credentials from a local store. |
298 | - |
299 | - This method is the inverse of `store_credentials_locally`. The |
300 | - default implementation looks for credentials stored in the |
301 | - GNOME keyring or KDE Wallet. |
302 | - |
303 | - :return: A `Credentials` object if one is found in the local |
304 | - store, and None otherise. |
305 | - """ |
306 | - credential_string = keyring.get_password( |
307 | - 'launchpadlib', self.consumer.key + '@' + self.service_root) |
308 | - if credential_string is not None: |
309 | - credentials = Credentials.from_string(credential_string) |
310 | - # The application name wasn't stored locally, because in a |
311 | - # desktop integration scenario, a single set of |
312 | - # credentials may be shared by many applications. We need |
313 | - # to set the application name for this specific instance |
314 | - # of the credentials. |
315 | - credentials.consumer.application_name = self.application_name |
316 | - return credentials |
317 | - return None |
318 | - |
319 | |
320 | class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine): |
321 | """The simplest (and, right now, the only) request token authorizer. |
322 | |
323 | === modified file 'src/launchpadlib/docs/introduction.txt' |
324 | --- src/launchpadlib/docs/introduction.txt 2010-10-21 15:36:08 +0000 |
325 | +++ src/launchpadlib/docs/introduction.txt 2011-01-04 21:27:13 +0000 |
326 | @@ -200,17 +200,6 @@ |
327 | Unauthorized: HTTP Error 401: Unauthorized |
328 | ... |
329 | |
330 | -Another function call is useful when the consumer name, access token |
331 | -and access secret are all known up-front. |
332 | - |
333 | - >>> launchpad = Launchpad.login( |
334 | - ... 'launchpad-library', 'salgado-change-anything', 'test') |
335 | - >>> sorted(launchpad.people) |
336 | - [...] |
337 | - |
338 | - >>> print launchpad.me.name |
339 | - salgado |
340 | - |
341 | Otherwise, the application should obtain authorization from the user |
342 | and get a new set of credentials directly from |
343 | Launchpad. |
344 | |
345 | === modified file 'src/launchpadlib/launchpad.py' |
346 | --- src/launchpadlib/launchpad.py 2010-12-16 21:47:02 +0000 |
347 | +++ src/launchpadlib/launchpad.py 2011-01-04 21:27:13 +0000 |
348 | @@ -22,24 +22,23 @@ |
349 | ] |
350 | |
351 | import os |
352 | -import socket |
353 | -import stat |
354 | import urlparse |
355 | +import warnings |
356 | |
357 | from lazr.restfulclient.resource import ( |
358 | CollectionWithKeyBasedLookup, |
359 | - HostedFile, |
360 | - ScalarValue, |
361 | + HostedFile, # Re-import for client convenience |
362 | + ScalarValue, # Re-import for client convenience |
363 | ServiceRoot, |
364 | ) |
365 | from lazr.restfulclient._browser import RestfulHttp |
366 | from launchpadlib.credentials import ( |
367 | AccessToken, |
368 | AnonymousAccessToken, |
369 | - AuthorizeRequestTokenWithBrowser, |
370 | Consumer, |
371 | Credentials, |
372 | - SystemWideConsumer, |
373 | + KeyringCredentialStore, |
374 | + UnencryptedFileCredentialStore, |
375 | ) |
376 | from launchpadlib import uris |
377 | |
378 | @@ -135,7 +134,8 @@ |
379 | and self.authorization_engine is not None): |
380 | # This access token is bad. Scrap it and create a new one. |
381 | self.launchpad.credentials.access_token = None |
382 | - self.authorization_engine(self.launchpad.credentials) |
383 | + self.authorization_engine( |
384 | + self.launchpad.credentials, self.launchpad.credential_store) |
385 | # Retry the request with the new credentials. |
386 | return self._request(*args) |
387 | return response, content |
388 | @@ -160,7 +160,7 @@ |
389 | RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES) |
390 | |
391 | def __init__(self, credentials, authorization_engine, |
392 | - service_root=uris.STAGING_SERVICE_ROOT, |
393 | + credential_store, service_root=uris.STAGING_SERVICE_ROOT, |
394 | cache=None, timeout=None, proxy_info=None, |
395 | version=DEFAULT_VERSION): |
396 | """Root access to the Launchpad API. |
397 | @@ -186,6 +186,8 @@ |
398 | "the version name from the root URI." % version) |
399 | raise ValueError(error) |
400 | |
401 | + self.credential_store = credential_store |
402 | + |
403 | # We already have an access token, but it might expire or |
404 | # become invalid during use. Store the authorization engine in |
405 | # case we need to authorize a new token during use. |
406 | @@ -204,18 +206,27 @@ |
407 | return AuthorizeRequestTokenWithBrowser(*args) |
408 | |
409 | @classmethod |
410 | + def credential_store_factory(cls, credential_save_failed): |
411 | + return KeyringCredentialStore(credential_save_failed) |
412 | + |
413 | + @classmethod |
414 | def login(cls, consumer_name, token_string, access_secret, |
415 | service_root=uris.STAGING_SERVICE_ROOT, |
416 | cache=None, timeout=None, proxy_info=None, |
417 | authorization_engine=None, allow_access_levels=None, |
418 | - max_failed_attempts=None, credential_save_failed=None, |
419 | - version=DEFAULT_VERSION): |
420 | + max_failed_attempts=None, credential_store=None, |
421 | + credential_save_failed=None, version=DEFAULT_VERSION): |
422 | """Convenience method for setting up access credentials. |
423 | |
424 | When all three pieces of credential information (the consumer |
425 | name, the access token and the access secret) are available, this |
426 | method can be used to quickly log into the service root. |
427 | |
428 | + This method is deprecated as of launchpadlib version |
429 | + 1.9.0. You should use Launchpad.login_anonymously() for |
430 | + anonymous access, and Launchpad.login_with() for all other |
431 | + purposes. |
432 | + |
433 | :param consumer_name: the application name. |
434 | :type consumer_name: string |
435 | :param token_string: the access token, as appropriate for the |
436 | @@ -237,36 +248,33 @@ |
437 | :return: The web service root |
438 | :rtype: `Launchpad` |
439 | """ |
440 | + cls._warn_of_deprecated_login_method("login") |
441 | access_token = AccessToken(token_string, access_secret) |
442 | credentials = Credentials( |
443 | consumer_name=consumer_name, access_token=access_token) |
444 | if authorization_engine is None: |
445 | authorization_engine = cls.authorization_engine_factory( |
446 | - service_root, None, consumer_name, allow_access_levels, |
447 | + service_root, consumer_name, allow_access_levels) |
448 | + if credential_store is None: |
449 | + credential_store = cls.credential_store_factory( |
450 | credential_save_failed) |
451 | - return cls(credentials, authorization_engine, service_root, cache, |
452 | - timeout, proxy_info, version) |
453 | + return cls(credentials, authorization_engine, credential_store, |
454 | + service_root, cache, timeout, proxy_info, version) |
455 | |
456 | @classmethod |
457 | def get_token_and_login(cls, consumer_name, |
458 | service_root=uris.STAGING_SERVICE_ROOT, |
459 | cache=None, timeout=None, proxy_info=None, |
460 | authorization_engine=None, allow_access_levels=[], |
461 | - max_failed_attempts=None, |
462 | + max_failed_attempts=None, credential_store=None, |
463 | credential_save_failed=None, |
464 | version=DEFAULT_VERSION): |
465 | """Get credentials from Launchpad and log into the service root. |
466 | |
467 | - This method is deprecated as of launchpadlib version 1.9.0. A |
468 | - launchpadlib application running on the end-user's computer |
469 | - should use `Launchpad.login_with()`. A launchpadlib |
470 | - application running on a web server, integrating Launchpad |
471 | - into some other website, should use |
472 | - `Credentials.get_request_token()` to obtain the authorization |
473 | - URL and |
474 | - `Credentials.exchange_request_token_for_access_token()` to |
475 | - obtain the actual OAuth access token. Then you can call |
476 | - `Launchpad.login()`. |
477 | + This method is deprecated as of launchpadlib version |
478 | + 1.9.0. You should use Launchpad.login_anonymously() for |
479 | + anonymous access and Launchpad.login_with() for all other |
480 | + purposes. |
481 | |
482 | :param consumer_name: Either a consumer name, as appropriate for |
483 | the `Consumer` constructor, or a premade Consumer object. |
484 | @@ -279,11 +287,28 @@ |
485 | `credential_save_failed`. |
486 | :param allow_access_levels: This argument is ignored, and only |
487 | present to preserve backwards compatibility. |
488 | - :param max_failed_attempts: This argument is ignored, and only |
489 | - present to preserve backwards compatibility. |
490 | :return: The web service root |
491 | :rtype: `Launchpad` |
492 | """ |
493 | + cls._warn_of_deprecated_login_method("get_token_and_login") |
494 | + return cls._authorize_token_and_login( |
495 | + consumer_name, service_root, cache, timeout, proxy_info, |
496 | + authorization_engine, allow_access_levels, |
497 | + credential_store, credential_save_failed, version) |
498 | + |
499 | + @classmethod |
500 | + def _authorize_token_and_login( |
501 | + cls, consumer_name, service_root, cache, timeout, proxy_info, |
502 | + authorization_engine, allow_access_levels, credential_store, |
503 | + credential_save_failed, version): |
504 | + """Authorize a request token. Log in with the resulting access token. |
505 | + |
506 | + This is the private, non-deprecated implementation of the |
507 | + deprecated method get_token_and_login(). Once |
508 | + get_token_and_login() is removed, this code can be streamlined |
509 | + and moved into its other call site, login_with(). |
510 | + """ |
511 | + |
512 | if isinstance(consumer_name, Consumer): |
513 | # Create the credentials with no Consumer, then set its .consumer |
514 | # property directly. |
515 | @@ -295,11 +320,23 @@ |
516 | credentials = Credentials(consumer_name) |
517 | if authorization_engine is None: |
518 | authorization_engine = cls.authorization_engine_factory( |
519 | - service_root, None, consumer_name, allow_access_levels, |
520 | + service_root, consumer_name, None, allow_access_levels) |
521 | + if credential_store is None: |
522 | + credential_store = cls.credential_store_factory( |
523 | credential_save_failed) |
524 | - credentials = authorization_engine(credentials) |
525 | - return cls(credentials, authorization_engine, service_root, cache, |
526 | - timeout, proxy_info, version) |
527 | + else: |
528 | + # A credential store was passed in, so we won't be using |
529 | + # any provided value for credential_save_failed. But at |
530 | + # least make sure we weren't given a conflicting value, |
531 | + # since that makes the calling code look confusing. |
532 | + cls._assert_login_argument_consistency( |
533 | + "credential_save_failed", credential_save_failed, |
534 | + credential_store.credential_save_failed, |
535 | + "credential_store") |
536 | + |
537 | + credentials = authorization_engine(credentials, credential_store) |
538 | + return cls(credentials, authorization_engine, credential_store, |
539 | + service_root, cache, timeout, proxy_info, version) |
540 | |
541 | @classmethod |
542 | def login_anonymously( |
543 | @@ -311,7 +348,7 @@ |
544 | service_root_dir) = cls._get_paths(service_root, launchpadlib_dir) |
545 | token = AnonymousAccessToken() |
546 | credentials = Credentials(consumer_name, access_token=token) |
547 | - return cls(credentials, None, service_root=service_root, |
548 | + return cls(credentials, None, None, service_root=service_root, |
549 | cache=cache_path, timeout=timeout, proxy_info=proxy_info, |
550 | version=version) |
551 | |
552 | @@ -322,23 +359,36 @@ |
553 | authorization_engine=None, allow_access_levels=None, |
554 | max_failed_attempts=None, credentials_file=None, |
555 | version=DEFAULT_VERSION, consumer_name=None, |
556 | - credential_save_failed=None): |
557 | - """Log in to Launchpad with possibly cached credentials. |
558 | + credential_save_failed=None, credential_store=None): |
559 | + """Log in to Launchpad, possibly acquiring and storing credentials. |
560 | |
561 | - Use this method to get a `Launchpad` object if you are writing |
562 | - a desktop application or a script. If the end-user has no |
563 | - cached Launchpad credential, their browser will open and |
564 | - they'll be asked to log in and authorize a desktop |
565 | + Use this method to get a `Launchpad` object. If the end-user |
566 | + has no cached Launchpad credential, their browser will open |
567 | + and they'll be asked to log in and authorize a desktop |
568 | integration. The authorized Launchpad credential will be |
569 | - stored, so that the next time your program (or any other |
570 | - program run by that user on the same computer) needs a |
571 | - Launchpad credential, it will be retrieved from local storage. |
572 | - |
573 | - By subclassing `RequestTokenAuthorizationEngine` and passing |
574 | - in an instance of the subclass as `authorization_engine`, you |
575 | - can change what happens when the end-user needs to authorize |
576 | - the Launchpad credential, and you can change how the |
577 | - authorized credential is stored and retrieved locally. |
578 | + stored securely: in the GNOME keyring, the KDE Wallet, or in |
579 | + an encrypted file on disk. |
580 | + |
581 | + The next time your program (or any other program run by that |
582 | + user on the same computer) invokes this method, the end-user |
583 | + will be prompted to unlock their keyring (or equivalent), and |
584 | + the credential will be retrieved from local storage and |
585 | + reused. |
586 | + |
587 | + You can customize this behavior in three ways: |
588 | + |
589 | + 1. Pass in a filename to `credentials_file`. The end-user's |
590 | + credential will be written to that file, and on subsequent |
591 | + runs read from that file. |
592 | + |
593 | + 2. Subclass `CredentialStore` and pass in an instance of the |
594 | + subclass as `credential_store`. This lets you change how |
595 | + the end-user's credential is stored and retrieved locally. |
596 | + |
597 | + 3. Subclass `RequestTokenAuthorizationEngine` and pass in an |
598 | + instance of the subclass as `authorization_engine`. This |
599 | + lets you change change what happens when the end-user needs |
600 | + to authorize the Launchpad credential. |
601 | |
602 | :param application_name: The application name. This is *not* |
603 | the OAuth consumer name. Unless a consumer_name is also |
604 | @@ -356,25 +406,14 @@ |
605 | cache. |
606 | :type launchpadlib_dir: string |
607 | |
608 | - :param authorization_engine: A strategy for getting the end-user to |
609 | - authorize an OAuth request token, for exchanging the |
610 | - request token for an access token, and for storing the |
611 | - access token locally so that it can be reused. |
612 | + :param authorization_engine: A strategy for getting the |
613 | + end-user to authorize an OAuth request token, for |
614 | + exchanging the request token for an access token, and for |
615 | + storing the access token locally so that it can be |
616 | + reused. By default, launchpadlib will open the end-user's |
617 | + web browser to have them authorize the request token. |
618 | :type authorization_engine: `RequestTokenAuthorizationEngine` |
619 | |
620 | - :param credential_save_failed: a callback that is called upon |
621 | - a failure to save the credentials locally. This argument is |
622 | - used to construct the default `authorization_engine`, so if |
623 | - you pass in your own `authorization_engine` any value for |
624 | - this argument will be ignored. |
625 | - :type credential_save_failed: A callable |
626 | - |
627 | - :param consumer_name: The consumer name, as appropriate for |
628 | - the `Consumer` constructor. You probably don't want to |
629 | - provide this, since providing it will prevent you from |
630 | - taking advantage of desktop-wide integration. |
631 | - :type consumer_name: string |
632 | - |
633 | :param allow_access_levels: The acceptable access levels for |
634 | this application. |
635 | |
636 | @@ -386,8 +425,32 @@ |
637 | |
638 | :type allow_access_levels: list of strings |
639 | |
640 | - :param credentials_file: ignored, only here for backward compatability |
641 | - :type credentials_file: string |
642 | + :param max_failed_attempts: Ignored; only present for |
643 | + backwards compatibility. |
644 | + |
645 | + :param credentials_file: The path to a file in which to store |
646 | + this user's OAuth access token. |
647 | + |
648 | + :param version: The version of the Launchpad web service to use. |
649 | + |
650 | + :param consumer_name: The consumer name, as appropriate for |
651 | + the `Consumer` constructor. You probably don't want to |
652 | + provide this, since providing it will prevent you from |
653 | + taking advantage of desktop-wide integration. |
654 | + :type consumer_name: string |
655 | + |
656 | + :param credential_save_failed: a callback that is called upon |
657 | + a failure to save the credentials locally. This argument is |
658 | + used to construct the default `credential_store`, so if |
659 | + you pass in your own `credential_store` any value for |
660 | + this argument will be ignored. |
661 | + :type credential_save_failed: A callable |
662 | + |
663 | + :param credential_store: A strategy for storing an OAuth |
664 | + access token locally. By default, tokens are stored in the |
665 | + GNOME keyring (or equivalent). If `credentials_file` is |
666 | + provided, then tokens are stored unencrypted in that file. |
667 | + :type credential_store: `CredentialStore` |
668 | |
669 | :return: A web service root authorized as the end-user. |
670 | :rtype: `Launchpad` |
671 | @@ -402,69 +465,111 @@ |
672 | "At least one of application_name, consumer_name, or " |
673 | "authorization_engine must be provided.") |
674 | |
675 | + if credentials_file is not None and credential_store is not None: |
676 | + raise ValueError( |
677 | + "At most one of credentials_file and credential_store " |
678 | + "must be provided.") |
679 | + |
680 | + if credential_store is None: |
681 | + if credentials_file is not None: |
682 | + # The end-user wants credentials stored in an |
683 | + # unencrypted file. |
684 | + credential_store = UnencryptedFileCredentialStore( |
685 | + credentials_file, credential_save_failed) |
686 | + else: |
687 | + credential_store = cls.credential_store_factory( |
688 | + credential_save_failed) |
689 | + else: |
690 | + # A credential store was passed in, so we won't be using |
691 | + # any provided value for credential_save_failed. But at |
692 | + # least make sure we weren't given a conflicting value, |
693 | + # since that makes the calling code look confusing. |
694 | + cls._assert_login_argument_consistency( |
695 | + 'credential_save_failed', credential_save_failed, |
696 | + credential_store.credential_save_failed, |
697 | + "credential_store") |
698 | + credential_store = credential_store |
699 | + |
700 | if authorization_engine is None: |
701 | authorization_engine = cls.authorization_engine_factory( |
702 | service_root, application_name, consumer_name, |
703 | - credential_save_failed, allow_access_levels) |
704 | + allow_access_levels) |
705 | else: |
706 | # An authorization engine was passed in, so we won't be |
707 | # using any provided values for application_name, |
708 | # consumer_name, or allow_access_levels. But at least make |
709 | # sure we weren't given conflicting values, since that |
710 | # makes the calling code look confusing. |
711 | - cls._assert_login_with_argument_consistency( |
712 | + cls._assert_login_argument_consistency( |
713 | "application_name", application_name, |
714 | authorization_engine.application_name) |
715 | |
716 | - cls._assert_login_with_argument_consistency( |
717 | + cls._assert_login_argument_consistency( |
718 | "consumer_name", consumer_name, |
719 | authorization_engine.consumer.key) |
720 | |
721 | - cls._assert_login_with_argument_consistency( |
722 | + cls._assert_login_argument_consistency( |
723 | "allow_access_levels", allow_access_levels, |
724 | authorization_engine.allow_access_levels) |
725 | |
726 | - credentials = authorization_engine.retrieve_credentials_from_local_store() |
727 | + credentials = credential_store.load( |
728 | + authorization_engine.unique_consumer_id) |
729 | |
730 | if credentials is None: |
731 | # Credentials were not found in the local store. Go |
732 | # through the authorization process. |
733 | - launchpad = cls.get_token_and_login( |
734 | - authorization_engine.consumer, service_root=service_root, |
735 | - cache=cache_path, timeout=timeout, proxy_info=proxy_info, |
736 | - authorization_engine=authorization_engine, |
737 | - version=version) |
738 | + launchpad = cls._authorize_token_and_login( |
739 | + authorization_engine.consumer, service_root, |
740 | + cache_path, timeout, proxy_info, authorization_engine, |
741 | + allow_access_levels, credential_store, |
742 | + credential_save_failed, version) |
743 | else: |
744 | + # The application name wasn't stored locally, because in a |
745 | + # desktop integration scenario, a single set of |
746 | + # credentials may be shared by many applications. We need |
747 | + # to set the application name for this specific instance |
748 | + # of the credentials. |
749 | + credentials.consumer.application_name = ( |
750 | + authorization_engine.application_name) |
751 | + |
752 | # Credentials were found in the local store. Create a |
753 | # Launchpad object. |
754 | launchpad = cls( |
755 | - credentials, authorization_engine, service_root=service_root, |
756 | - cache=cache_path, timeout=timeout, proxy_info=proxy_info, |
757 | - version=version) |
758 | + credentials, authorization_engine, credential_store, |
759 | + service_root=service_root, cache=cache_path, timeout=timeout, |
760 | + proxy_info=proxy_info, version=version) |
761 | return launchpad |
762 | |
763 | @classmethod |
764 | - def _assert_login_with_argument_consistency( |
765 | - cls, argument_name, argument_value, authorization_engine_value): |
766 | - """Helper to find conflicting values passed into login_with. |
767 | - |
768 | - Many of the arguments to login_with are used to build an |
769 | - authorization engine. If an authorization engine is passed in, |
770 | - many of the arguments become redundant. We'll allow redundant |
771 | - arguments through, but if a login_with argument *conflicts* with |
772 | - the argument in the provided authorization engine, we raise an |
773 | - error. |
774 | + def _warn_of_deprecated_login_method(cls, name): |
775 | + warnings.warn( |
776 | + ("The Launchpad.%s() method is deprecated. You should use " |
777 | + "Launchpad.login_anonymous() for anonymous access and " |
778 | + "Launchpad.login_with() for all other purposes.") % name, |
779 | + DeprecationWarning) |
780 | + |
781 | + @classmethod |
782 | + def _assert_login_argument_consistency( |
783 | + cls, argument_name, argument_value, object_value, |
784 | + object_name="authorization engine"): |
785 | + """Helper to find conflicting values passed into the login methods. |
786 | + |
787 | + Many of the arguments to login_with are used to build other |
788 | + objects--the authorization engine or the credential store. If |
789 | + these objects are provided directly, many of the arguments |
790 | + become redundant. We'll allow redundant arguments through, but |
791 | + if a argument *conflicts* with the corresponding value in the |
792 | + provided object, we raise an error. |
793 | """ |
794 | inconsistent_value_message = ( |
795 | "Inconsistent values given for %s: " |
796 | - "(%r passed in to login_with(), versus %r in authorization " |
797 | - "engine). You don't need to pass in %s to login_with() if you " |
798 | - "pass in an authorization engine, so just omit that argument.") |
799 | - if (argument_value is not None |
800 | - and argument_value != authorization_engine_value): |
801 | + "(%r passed in, versus %r in %s). " |
802 | + "You don't need to pass in %s if you pass in %s, " |
803 | + "so just omit that argument.") |
804 | + if (argument_value is not None and argument_value != object_value): |
805 | raise ValueError(inconsistent_value_message % ( |
806 | - argument_name, argument_value, authorization_engine_value, |
807 | - argument_name)) |
808 | + argument_name, argument_value, object_value, |
809 | + object_name, argument_name, object_name)) |
810 | |
811 | |
812 | @classmethod |
813 | |
814 | === modified file 'src/launchpadlib/testing/helpers.py' |
815 | --- src/launchpadlib/testing/helpers.py 2010-12-16 21:47:02 +0000 |
816 | +++ src/launchpadlib/testing/helpers.py 2011-01-04 21:27:13 +0000 |
817 | @@ -21,6 +21,10 @@ |
818 | |
819 | __metaclass__ = type |
820 | __all__ = [ |
821 | + 'BadSaveKeyring', |
822 | + 'fake_keyring', |
823 | + 'FauxSocketModule', |
824 | + 'InMemoryKeyring', |
825 | 'NoNetworkAuthorizationEngine', |
826 | 'NoNetworkLaunchpad', |
827 | 'TestableLaunchpad', |
828 | @@ -29,28 +33,17 @@ |
829 | 'salgado_with_full_permissions', |
830 | ] |
831 | |
832 | -import simplejson |
833 | +from contextlib import contextmanager |
834 | |
835 | +import launchpadlib |
836 | from launchpadlib.launchpad import Launchpad |
837 | from launchpadlib.credentials import ( |
838 | AccessToken, |
839 | - AuthorizeRequestTokenWithBrowser, |
840 | Credentials, |
841 | RequestTokenAuthorizationEngine, |
842 | ) |
843 | |
844 | |
845 | -class TestableLaunchpad(Launchpad): |
846 | - """A base class for talking to the testing root service.""" |
847 | - |
848 | - def __init__(self, credentials, service_root=None, |
849 | - cache=None, timeout=None, proxy_info=None, |
850 | - version=Launchpad.DEFAULT_VERSION): |
851 | - super(TestableLaunchpad, self).__init__( |
852 | - credentials, 'test_dev', cache, timeout, proxy_info, |
853 | - version=version) |
854 | - |
855 | - |
856 | class NoNetworkAuthorizationEngine(RequestTokenAuthorizationEngine): |
857 | """An authorization engine that doesn't open a web browser. |
858 | |
859 | @@ -91,10 +84,11 @@ |
860 | It can't be used to interact with the API. |
861 | """ |
862 | |
863 | - def __init__(self, credentials, authorization_engine, service_root, |
864 | - cache, timeout, proxy_info, version): |
865 | + def __init__(self, credentials, authorization_engine, credential_store, |
866 | + service_root, cache, timeout, proxy_info, version): |
867 | self.credentials = credentials |
868 | self.authorization_engine = authorization_engine |
869 | + self.credential_store = credential_store |
870 | self.passed_in_args = dict( |
871 | service_root=service_root, cache=cache, timeout=timeout, |
872 | proxy_info=proxy_info, version=version) |
873 | @@ -104,20 +98,83 @@ |
874 | return NoNetworkAuthorizationEngine(*args) |
875 | |
876 | |
877 | +class TestableLaunchpad(Launchpad): |
878 | + """A base class for talking to the testing root service.""" |
879 | + |
880 | + def __init__(self, credentials, authorization_engine=None, |
881 | + credential_store=None, service_root="test_dev", |
882 | + cache=None, timeout=None, proxy_info=None, |
883 | + version=Launchpad.DEFAULT_VERSION): |
884 | + """Provide test-friendly defaults. |
885 | + |
886 | + :param authorization_engine: Defaults to None, since a test |
887 | + environment can't use an authorization engine. |
888 | + :param credential_store: Defaults to None, since tests |
889 | + generally pass in fully-formed Credentials objects. |
890 | + :param service_root: Defaults to 'test_dev'. |
891 | + """ |
892 | + super(TestableLaunchpad, self).__init__( |
893 | + credentials, authorization_engine, credential_store, |
894 | + service_root=service_root, cache=cache, timeout=timeout, |
895 | + proxy_info=proxy_info, version=version) |
896 | + |
897 | + |
898 | +@contextmanager |
899 | +def fake_keyring(fake): |
900 | + original_keyring = launchpadlib.credentials.keyring |
901 | + launchpadlib.credentials.keyring = fake |
902 | + try: |
903 | + yield |
904 | + finally: |
905 | + launchpadlib.credentials.keyring = original_keyring |
906 | + |
907 | + |
908 | +class FauxSocketModule: |
909 | + """A socket module replacement that provides a fake hostname.""" |
910 | + |
911 | + def gethostname(self): |
912 | + return 'HOSTNAME' |
913 | + |
914 | + |
915 | +class BadSaveKeyring: |
916 | + """A keyring that generates errors when saving passwords.""" |
917 | + |
918 | + def get_password(self, service, username): |
919 | + return None |
920 | + |
921 | + def set_password(self, service, username, password): |
922 | + raise RuntimeError |
923 | + |
924 | + |
925 | +class InMemoryKeyring: |
926 | + """A keyring that saves passwords only in memory.""" |
927 | + |
928 | + def __init__(self): |
929 | + self.data = {} |
930 | + |
931 | + def set_password(self, service, username, password): |
932 | + self.data[service, username] = password |
933 | + |
934 | + def get_password(self, service, username): |
935 | + return self.data.get((service, username)) |
936 | + |
937 | + |
938 | class KnownTokens: |
939 | """Known access token/secret combinations.""" |
940 | |
941 | def __init__(self, token_string, access_secret): |
942 | self.token_string = token_string |
943 | self.access_secret = access_secret |
944 | + self.token = AccessToken(token_string, access_secret) |
945 | + self.credentials = Credentials( |
946 | + consumer_name="launchpad-library", access_token=self.token) |
947 | |
948 | def login(self, cache=None, timeout=None, proxy_info=None, |
949 | version=Launchpad.DEFAULT_VERSION): |
950 | - """Login using these credentials.""" |
951 | - return TestableLaunchpad.login( |
952 | - 'launchpad-library', self.token_string, self.access_secret, |
953 | - cache=cache, timeout=timeout, proxy_info=proxy_info, |
954 | - version=version) |
955 | + """Create a Launchpad object using these credentials.""" |
956 | + return TestableLaunchpad( |
957 | + self.credentials, cache=cache, timeout=timeout, |
958 | + proxy_info=proxy_info, version=version) |
959 | |
960 | |
961 | salgado_with_full_permissions = KnownTokens('salgado-change-anything', 'test') |
962 | |
963 | === added file 'src/launchpadlib/tests/test_credential_store.py' |
964 | --- src/launchpadlib/tests/test_credential_store.py 1970-01-01 00:00:00 +0000 |
965 | +++ src/launchpadlib/tests/test_credential_store.py 2011-01-04 21:27:13 +0000 |
966 | @@ -0,0 +1,138 @@ |
967 | +# Copyright 2010 Canonical Ltd. |
968 | + |
969 | +# This file is part of launchpadlib. |
970 | +# |
971 | +# launchpadlib is free software: you can redistribute it and/or modify it |
972 | +# under the terms of the GNU Lesser General Public License as published by the |
973 | +# Free Software Foundation, version 3 of the License. |
974 | +# |
975 | +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
976 | +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
977 | +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
978 | +# for more details. |
979 | +# |
980 | +# You should have received a copy of the GNU Lesser General Public License |
981 | +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
982 | + |
983 | +"""Tests for the credential store classes.""" |
984 | + |
985 | +import os |
986 | +import tempfile |
987 | +import unittest |
988 | + |
989 | +from launchpadlib.testing.helpers import ( |
990 | + fake_keyring, |
991 | + InMemoryKeyring, |
992 | +) |
993 | + |
994 | +from launchpadlib.credentials import ( |
995 | + AccessToken, |
996 | + Credentials, |
997 | + KeyringCredentialStore, |
998 | + UnencryptedFileCredentialStore, |
999 | +) |
1000 | + |
1001 | + |
1002 | +class CredentialStoreTestCase(unittest.TestCase): |
1003 | + |
1004 | + def make_credential(self, consumer_key): |
1005 | + """Helper method to make a fake credential.""" |
1006 | + return Credentials( |
1007 | + "app name", consumer_secret='consumer_secret:42', |
1008 | + access_token=AccessToken(consumer_key, 'access_secret:168')) |
1009 | + |
1010 | + |
1011 | +class TestUnencryptedFileCredentialStore(CredentialStoreTestCase): |
1012 | + """Tests for the UnencryptedFileCredentialStore class.""" |
1013 | + |
1014 | + def setUp(self): |
1015 | + ignore, self.filename = tempfile.mkstemp() |
1016 | + self.store = UnencryptedFileCredentialStore(self.filename) |
1017 | + |
1018 | + def tearDown(self): |
1019 | + if os.path.exists(self.filename): |
1020 | + os.remove(self.filename) |
1021 | + |
1022 | + def test_save_and_load(self): |
1023 | + # Make sure you can save and load credentials to a file. |
1024 | + credential = self.make_credential("consumer key") |
1025 | + self.store.save(credential, "unique key") |
1026 | + credential2 = self.store.load("unique key") |
1027 | + self.assertEquals(credential.consumer.key, credential2.consumer.key) |
1028 | + |
1029 | + def test_unique_id_doesnt_matter(self): |
1030 | + # If a file contains a credential, that credential will be |
1031 | + # accessed no matter what unique ID you specify. |
1032 | + credential = self.make_credential("consumer key") |
1033 | + self.store.save(credential, "some key") |
1034 | + credential2 = self.store.load("some other key") |
1035 | + self.assertEquals(credential.consumer.key, credential2.consumer.key) |
1036 | + |
1037 | + def test_file_only_contains_one_credential(self): |
1038 | + # A credential file may contain only one credential. If you |
1039 | + # write two credentials with different unique IDs to the same |
1040 | + # file, the first credential will be overwritten with the |
1041 | + # second. |
1042 | + credential1 = self.make_credential("consumer key") |
1043 | + credential2 = self.make_credential("consumer key2") |
1044 | + self.store.save(credential1, "unique key 1") |
1045 | + self.store.save(credential1, "unique key 2") |
1046 | + loaded = self.store.load("unique key 1") |
1047 | + self.assertEquals(loaded.consumer.key, credential2.consumer.key) |
1048 | + |
1049 | + |
1050 | +class TestKeyringCredentialStore(CredentialStoreTestCase): |
1051 | + """Tests for the KeyringCredentialStore class.""" |
1052 | + |
1053 | + def setUp(self): |
1054 | + self.keyring = InMemoryKeyring() |
1055 | + self.store = KeyringCredentialStore() |
1056 | + |
1057 | + def test_save_and_load(self): |
1058 | + # Make sure you can save and load credentials to a keyring. |
1059 | + with fake_keyring(self.keyring): |
1060 | + credential = self.make_credential("consumer key") |
1061 | + self.store.save(credential, "unique key") |
1062 | + credential2 = self.store.load("unique key") |
1063 | + self.assertEquals( |
1064 | + credential.consumer.key, credential2.consumer.key) |
1065 | + |
1066 | + def test_lookup_by_unique_key(self): |
1067 | + # Credentials in the keyring are looked up by the unique ID |
1068 | + # under which they were stored. |
1069 | + with fake_keyring(self.keyring): |
1070 | + credential1 = self.make_credential("consumer key1") |
1071 | + self.store.save(credential1, "key 1") |
1072 | + |
1073 | + credential2 = self.make_credential("consumer key2") |
1074 | + self.store.save(credential2, "key 2") |
1075 | + |
1076 | + loaded1 = self.store.load("key 1") |
1077 | + self.assertEquals( |
1078 | + credential1.consumer.key, loaded1.consumer.key) |
1079 | + |
1080 | + loaded2 = self.store.load("key 2") |
1081 | + self.assertEquals( |
1082 | + credential2.consumer.key, loaded2.consumer.key) |
1083 | + |
1084 | + def test_reused_unique_id_overwrites_old_credential(self): |
1085 | + # Writing a credential to the keyring with a given unique ID |
1086 | + # will overwrite any credential stored under that ID. |
1087 | + |
1088 | + with fake_keyring(self.keyring): |
1089 | + credential1 = self.make_credential("consumer key1") |
1090 | + self.store.save(credential1, "the only key") |
1091 | + |
1092 | + credential2 = self.make_credential("consumer key2") |
1093 | + self.store.save(credential2, "the only key") |
1094 | + |
1095 | + loaded = self.store.load("the only key") |
1096 | + self.assertEquals( |
1097 | + credential2.consumer.key, loaded.consumer.key) |
1098 | + |
1099 | + def test_bad_unique_id_returns_none(self): |
1100 | + # Trying to load a credential without providing a good unique |
1101 | + # ID will get you None. |
1102 | + with fake_keyring(self.keyring): |
1103 | + self.assertEquals(None, self.store.load("no such key")) |
1104 | + |
1105 | |
1106 | === modified file 'src/launchpadlib/tests/test_http.py' |
1107 | --- src/launchpadlib/tests/test_http.py 2010-12-20 17:44:36 +0000 |
1108 | +++ src/launchpadlib/tests/test_http.py 2011-01-04 21:27:13 +0000 |
1109 | @@ -71,14 +71,15 @@ |
1110 | :param responses: A list of HttpResponse objects to use |
1111 | in response to requests. |
1112 | """ |
1113 | + super(SimulatedResponsesHttp, self).__init__(*args) |
1114 | self.sent_responses = [] |
1115 | self.unsent_responses = responses |
1116 | - super(SimulatedResponsesHttp, self).__init__(*args) |
1117 | + self.cache = None |
1118 | |
1119 | def _request(self, *args): |
1120 | response = self.unsent_responses.popleft() |
1121 | self.sent_responses.append(response) |
1122 | - return self.retry_on_bad_token(response, response.content) |
1123 | + return self.retry_on_bad_token(response, response.content, *args) |
1124 | |
1125 | |
1126 | class SimulatedResponsesLaunchpad(Launchpad): |
1127 | |
1128 | === modified file 'src/launchpadlib/tests/test_launchpad.py' |
1129 | --- src/launchpadlib/tests/test_launchpad.py 2010-12-20 17:44:36 +0000 |
1130 | +++ src/launchpadlib/tests/test_launchpad.py 2011-01-04 21:27:13 +0000 |
1131 | @@ -23,9 +23,8 @@ |
1132 | import socket |
1133 | import stat |
1134 | import tempfile |
1135 | -import textwrap |
1136 | import unittest |
1137 | -from contextlib import contextmanager |
1138 | +import warnings |
1139 | |
1140 | from lazr.restfulclient.resource import ServiceRoot |
1141 | |
1142 | @@ -38,9 +37,17 @@ |
1143 | import launchpadlib.launchpad |
1144 | from launchpadlib.launchpad import Launchpad |
1145 | from launchpadlib.testing.helpers import ( |
1146 | + BadSaveKeyring, |
1147 | + fake_keyring, |
1148 | + FauxSocketModule, |
1149 | + InMemoryKeyring, |
1150 | NoNetworkAuthorizationEngine, |
1151 | NoNetworkLaunchpad, |
1152 | ) |
1153 | +from launchpadlib.credentials import ( |
1154 | + KeyringCredentialStore, |
1155 | + UnencryptedFileCredentialStore, |
1156 | + ) |
1157 | |
1158 | # A dummy service root for use in tests |
1159 | SERVICE_ROOT = "http://api.example.com/" |
1160 | @@ -112,7 +119,7 @@ |
1161 | version = "version-foo" |
1162 | root = uris.service_roots['staging'] + version |
1163 | try: |
1164 | - Launchpad(None, None, service_root=root, version=version) |
1165 | + Launchpad(None, None, None, service_root=root, version=version) |
1166 | except ValueError, e: |
1167 | self.assertTrue(str(e).startswith( |
1168 | "It looks like you're using a service root that incorporates " |
1169 | @@ -124,30 +131,17 @@ |
1170 | # Make sure the problematic URL is caught even if it has a |
1171 | # slash on the end. |
1172 | root += '/' |
1173 | - self.assertRaises(ValueError, Launchpad, None, None, |
1174 | + self.assertRaises(ValueError, Launchpad, None, None, None, |
1175 | service_root=root, version=version) |
1176 | |
1177 | # Test that the default version has the same problem |
1178 | # when no explicit version is specified |
1179 | default_version = NoNetworkLaunchpad.DEFAULT_VERSION |
1180 | root = uris.service_roots['staging'] + default_version + '/' |
1181 | - self.assertRaises(ValueError, Launchpad, None, None, |
1182 | + self.assertRaises(ValueError, Launchpad, None, None, None, |
1183 | service_root=root) |
1184 | |
1185 | |
1186 | -class InMemoryKeyring: |
1187 | - """A keyring that saves passwords only in memory.""" |
1188 | - |
1189 | - def __init__(self): |
1190 | - self.data = {} |
1191 | - |
1192 | - def set_password(self, service, username, password): |
1193 | - self.data[service, username] = password |
1194 | - |
1195 | - def get_password(self, service, username): |
1196 | - return self.data.get((service, username)) |
1197 | - |
1198 | - |
1199 | class TestRequestTokenAuthorizationEngine(unittest.TestCase): |
1200 | """Tests for the RequestTokenAuthorizationEngine class.""" |
1201 | |
1202 | @@ -174,8 +168,33 @@ |
1203 | SERVICE_ROOT, application_name='name', consumer_name='name') |
1204 | |
1205 | |
1206 | -class TestLaunchpadLoginWith(unittest.TestCase): |
1207 | - """Tests for Launchpad.login_with().""" |
1208 | +class TestLaunchpadLoginWithCredentialsFile(unittest.TestCase): |
1209 | + """Tests for Launchpad.login_with() with a credentials file.""" |
1210 | + |
1211 | + def test_filename(self): |
1212 | + ignore, filename = tempfile.mkstemp() |
1213 | + launchpad = NoNetworkLaunchpad.login_with( |
1214 | + application_name='not important', credentials_file=filename) |
1215 | + |
1216 | + # The credentials are stored unencrypted in the file you |
1217 | + # specify. |
1218 | + credentials = Credentials.load_from_path(filename) |
1219 | + self.assertEquals(credentials.consumer.key, |
1220 | + launchpad.credentials.consumer.key) |
1221 | + os.remove(filename) |
1222 | + |
1223 | + def test_cannot_specify_both_filename_and_store(self): |
1224 | + ignore, filename = tempfile.mkstemp() |
1225 | + store = KeyringCredentialStore() |
1226 | + self.assertRaises( |
1227 | + ValueError, NoNetworkLaunchpad.login_with, |
1228 | + application_name='not important', credentials_file=filename, |
1229 | + credential_store=store) |
1230 | + os.remove(filename) |
1231 | + |
1232 | + |
1233 | +class KeyringTest(unittest.TestCase): |
1234 | + """Base class for tests that use the keyring.""" |
1235 | |
1236 | def setUp(self): |
1237 | # For these tests we want to disable retrieving or storing credentials |
1238 | @@ -183,30 +202,28 @@ |
1239 | # instaed. |
1240 | self._saved_keyring = launchpadlib.credentials.keyring |
1241 | launchpadlib.credentials.keyring = InMemoryKeyring() |
1242 | - self.temp_dir = tempfile.mkdtemp() |
1243 | |
1244 | def tearDown(self): |
1245 | # Restore the gnomekeyring module that we disabled in setUp. |
1246 | launchpadlib.credentials.keyring = self._saved_keyring |
1247 | + |
1248 | + |
1249 | +class TestLaunchpadLoginWith(KeyringTest): |
1250 | + """Tests for Launchpad.login_with().""" |
1251 | + |
1252 | + def setUp(self): |
1253 | + super(TestLaunchpadLoginWith, self).setUp() |
1254 | + self.temp_dir = tempfile.mkdtemp() |
1255 | + |
1256 | + def tearDown(self): |
1257 | + super(TestLaunchpadLoginWith, self).tearDown() |
1258 | shutil.rmtree(self.temp_dir) |
1259 | |
1260 | - def test_max_failed_attempts_accepted(self): |
1261 | - # You can pass in a value for the 'max_failed_attempts' |
1262 | - # argument, even though that argument doesn't do anything. |
1263 | - launchpad = NoNetworkLaunchpad.login_with( |
1264 | - 'not important', max_failed_attempts=5) |
1265 | - |
1266 | - def test_credentials_file_accepted(self): |
1267 | - # You can pass in a value for the 'credentials_file' |
1268 | - # argument, even though that argument doesn't do anything. |
1269 | - launchpad = NoNetworkLaunchpad.login_with( |
1270 | - 'not important', credentials_file='foo') |
1271 | - |
1272 | def test_dirs_created(self): |
1273 | # The path we pass into login_with() is the directory where |
1274 | - # cache and credentials for all service roots are stored. |
1275 | + # cache for all service roots are stored. |
1276 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
1277 | - launchpad = NoNetworkLaunchpad.login_with( |
1278 | + NoNetworkLaunchpad.login_with( |
1279 | 'not important', service_root=SERVICE_ROOT, |
1280 | launchpadlib_dir=launchpadlib_dir) |
1281 | # The 'launchpadlib' dir got created. |
1282 | @@ -233,7 +250,7 @@ |
1283 | statinfo = os.stat(launchpadlib_dir) |
1284 | mode = stat.S_IMODE(statinfo.st_mode) |
1285 | self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) |
1286 | - launchpad = NoNetworkLaunchpad.login_with( |
1287 | + NoNetworkLaunchpad.login_with( |
1288 | 'not important', service_root=SERVICE_ROOT, |
1289 | launchpadlib_dir=launchpadlib_dir) |
1290 | # Verify the mode has been changed to 0700 |
1291 | @@ -243,7 +260,7 @@ |
1292 | |
1293 | def test_dirs_created_are_secure(self): |
1294 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
1295 | - launchpad = NoNetworkLaunchpad.login_with( |
1296 | + NoNetworkLaunchpad.login_with( |
1297 | 'not important', service_root=SERVICE_ROOT, |
1298 | launchpadlib_dir=launchpadlib_dir) |
1299 | self.assertTrue(os.path.isdir(launchpadlib_dir)) |
1300 | @@ -300,8 +317,7 @@ |
1301 | # token. |
1302 | engine = NoNetworkAuthorizationEngine( |
1303 | SERVICE_ROOT, 'application name') |
1304 | - launchpad = NoNetworkLaunchpad.login_with( |
1305 | - authorization_engine=engine) |
1306 | + NoNetworkLaunchpad.login_with(authorization_engine=engine) |
1307 | self.assertEquals(engine.request_tokens_obtained, 1) |
1308 | self.assertEquals(engine.access_tokens_obtained, 1) |
1309 | |
1310 | @@ -311,9 +327,13 @@ |
1311 | self.assertRaises(ValueError, NoNetworkLaunchpad.login_with) |
1312 | |
1313 | def test_application_name_identifies_app(self): |
1314 | + # If you pass in application_name, that's good enough to identify |
1315 | + # your application. |
1316 | NoNetworkLaunchpad.login_with(application_name="name") |
1317 | |
1318 | def test_consumer_name_identifies_app(self): |
1319 | + # If you pass in consumer_name, that's good enough to identify |
1320 | + # your application. |
1321 | NoNetworkLaunchpad.login_with(consumer_name="name") |
1322 | |
1323 | def test_inconsistent_application_name_rejected(self): |
1324 | @@ -344,6 +364,19 @@ |
1325 | allow_access_levels=['BAR'], |
1326 | authorization_engine=engine) |
1327 | |
1328 | + def test_inconsistent_credential_save_failed(self): |
1329 | + # Catch an attempt to specify inconsistent callbacks for |
1330 | + # credential save failure. |
1331 | + def callback1(): |
1332 | + pass |
1333 | + store = KeyringCredentialStore(credential_save_failed=callback1) |
1334 | + |
1335 | + def callback2(): |
1336 | + pass |
1337 | + self.assertRaises(ValueError, NoNetworkLaunchpad.login_with, |
1338 | + "app name", credential_store=store, |
1339 | + credential_save_failed=callback2) |
1340 | + |
1341 | def test_non_desktop_integration(self): |
1342 | # When doing a non-desktop integration, you must specify a |
1343 | # consumer_name. You can pass a list of allowable access |
1344 | @@ -472,30 +505,32 @@ |
1345 | self.assertRaises( |
1346 | ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo') |
1347 | |
1348 | - |
1349 | -class FauxSocketModule: |
1350 | - """A socket module replacement that provides a fake hostname.""" |
1351 | - |
1352 | - def gethostname(self): |
1353 | - return 'HOSTNAME' |
1354 | - |
1355 | - |
1356 | -class BadSaveKeyring: |
1357 | - """A keyring that generates errors when saving passwords.""" |
1358 | - |
1359 | - def get_password(self, service, username): |
1360 | - return None |
1361 | - |
1362 | - def set_password(self, service, username, password): |
1363 | - raise RuntimeError |
1364 | - |
1365 | - |
1366 | -@contextmanager |
1367 | -def fake_keyring(fake): |
1368 | - original_keyring = launchpadlib.credentials.keyring |
1369 | - launchpadlib.credentials.keyring = fake |
1370 | - yield |
1371 | - launchpadlib.credentials.keyring = original_keyring |
1372 | + def test_max_failed_attempts_accepted(self): |
1373 | + # You can pass in a value for the 'max_failed_attempts' |
1374 | + # argument, even though that argument doesn't do anything. |
1375 | + NoNetworkLaunchpad.login_with( |
1376 | + 'not important', max_failed_attempts=5) |
1377 | + |
1378 | + |
1379 | +class TestDeprecatedLoginMethods(KeyringTest): |
1380 | + """Make sure the deprecated login methods still work.""" |
1381 | + |
1382 | + def test_login_is_deprecated(self): |
1383 | + # login() works but triggers a deprecation warning. |
1384 | + with warnings.catch_warnings(record=True) as caught: |
1385 | + warnings.simplefilter("always") |
1386 | + launchpad = NoNetworkLaunchpad.login( |
1387 | + 'consumer', 'token', 'secret') |
1388 | + self.assertEquals(len(caught), 1) |
1389 | + self.assertEquals(caught[0].category, DeprecationWarning) |
1390 | + |
1391 | + def test_get_token_and_login_is_deprecated(self): |
1392 | + # get_token_and_login() works but triggers a deprecation warning. |
1393 | + with warnings.catch_warnings(record=True) as caught: |
1394 | + warnings.simplefilter("always") |
1395 | + launchpad = NoNetworkLaunchpad.get_token_and_login('consumer') |
1396 | + self.assertEquals(len(caught), 1) |
1397 | + self.assertEquals(caught[0].category, DeprecationWarning) |
1398 | |
1399 | |
1400 | class TestCredenitialSaveFailedCallback(unittest.TestCase): |
1401 | @@ -578,7 +613,6 @@ |
1402 | keyring = InMemoryKeyring() |
1403 | # Be paranoid about the keyring starting out empty. |
1404 | assert not keyring.data, 'oops, a fresh keyring has data in it' |
1405 | - service_root = 'http://api.example.com/' |
1406 | with fake_keyring(keyring): |
1407 | # Create stored credentials for the same application but against |
1408 | # two different sites (service roots). |
Hi Leonard,
This change looks good. I did find one problem with the exception handling though.
I am not sure if I like how the bare except clause in CredentialStore .save() does not re-raise exceptions. I hope EXPLOSIVE_ERRORS is comprehensive! If I override do_save() in a subclass and raise a logic error at runtime (TypeError None or something), won't save() just eat it?
On a related note, upon reading the sourcecode, I noticed that EXPLOSIVE_ERRORS is just the three key errors (MemoryError, KeyboardInterrupt, SystemExit). Does launchpadlib still support Python2.5? If you use Python2.6 only, then I do not believe you need to worry about these.
I saw one small docstring enhancement: you may want to leave a note in the save() docstring that says "Developers should not override this. They should override do_save() instead". It helps to be extra clear when showing two public methods like this, with one hookable, one not.
With the above questions about exception handling considered, r=mars.
Maris