Merge lp:~leonardr/launchpadlib/wsgi-fake-launchpad into lp:launchpadlib

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
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
Review via email: mp+14021@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

Ignore the name of this branch; it has nothing to do with the content.

This branch creates a new class, SimulatedLaunchpadBrowser, 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.

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

Subscribers

People subscribed via source and target branches