Merge lp:~leonardr/launchpadlib/wsgi-fake-launchpad into lp:launchpadlib
- wsgi-fake-launchpad
- Merge into trunk
Proposed by
Leonard Richardson
Status: | Merged |
---|---|
Merged at revision: | not available |
Proposed branch: | lp:~leonardr/launchpadlib/wsgi-fake-launchpad |
Merge into: | lp:launchpadlib |
Diff against target: |
279 lines 3 files modified
src/launchpadlib/credentials.py (+78/-4) src/launchpadlib/docs/browser.txt (+145/-0) src/launchpadlib/launchpad.py (+2/-0) |
To merge this branch: | bzr merge lp:~leonardr/launchpadlib/wsgi-fake-launchpad |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Brad Crittenden (community) | code | Approve | |
Review via email: mp+14021@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote : | # |
Revision history for this message
Leonard Richardson (leonardr) wrote : | # |
Bug 461901 deals with fixing the error codes.
- 73. By Leonard Richardson
-
Did I move the launchpadlib directory? Apparently I did. Moving it back.
- 74. By Leonard Richardson
-
I meant the 409 response code, not 209.
Revision history for this message
Brad Crittenden (bac) wrote : | # |
Hi Leonard,
This branch looks good with the following issues:
* 209 should be 409.
* You need to undo the mv of launchpadlib out of src.
* The symbols from uris imported into launchpad.py for compatability need to be added to __ALL__.
review:
Approve
(code)
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'src/launchpadlib/credentials.py' |
2 | --- src/launchpadlib/credentials.py 2009-10-22 15:26:57 +0000 |
3 | +++ src/launchpadlib/credentials.py 2009-10-27 12:59:09 +0000 |
4 | @@ -23,16 +23,18 @@ |
5 | 'Credentials', |
6 | ] |
7 | |
8 | +import base64 |
9 | import cgi |
10 | import httplib2 |
11 | -from urllib import urlencode |
12 | +from urllib import urlencode, quote |
13 | +from urlparse import urljoin |
14 | |
15 | from lazr.restfulclient.errors import HTTPError |
16 | from lazr.restfulclient.authorize.oauth import ( |
17 | AccessToken as _AccessToken, Consumer, OAuthAuthorizer) |
18 | |
19 | +from launchpadlib import uris |
20 | |
21 | -STAGING_WEB_ROOT = 'https://staging.launchpad.net/' |
22 | request_token_page = '+request-token' |
23 | access_token_page = '+access-token' |
24 | authorize_token_page = '+authorize-token' |
25 | @@ -48,7 +50,7 @@ |
26 | """ |
27 | _request_token = None |
28 | |
29 | - def get_request_token(self, context=None, web_root=STAGING_WEB_ROOT): |
30 | + def get_request_token(self, context=None, web_root=uris.STAGING_WEB_ROOT): |
31 | """Request an OAuth token to Launchpad. |
32 | |
33 | Also store the token in self._request_token. |
34 | @@ -82,7 +84,7 @@ |
35 | return url |
36 | |
37 | def exchange_request_token_for_access_token( |
38 | - self, web_root=STAGING_WEB_ROOT): |
39 | + self, web_root=uris.STAGING_WEB_ROOT): |
40 | """Exchange the previously obtained request token for an access token. |
41 | |
42 | This method must not be called unless get_request_token() has been |
43 | @@ -127,3 +129,75 @@ |
44 | "Query string must have exactly one context") |
45 | context = context[0] |
46 | return cls(key, secret, context) |
47 | + |
48 | + |
49 | +class SimulatedLaunchpadBrowser(object): |
50 | + """A programmable substitute for a human-operated web browser. |
51 | + |
52 | + Used by client programs to interact with Launchpad's credential |
53 | + pages, without opening them in the user's actual web browser. |
54 | + """ |
55 | + |
56 | + def __init__(self, web_root=uris.STAGING_WEB_ROOT): |
57 | + self.web_root = web_root |
58 | + self.http = httplib2.Http() |
59 | + |
60 | + def _auth_header(self, username, password): |
61 | + """Utility method to generate a Basic auth header.""" |
62 | + auth = base64.encodestring("%s:%s" % (username, password))[:-1] |
63 | + return "Basic " + auth |
64 | + |
65 | + def get_token_info(self, username, password, request_token, |
66 | + access_levels=''): |
67 | + """Retrieve a JSON representation of a request token. |
68 | + |
69 | + This is useful for verifying that the end-user gave a valid |
70 | + username and password, and for reconciling the client's |
71 | + allowable access levels with the access levels defined in |
72 | + Launchpad. |
73 | + """ |
74 | + if access_levels != '': |
75 | + s = "&allow_permission=" |
76 | + access_levels = s + s.join(access_levels) |
77 | + page = "%s?oauth_token=%s%s" % ( |
78 | + authorize_token_page, request_token, access_levels) |
79 | + url = urljoin(self.web_root, page) |
80 | + # We can't use httplib2's add_credentials, because Launchpad |
81 | + # doesn't respond to credential-less access with a 401 |
82 | + # response code. |
83 | + headers = {'Accept' : 'application/json'} |
84 | + headers['Authorization'] = self._auth_header(username, password) |
85 | + response, content = self.http.request(url, headers=headers) |
86 | + # Detect common error conditions and set the response code |
87 | + # appropriately. This lets code that uses |
88 | + # SimulatedLaunchpadBrowser detect standard response codes |
89 | + # instead of having Launchpad-specific knowledge. |
90 | + location = response.get('content-location') |
91 | + if response.status == 200 and '+login' in location: |
92 | + response.status = 401 |
93 | + elif response.get('content-type') != 'application/json': |
94 | + response.status = 500 |
95 | + return response, content |
96 | + |
97 | + def grant_access(self, username, password, request_token, access_level): |
98 | + """Grant a level of access to an application on behalf of a user.""" |
99 | + headers = {'Content-type' : 'application/x-www-form-urlencoded'} |
100 | + headers['Authorization'] = self._auth_header(username, password) |
101 | + body = "oauth_token=%s&field.actions.%s=True" % ( |
102 | + quote(request_token), quote(access_level)) |
103 | + url = urljoin(self.web_root, "+authorize-token") |
104 | + response, content = self.http.request( |
105 | + url, method="POST", headers=headers, body=body) |
106 | + # This would be much less fragile if Launchpad gave us an |
107 | + # error code to work with. |
108 | + if "Unauthenticated user POSTing to page" in content: |
109 | + response.status = 401 # Unauthorized |
110 | + elif 'Request already reviewed' in content: |
111 | + response.status = 409 # Conflict |
112 | + elif 'What level of access' in content: |
113 | + response.status = 400 # Bad Request |
114 | + elif 'Unable to identify application' in content: |
115 | + response.status = 400 # Bad Request |
116 | + elif not 'Almost finished' in content: |
117 | + response.status = 500 # Internal Server Error |
118 | + return response, content |
119 | |
120 | === added file 'src/launchpadlib/docs/browser.txt' |
121 | --- src/launchpadlib/docs/browser.txt 1970-01-01 00:00:00 +0000 |
122 | +++ src/launchpadlib/docs/browser.txt 2009-10-27 12:59:09 +0000 |
123 | @@ -0,0 +1,145 @@ |
124 | +******************************* |
125 | +The simulated Launchpad browser |
126 | +******************************* |
127 | + |
128 | +The SimulatedLaunchpadBrowser class is a scriptable browser-like class |
129 | +that can be trusted with the end-user's username and password. It |
130 | +fulfils the same function as the user's web browser, but because it's |
131 | +scriptable can be used to create non-browser trusted clients. |
132 | + |
133 | + >>> username = 'salgado@ubuntu.com' |
134 | + >>> password = 'zeca' |
135 | + >>> web_root = 'http://launchpad.dev:8085/' |
136 | + |
137 | +Before showing how SimulatedLaunchpadBrowser can authorize a request |
138 | +token, let's create a request token to authorize. |
139 | + |
140 | + >>> from launchpadlib.credentials import Credentials |
141 | + >>> credentials = Credentials("doctest consumer") |
142 | + >>> validate_url = credentials.get_request_token(web_root=web_root) |
143 | + >>> request_token = credentials._request_token.key |
144 | + |
145 | +get_token_info() |
146 | +================ |
147 | + |
148 | +If you have the end-user's username and password, you can use |
149 | +get_token_info() to get information about one of the user's request |
150 | +tokens. It's useful for confirming that the end-user gave the correct |
151 | +username and password, and for reconciling the list of access levels a |
152 | +client will accept with Launchpad's master list. |
153 | + |
154 | + >>> from launchpadlib.credentials import SimulatedLaunchpadBrowser |
155 | + >>> from launchpadlib.testing.helpers import TestableLaunchpad |
156 | + |
157 | + >>> browser = SimulatedLaunchpadBrowser(web_root) |
158 | + |
159 | +If you make an unauthorized request, you'll get a 401 error. |
160 | +(Launchpad returns 200, but SimulatedLaunchpadBrowser sniffs it and |
161 | +changes it to a 401.) |
162 | + |
163 | + >>> response, content = browser.get_token_info( |
164 | + ... "baduser", "badpasword", request_token) |
165 | + >>> print response.status |
166 | + 401 |
167 | + |
168 | +If you provide the right authorization, you'll get back information |
169 | +about your request token. |
170 | + |
171 | + >>> response, content = browser.get_token_info( |
172 | + ... username, password, request_token) |
173 | + >>> print response['content-type'] |
174 | + application/json |
175 | + >>> response['content-location'] == validate_url |
176 | + True |
177 | + |
178 | + >>> import simplejson |
179 | + >>> json = simplejson.loads(content) |
180 | + >>> json['oauth_token'] == request_token |
181 | + True |
182 | + |
183 | + >>> print json['oauth_token_consumer'] |
184 | + doctest consumer |
185 | + |
186 | +You'll also get information about the available access |
187 | +levels. |
188 | + |
189 | + >>> print sorted([level['value'] for level in json['access_levels']]) |
190 | + ['READ_PRIVATE', ... 'UNAUTHORIZED', ...] |
191 | + |
192 | +If you provide a list of possible access levels, you'll |
193 | +get back a list that reconciles the list you gave with |
194 | +Launchpad's access levels. |
195 | + |
196 | + >>> response, content = browser.get_token_info( |
197 | + ... username, password, request_token, |
198 | + ... ["READ_PUBLIC", "READ_PRIVATE", "NO_SUCH_ACCESS_LEVEL"]) |
199 | + |
200 | + >>> print response['content-type'] |
201 | + application/json |
202 | + |
203 | + >>> json = simplejson.loads(content) |
204 | + >>> print sorted( |
205 | + ... [level['value'] for level in json['access_levels']]) |
206 | + ['READ_PRIVATE', 'READ_PUBLIC', 'UNAUTHORIZED'] |
207 | + |
208 | +Note that the nonexistent access level has been removed from the |
209 | +reconciled list, and the "Unauthorized" access level (which must |
210 | +always be an option) has been added. |
211 | + |
212 | +grant_access() |
213 | +============== |
214 | + |
215 | +If you have the end-user's username and password, you can use |
216 | +grant_access() to authorize a request token. |
217 | + |
218 | +If you make an unauthorized request, you'll get a 401 error. (As with |
219 | +get_token_info(), Launchpad returns 200, but SimulatedLaunchpadBrowser |
220 | +sniffs it and changes it to a 401.) |
221 | + |
222 | + >>> access_level = "READ_PRIVATE" |
223 | + |
224 | + >>> response, content = browser.grant_access( |
225 | + ... "baduser", "badpasword", request_token, access_level) |
226 | + >>> print response.status |
227 | + 401 |
228 | + |
229 | +If you try to grant an invalid level of access, you'll get a |
230 | +400 error. |
231 | + |
232 | + >>> response, content = browser.grant_access( |
233 | + ... username, password, request_token, |
234 | + ... "NO_SUCH_ACCESS_LEVEL") |
235 | + >>> print response.status |
236 | + 400 |
237 | + |
238 | +If you provide all the necessary information, you'll get a 200 |
239 | +response code and the request token will be authorized. |
240 | + |
241 | + >>> response, content = browser.grant_access( |
242 | + ... username, password, request_token, access_level) |
243 | + >>> print response.status |
244 | + 200 |
245 | + |
246 | +If you try to grant access to a request token that's already |
247 | +been authorized, you'll get a 409 error. |
248 | + |
249 | + >>> response, content = browser.grant_access( |
250 | + ... username, password, request_token, access_level) |
251 | + >>> print response.status |
252 | + 409 |
253 | + |
254 | +Now that the request token is authorized, we can exchange it for an |
255 | +access token. |
256 | + |
257 | + >>> credentials.exchange_request_token_for_access_token( |
258 | + ... web_root=web_root) |
259 | + >>> credentials.access_token.key is None |
260 | + False |
261 | + |
262 | +If you try to grant access to a request token that's already been |
263 | +exchanged for an access token, you'll get a 400 error. |
264 | + |
265 | + >>> response, content = browser.grant_access( |
266 | + ... username, password, request_token, access_level) |
267 | + >>> print response.status |
268 | + 400 |
269 | |
270 | === modified file 'src/launchpadlib/launchpad.py' |
271 | --- src/launchpadlib/launchpad.py 2009-10-23 15:43:21 +0000 |
272 | +++ src/launchpadlib/launchpad.py 2009-10-27 12:59:09 +0000 |
273 | @@ -35,6 +35,8 @@ |
274 | from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT |
275 | from launchpadlib import uris |
276 | |
277 | +# Import some constants for backwards compatibility. |
278 | +from launchpadlib.uris import EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT |
279 | OAUTH_REALM = 'https://api.launchpad.net' |
280 | |
281 |
Ignore the name of this branch; it has nothing to do with the content.
This branch creates a new class, SimulatedLaunch padBrowser, which is designed to do the same job as the user's web browser. You give it your username and password, and it will authorize an OAuth request token on your behalf.
There are two methods: get_token_info(), which gets information about an access token from Launchpad, and grant_access(), which authorizes a request token so that other parts of launchpadlib can exchange it for an access token.
There is a lot of code that examines the HTML and plain-text strings Launchpad sends in different situations and turns them into standard error codes. Obviously it would be better if Launchpad just sent those error codes in the first place.
I also made some minor changes to use constants from the uris module instead of re-defining hard-coded strings.