Merge lp:~leonardr/launchpadlib/separate-login-for-cronjob into lp:launchpadlib

Proposed by Leonard Richardson
Status: Merged
Approved by: Gavin Panella
Approved revision: 128
Merged at revision: 103
Proposed branch: lp:~leonardr/launchpadlib/separate-login-for-cronjob
Merge into: lp:launchpadlib
Diff against target: 1325 lines (+638/-234)
7 files modified
src/launchpadlib/NEWS.txt (+7/-0)
src/launchpadlib/credentials.py (+139/-67)
src/launchpadlib/launchpad.py (+200/-95)
src/launchpadlib/testing/helpers.py (+53/-6)
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/separate-login-for-cronjob
Reviewer Review Type Date Requested Status
Gavin Panella Approve
Review via email: mp+44592@code.launchpad.net

Description of the change

This branch has three interrelated purposes:

1. Currently the "request token authorization engine" object includes two strategies: a strategy for authorizing an OAuth request token, and a strategy for storing the resulting launchpadlib credentials locally. This branch decouples those two strategies. The strategy for storing launchpadlib credentials is now kept in a separate object, the "credential store". The code from RequestTokenAuthorizationEngine that stores credentials in the GNOME keyring is moved into the KeyringCredentialStore.

2. Currently there is no way to store launchpadlib credentials unencrypted in a file on disk. We deliberately got rid of this feature for security reasons, but there is a legitimate case for this: see bug 686690. This branch introduces a new credential store, the UnencryptedFileCredentialStore, which has the old behavior.

3. The difference between storing launchpadlib credentials unencrypted on disk vs. in the keyring is a big, big difference. We can't treat it as the difference between passing in a filename to login_with() and not passing in a filename. So I've split login_with() into two methods: login_securely() and login_insecurely(). You use login_insecurely() when you must: when your script must run without any user interaction whatsoever. You use login_securely() when you can.

