Merge lp:~leonardr/launchpadlib/improve-workflow into lp:launchpadlib

Proposed by Leonard Richardson
Status: Merged
Approved by: Martin Pool
Approved revision: 97
Merged at revision: 93
Proposed branch: lp:~leonardr/launchpadlib/improve-workflow
Merge into: lp:launchpadlib
Diff against target: 268 lines (+68/-32)
4 files modified
src/launchpadlib/NEWS.txt (+9/-0)
src/launchpadlib/credentials.py (+36/-13)
src/launchpadlib/launchpad.py (+12/-8)
src/launchpadlib/tests/test_launchpad.py (+11/-11)
To merge this branch: bzr merge lp:~leonardr/launchpadlib/improve-workflow
Reviewer Review Type Date Requested Status
Martin Pool (community) Approve
Review via email: mp+29849@code.launchpad.net

Description of the change

This branch improves the console-based workflow for exchanging a request token for an access token.

Here's the old workflow:
1. Tab into your browser app.
2. Authorize (or don't) the launchpadlib app.
3. Tab into the console.
4. Hit Enter.

My original plan was to eliminate all but step 2. If I could somehow do this, the current launchpadlib workflow would be more or less the same as the proposed expensive workflow involving a custom desktop application and a brand new OAuth access level.

Unfortunately, a combination of problems with the webbrowser module/Firefox/the standard Ubuntu window manager foiled my plan to eliminate step 1, and Javascript security issues foiled my plan to eliminate step 3.

But, I was able to eliminate step 4, with a minor change to Launchpad (https://code.edge.launchpad.net/~leonardr/launchpad/distinguish-between-unauthorized-and-forbidden). This branch eliminates the "hit Enter" step by periodically polling Launchpad, attempting to exchange the request token for an access token. If the user has decided to authorize the app, this request returns a 200 response code with the access token information. If the user has declined to authorize the app, this request returns a 403 response code (this is the minor change to Launchpad). If the user has yet to make a decision, this request returns a 401 response code.

Since it's now possible to distinguish "I don't want you to have access to my Launchpad account" from "I haven't made a decision about giving you access", I took the opportunity to improve launchpadlib's behavior when access is denied. Previously launchpadlib just raised an unhelpful HTTPError. Now it ensures that login_with() and the like return None instead of a Launchpad object. When you call login_with(), you can check the return value and know that if it's None, the user made a decision not to give you access--you can sys.exit() or complain or whatever.

Bonus: since the console-based workflow no longer reads from stdin, it should be now possible to use it from a GUI application.

Since this branch only works on a version of Launchpad that has my branch installed, I won't be releasing a version of launchpadlib that includes this branch until my Launchpad branch is fully deployed.

To post a comment you must log in.
Revision history for this message
Martin Pool (mbp) wrote :

Nice cover letter.

> Since it's now possible to distinguish "I don't want you to have access to my Launchpad account" from "I haven't made a decision about giving you access", I took the opportunity to improve launchpadlib's behavior when access is denied. Previously launchpadlib just raised an unhelpful HTTPError. Now it ensures that login_with() and the like return None instead of a Launchpad object. When you call login_with(), you can check the return value and know that if it's None, the user made a decision not to give you access--you can sys.exit() or complain or whatever.

This of course needs to go into the docs and I would say ideally to get a brief post on the blog (even just a pointer to this mp.)

I think you should raise a specific exception if the user denies access, rather than returning None:

1- If the app ignores the return code, we don't want it to just blithely continue and then cause knock-on errors. The default should be for the app to stop.
2- Returning None for errors is just bad.
3- It gives you a space to eventually possibly pass back a sensible message. (I mean I doubt the user wants to explain why, but perhaps you can also in the future say "this app is banned" or "you're creating too many tokens" etc.) I would say you should probably put the error body in to the exception, even if at the moment it's not super helpful.

+ print " (%s)" % self.authorization_url
+ print "should be opening in your browser. Use your browser to"
+ print "to authorize this program to access Launchpad on your behalf."

It would be nice to not directly print but rather pass this to a callback function so guis can hook it. It could be a followon but perhaps you could just hoist it out while you're here.

- launchpad.credentials.save_to_path(consumer_credentials_path)
- os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)
+ if launchpad is not None:
+ launchpad.credentials.save_to_path(consumer_credentials_path)
+ os.chmod(
+ consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)

It's always much more secure to create the path secure (by passing a third parameter to os.open) than to chmod it later: the app may be interrupted, someone may grab it in the race window, etc.

Otherwise looks good.

review: Needs Fixing
Revision history for this message
James Westby (james-w) wrote :

Hi,

One trick I have seen that is a bit nicer than polling is to pass a localhost
url as the OAuth callback, and then set up a listening socket at that URL, and
respond to any requests made to it by checking the status of the token.

It's not that much different really, but gives you a more responsive application
for users without hitting LP frequently.

Thanks,

James

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

I responded to your comments and also changed the code that creates directories so that they're chmod 0700. Take another look.

94. By Leonard Richardson

Response to feedback.

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

+1 on all of poolies points.

James - the local socket thing is very fragile. better to use long
poll on LP to have lp signal when its ready.

-Rob

Revision history for this message
Martin Pool (mbp) wrote :

+ self.output(self.WAITING_FOR_USER % self.authorization_url)
+ while credentials.access_token is None:
+ try:
+ credentials.exchange_request_token_for_access_token(web_root)
+ except HTTPError, e:
+ if e.response.status == 401:
+ # The user has not made their decision yet.
+ time.sleep(1)
+ elif e.response.status == 403:
+ # The user decided not to authorize this
+ # application.
+ raise EndUserDeclinedAuthorization(e.content)
+ else:
+ # The user decided to grant access.
+ # credentials.access_token is no longer None.
+ break

This treats a 500 error as deciding to grant access. Really? I think you want the 'break' inside the try block. Aside from that it looks good.

 review: tweak

I do wonder a bit about the load this will introduce but perhaps we can measure that. Can we find out from the logs how many times this is called at the moment?

review: Needs Fixing
95. By Leonard Richardson

Response to feedback.

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

Ok, take another look.

1. I made the poll time configurable with a module constant, and moved the sleep() call to before the request so it will always be called once. Since it would take superhuman reflexes to click a link before the sleep() call expires (for any reasonable value of the poll time), this will save at least one HTTP request every time.

2. If Launchpad goes down during the poll, the poll will continue instead of the program assuming the user granted access. (I tested this by killing launchpad.dev after opening token-authorized in my browser.)

3. I saw only 50 requests for +access-token for July 16th (a Friday), out of about 4.5 million requests total. Even a large number of additional POSTs should go unnoticed. But I'd be OK with increasing the poll time to 2 or 3 seconds.

Revision history for this message
Martin Pool (mbp) wrote :

+ else:
+ # The user has not made their decision yet,
+ # or there was an error accessing the server.
+ pass

So this seems to me to mean that if the server is giving some non-403 error, the client will silently keep polling indefinitely. I think that is a bit strange. I can see how you might want to keep polling even if you get a transient error, but I think you should at least print the error. That's what I meant in my comment earlier today.

btw I suggest if you're making substantive changes is response to feedback you actually describe them in the commit message. If someone annotates the code later just "response to feedback" doesn't explain a lot.

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

All right, I've changed the code so that if there's a problem communicating with Launchpad after the initial browser open, the exception will be printed to stdout.

96. By Leonard Richardson

If there's a problem communicating with Launchpad, print out the exception before retrying.

97. By Leonard Richardson

Merge with trunk.

Revision history for this message
Martin Pool (mbp) :
review: Approve

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-07-19 18:38:21 +0000
3+++ src/launchpadlib/NEWS.txt 2010-07-20 15:57:51 +0000
4@@ -5,6 +5,15 @@
5 1.6.3 (Unreleased)
6 ==================
7
8+- Instead of making the end-user hit Enter after authorizing an
9+ application to access their Launchpad account, launchpadlib will
10+ automatically poll Launchpad until the user makes a decision.
11+
12+- Previously, if the end-user explicitly denied access to a
13+ launchpadlib application, launchpadlib raised an unhelpful
14+ exception. Now, methods like login_with() will return None instead
15+ of a Launchpad object.
16+
17 - Improved the XSLT stylesheet to reflect Launchpad's more complex
18 top-level structure. [bug=286941]
19
20
21=== modified file 'src/launchpadlib/credentials.py'
22--- src/launchpadlib/credentials.py 2010-03-16 21:05:15 +0000
23+++ src/launchpadlib/credentials.py 2010-07-20 15:57:51 +0000
24@@ -30,6 +30,7 @@
25 import httplib2
26 import sys
27 import textwrap
28+import time
29 from urllib import urlencode, quote
30 from urlparse import urljoin
31 import webbrowser
32@@ -45,6 +46,7 @@
33 request_token_page = '+request-token'
34 access_token_page = '+access-token'
35 authorize_token_page = '+authorize-token'
36+access_token_poll_time = 1
37
38
39 class Credentials(OAuthAuthorizer):
40@@ -512,6 +514,8 @@
41 themselves.
42 """
43
44+ WAITING_FOR_USER = "The authorization page:\n (%s)\nshould be opening in your browser. Use your browser to authorize\nthis program to access Launchpad on your behalf. \n\nWaiting to hear from Launchpad about your decision..."
45+
46 def __init__(self, web_root, consumer_name, request_token,
47 allow_access_levels=[], max_failed_attempts=3):
48 web_root = uris.lookup_web_root(web_root)
49@@ -525,20 +529,35 @@
50 web_root, consumer_name, request_token,
51 allow_access_levels, max_failed_attempts)
52
53- def __call__(self):
54+ def output(self, message):
55+ """Display a message.
56+
57+ By default, prints the message to standard output. The message
58+ does not require any user interaction--it's solely
59+ informative.
60+ """
61+ print message
62+
63+ def __call__(self, credentials, web_root):
64 self.open_page_in_user_browser(self.authorization_url)
65- print "The authorization page:"
66- print " (%s)" % self.authorization_url
67- print "should be opening in your browser. After you have authorized"
68- print "this program to access Launchpad on your behalf you should come"
69- print ("back here and press <Enter> to finish the authentication "
70- "process.")
71- self.wait_for_request_token_authorization()
72-
73- def wait_for_request_token_authorization(self):
74- """Get the end-user to hit enter."""
75- sys.stdin.readline()
76-
77+ self.output(self.WAITING_FOR_USER % self.authorization_url)
78+ while credentials.access_token is None:
79+ time.sleep(access_token_poll_time)
80+ try:
81+ credentials.exchange_request_token_for_access_token(web_root)
82+ break
83+ except HTTPError, e:
84+ if e.response.status == 403:
85+ # The user decided not to authorize this
86+ # application.
87+ raise EndUserDeclinedAuthorization(e.content)
88+ elif e.response.status == 401:
89+ # The user has not made a decision yet.
90+ pass
91+ else:
92+ # There was an error accessing the server.
93+ print "Unexpected response from Launchpad:"
94+ print e
95
96 class TokenAuthorizationException(Exception):
97 pass
98@@ -548,6 +567,10 @@
99 pass
100
101
102+class EndUserDeclinedAuthorization(TokenAuthorizationException):
103+ pass
104+
105+
106 class ClientError(TokenAuthorizationException):
107 pass
108
109
110=== modified file 'src/launchpadlib/launchpad.py'
111--- src/launchpadlib/launchpad.py 2010-06-21 15:16:28 +0000
112+++ src/launchpadlib/launchpad.py 2010-07-20 15:57:51 +0000
113@@ -210,8 +210,10 @@
114 web_root, authorization_json['oauth_token_consumer'],
115 authorization_json['oauth_token'], allow_access_levels,
116 max_failed_attempts)
117- authorizer()
118- credentials.exchange_request_token_for_access_token(web_root)
119+ authorizer(credentials, web_root)
120+ if credentials.access_token is None:
121+ # The end-user refused to authorize the application.
122+ return None
123 return cls(credentials, service_root, cache, timeout, proxy_info,
124 version)
125
126@@ -273,7 +275,7 @@
127 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
128 credentials_path = os.path.join(service_root_dir, 'credentials')
129 if not os.path.exists(credentials_path):
130- os.makedirs(credentials_path)
131+ os.makedirs(credentials_path, 0700)
132 if credentials_file is None:
133 consumer_credentials_path = os.path.join(credentials_path,
134 consumer_name)
135@@ -292,8 +294,10 @@
136 authorizer_class=authorizer_class,
137 allow_access_levels=allow_access_levels,
138 max_failed_attempts=max_failed_attempts, version=version)
139- launchpad.credentials.save_to_path(consumer_credentials_path)
140- os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)
141+ if launchpad is not None:
142+ launchpad.credentials.save_to_path(consumer_credentials_path)
143+ os.chmod(
144+ consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)
145 return launchpad
146
147 @classmethod
148@@ -320,8 +324,8 @@
149 launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')
150 launchpadlib_dir = os.path.expanduser(launchpadlib_dir)
151 if not os.path.exists(launchpadlib_dir):
152- os.makedirs(launchpadlib_dir,0700)
153- os.chmod(launchpadlib_dir,0700)
154+ os.makedirs(launchpadlib_dir, 0700)
155+ os.chmod(launchpadlib_dir, 0700)
156 # Determine the real service root.
157 service_root = uris.lookup_service_root(service_root)
158 # Each service root has its own cache and credential dirs.
159@@ -330,5 +334,5 @@
160 service_root_dir = os.path.join(launchpadlib_dir, host_name)
161 cache_path = os.path.join(service_root_dir, 'cache')
162 if not os.path.exists(cache_path):
163- os.makedirs(cache_path)
164+ os.makedirs(cache_path, 0700)
165 return (service_root, launchpadlib_dir, cache_path, service_root_dir)
166
167=== modified file 'src/launchpadlib/tests/test_launchpad.py'
168--- src/launchpadlib/tests/test_launchpad.py 2010-07-15 00:49:57 +0000
169+++ src/launchpadlib/tests/test_launchpad.py 2010-07-20 15:57:51 +0000
170@@ -150,7 +150,7 @@
171 # cache and credentials for all service roots are stored.
172 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
173 launchpad = NoNetworkLaunchpad.login_with(
174- 'not important', service_root='http://api.example.com/beta',
175+ 'not important', service_root='http://api.example.com/',
176 launchpadlib_dir=launchpadlib_dir)
177 # The 'launchpadlib' dir got created.
178 self.assertTrue(os.path.isdir(launchpadlib_dir))
179@@ -174,7 +174,7 @@
180 mode = stat.S_IMODE(statinfo.st_mode)
181 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
182 launchpad = NoNetworkLaunchpad.login_with(
183- 'not important', service_root='http://api.example.com/beta',
184+ 'not important', service_root='http://api.example.com/',
185 launchpadlib_dir=launchpadlib_dir)
186 # Verify the mode has been changed to 0700
187 statinfo = os.stat(launchpadlib_dir)
188@@ -184,7 +184,7 @@
189 def test_dirs_created_are_secure(self):
190 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
191 launchpad = NoNetworkLaunchpad.login_with(
192- 'not important', service_root='http://api.example.com/beta',
193+ 'not important', service_root='http://api.example.com/',
194 launchpadlib_dir=launchpadlib_dir)
195 self.assertTrue(os.path.isdir(launchpadlib_dir))
196 # Verify the mode is safe
197@@ -213,7 +213,7 @@
198
199 def test_no_credentials_calls_get_token_and_login(self):
200 # If no credentials are found, get_token_and_login() is called.
201- service_root = 'http://api.example.com/beta'
202+ service_root = 'http://api.example.com/'
203 timeout = object()
204 proxy_info = object()
205 launchpad = NoNetworkLaunchpad.login_with(
206@@ -235,7 +235,7 @@
207 """Test the anonymous login helper function."""
208 launchpad = NoNetworkLaunchpad.login_anonymously(
209 'anonymous access', launchpadlib_dir=self.temp_dir,
210- service_root='http://api.example.com/beta')
211+ service_root='http://api.example.com/')
212 self.assertEqual(launchpad.credentials.access_token.key, '')
213 self.assertEqual(launchpad.credentials.access_token.secret, '')
214
215@@ -250,7 +250,7 @@
216 # credentials are saved.
217 launchpad = NoNetworkLaunchpad.login_with(
218 'app name', launchpadlib_dir=self.temp_dir,
219- service_root='http://api.example.com/beta')
220+ service_root='http://api.example.com/')
221 credentials_path = os.path.join(
222 self.temp_dir, 'api.example.com', 'credentials', 'app name')
223 self.assertTrue(os.path.exists(credentials_path))
224@@ -270,7 +270,7 @@
225 # writable by the user.
226 launchpad = NoNetworkLaunchpad.login_with(
227 'app name', launchpadlib_dir=self.temp_dir,
228- service_root='http://api.example.com/beta')
229+ service_root='http://api.example.com/')
230 credentials_path = os.path.join(
231 self.temp_dir, 'api.example.com', 'credentials', 'app name')
232 statinfo = os.stat(credentials_path)
233@@ -291,7 +291,7 @@
234
235 launchpad = NoNetworkLaunchpad.login_with(
236 'app name', launchpadlib_dir=self.temp_dir,
237- service_root='http://api.example.com/beta')
238+ service_root='http://api.example.com/')
239 self.assertFalse(launchpad.get_token_and_login_called)
240 self.assertEqual(launchpad.credentials.consumer.key, 'app name')
241 self.assertEqual(
242@@ -335,7 +335,7 @@
243 old_home = os.environ['HOME']
244 os.environ['HOME'] = self.temp_dir
245 launchpad = NoNetworkLaunchpad.login_with(
246- 'app name', service_root='http://api.example.com/beta')
247+ 'app name', service_root='http://api.example.com/')
248 # Reset the environment to the old value.
249 os.environ['HOME'] = old_home
250
251@@ -375,7 +375,7 @@
252 launchpad = NoNetworkLaunchpad.login_with(
253 'app name', launchpadlib_dir=self.temp_dir,
254 credentials_file=my_credentials_path,
255- service_root='http://api.example.com/beta')
256+ service_root='http://api.example.com/')
257 default_credentials_path = os.path.join(
258 self.temp_dir, 'api.example.com', 'credentials', 'app name')
259 self.assertFalse(os.path.exists(default_credentials_path))
260@@ -387,7 +387,7 @@
261 launchpad = NoNetworkLaunchpad.login_with(
262 'app name', launchpadlib_dir=self.temp_dir,
263 credentials_file=my_credentials_path,
264- service_root='http://api.example.com/beta')
265+ service_root='http://api.example.com/')
266 self.assertFalse(os.path.exists(default_credentials_path))
267 self.assertTrue(os.path.exists(my_credentials_path))
268 self.assertFalse(launchpad.get_token_and_login_called)

Subscribers

People subscribed via source and target branches