Merge lp:~leonardr/launchpadlib/launchpad-integration into lp:launchpadlib

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
Reviewer Review Type Date Requested Status
Māris Fogels (community) Approve
Review via email: mp+45176@code.launchpad.net

Description of the change

This branch makes some minor changes to launchpadlib that I made while integrating my branch https://code.launchpad.net/~leonardr/launchpadlib/separate-login-for-cronjob into Launchpad.

* 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_engine.
* 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
Māris Fogels (mars) wrote :

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

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

Good catch. I've changed the behavior to raise the exception if credential_save_failed is not present, and added a test. I've also changed the docstring.

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).

Subscribers

People subscribed via source and target branches