I've taken this opportunity to deprecate most of the old login helpers: login_with(), login(), and get_token_and_login(). These methods arose over time to make the login code easier to write, and they weren't planned out. This is a good opportunity to start phasing them out. There are now three login helpers, one for each approach to credential security: login_insecurely(), login_securely(), and login_anonymously() (which doesn't get a credential at all).

Since I'm defining new login methods, I've also taken this opportunity to reorder the arguments they accept. Over the past couple of years we've added lots of arguments to the end of the login_with() signature, and it got very disorganized.

4. Other notes:

I moved fake_keyring, FauxSocketModule, BadSaveKeyring, and InMemoryKeyring from test_launchpad.py to testing/helpers.py.

I will need to do a follow-up Launchpad branch to get it to use the new methods, and a follow-up launchpadlib branch to stop KnownTokens from using the deprecated login() method. (I put an XXX in this part of the code).

To post a comment you must log in.
128. By Leonard Richardson

Merge from trunk.

Revision history for this message
Robert Collins (lifeless) wrote :

Reading the cover, login_insecurely and login_securely seem like value
judgements rather than api changes.

Isn't the real difference
login-and-can-prompt-for-credentials
login-using-cached-credentials-only
?

After all, disk files in an encrypted per-account system are
approximately as secure as those in the gnome keyring (both need only
compromise the account to access the credentials).

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

I'm not attached to the current method names at all. That said, the difference is not quite "login-and-can-prompt-for-credentials" versus "login-using-cached-credentials-only". The difference is which credential store is used to load *and save* the credential.

Even though login_insecurely is "the one you should use for cron scripts", if there is no credential it will still cause a browser open and require user input. So we can't use my original ideas for names, "login_interactive" and "login_non_interactive", nor anything like "login_using_cached_credentials_only".

My principles:

1. The difference between the methods is "look in the keyring" versus "look in an unencrypted (as far as launchpadlib knows) file".
2, I would like to guide people towards the keyring method. The unencrypted file method should be for people who have problems with the keyring method.

Related thoughts on encrypted filesystems:

I've got an encrypted home directory. I set up a cronjob to run a launchpadlib script. I use login_insecurely(), but I store my credential inside my home directory. Because I have an encrypted home directory, it's not really 'insecure'--it's about as secure as stuff in my keyring.

But the cronjob will only run when my home directory is unencrypted, ie. when I'm logged in. When I'm not logged in, the credential will be unreadable and the cronjob will fail. If I want the cronjob to run all the time, I need to store the credential in an unencrypted filesystem. I think that's the common cron scenario, and I'd say that is 'insecure', relatively speaking.

So, I don't think the existing names are terribly misleading. You can use login_insecurely() in a secure way, if you'll be logged in whenever the script runs. But if you're already logged in, login_securely() will work just as well. And the common case for login_securely() *is* more secure than the common case for login_insecurely().

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (5.1 KiB)

The new CredentialStore stuff is cool.

I've got lots of comments, but they're all fairly minor.

[1]

+ * Launchpad.login_securely, for programs that are run with user
+ interaction.
+ * Launchpad.login_insecurely, for programs that must run with no
+ user interaction.

These names worry me. I don't want to debate these names if they have
already been discussed. I just want to say: if I am the only person to
have seen them then I think they need some discussion, though not a
lot.

[2]

+
+
+ def do_load(self, unique_key):

Dangerous extra blank line.

[3]

Some lint:

credentials.py:29: 'base64' imported but unused
credentials.py:34: 'sys' imported but unused
credentials.py:35: 'textwrap' imported but unused
credentials.py:37: 'quote' imported but unused
launchpad.py:25: 'socket' imported but unused
launchpad.py:26: 'stat' imported but unused
launchpad.py:29: 'HostedFile' imported but unused
launchpad.py:29: 'ScalarValue' imported but unused
launchpad.py:36: 'SystemWideConsumer' imported but unused
launchpad.py:52: 'STAGING_SERVICE_ROOT' imported but unused
launchpad.py:52: 'EDGE_SERVICE_ROOT' imported but unused
testing/helpers.py:37: 'simplejson' imported but unused
testing/helpers.py:41: 'AuthorizeRequestTokenWithBrowser' imported but unused
testing/helpers.py:41: 'Credentials' imported but unused
tests/test_launchpad.py:21: 'StringIO' imported but unused
tests/test_launchpad.py:27: 'textwrap' imported but unused
tests/test_launchpad.py:226: local variable 'launchpad' is assigned to but never used
tests/test_launchpad.py:253: local variable 'launchpad' is assigned to but never used
tests/test_launchpad.py:263: local variable 'launchpad' is assigned to but never used
tests/test_launchpad.py:320: local variable 'launchpad' is assigned to but never used
tests/test_launchpad.py:534: local variable 'launchpad' is assigned to but never used
tests/test_launchpad.py:618: local variable 'service_root' is assigned to but never used

[4]

+ def __init__(self, file, credential_save_failed=None):
+ super(UnencryptedFileCredentialStore, self).__init__(
+ credential_save_failed)
+ self.file = file

Because file() is a builtin function/type consider using filename or
filepath instead, or something like that.

[5]

+ This method is deprecated as of launchpadlib version
+ 1.9.0. You should use Launchpad.login_anonymously() for
+ anonymous access, Launchpad.login_insecurely() for scripts
+ that need to run without any user interaction, and
+ Launchpad.login_securely() for all other purposes, possibly
+ specifying a custom authorization_engine or credential_store.

Perhaps it's worth putting some or all of this into a deprecation
warning, warnings.warn("...", DeprecationWarning)?

[6]

+@contextmanager
+def fake_keyring(fake):
+ original_keyring = launchpadlib.credentials.keyring
+ launchpadlib.credentials.keyring = fake
+ yield
+ launchpadlib.credentials.keyring = original_keyring

This will break when a test fails because the yield can raise an
exception. Something like the following would work better I think:

@contextmanager
def fake_keyring(fake):
    orig...

Read more...

review: Approve
Revision history for this message
Robert Collins (lifeless) wrote :

So, these names then are essentially aliases to (pseudocode)
login(... store=keyringstore)
login(... store=filestore)

why not expose that instead.
login(... store=None)
"""
store: a store to request credentials from. If None, GnomeKeyringStore
will be used, as the most reliable and secure store we have available
today. Using a different store (see <...> for a list of stores) is
needed when running scripts outside of a desktop context such as from
cron.
"""

?

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

> So, these names then are essentially aliases to (pseudocode)
> login(... store=keyringstore)
> login(... store=filestore)
>
> why not expose that instead.
> login(... store=None)
> """
> store: a store to request credentials from. If None, GnomeKeyringStore
> will be used, as the most reliable and secure store we have available
> today. Using a different store (see <...> for a list of stores) is
> needed when running scripts outside of a desktop context such as from
> cron.
> """

I'm fine with this. The main problem is that we have already staked out the namespace of generic names. login() and login_with() are existing methods that I hope to deprecate with this branch.

I think it wouldn't be _too_ bad to change the behavior of login() in 1.9.0, since everyone I know of uses login_with(). We could even call it 2.0.0. (But, I think a 3.0.0 would not be far behind--I was planning to make another big backward-incompatible change, though I don't remember what it was at the moment.)

