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
=== modified file 'src/launchpadlib/NEWS.txt'
--- src/launchpadlib/NEWS.txt 2010-12-20 12:41:52 +0000
+++ src/launchpadlib/NEWS.txt 2011-01-04 19:04:27 +0000
@@ -11,6 +11,13 @@
1111
12- The HTML generated by wadl-to-refhtml.xsl now validates.12- The HTML generated by wadl-to-refhtml.xsl now validates.
1313
14- Most of the helper login methods have been deprecated. There are now
15 only two helper methods:
16
17 * Launchpad.login_anonymously, for anonymous credential-free access.
18 * Launchpad.login_with, for programs that need a credential.
19
20
141.8.0 (2010-11-15)211.8.0 (2010-11-15)
15==================22==================
1623
1724
=== modified file 'src/launchpadlib/credentials.py'
--- src/launchpadlib/credentials.py 2010-12-16 22:33:31 +0000
+++ src/launchpadlib/credentials.py 2011-01-04 19:04:27 +0000
@@ -21,19 +21,19 @@
21 'AccessToken',21 'AccessToken',
22 'AnonymousAccessToken',22 'AnonymousAccessToken',
23 'AuthorizeRequestTokenWithBrowser',23 'AuthorizeRequestTokenWithBrowser',
24 'CredentialStore',
24 'RequestTokenAuthorizationEngine',25 'RequestTokenAuthorizationEngine',
25 'Consumer',26 'Consumer',
26 'Credentials',27 'Credentials',
27 ]28 ]
2829
29import base64
30import cgi30import cgi
31from cStringIO import StringIO31from cStringIO import StringIO
32import httplib232import httplib2
33import sys33import os
34import textwrap34import stat
35import time35import time
36from urllib import urlencode, quote36from urllib import urlencode
37from urlparse import urljoin37from urlparse import urljoin
38import webbrowser38import webbrowser
3939
@@ -212,6 +212,122 @@
212 super(AnonymousAccessToken, self).__init__('','')212 super(AnonymousAccessToken, self).__init__('','')
213213
214214
215class CredentialStore(object):
216 """Store OAuth credentials locally.
217
218 This is a generic superclass. To implement a specific way of
219 storing credentials locally you'll need to subclass this class,
220 and implement `do_save` and `do_load`.
221 """
222
223 def __init__(self, credential_save_failed=None):
224 """Constructor.
225
226 :param credential_save_failed: A callback to be invoked if the
227 save to local storage fails. You should never invoke this
228 callback yourself! Instead, you should raise an exception
229 from do_save().
230 """
231 self.credential_save_failed = credential_save_failed
232
233 def save(self, credentials, unique_consumer_id):
234 """Save the credentials and invoke the callback on failure."""
235 try:
236 self.do_save(credentials, unique_consumer_id)
237 except EXPLOSIVE_ERRORS:
238 raise
239 except:
240 if self.credential_save_failed is not None:
241 self.credential_save_failed()
242 return credentials
243
244 def do_save(self, credentials, unique_consumer_id):
245 """Store newly-authorized credentials locally for later use.
246
247 :param credentials: A Credentials object to save.
248 :param unique_consumer_id: A string uniquely identifying an
249 OAuth consumer on a Launchpad instance.
250 """
251 raise NotImplementedError()
252
253 def load(self, unique_key):
254 """Retrieve credentials from a local store.
255
256 This method is the inverse of `save`.
257
258 There's no special behavior in this method--it just calls
259 `do_load`. There _is_ special behavior in `save`, and this
260 way, developers can remember to implement `do_save` and
261 `do_load`, not `do_save` and `load`.
262
263 :param unique_key: A string uniquely identifying an OAuth consumer
264 on a Launchpad instance.
265
266 :return: A `Credentials` object if one is found in the local
267 store, and None otherise.
268 """
269 return self.do_load(unique_key)
270
271 def do_load(self, unique_key):
272 """Retrieve credentials from a local store.
273
274 This method is the inverse of `do_save`.
275
276 :param unique_key: A string uniquely identifying an OAuth consumer
277 on a Launchpad instance.
278
279 :return: A `Credentials` object if one is found in the local
280 store, and None otherise.
281 """
282 raise NotImplementedError()
283
284
285class KeyringCredentialStore(CredentialStore):
286 """Store credentials in the GNOME keyring or KDE wallet.
287
288 This is a good solution for desktop applications and interactive
289 scripts. It doesn't work for non-interactive scripts, or for
290 integrating third-party websites into Launchpad.
291 """
292
293 def do_save(self, credentials, unique_key):
294 """Store newly-authorized credentials in the keyring."""
295 keyring.set_password(
296 'launchpadlib', unique_key, credentials.serialize())
297
298 def do_load(self, unique_key):
299 """Retrieve credentials from the keyring."""
300 credential_string = keyring.get_password(
301 'launchpadlib', unique_key)
302 if credential_string is not None:
303 return Credentials.from_string(credential_string)
304 return None
305
306
307class UnencryptedFileCredentialStore(CredentialStore):
308 """Store credentials unencrypted in a file on disk.
309
310 This is a good solution for scripts that need to run without any
311 user interaction.
312 """
313
314 def __init__(self, filename, credential_save_failed=None):
315 super(UnencryptedFileCredentialStore, self).__init__(
316 credential_save_failed)
317 self.filename = filename
318
319 def do_save(self, credentials, unique_key):
320 """Save the credentials to disk."""
321 credentials.save_to_path(self.filename)
322
323 def do_load(self, unique_key):
324 """Load the credentials from disk."""
325 if (os.path.exists(self.filename)
326 and not os.stat(self.filename)[stat.ST_SIZE] == 0):
327 return Credentials.load_from_path(self.filename)
328 return None
329
330
215class RequestTokenAuthorizationEngine(object):331class RequestTokenAuthorizationEngine(object):
216 """The superclass of all request token authorizers.332 """The superclass of all request token authorizers.
217333
@@ -219,18 +335,12 @@
219 since that varies depending on how you want the end-user to335 since that varies depending on how you want the end-user to
220 authorize a request token. You'll need to subclass this class and336 authorize a request token. You'll need to subclass this class and
221 implement `make_end_user_authorize_token`.337 implement `make_end_user_authorize_token`.
222
223 This class does implement a default strategy for storing
224 authorized tokens in the GNOME keyring or KDE Wallet, but you can
225 override this by implementing `store_credentials_locally` and
226 `retrieve_credentials_from_local_store`.
227 """338 """
228339
229 UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"340 UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"
230341
231 def __init__(self, service_root, application_name=None,342 def __init__(self, service_root, application_name=None,
232 consumer_name=None, credential_save_failed=None,343 consumer_name=None, allow_access_levels=None):
233 allow_access_levels=None):
234 """Base class initialization.344 """Base class initialization.
235345
236 :param service_root: The root of the Launchpad instance being346 :param service_root: The root of the Launchpad instance being
@@ -250,12 +360,6 @@
250 integration. The exception is when you're integrating a360 integration. The exception is when you're integrating a
251 third-party website into Launchpad.361 third-party website into Launchpad.
252362
253 :param credential_save_failed: A callback method to be invoked
254 if the credentials cannot be stored locally.
255
256 You should not invoke this method yourself; instead, you
257 should raise an exception in `store_credentials_locally()`.
258
259 :param allow_access_levels: A list of the Launchpad access363 :param allow_access_levels: A list of the Launchpad access
260 levels to present to the user. ('READ_PUBLIC' and so on.)364 levels to present to the user. ('READ_PUBLIC' and so on.)
261 Your value for this argument will be ignored during a365 Your value for this argument will be ignored during a
@@ -272,7 +376,8 @@
272 if application_name is not None and consumer_name is not None:376 if application_name is not None and consumer_name is not None:
273 raise ValueError(377 raise ValueError(
274 "You must provide only one of application_name and "378 "You must provide only one of application_name and "
275 "consumer_name.")379 "consumer_name. (You provided %r and %r.)" % (
380 application_name, consumer_name))
276381
277 if consumer_name is None:382 if consumer_name is None:
278 # System-wide integration. Create a system-wide consumer383 # System-wide integration. Create a system-wide consumer
@@ -290,7 +395,11 @@
290 self.application_name = application_name395 self.application_name = application_name
291396
292 self.allow_access_levels = allow_access_levels or []397 self.allow_access_levels = allow_access_levels or []
293 self.credential_save_failed = credential_save_failed398
399 @property
400 def unique_consumer_id(self):
401 """Return a string identifying this consumer on this host."""
402 return self.consumer.key + '@' + self.service_root
294403
295 def authorization_url(self, request_token):404 def authorization_url(self, request_token):
296 """Return the authorization URL for a request token.405 """Return the authorization URL for a request token.
@@ -307,17 +416,22 @@
307 + allow_permission.join(self.allow_access_levels))416 + allow_permission.join(self.allow_access_levels))
308 return urljoin(self.web_root, page)417 return urljoin(self.web_root, page)
309418
310 def __call__(self, credentials):419 def __call__(self, credentials, credential_store):
311 """Authorize a token and associate it with the given credentials.420 """Authorize a token and associate it with the given credentials.
312421
313 The `credential_save_failed` callback will be invoked if422 If the credential store runs into a problem storing the
314 there's a problem storing the credentials locally. It will not423 credential locally, the `credential_save_failed` callback will
315 be invoked if there's a problem authorizing the credentials.424 be invoked. The callback will not be invoked if there's a
425 problem authorizing the credentials.
316426
317 :param credentials: A `Credentials` object. If the end-user427 :param credentials: A `Credentials` object. If the end-user
318 authorizes these credentials, this object will have its428 authorizes these credentials, this object will have its
319 .access_token property set.429 .access_token property set.
320430
431 :param credential_store: A `CredentialStore` object. If the
432 end-user authorizes the credentials, they will be
433 persisted locally using this object.
434
321 :return: If the credentials are successfully authorized, the435 :return: If the credentials are successfully authorized, the
322 return value is the `Credentials` object originally passed436 return value is the `Credentials` object originally passed
323 in. Otherwise the return value is None.437 in. Otherwise the return value is None.
@@ -328,13 +442,8 @@
328 if credentials.access_token is None:442 if credentials.access_token is None:
329 # The end-user refused to authorize the application.443 # The end-user refused to authorize the application.
330 return None444 return None
331 try:445 # save() invokes the callback on failure.
332 self.store_credentials_locally(credentials)446 credential_store.save(credentials, self.unique_consumer_id)
333 except EXPLOSIVE_ERRORS:
334 raise
335 except:
336 if self.credential_save_failed is not None:
337 self.credential_save_failed()
338 return credentials447 return credentials
339448
340 def get_request_token(self, credentials):449 def get_request_token(self, credentials):
@@ -363,43 +472,6 @@
363 """472 """
364 raise NotImplementedError()473 raise NotImplementedError()
365474
366 def store_credentials_locally(self, credentials):
367 """Store newly-authorized credentials for later use.
368
369 By default, credentials are stored in the GNOME keyring or KDE
370 Wallet. This is a good solution for desktop applications and
371 local scripts, but if you are integrating a third-party
372 website into Launchpad you'll need to implement something else
373 (such as storing the credentials in a database).
374 """
375 keyring.set_password(
376 'launchpadlib',
377 (credentials.consumer.key + '@' + self.service_root),
378 credentials.serialize())
379
380 def retrieve_credentials_from_local_store(self):
381 """Retrieve credentials from a local store.
382
383 This method is the inverse of `store_credentials_locally`. The
384 default implementation looks for credentials stored in the
385 GNOME keyring or KDE Wallet.
386
387 :return: A `Credentials` object if one is found in the local
388 store, and None otherise.
389 """
390 credential_string = keyring.get_password(
391 'launchpadlib', self.consumer.key + '@' + self.service_root)
392 if credential_string is not None:
393 credentials = Credentials.from_string(credential_string)
394 # The application name wasn't stored locally, because in a
395 # desktop integration scenario, a single set of
396 # credentials may be shared by many applications. We need
397 # to set the application name for this specific instance
398 # of the credentials.
399 credentials.consumer.application_name = self.application_name
400 return credentials
401 return None
402
403475
404class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):476class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):
405 """The simplest (and, right now, the only) request token authorizer.477 """The simplest (and, right now, the only) request token authorizer.
406478
=== modified file 'src/launchpadlib/launchpad.py'
--- src/launchpadlib/launchpad.py 2010-12-16 21:47:02 +0000
+++ src/launchpadlib/launchpad.py 2011-01-04 19:04:27 +0000
@@ -22,24 +22,23 @@
22 ]22 ]
2323
24import os24import os
25import socket
26import stat
27import urlparse25import urlparse
26import warnings
2827
29from lazr.restfulclient.resource import (28from lazr.restfulclient.resource import (
30 CollectionWithKeyBasedLookup,29 CollectionWithKeyBasedLookup,
31 HostedFile,30 HostedFile, # Re-import for client convenience
32 ScalarValue,31 ScalarValue, # Re-import for client convenience
33 ServiceRoot,32 ServiceRoot,
34 )33 )
35from lazr.restfulclient._browser import RestfulHttp34from lazr.restfulclient._browser import RestfulHttp
36from launchpadlib.credentials import (35from launchpadlib.credentials import (
37 AccessToken,36 AccessToken,
38 AnonymousAccessToken,37 AnonymousAccessToken,
39 AuthorizeRequestTokenWithBrowser,
40 Consumer,38 Consumer,
41 Credentials,39 Credentials,
42 SystemWideConsumer,40 KeyringCredentialStore,
41 UnencryptedFileCredentialStore,
43 )42 )
44from launchpadlib import uris43from launchpadlib import uris
4544
@@ -135,7 +134,8 @@
135 and self.authorization_engine is not None):134 and self.authorization_engine is not None):
136 # This access token is bad. Scrap it and create a new one.135 # This access token is bad. Scrap it and create a new one.
137 self.launchpad.credentials.access_token = None136 self.launchpad.credentials.access_token = None
138 self.authorization_engine(self.launchpad.credentials)137 self.authorization_engine(
138 self.launchpad.credentials, self.launchpad.credential_store)
139 # Retry the request with the new credentials.139 # Retry the request with the new credentials.
140 return self._request(*args)140 return self._request(*args)
141 return response, content141 return response, content
@@ -160,7 +160,7 @@
160 RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES)160 RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES)
161161
162 def __init__(self, credentials, authorization_engine,162 def __init__(self, credentials, authorization_engine,
163 service_root=uris.STAGING_SERVICE_ROOT,163 credential_store, service_root=uris.STAGING_SERVICE_ROOT,
164 cache=None, timeout=None, proxy_info=None,164 cache=None, timeout=None, proxy_info=None,
165 version=DEFAULT_VERSION):165 version=DEFAULT_VERSION):
166 """Root access to the Launchpad API.166 """Root access to the Launchpad API.
@@ -186,6 +186,8 @@
186 "the version name from the root URI." % version)186 "the version name from the root URI." % version)
187 raise ValueError(error)187 raise ValueError(error)
188188
189 self.credential_store = credential_store
190
189 # We already have an access token, but it might expire or191 # We already have an access token, but it might expire or
190 # become invalid during use. Store the authorization engine in192 # become invalid during use. Store the authorization engine in
191 # case we need to authorize a new token during use.193 # case we need to authorize a new token during use.
@@ -204,18 +206,27 @@
204 return AuthorizeRequestTokenWithBrowser(*args)206 return AuthorizeRequestTokenWithBrowser(*args)
205207
206 @classmethod208 @classmethod
209 def credential_store_factory(cls, credential_save_failed):
210 return KeyringCredentialStore(credential_save_failed)
211
212 @classmethod
207 def login(cls, consumer_name, token_string, access_secret,213 def login(cls, consumer_name, token_string, access_secret,
208 service_root=uris.STAGING_SERVICE_ROOT,214 service_root=uris.STAGING_SERVICE_ROOT,
209 cache=None, timeout=None, proxy_info=None,215 cache=None, timeout=None, proxy_info=None,
210 authorization_engine=None, allow_access_levels=None,216 authorization_engine=None, allow_access_levels=None,
211 max_failed_attempts=None, credential_save_failed=None,217 max_failed_attempts=None, credential_store=None,
212 version=DEFAULT_VERSION):218 credential_save_failed=None, version=DEFAULT_VERSION):
213 """Convenience method for setting up access credentials.219 """Convenience method for setting up access credentials.
214220
215 When all three pieces of credential information (the consumer221 When all three pieces of credential information (the consumer
216 name, the access token and the access secret) are available, this222 name, the access token and the access secret) are available, this
217 method can be used to quickly log into the service root.223 method can be used to quickly log into the service root.
218224
225 This method is deprecated as of launchpadlib version
226 1.9.0. You should use Launchpad.login_anonymously() for
227 anonymous access, and Launchpad.login_with() for all other
228 purposes.
229
219 :param consumer_name: the application name.230 :param consumer_name: the application name.
220 :type consumer_name: string231 :type consumer_name: string
221 :param token_string: the access token, as appropriate for the232 :param token_string: the access token, as appropriate for the
@@ -237,36 +248,33 @@
237 :return: The web service root248 :return: The web service root
238 :rtype: `Launchpad`249 :rtype: `Launchpad`
239 """250 """
251 cls._warn_of_deprecated_login_method("login")
240 access_token = AccessToken(token_string, access_secret)252 access_token = AccessToken(token_string, access_secret)
241 credentials = Credentials(253 credentials = Credentials(
242 consumer_name=consumer_name, access_token=access_token)254 consumer_name=consumer_name, access_token=access_token)
243 if authorization_engine is None:255 if authorization_engine is None:
244 authorization_engine = cls.authorization_engine_factory(256 authorization_engine = cls.authorization_engine_factory(
245 service_root, None, consumer_name, allow_access_levels,257 service_root, consumer_name, allow_access_levels)
258 if credential_store is None:
259 credential_store = cls.credential_store_factory(
246 credential_save_failed)260 credential_save_failed)
247 return cls(credentials, authorization_engine, service_root, cache,261 return cls(credentials, authorization_engine, credential_store,
248 timeout, proxy_info, version)262 service_root, cache, timeout, proxy_info, version)
249263
250 @classmethod264 @classmethod
251 def get_token_and_login(cls, consumer_name,265 def get_token_and_login(cls, consumer_name,
252 service_root=uris.STAGING_SERVICE_ROOT,266 service_root=uris.STAGING_SERVICE_ROOT,
253 cache=None, timeout=None, proxy_info=None,267 cache=None, timeout=None, proxy_info=None,
254 authorization_engine=None, allow_access_levels=[],268 authorization_engine=None, allow_access_levels=[],
255 max_failed_attempts=None,269 max_failed_attempts=None, credential_store=None,
256 credential_save_failed=None,270 credential_save_failed=None,
257 version=DEFAULT_VERSION):271 version=DEFAULT_VERSION):
258 """Get credentials from Launchpad and log into the service root.272 """Get credentials from Launchpad and log into the service root.
259273
260 This method is deprecated as of launchpadlib version 1.9.0. A274 This method is deprecated as of launchpadlib version
261 launchpadlib application running on the end-user's computer275 1.9.0. You should use Launchpad.login_anonymously() for
262 should use `Launchpad.login_with()`. A launchpadlib276 anonymous access and Launchpad.login_with() for all other
263 application running on a web server, integrating Launchpad277 purposes.
264 into some other website, should use
265 `Credentials.get_request_token()` to obtain the authorization
266 URL and
267 `Credentials.exchange_request_token_for_access_token()` to
268 obtain the actual OAuth access token. Then you can call
269 `Launchpad.login()`.
270278
271 :param consumer_name: Either a consumer name, as appropriate for279 :param consumer_name: Either a consumer name, as appropriate for
272 the `Consumer` constructor, or a premade Consumer object.280 the `Consumer` constructor, or a premade Consumer object.
@@ -279,11 +287,28 @@
279 `credential_save_failed`.287 `credential_save_failed`.
280 :param allow_access_levels: This argument is ignored, and only288 :param allow_access_levels: This argument is ignored, and only
281 present to preserve backwards compatibility.289 present to preserve backwards compatibility.
282 :param max_failed_attempts: This argument is ignored, and only
283 present to preserve backwards compatibility.
284 :return: The web service root290 :return: The web service root
285 :rtype: `Launchpad`291 :rtype: `Launchpad`
286 """292 """
293 cls._warn_of_deprecated_login_method("get_token_and_login")
294 return cls._authorize_token_and_login(
295 consumer_name, service_root, cache, timeout, proxy_info,
296 authorization_engine, allow_access_levels,
297 credential_store, credential_save_failed, version)
298
299 @classmethod
300 def _authorize_token_and_login(
301 cls, consumer_name, service_root, cache, timeout, proxy_info,
302 authorization_engine, allow_access_levels, credential_store,
303 credential_save_failed, version):
304 """Authorize a request token. Log in with the resulting access token.
305
306 This is the private, non-deprecated implementation of the
307 deprecated method get_token_and_login(). Once
308 get_token_and_login() is removed, this code can be streamlined
309 and moved into its other call site, login_with().
310 """
311
287 if isinstance(consumer_name, Consumer):312 if isinstance(consumer_name, Consumer):
288 # Create the credentials with no Consumer, then set its .consumer313 # Create the credentials with no Consumer, then set its .consumer
289 # property directly.314 # property directly.
@@ -295,11 +320,23 @@
295 credentials = Credentials(consumer_name)320 credentials = Credentials(consumer_name)
296 if authorization_engine is None:321 if authorization_engine is None:
297 authorization_engine = cls.authorization_engine_factory(322 authorization_engine = cls.authorization_engine_factory(
298 service_root, None, consumer_name, allow_access_levels,323 service_root, consumer_name, None, allow_access_levels)
324 if credential_store is None:
325 credential_store = cls.credential_store_factory(
299 credential_save_failed)326 credential_save_failed)
300 credentials = authorization_engine(credentials)327 else:
301 return cls(credentials, authorization_engine, service_root, cache,328 # A credential store was passed in, so we won't be using
302 timeout, proxy_info, version)329 # any provided value for credential_save_failed. But at
330 # least make sure we weren't given a conflicting value,
331 # since that makes the calling code look confusing.
332 cls._assert_login_argument_consistency(
333 "credential_save_failed", credential_save_failed,
334 credential_store.credential_save_failed,
335 "credential_store")
336
337 credentials = authorization_engine(credentials, credential_store)
338 return cls(credentials, authorization_engine, credential_store,
339 service_root, cache, timeout, proxy_info, version)
303340
304 @classmethod341 @classmethod
305 def login_anonymously(342 def login_anonymously(
@@ -311,7 +348,7 @@
311 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)348 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
312 token = AnonymousAccessToken()349 token = AnonymousAccessToken()
313 credentials = Credentials(consumer_name, access_token=token)350 credentials = Credentials(consumer_name, access_token=token)
314 return cls(credentials, None, service_root=service_root,351 return cls(credentials, None, None, service_root=service_root,
315 cache=cache_path, timeout=timeout, proxy_info=proxy_info,352 cache=cache_path, timeout=timeout, proxy_info=proxy_info,
316 version=version)353 version=version)
317354
@@ -322,23 +359,36 @@
322 authorization_engine=None, allow_access_levels=None,359 authorization_engine=None, allow_access_levels=None,
323 max_failed_attempts=None, credentials_file=None,360 max_failed_attempts=None, credentials_file=None,
324 version=DEFAULT_VERSION, consumer_name=None,361 version=DEFAULT_VERSION, consumer_name=None,
325 credential_save_failed=None):362 credential_save_failed=None, credential_store=None):
326 """Log in to Launchpad with possibly cached credentials.363 """Log in to Launchpad, possibly acquiring and storing credentials.
327364
328 Use this method to get a `Launchpad` object if you are writing365 Use this method to get a `Launchpad` object. If the end-user
329 a desktop application or a script. If the end-user has no366 has no cached Launchpad credential, their browser will open
330 cached Launchpad credential, their browser will open and367 and they'll be asked to log in and authorize a desktop
331 they'll be asked to log in and authorize a desktop
332 integration. The authorized Launchpad credential will be368 integration. The authorized Launchpad credential will be
333 stored, so that the next time your program (or any other369 stored securely: in the GNOME keyring, the KDE Wallet, or in
334 program run by that user on the same computer) needs a370 an encrypted file on disk.
335 Launchpad credential, it will be retrieved from local storage.371
336372 The next time your program (or any other program run by that
337 By subclassing `RequestTokenAuthorizationEngine` and passing373 user on the same computer) invokes this method, the end-user
338 in an instance of the subclass as `authorization_engine`, you374 will be prompted to unlock their keyring (or equivalent), and
339 can change what happens when the end-user needs to authorize375 the credential will be retrieved from local storage and
340 the Launchpad credential, and you can change how the376 reused.
341 authorized credential is stored and retrieved locally.377
378 You can customize this behavior in three ways:
379
380 1. Pass in a filename to `credentials_file`. The end-user's
381 credential will be written to that file, and on subsequent
382 runs read from that file.
383
384 2. Subclass `CredentialStore` and pass in an instance of the
385 subclass as `credential_store`. This lets you change how
386 the end-user's credential is stored and retrieved locally.
387
388 3. Subclass `RequestTokenAuthorizationEngine` and pass in an
389 instance of the subclass as `authorization_engine`. This
390 lets you change change what happens when the end-user needs
391 to authorize the Launchpad credential.
342392
343 :param application_name: The application name. This is *not*393 :param application_name: The application name. This is *not*
344 the OAuth consumer name. Unless a consumer_name is also394 the OAuth consumer name. Unless a consumer_name is also
@@ -356,25 +406,14 @@
356 cache.406 cache.
357 :type launchpadlib_dir: string407 :type launchpadlib_dir: string
358408
359 :param authorization_engine: A strategy for getting the end-user to409 :param authorization_engine: A strategy for getting the
360 authorize an OAuth request token, for exchanging the410 end-user to authorize an OAuth request token, for
361 request token for an access token, and for storing the411 exchanging the request token for an access token, and for
362 access token locally so that it can be reused.412 storing the access token locally so that it can be
413 reused. By default, launchpadlib will open the end-user's
414 web browser to have them authorize the request token.
363 :type authorization_engine: `RequestTokenAuthorizationEngine`415 :type authorization_engine: `RequestTokenAuthorizationEngine`
364416
365 :param credential_save_failed: a callback that is called upon
366 a failure to save the credentials locally. This argument is
367 used to construct the default `authorization_engine`, so if
368 you pass in your own `authorization_engine` any value for
369 this argument will be ignored.
370 :type credential_save_failed: A callable
371
372 :param consumer_name: The consumer name, as appropriate for
373 the `Consumer` constructor. You probably don't want to
374 provide this, since providing it will prevent you from
375 taking advantage of desktop-wide integration.
376 :type consumer_name: string
377
378 :param allow_access_levels: The acceptable access levels for417 :param allow_access_levels: The acceptable access levels for
379 this application.418 this application.
380419
@@ -386,8 +425,32 @@
386425
387 :type allow_access_levels: list of strings426 :type allow_access_levels: list of strings
388427
389 :param credentials_file: ignored, only here for backward compatability428 :param max_failed_attempts: Ignored; only present for
390 :type credentials_file: string429 backwards compatibility.
430
431 :param credentials_file: The path to a file in which to store
432 this user's OAuth access token.
433
434 :param version: The version of the Launchpad web service to use.
435
436 :param consumer_name: The consumer name, as appropriate for
437 the `Consumer` constructor. You probably don't want to
438 provide this, since providing it will prevent you from
439 taking advantage of desktop-wide integration.
440 :type consumer_name: string
441
442 :param credential_save_failed: a callback that is called upon
443 a failure to save the credentials locally. This argument is
444 used to construct the default `credential_store`, so if
445 you pass in your own `credential_store` any value for
446 this argument will be ignored.
447 :type credential_save_failed: A callable
448
449 :param credential_store: A strategy for storing an OAuth
450 access token locally. By default, tokens are stored in the
451 GNOME keyring (or equivalent). If `credentials_file` is
452 provided, then tokens are stored unencrypted in that file.
453 :type credential_store: `CredentialStore`
391454
392 :return: A web service root authorized as the end-user.455 :return: A web service root authorized as the end-user.
393 :rtype: `Launchpad`456 :rtype: `Launchpad`
@@ -402,69 +465,111 @@
402 "At least one of application_name, consumer_name, or "465 "At least one of application_name, consumer_name, or "
403 "authorization_engine must be provided.")466 "authorization_engine must be provided.")
404467
468 if credentials_file is not None and credential_store is not None:
469 raise ValueError(
470 "At most one of credentials_file and credential_store "
471 "must be provided.")
472
473 if credential_store is None:
474 if credentials_file is not None:
475 # The end-user wants credentials stored in an
476 # unencrypted file.
477 credential_store = UnencryptedFileCredentialStore(
478 credentials_file, credential_save_failed)
479 else:
480 credential_store = cls.credential_store_factory(
481 credential_save_failed)
482 else:
483 # A credential store was passed in, so we won't be using
484 # any provided value for credential_save_failed. But at
485 # least make sure we weren't given a conflicting value,
486 # since that makes the calling code look confusing.
487 cls._assert_login_argument_consistency(
488 'credential_save_failed', credential_save_failed,
489 credential_store.credential_save_failed,
490 "credential_store")
491 credential_store = credential_store
492
405 if authorization_engine is None:493 if authorization_engine is None:
406 authorization_engine = cls.authorization_engine_factory(494 authorization_engine = cls.authorization_engine_factory(
407 service_root, application_name, consumer_name,495 service_root, application_name, consumer_name,
408 credential_save_failed, allow_access_levels)496 allow_access_levels)
409 else:497 else:
410 # An authorization engine was passed in, so we won't be498 # An authorization engine was passed in, so we won't be
411 # using any provided values for application_name,499 # using any provided values for application_name,
412 # consumer_name, or allow_access_levels. But at least make500 # consumer_name, or allow_access_levels. But at least make
413 # sure we weren't given conflicting values, since that501 # sure we weren't given conflicting values, since that
414 # makes the calling code look confusing.502 # makes the calling code look confusing.
415 cls._assert_login_with_argument_consistency(503 cls._assert_login_argument_consistency(
416 "application_name", application_name,504 "application_name", application_name,
417 authorization_engine.application_name)505 authorization_engine.application_name)
418506
419 cls._assert_login_with_argument_consistency(507 cls._assert_login_argument_consistency(
420 "consumer_name", consumer_name,508 "consumer_name", consumer_name,
421 authorization_engine.consumer.key)509 authorization_engine.consumer.key)
422510
423 cls._assert_login_with_argument_consistency(511 cls._assert_login_argument_consistency(
424 "allow_access_levels", allow_access_levels,512 "allow_access_levels", allow_access_levels,
425 authorization_engine.allow_access_levels)513 authorization_engine.allow_access_levels)
426514
427 credentials = authorization_engine.retrieve_credentials_from_local_store()515 credentials = credential_store.load(
516 authorization_engine.unique_consumer_id)
428517
429 if credentials is None:518 if credentials is None:
430 # Credentials were not found in the local store. Go519 # Credentials were not found in the local store. Go
431 # through the authorization process.520 # through the authorization process.
432 launchpad = cls.get_token_and_login(521 launchpad = cls._authorize_token_and_login(
433 authorization_engine.consumer, service_root=service_root,522 authorization_engine.consumer, service_root,
434 cache=cache_path, timeout=timeout, proxy_info=proxy_info,523 cache_path, timeout, proxy_info, authorization_engine,
435 authorization_engine=authorization_engine,524 allow_access_levels, credential_store,
436 version=version)525 credential_save_failed, version)
437 else:526 else:
527 # The application name wasn't stored locally, because in a
528 # desktop integration scenario, a single set of
529 # credentials may be shared by many applications. We need
530 # to set the application name for this specific instance
531 # of the credentials.
532 credentials.consumer.application_name = (
533 authorization_engine.application_name)
534
438 # Credentials were found in the local store. Create a535 # Credentials were found in the local store. Create a
439 # Launchpad object.536 # Launchpad object.
440 launchpad = cls(537 launchpad = cls(
441 credentials, authorization_engine, service_root=service_root,538 credentials, authorization_engine, credential_store,
442 cache=cache_path, timeout=timeout, proxy_info=proxy_info,539 service_root=service_root, cache=cache_path, timeout=timeout,
443 version=version)540 proxy_info=proxy_info, version=version)
444 return launchpad541 return launchpad
445542
446 @classmethod543 @classmethod
447 def _assert_login_with_argument_consistency(544 def _warn_of_deprecated_login_method(cls, name):
448 cls, argument_name, argument_value, authorization_engine_value):545 warnings.warn(
449 """Helper to find conflicting values passed into login_with.546 ("The Launchpad.%s() method is deprecated. You should use "
450547 "Launchpad.login_anonymous() for anonymous access and "
451 Many of the arguments to login_with are used to build an548 "Launchpad.login_with() for all other purposes.") % name,
452 authorization engine. If an authorization engine is passed in,549 DeprecationWarning)
453 many of the arguments become redundant. We'll allow redundant550
454 arguments through, but if a login_with argument *conflicts* with551 @classmethod
455 the argument in the provided authorization engine, we raise an552 def _assert_login_argument_consistency(
456 error.553 cls, argument_name, argument_value, object_value,
554 object_name="authorization engine"):
555 """Helper to find conflicting values passed into the login methods.
556
557 Many of the arguments to login_with are used to build other
558 objects--the authorization engine or the credential store. If
559 these objects are provided directly, many of the arguments
560 become redundant. We'll allow redundant arguments through, but
561 if a argument *conflicts* with the corresponding value in the
562 provided object, we raise an error.
457 """563 """
458 inconsistent_value_message = (564 inconsistent_value_message = (
459 "Inconsistent values given for %s: "565 "Inconsistent values given for %s: "
460 "(%r passed in to login_with(), versus %r in authorization "566 "(%r passed in, versus %r in %s). "
461 "engine). You don't need to pass in %s to login_with() if you "567 "You don't need to pass in %s if you pass in %s, "
462 "pass in an authorization engine, so just omit that argument.")568 "so just omit that argument.")
463 if (argument_value is not None569 if (argument_value is not None and argument_value != object_value):
464 and argument_value != authorization_engine_value):
465 raise ValueError(inconsistent_value_message % (570 raise ValueError(inconsistent_value_message % (
466 argument_name, argument_value, authorization_engine_value,571 argument_name, argument_value, object_value,
467 argument_name))572 object_name, argument_name, object_name))
468573
469574
470 @classmethod575 @classmethod
471576
=== modified file 'src/launchpadlib/testing/helpers.py'
--- src/launchpadlib/testing/helpers.py 2010-12-16 21:47:02 +0000
+++ src/launchpadlib/testing/helpers.py 2011-01-04 19:04:27 +0000
@@ -21,6 +21,10 @@
2121
22__metaclass__ = type22__metaclass__ = type
23__all__ = [23__all__ = [
24 'BadSaveKeyring',
25 'fake_keyring',
26 'FauxSocketModule',
27 'InMemoryKeyring',
24 'NoNetworkAuthorizationEngine',28 'NoNetworkAuthorizationEngine',
25 'NoNetworkLaunchpad',29 'NoNetworkLaunchpad',
26 'TestableLaunchpad',30 'TestableLaunchpad',
@@ -29,13 +33,12 @@
29 'salgado_with_full_permissions',33 'salgado_with_full_permissions',
30 ]34 ]
3135
32import simplejson36from contextlib import contextmanager
3337
38import launchpadlib
34from launchpadlib.launchpad import Launchpad39from launchpadlib.launchpad import Launchpad
35from launchpadlib.credentials import (40from launchpadlib.credentials import (
36 AccessToken,41 AccessToken,
37 AuthorizeRequestTokenWithBrowser,
38 Credentials,
39 RequestTokenAuthorizationEngine,42 RequestTokenAuthorizationEngine,
40 )43 )
4144
@@ -91,10 +94,11 @@
91 It can't be used to interact with the API.94 It can't be used to interact with the API.
92 """95 """
9396
94 def __init__(self, credentials, authorization_engine, service_root,97 def __init__(self, credentials, authorization_engine, credential_store,
95 cache, timeout, proxy_info, version):98 service_root, cache, timeout, proxy_info, version):
96 self.credentials = credentials99 self.credentials = credentials
97 self.authorization_engine = authorization_engine100 self.authorization_engine = authorization_engine
101 self.credential_store = credential_store
98 self.passed_in_args = dict(102 self.passed_in_args = dict(
99 service_root=service_root, cache=cache, timeout=timeout,103 service_root=service_root, cache=cache, timeout=timeout,
100 proxy_info=proxy_info, version=version)104 proxy_info=proxy_info, version=version)
@@ -104,8 +108,51 @@
104 return NoNetworkAuthorizationEngine(*args)108 return NoNetworkAuthorizationEngine(*args)
105109
106110
111@contextmanager
112def fake_keyring(fake):
113 original_keyring = launchpadlib.credentials.keyring
114 launchpadlib.credentials.keyring = fake
115 try:
116 yield
117 finally:
118 launchpadlib.credentials.keyring = original_keyring
119
120
121class FauxSocketModule:
122 """A socket module replacement that provides a fake hostname."""
123
124 def gethostname(self):
125 return 'HOSTNAME'
126
127
128class BadSaveKeyring:
129 """A keyring that generates errors when saving passwords."""
130
131 def get_password(self, service, username):
132 return None
133
134 def set_password(self, service, username, password):
135 raise RuntimeError
136
137
138class InMemoryKeyring:
139 """A keyring that saves passwords only in memory."""
140
141 def __init__(self):
142 self.data = {}
143
144 def set_password(self, service, username, password):
145 self.data[service, username] = password
146
147 def get_password(self, service, username):
148 return self.data.get((service, username))
149
150
107class KnownTokens:151class KnownTokens:
108 """Known access token/secret combinations."""152 """Known access token/secret combinations.
153
154 XXX This will need to be redone when integrating into Launchpad.
155 """
109156
110 def __init__(self, token_string, access_secret):157 def __init__(self, token_string, access_secret):
111 self.token_string = token_string158 self.token_string = token_string
112159
=== added file 'src/launchpadlib/tests/test_credential_store.py'
--- src/launchpadlib/tests/test_credential_store.py 1970-01-01 00:00:00 +0000
+++ src/launchpadlib/tests/test_credential_store.py 2011-01-04 19:04:27 +0000
@@ -0,0 +1,138 @@
1# Copyright 2010 Canonical Ltd.
2
3# This file is part of launchpadlib.
4#
5# launchpadlib is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by the
7# Free Software Foundation, version 3 of the License.
8#
9# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
12# for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the credential store classes."""
18
19import os
20import tempfile
21import unittest
22
23from launchpadlib.testing.helpers import (
24 fake_keyring,
25 InMemoryKeyring,
26)
27
28from launchpadlib.credentials import (
29 AccessToken,
30 Credentials,
31 KeyringCredentialStore,
32 UnencryptedFileCredentialStore,
33)
34
35
36class CredentialStoreTestCase(unittest.TestCase):
37
38 def make_credential(self, consumer_key):
39 """Helper method to make a fake credential."""
40 return Credentials(
41 "app name", consumer_secret='consumer_secret:42',
42 access_token=AccessToken(consumer_key, 'access_secret:168'))
43
44
45class TestUnencryptedFileCredentialStore(CredentialStoreTestCase):
46 """Tests for the UnencryptedFileCredentialStore class."""
47
48 def setUp(self):
49 ignore, self.filename = tempfile.mkstemp()
50 self.store = UnencryptedFileCredentialStore(self.filename)
51
52 def tearDown(self):
53 if os.path.exists(self.filename):
54 os.remove(self.filename)
55
56 def test_save_and_load(self):
57 # Make sure you can save and load credentials to a file.
58 credential = self.make_credential("consumer key")
59 self.store.save(credential, "unique key")
60 credential2 = self.store.load("unique key")
61 self.assertEquals(credential.consumer.key, credential2.consumer.key)
62
63 def test_unique_id_doesnt_matter(self):
64 # If a file contains a credential, that credential will be
65 # accessed no matter what unique ID you specify.
66 credential = self.make_credential("consumer key")
67 self.store.save(credential, "some key")
68 credential2 = self.store.load("some other key")
69 self.assertEquals(credential.consumer.key, credential2.consumer.key)
70
71 def test_file_only_contains_one_credential(self):
72 # A credential file may contain only one credential. If you
73 # write two credentials with different unique IDs to the same
74 # file, the first credential will be overwritten with the
75 # second.
76 credential1 = self.make_credential("consumer key")
77 credential2 = self.make_credential("consumer key2")
78 self.store.save(credential1, "unique key 1")
79 self.store.save(credential1, "unique key 2")
80 loaded = self.store.load("unique key 1")
81 self.assertEquals(loaded.consumer.key, credential2.consumer.key)
82
83
84class TestKeyringCredentialStore(CredentialStoreTestCase):
85 """Tests for the KeyringCredentialStore class."""
86
87 def setUp(self):
88 self.keyring = InMemoryKeyring()
89 self.store = KeyringCredentialStore()
90
91 def test_save_and_load(self):
92 # Make sure you can save and load credentials to a keyring.
93 with fake_keyring(self.keyring):
94 credential = self.make_credential("consumer key")
95 self.store.save(credential, "unique key")
96 credential2 = self.store.load("unique key")
97 self.assertEquals(
98 credential.consumer.key, credential2.consumer.key)
99
100 def test_lookup_by_unique_key(self):
101 # Credentials in the keyring are looked up by the unique ID
102 # under which they were stored.
103 with fake_keyring(self.keyring):
104 credential1 = self.make_credential("consumer key1")
105 self.store.save(credential1, "key 1")
106
107 credential2 = self.make_credential("consumer key2")
108 self.store.save(credential2, "key 2")
109
110 loaded1 = self.store.load("key 1")
111 self.assertEquals(
112 credential1.consumer.key, loaded1.consumer.key)
113
114 loaded2 = self.store.load("key 2")
115 self.assertEquals(
116 credential2.consumer.key, loaded2.consumer.key)
117
118 def test_reused_unique_id_overwrites_old_credential(self):
119 # Writing a credential to the keyring with a given unique ID
120 # will overwrite any credential stored under that ID.
121
122 with fake_keyring(self.keyring):
123 credential1 = self.make_credential("consumer key1")
124 self.store.save(credential1, "the only key")
125
126 credential2 = self.make_credential("consumer key2")
127 self.store.save(credential2, "the only key")
128
129 loaded = self.store.load("the only key")
130 self.assertEquals(
131 credential2.consumer.key, loaded.consumer.key)
132
133 def test_bad_unique_id_returns_none(self):
134 # Trying to load a credential without providing a good unique
135 # ID will get you None.
136 with fake_keyring(self.keyring):
137 self.assertEquals(None, self.store.load("no such key"))
138
0139
=== modified file 'src/launchpadlib/tests/test_http.py'
--- src/launchpadlib/tests/test_http.py 2010-12-20 17:44:36 +0000
+++ src/launchpadlib/tests/test_http.py 2011-01-04 19:04:27 +0000
@@ -71,14 +71,15 @@
71 :param responses: A list of HttpResponse objects to use71 :param responses: A list of HttpResponse objects to use
72 in response to requests.72 in response to requests.
73 """73 """
74 super(SimulatedResponsesHttp, self).__init__(*args)
74 self.sent_responses = []75 self.sent_responses = []
75 self.unsent_responses = responses76 self.unsent_responses = responses
76 super(SimulatedResponsesHttp, self).__init__(*args)77 self.cache = None
7778
78 def _request(self, *args):79 def _request(self, *args):
79 response = self.unsent_responses.popleft()80 response = self.unsent_responses.popleft()
80 self.sent_responses.append(response)81 self.sent_responses.append(response)
81 return self.retry_on_bad_token(response, response.content)82 return self.retry_on_bad_token(response, response.content, *args)
8283
8384
84class SimulatedResponsesLaunchpad(Launchpad):85class SimulatedResponsesLaunchpad(Launchpad):
8586
=== modified file 'src/launchpadlib/tests/test_launchpad.py'
--- src/launchpadlib/tests/test_launchpad.py 2010-12-20 17:44:36 +0000
+++ src/launchpadlib/tests/test_launchpad.py 2011-01-04 19:04:27 +0000
@@ -23,9 +23,8 @@
23import socket23import socket
24import stat24import stat
25import tempfile25import tempfile
26import textwrap
27import unittest26import unittest
28from contextlib import contextmanager27import warnings
2928
30from lazr.restfulclient.resource import ServiceRoot29from lazr.restfulclient.resource import ServiceRoot
3130
@@ -38,9 +37,17 @@
38import launchpadlib.launchpad37import launchpadlib.launchpad
39from launchpadlib.launchpad import Launchpad38from launchpadlib.launchpad import Launchpad
40from launchpadlib.testing.helpers import (39from launchpadlib.testing.helpers import (
40 BadSaveKeyring,
41 fake_keyring,
42 FauxSocketModule,
43 InMemoryKeyring,
41 NoNetworkAuthorizationEngine,44 NoNetworkAuthorizationEngine,
42 NoNetworkLaunchpad,45 NoNetworkLaunchpad,
43 )46 )
47from launchpadlib.credentials import (
48 KeyringCredentialStore,
49 UnencryptedFileCredentialStore,
50 )
4451
45# A dummy service root for use in tests52# A dummy service root for use in tests
46SERVICE_ROOT = "http://api.example.com/"53SERVICE_ROOT = "http://api.example.com/"
@@ -112,7 +119,7 @@
112 version = "version-foo"119 version = "version-foo"
113 root = uris.service_roots['staging'] + version120 root = uris.service_roots['staging'] + version
114 try:121 try:
115 Launchpad(None, None, service_root=root, version=version)122 Launchpad(None, None, None, service_root=root, version=version)
116 except ValueError, e:123 except ValueError, e:
117 self.assertTrue(str(e).startswith(124 self.assertTrue(str(e).startswith(
118 "It looks like you're using a service root that incorporates "125 "It looks like you're using a service root that incorporates "
@@ -124,30 +131,17 @@
124 # Make sure the problematic URL is caught even if it has a131 # Make sure the problematic URL is caught even if it has a
125 # slash on the end.132 # slash on the end.
126 root += '/'133 root += '/'
127 self.assertRaises(ValueError, Launchpad, None, None,134 self.assertRaises(ValueError, Launchpad, None, None, None,
128 service_root=root, version=version)135 service_root=root, version=version)
129136
130 # Test that the default version has the same problem137 # Test that the default version has the same problem
131 # when no explicit version is specified138 # when no explicit version is specified
132 default_version = NoNetworkLaunchpad.DEFAULT_VERSION139 default_version = NoNetworkLaunchpad.DEFAULT_VERSION
133 root = uris.service_roots['staging'] + default_version + '/'140 root = uris.service_roots['staging'] + default_version + '/'
134 self.assertRaises(ValueError, Launchpad, None, None,141 self.assertRaises(ValueError, Launchpad, None, None, None,
135 service_root=root)142 service_root=root)
136143
137144
138class InMemoryKeyring:
139 """A keyring that saves passwords only in memory."""
140
141 def __init__(self):
142 self.data = {}
143
144 def set_password(self, service, username, password):
145 self.data[service, username] = password
146
147 def get_password(self, service, username):
148 return self.data.get((service, username))
149
150
151class TestRequestTokenAuthorizationEngine(unittest.TestCase):145class TestRequestTokenAuthorizationEngine(unittest.TestCase):
152 """Tests for the RequestTokenAuthorizationEngine class."""146 """Tests for the RequestTokenAuthorizationEngine class."""
153147
@@ -174,8 +168,33 @@
174 SERVICE_ROOT, application_name='name', consumer_name='name')168 SERVICE_ROOT, application_name='name', consumer_name='name')
175169
176170
177class TestLaunchpadLoginWith(unittest.TestCase):171class TestLaunchpadLoginWithCredentialsFile(unittest.TestCase):
178 """Tests for Launchpad.login_with()."""172 """Tests for Launchpad.login_with() with a credentials file."""
173
174 def test_filename(self):
175 ignore, filename = tempfile.mkstemp()
176 launchpad = NoNetworkLaunchpad.login_with(
177 application_name='not important', credentials_file=filename)
178
179 # The credentials are stored unencrypted in the file you
180 # specify.
181 credentials = Credentials.load_from_path(filename)
182 self.assertEquals(credentials.consumer.key,
183 launchpad.credentials.consumer.key)
184 os.remove(filename)
185
186 def test_cannot_specify_both_filename_and_store(self):
187 ignore, filename = tempfile.mkstemp()
188 store = KeyringCredentialStore()
189 self.assertRaises(
190 ValueError, NoNetworkLaunchpad.login_with,
191 application_name='not important', credentials_file=filename,
192 credential_store=store)
193 os.remove(filename)
194
195
196class KeyringTest(unittest.TestCase):
197 """Base class for tests that use the keyring."""
179198
180 def setUp(self):199 def setUp(self):
181 # For these tests we want to disable retrieving or storing credentials200 # For these tests we want to disable retrieving or storing credentials
@@ -183,30 +202,28 @@
183 # instaed.202 # instaed.
184 self._saved_keyring = launchpadlib.credentials.keyring203 self._saved_keyring = launchpadlib.credentials.keyring
185 launchpadlib.credentials.keyring = InMemoryKeyring()204 launchpadlib.credentials.keyring = InMemoryKeyring()
186 self.temp_dir = tempfile.mkdtemp()
187205
188 def tearDown(self):206 def tearDown(self):
189 # Restore the gnomekeyring module that we disabled in setUp.207 # Restore the gnomekeyring module that we disabled in setUp.
190 launchpadlib.credentials.keyring = self._saved_keyring208 launchpadlib.credentials.keyring = self._saved_keyring
209
210
211class TestLaunchpadLoginWith(KeyringTest):
212 """Tests for Launchpad.login_with()."""
213
214 def setUp(self):
215 super(TestLaunchpadLoginWith, self).setUp()
216 self.temp_dir = tempfile.mkdtemp()
217
218 def tearDown(self):
219 super(TestLaunchpadLoginWith, self).tearDown()
191 shutil.rmtree(self.temp_dir)220 shutil.rmtree(self.temp_dir)
192221
193 def test_max_failed_attempts_accepted(self):
194 # You can pass in a value for the 'max_failed_attempts'
195 # argument, even though that argument doesn't do anything.
196 launchpad = NoNetworkLaunchpad.login_with(
197 'not important', max_failed_attempts=5)
198
199 def test_credentials_file_accepted(self):
200 # You can pass in a value for the 'credentials_file'
201 # argument, even though that argument doesn't do anything.
202 launchpad = NoNetworkLaunchpad.login_with(
203 'not important', credentials_file='foo')
204
205 def test_dirs_created(self):222 def test_dirs_created(self):
206 # The path we pass into login_with() is the directory where223 # The path we pass into login_with() is the directory where
207 # cache and credentials for all service roots are stored.224 # cache for all service roots are stored.
208 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')225 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
209 launchpad = NoNetworkLaunchpad.login_with(226 NoNetworkLaunchpad.login_with(
210 'not important', service_root=SERVICE_ROOT,227 'not important', service_root=SERVICE_ROOT,
211 launchpadlib_dir=launchpadlib_dir)228 launchpadlib_dir=launchpadlib_dir)
212 # The 'launchpadlib' dir got created.229 # The 'launchpadlib' dir got created.
@@ -233,7 +250,7 @@
233 statinfo = os.stat(launchpadlib_dir)250 statinfo = os.stat(launchpadlib_dir)
234 mode = stat.S_IMODE(statinfo.st_mode)251 mode = stat.S_IMODE(statinfo.st_mode)
235 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)252 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
236 launchpad = NoNetworkLaunchpad.login_with(253 NoNetworkLaunchpad.login_with(
237 'not important', service_root=SERVICE_ROOT,254 'not important', service_root=SERVICE_ROOT,
238 launchpadlib_dir=launchpadlib_dir)255 launchpadlib_dir=launchpadlib_dir)
239 # Verify the mode has been changed to 0700256 # Verify the mode has been changed to 0700
@@ -243,7 +260,7 @@
243260
244 def test_dirs_created_are_secure(self):261 def test_dirs_created_are_secure(self):
245 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')262 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
246 launchpad = NoNetworkLaunchpad.login_with(263 NoNetworkLaunchpad.login_with(
247 'not important', service_root=SERVICE_ROOT,264 'not important', service_root=SERVICE_ROOT,
248 launchpadlib_dir=launchpadlib_dir)265 launchpadlib_dir=launchpadlib_dir)
249 self.assertTrue(os.path.isdir(launchpadlib_dir))266 self.assertTrue(os.path.isdir(launchpadlib_dir))
@@ -300,8 +317,7 @@
300 # token.317 # token.
301 engine = NoNetworkAuthorizationEngine(318 engine = NoNetworkAuthorizationEngine(
302 SERVICE_ROOT, 'application name')319 SERVICE_ROOT, 'application name')
303 launchpad = NoNetworkLaunchpad.login_with(320 NoNetworkLaunchpad.login_with(authorization_engine=engine)
304 authorization_engine=engine)
305 self.assertEquals(engine.request_tokens_obtained, 1)321 self.assertEquals(engine.request_tokens_obtained, 1)
306 self.assertEquals(engine.access_tokens_obtained, 1)322 self.assertEquals(engine.access_tokens_obtained, 1)
307323
@@ -311,9 +327,13 @@
311 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with)327 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with)
312328
313 def test_application_name_identifies_app(self):329 def test_application_name_identifies_app(self):
330 # If you pass in application_name, that's good enough to identify
331 # your application.
314 NoNetworkLaunchpad.login_with(application_name="name")332 NoNetworkLaunchpad.login_with(application_name="name")
315333
316 def test_consumer_name_identifies_app(self):334 def test_consumer_name_identifies_app(self):
335 # If you pass in consumer_name, that's good enough to identify
336 # your application.
317 NoNetworkLaunchpad.login_with(consumer_name="name")337 NoNetworkLaunchpad.login_with(consumer_name="name")
318338
319 def test_inconsistent_application_name_rejected(self):339 def test_inconsistent_application_name_rejected(self):
@@ -344,6 +364,19 @@
344 allow_access_levels=['BAR'],364 allow_access_levels=['BAR'],
345 authorization_engine=engine)365 authorization_engine=engine)
346366
367 def test_inconsistent_credential_save_failed(self):
368 # Catch an attempt to specify inconsistent callbacks for
369 # credential save failure.
370 def callback1():
371 pass
372 store = KeyringCredentialStore(credential_save_failed=callback1)
373
374 def callback2():
375 pass
376 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
377 "app name", credential_store=store,
378 credential_save_failed=callback2)
379
347 def test_non_desktop_integration(self):380 def test_non_desktop_integration(self):
348 # When doing a non-desktop integration, you must specify a381 # When doing a non-desktop integration, you must specify a
349 # consumer_name. You can pass a list of allowable access382 # consumer_name. You can pass a list of allowable access
@@ -472,30 +505,32 @@
472 self.assertRaises(505 self.assertRaises(
473 ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo')506 ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo')
474507
475508 def test_max_failed_attempts_accepted(self):
476class FauxSocketModule:509 # You can pass in a value for the 'max_failed_attempts'
477 """A socket module replacement that provides a fake hostname."""510 # argument, even though that argument doesn't do anything.
478511 NoNetworkLaunchpad.login_with(
479 def gethostname(self):512 'not important', max_failed_attempts=5)
480 return 'HOSTNAME'513
481514
482515class TestDeprecatedLoginMethods(KeyringTest):
483class BadSaveKeyring:516 """Make sure the deprecated login methods still work."""
484 """A keyring that generates errors when saving passwords."""517
485518 def test_login_is_deprecated(self):
486 def get_password(self, service, username):519 # login() works but triggers a deprecation warning.
487 return None520 with warnings.catch_warnings(record=True) as caught:
488521 warnings.simplefilter("always")
489 def set_password(self, service, username, password):522 launchpad = NoNetworkLaunchpad.login(
490 raise RuntimeError523 'consumer', 'token', 'secret')
491524 self.assertEquals(len(caught), 1)
492525 self.assertEquals(caught[0].category, DeprecationWarning)
493@contextmanager526
494def fake_keyring(fake):527 def test_get_token_and_login_is_deprecated(self):
495 original_keyring = launchpadlib.credentials.keyring528 # get_token_and_login() works but triggers a deprecation warning.
496 launchpadlib.credentials.keyring = fake529 with warnings.catch_warnings(record=True) as caught:
497 yield530 warnings.simplefilter("always")
498 launchpadlib.credentials.keyring = original_keyring531 launchpad = NoNetworkLaunchpad.get_token_and_login('consumer')
532 self.assertEquals(len(caught), 1)
533 self.assertEquals(caught[0].category, DeprecationWarning)
499534
500535
501class TestCredenitialSaveFailedCallback(unittest.TestCase):536class TestCredenitialSaveFailedCallback(unittest.TestCase):
@@ -578,7 +613,6 @@
578 keyring = InMemoryKeyring()613 keyring = InMemoryKeyring()
579 # Be paranoid about the keyring starting out empty.614 # Be paranoid about the keyring starting out empty.
580 assert not keyring.data, 'oops, a fresh keyring has data in it'615 assert not keyring.data, 'oops, a fresh keyring has data in it'
581 service_root = 'http://api.example.com/'
582 with fake_keyring(keyring):616 with fake_keyring(keyring):
583 # Create stored credentials for the same application but against617 # Create stored credentials for the same application but against
584 # two different sites (service roots).618 # two different sites (service roots).

Subscribers

People subscribed via source and target branches