Without an alias for cron usage, the code to run a cron script would look like this:

store = UnencryptedFileCredentialStore(credentials_file)
launchpad = Launchpad.login("my application" "production", credential_store=store)

Martin, are you OK with this?

129. By Leonard Richardson

Response to feedback: lint cleanup, (hackily) replace mktemp with mkstemp.

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

I've addressed all of your comments except for [10]. I need to add
tests for the deprecation warning, which I will do with the
catch_warnings context manager. I also have some unknown test failures that
I don't remember having when I left for vacation last year, and I need
to look into those.

I got rid of most of the lint. launchpad.HostedFile and
launchpad.ScalarValue are re-imported from lazr.restfulclient for
client convenience, so I added comments to that effect.

Re: mkstemp. I deliberately avoided mkstemp because mkstemp creates an
empty file. lazr.restfulclient's Credentials.load_from_file() crashes
if you give it an empty file. Should I do a lazr.restfulclient release
and make load_from_path() treat an empty file the same as a
nonexistant file?

In the meantime, I have put a hack in place to make mkstemp work on
the launchpadlib level.

See my previous comment re: the method names. I'm okay with one
generic name, but we took the most generic name ("login") in the first
version of launchpadlib, and then we took another generic name
("login_with") in a later revision.

Revision history for this message
Gavin Panella (allenap) wrote :

> Re: mkstemp. I deliberately avoided mkstemp because mkstemp creates
> an empty file. lazr.restfulclient's Credentials.load_from_file()
> crashes if you give it an empty file. Should I do a
> lazr.restfulclient release and make load_from_path() treat an empty
> file the same as a nonexistant file?
>
> In the meantime, I have put a hack in place to make mkstemp work on
> the launchpadlib level.

An alternate approach might be to safely create a temporary directory
with tempfile.mkdtemp() then use static names within.

[13]

+ warnings.warn(
+ ("The Launchpad.%s() method is deprecated. You should use "
+ "Launchpad.login_anonymously() for anonymous access, "
+ "Launchpad.login_insecurely() for scripts that need to run "
+ "without any user interaction, and Launchpad.login_securely() "
+ "for all other purposes.") % name)

I haven't used warnings much before so I don't know if it works well
in practice, but you could pass DeprecationWarning as a second
argument to warn(). I think this makes it easier for users to filter
it out without missing other warnings.

130. By Leonard Richardson

Re-activated deprecation warning.

131. By Leonard Richardson

Fix test failures, which were caused by cached responses from actual usage being sent to the NoNetworkLaunchpad.

132. By Leonard Richardson

Finally got rid of all test failures and unxpected deprecation warnings.

133. By Leonard Richardson

Removed login_insecurely and login_securely. They are both now part of login_with(), which has been un-deprecated.

134. By Leonard Richardson

We don't need to test credential_save_failed twice now that there's only one method.

135. By Leonard Richardson

Updated the NEWS to reflect my most recent change.

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

After evaluating my options I've decided to un-deprecate login_with(). This method acts like login_insecurely() when you specify a file to store the credential in, and acts like login_securely() when you don't. That's a perfect description of what I wanted login() to do.

Once we get rid of login(), we'll have space to come back to this problem and call the resulting method login(). At the very least, we need to rationalize the arguments to login_with(), which are out of control and presented in random order since we kept tacking arguments on to the end of the list.

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

Subscribers

People subscribed via source and target branches