Merge lp:~barry/ubuntu/natty/python-launchpadlib/bug-702375-1.9.6 into lp:ubuntu/natty/python-launchpadlib
- Natty (11.04)
- bug-702375-1.9.6
- Merge into natty
Status: | Superseded |
---|---|
Proposed branch: | lp:~barry/ubuntu/natty/python-launchpadlib/bug-702375-1.9.6 |
Merge into: | lp:ubuntu/natty/python-launchpadlib |
Diff against target: |
5718 lines (+2309/-2603) 28 files modified
PKG-INFO (+105/-1) README.txt (+11/-1) debian/changelog (+6/-0) setup.cfg (+5/-0) setup.py (+1/-0) src/launchpadlib.egg-info/PKG-INFO (+258/-0) src/launchpadlib.egg-info/SOURCES.txt (+3/-3) src/launchpadlib.egg-info/not-zip-safe (+1/-0) src/launchpadlib.egg-info/requires.txt (+2/-1) src/launchpadlib/NEWS.txt (+104/-0) src/launchpadlib/__init__.py (+1/-1) src/launchpadlib/apps.py (+0/-119) src/launchpadlib/credentials.py (+367/-345) src/launchpadlib/docs/browser.txt (+0/-151) src/launchpadlib/docs/command-line.txt (+3/-171) src/launchpadlib/docs/hosted-files.txt (+2/-2) src/launchpadlib/docs/introduction.txt (+26/-137) src/launchpadlib/docs/operations.txt (+27/-0) src/launchpadlib/docs/people.txt (+1/-1) src/launchpadlib/docs/toplevel.txt (+18/-12) src/launchpadlib/docs/trusted-client.txt (+0/-224) src/launchpadlib/launchpad.py (+386/-109) src/launchpadlib/testing/helpers.py (+136/-114) src/launchpadlib/tests/test_credential_store.py (+138/-0) src/launchpadlib/tests/test_http.py (+236/-0) src/launchpadlib/tests/test_launchpad.py (+441/-158) src/launchpadlib/uris.py (+31/-8) src/launchpadlib/wadl-to-refhtml.xsl (+0/-1045) |
To merge this branch: | bzr merge lp:~barry/ubuntu/natty/python-launchpadlib/bug-702375-1.9.6 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Sebastien Bacher | Needs Information | ||
Leonard Richardson | Pending | ||
Ubuntu branches | Pending | ||
Francis J. Lacoste | Pending | ||
Review via email: mp+49711@code.launchpad.net |
This proposal supersedes a proposal from 2011-02-08.
This proposal has been superseded by a proposal from 2011-02-22.
Commit message
Description of the change
Updated to launchpadlib 1.9.6
Didier Roche-Tolomelli (didrocks) wrote : Posted in a previous version of this proposal | # |
Barry Warsaw (barry) wrote : Posted in a previous version of this proposal | # |
Actually, I think Leonard has the definitive word on API issues, so I'll request a comment from him.
Francis J. Lacoste (flacoste) wrote : Posted in a previous version of this proposal | # |
My understanding was that all changes were backward compatible. You only get the new behavior when you change the API.
Francis J. Lacoste (flacoste) wrote : Posted in a previous version of this proposal | # |
According to Benji, the only backward incompatible change is the removal of the edge root. We could add it back but make it only pointing at production.
But at the same time, this is alpha release, so it's probably a good idea to have any apps pointing at edge be upgraded. Make it a good time to take advantage of the new desktop login possibility.
Leonard Richardson (leonardr) wrote : Posted in a previous version of this proposal | # |
I plan to contact the developers of launchpadlib apps that are in Ubuntu and help them make the upgrade.
This branch contains all the changes I expected.
Leonard Richardson (leonardr) wrote : Posted in a previous version of this proposal | # |
BTW, today launchpadlib stopped working with edge. I have a branch to make existing code work (by giving a deprecation warning to people who use edge, and having them use production instead). It would be great to get this into Natty:
https:/
It would also be great to get my backport of this feature into Maverick:
https:/
Barry Warsaw (barry) wrote : Posted in a previous version of this proposal | # |
I changed this to a work-in-progress and will obsolete the branch. Instead, I'm going to work through Luca to get 1.9.6 into Debian unstable and then we'll sync it over to Natty.
For backporting to Maverick, we'll have to do an SRU.
https:/
Leonard, if you can do the bookkeeping work on proposing the SRU, I'll prepare a package branch.
Barry Warsaw (barry) wrote : | # |
Actually, I changed this back since it would be nice to land in Ubuntu while we're waiting for Debian to update. I resubmitted this mp for the 1.9.6 branch and have uploaded a package to my PPA. Please test and provide review again here.
Sebastien Bacher (seb128) wrote : | # |
Since 1.9.7 is available should that merge request be updated?
Sebastien Bacher (seb128) wrote : | # |
setting to "Work in Progress" so it gets off the sponsoring list, set it back to Needs Review once you have an update ready to review
Unmerged revisions
- 19. By Barry Warsaw
-
New upstream release. (LP: #702375)
- 18. By Barry Warsaw
-
New upstream release. (LP: #702375)
Preview Diff
1 | === modified file 'PKG-INFO' |
2 | --- PKG-INFO 2010-12-07 19:30:29 +0000 |
3 | +++ PKG-INFO 2011-02-14 21:31:44 +0000 |
4 | @@ -1,6 +1,6 @@ |
5 | Metadata-Version: 1.0 |
6 | Name: launchpadlib |
7 | -Version: 1.6.2 |
8 | +Version: 1.9.6 |
9 | Summary: Script Launchpad through its web services interfaces. Officially supported. |
10 | Home-page: https://help.launchpad.net/API/launchpadlib |
11 | Author: LAZR Developers |
12 | @@ -31,6 +31,110 @@ |
13 | NEWS for launchpadlib |
14 | ===================== |
15 | |
16 | + 1.9.6 (2011-02-14) |
17 | + ================== |
18 | + |
19 | + - Added EDGE_SERVICE_ROOT and the 'edge' alias back, though they both |
20 | + operate on production behind the scenes. Using the 'edge' alias will |
21 | + cause a deprecation warning. |
22 | + |
23 | + 1.9.5 (2011-02-08) |
24 | + ================== |
25 | + |
26 | + - Fixed a bug that prevented the deprecated get_token_and_login code |
27 | + from working, and that required that users of get_token_and_login |
28 | + get a new token on every usage. |
29 | + |
30 | + 1.9.4 (2011-01-18) |
31 | + ================== |
32 | + |
33 | + - Removed references to the 'edge' service root, which is being phased out. |
34 | + |
35 | + - Fixed a minor bug in the upload_release_tarball contrib script which |
36 | + was causing tarballs to be uploaded with the wrong media type. |
37 | + |
38 | + - The XSLT stylesheet for converting the Launchpad WADL into HTML |
39 | + documentation has been moved back into Launchpad. |
40 | + |
41 | + 1.9.3 (2011-01-10) |
42 | + ================== |
43 | + |
44 | + - The keyring package import is now delayed until the keyring needs to be |
45 | + accessed. This reduces launchapdlib users' exposure to unintended side |
46 | + effects of importing keyring (KWallet authorization dialogs and the |
47 | + registration of a SIGCHLD handler). |
48 | + |
49 | + 1.9.2 (2011-01-07) |
50 | + ================== |
51 | + |
52 | + - Added a missing import. |
53 | + |
54 | + 1.9.1 (2011-01-06) |
55 | + ================== |
56 | + |
57 | + - Corrected a test failure. |
58 | + |
59 | + 1.9.0 (2011-01-05) |
60 | + ================== |
61 | + |
62 | + - When an authorization token expires or becomes invalid, attempt to |
63 | + acquire a new one, even in the middle of a session, rather than |
64 | + crashing. |
65 | + |
66 | + - The HTML generated by wadl-to-refhtml.xsl now validates. |
67 | + |
68 | + - Most of the helper login methods have been deprecated. There are now |
69 | + only two helper methods: |
70 | + |
71 | + * Launchpad.login_anonymously, for anonymous credential-free access. |
72 | + * Launchpad.login_with, for programs that need a credential. |
73 | + |
74 | + |
75 | + 1.8.0 (2010-11-15) |
76 | + ================== |
77 | + |
78 | + - Store authorization tokens in the Gnome keyring or KDE wallet, when |
79 | + available. The credentials_file parameter of Launchpad.login_with() is now |
80 | + ignored. |
81 | + |
82 | + - By default, Launchpad.login_with() now asks Launchpad for |
83 | + desktop-wide integration. This removes the need for each individual |
84 | + application to get its own OAuth token. |
85 | + |
86 | + 1.7.0 (2010-09-23) |
87 | + ================== |
88 | + |
89 | + - Removed "fake Launchpad browser" code that didn't work and was |
90 | + misleading developers. |
91 | + |
92 | + - Added support for http://qastaging.launchpad.net by adding |
93 | + astaging to the uris. |
94 | + |
95 | + 1.6.5 (2010-08-23) |
96 | + ================== |
97 | + |
98 | + - Make launchpadlib compatible with the latest lazr.restfulclient. |
99 | + |
100 | + 1.6.4 (2010-08-18) |
101 | + ================== |
102 | + |
103 | + - Test fixes. |
104 | + |
105 | + 1.6.3 (2010-08-12) |
106 | + ================== |
107 | + |
108 | + - Instead of making the end-user hit Enter after authorizing an |
109 | + application to access their Launchpad account, launchpadlib will |
110 | + automatically poll Launchpad until the user makes a decision. |
111 | + |
112 | + - launchpadlib now raises a more helpful exception when the end-user |
113 | + explicitly denies access to a launchpadlib application. |
114 | + |
115 | + - Improved the XSLT stylesheet to reflect Launchpad's more complex |
116 | + top-level structure. [bug=286941] |
117 | + |
118 | + - Test fixes. [bug=488448,616055] |
119 | + |
120 | 1.6.2 (2010-06-21) |
121 | ================== |
122 | |
123 | |
124 | === modified file 'README.txt' |
125 | --- README.txt 2010-12-07 19:30:29 +0000 |
126 | +++ README.txt 2011-02-14 21:31:44 +0000 |
127 | @@ -19,7 +19,7 @@ |
128 | |
129 | |
130 | Overview |
131 | -******** |
132 | +======== |
133 | |
134 | launchpadlib is a standalone Python library for scripting Launchpad through |
135 | its web services interface. It is the officially supported bindings to the |
136 | @@ -44,3 +44,13 @@ |
137 | Please submit bug reports to |
138 | |
139 | https://bugs.launchpad.net/launchpadlib |
140 | + |
141 | + |
142 | +Credential storage |
143 | +================== |
144 | + |
145 | +After authorizing an application to access Launchpad on a user's behalf, |
146 | +launchpadlib will attempt to store those credentials in the most |
147 | +system-appropriate way. That is, on Gnome it will store them in the Gnome |
148 | +keyring, on KDE it will store them in the KDE wallet and if neither of those |
149 | +is available it will store them on disk. |
150 | |
151 | === modified file 'debian/changelog' |
152 | --- debian/changelog 2010-12-07 19:30:29 +0000 |
153 | +++ debian/changelog 2011-02-14 21:31:44 +0000 |
154 | @@ -1,3 +1,9 @@ |
155 | +python-launchpadlib (1.9.6-0ubuntu1) UNRELEASED; urgency=low |
156 | + |
157 | + * New upstream release. (LP: #702375) |
158 | + |
159 | + -- Barry Warsaw <barry@ubuntu.com> Mon, 14 Feb 2011 15:43:07 -0500 |
160 | + |
161 | python-launchpadlib (1.8.0.is.1.6.2-0ubuntu1) natty; urgency=low |
162 | |
163 | * Revert to 1.6.2 for the time being. 1.8.0 completely breaks the login_with |
164 | |
165 | === added file 'setup.cfg' |
166 | --- setup.cfg 1970-01-01 00:00:00 +0000 |
167 | +++ setup.cfg 2011-02-14 21:31:44 +0000 |
168 | @@ -0,0 +1,5 @@ |
169 | +[egg_info] |
170 | +tag_build = |
171 | +tag_date = 0 |
172 | +tag_svn_revision = 0 |
173 | + |
174 | |
175 | === modified file 'setup.py' |
176 | --- setup.py 2010-12-07 19:30:29 +0000 |
177 | +++ setup.py 2011-02-14 21:31:44 +0000 |
178 | @@ -60,6 +60,7 @@ |
179 | license='LGPL v3', |
180 | install_requires=[ |
181 | 'httplib2', |
182 | + 'keyring', |
183 | 'lazr.restfulclient>=0.9.19', |
184 | 'lazr.uri', |
185 | 'oauth', |
186 | |
187 | === added file 'src/launchpadlib.egg-info/PKG-INFO' |
188 | --- src/launchpadlib.egg-info/PKG-INFO 1970-01-01 00:00:00 +0000 |
189 | +++ src/launchpadlib.egg-info/PKG-INFO 2011-02-14 21:31:44 +0000 |
190 | @@ -0,0 +1,258 @@ |
191 | +Metadata-Version: 1.0 |
192 | +Name: launchpadlib |
193 | +Version: 1.9.6 |
194 | +Summary: Script Launchpad through its web services interfaces. Officially supported. |
195 | +Home-page: https://help.launchpad.net/API/launchpadlib |
196 | +Author: LAZR Developers |
197 | +Author-email: lazr-developers@lists.launchpad.net |
198 | +License: LGPL v3 |
199 | +Download-URL: https://launchpad.net/launchpadlib/+download |
200 | +Description: .. |
201 | + This file is part of launchpadlib. |
202 | + |
203 | + launchpadlib is free software: you can redistribute it and/or modify it |
204 | + under the terms of the GNU Lesser General Public License as published by |
205 | + the Free Software Foundation, version 3 of the License. |
206 | + |
207 | + launchpadlib is distributed in the hope that it will be useful, but |
208 | + WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY |
209 | + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public |
210 | + License for more details. |
211 | + |
212 | + You should have received a copy of the GNU Lesser General Public License |
213 | + along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
214 | + |
215 | + launchpadlib |
216 | + ************ |
217 | + |
218 | + See https://help.launchpad.net/API/launchpadlib . |
219 | + |
220 | + ===================== |
221 | + NEWS for launchpadlib |
222 | + ===================== |
223 | + |
224 | + 1.9.6 (2011-02-14) |
225 | + ================== |
226 | + |
227 | + - Added EDGE_SERVICE_ROOT and the 'edge' alias back, though they both |
228 | + operate on production behind the scenes. Using the 'edge' alias will |
229 | + cause a deprecation warning. |
230 | + |
231 | + 1.9.5 (2011-02-08) |
232 | + ================== |
233 | + |
234 | + - Fixed a bug that prevented the deprecated get_token_and_login code |
235 | + from working, and that required that users of get_token_and_login |
236 | + get a new token on every usage. |
237 | + |
238 | + 1.9.4 (2011-01-18) |
239 | + ================== |
240 | + |
241 | + - Removed references to the 'edge' service root, which is being phased out. |
242 | + |
243 | + - Fixed a minor bug in the upload_release_tarball contrib script which |
244 | + was causing tarballs to be uploaded with the wrong media type. |
245 | + |
246 | + - The XSLT stylesheet for converting the Launchpad WADL into HTML |
247 | + documentation has been moved back into Launchpad. |
248 | + |
249 | + 1.9.3 (2011-01-10) |
250 | + ================== |
251 | + |
252 | + - The keyring package import is now delayed until the keyring needs to be |
253 | + accessed. This reduces launchapdlib users' exposure to unintended side |
254 | + effects of importing keyring (KWallet authorization dialogs and the |
255 | + registration of a SIGCHLD handler). |
256 | + |
257 | + 1.9.2 (2011-01-07) |
258 | + ================== |
259 | + |
260 | + - Added a missing import. |
261 | + |
262 | + 1.9.1 (2011-01-06) |
263 | + ================== |
264 | + |
265 | + - Corrected a test failure. |
266 | + |
267 | + 1.9.0 (2011-01-05) |
268 | + ================== |
269 | + |
270 | + - When an authorization token expires or becomes invalid, attempt to |
271 | + acquire a new one, even in the middle of a session, rather than |
272 | + crashing. |
273 | + |
274 | + - The HTML generated by wadl-to-refhtml.xsl now validates. |
275 | + |
276 | + - Most of the helper login methods have been deprecated. There are now |
277 | + only two helper methods: |
278 | + |
279 | + * Launchpad.login_anonymously, for anonymous credential-free access. |
280 | + * Launchpad.login_with, for programs that need a credential. |
281 | + |
282 | + |
283 | + 1.8.0 (2010-11-15) |
284 | + ================== |
285 | + |
286 | + - Store authorization tokens in the Gnome keyring or KDE wallet, when |
287 | + available. The credentials_file parameter of Launchpad.login_with() is now |
288 | + ignored. |
289 | + |
290 | + - By default, Launchpad.login_with() now asks Launchpad for |
291 | + desktop-wide integration. This removes the need for each individual |
292 | + application to get its own OAuth token. |
293 | + |
294 | + 1.7.0 (2010-09-23) |
295 | + ================== |
296 | + |
297 | + - Removed "fake Launchpad browser" code that didn't work and was |
298 | + misleading developers. |
299 | + |
300 | + - Added support for http://qastaging.launchpad.net by adding |
301 | + astaging to the uris. |
302 | + |
303 | + 1.6.5 (2010-08-23) |
304 | + ================== |
305 | + |
306 | + - Make launchpadlib compatible with the latest lazr.restfulclient. |
307 | + |
308 | + 1.6.4 (2010-08-18) |
309 | + ================== |
310 | + |
311 | + - Test fixes. |
312 | + |
313 | + 1.6.3 (2010-08-12) |
314 | + ================== |
315 | + |
316 | + - Instead of making the end-user hit Enter after authorizing an |
317 | + application to access their Launchpad account, launchpadlib will |
318 | + automatically poll Launchpad until the user makes a decision. |
319 | + |
320 | + - launchpadlib now raises a more helpful exception when the end-user |
321 | + explicitly denies access to a launchpadlib application. |
322 | + |
323 | + - Improved the XSLT stylesheet to reflect Launchpad's more complex |
324 | + top-level structure. [bug=286941] |
325 | + |
326 | + - Test fixes. [bug=488448,616055] |
327 | + |
328 | + 1.6.2 (2010-06-21) |
329 | + ================== |
330 | + |
331 | + - Extended the optimization from version 1.6.1 to apply to Launchpad's |
332 | + top-level collection of people. |
333 | + |
334 | + 1.6.1 (2010-06-16) |
335 | + ================== |
336 | + |
337 | + - Added an optimization that lets launchpadlib avoid making an HTTP |
338 | + request in some situations. |
339 | + |
340 | + 1.6.0 (2010-04-07) |
341 | + ================== |
342 | + |
343 | + - Fixed a test to work against the latest version of Launchpad. |
344 | + |
345 | + 1.5.8 (2010-03-25) |
346 | + ================== |
347 | + |
348 | + - Use version 1.0 of the Launchpad web service by default. |
349 | + |
350 | + 1.5.7 (2010-03-16) |
351 | + ================== |
352 | + |
353 | + - Send a Referer header whenever making requests to the Launchpad |
354 | + website (as opposed to the web service) to avoid falling afoul of |
355 | + new cross-site-request-forgery countermeasures. |
356 | + |
357 | + 1.5.6 (2010-03-04) |
358 | + ================== |
359 | + |
360 | + - Fixed a minor bug when using login_with() to access a version of the |
361 | + Launchpad web service other than the default. |
362 | + |
363 | + - Added a check to catch old client code that would cause newer |
364 | + versions of launchpadlib to make nonsensical requests to |
365 | + https://api.launchpad.dev/beta/beta/, and raise a helpful exception |
366 | + telling the developer how to fix it. |
367 | + |
368 | + 1.5.5 |
369 | + ===== |
370 | + |
371 | + - Added the ability to access different versions of the Launchpad web |
372 | + service. |
373 | + |
374 | + 1.5.4 (2009-12-17) |
375 | + ================== |
376 | + |
377 | + - Made it easy to get anonymous access to a Launchpad instance. |
378 | + |
379 | + - Made it easy to plug in different clients that take the user's |
380 | + Launchpad login and password for purposes of authorizing a request |
381 | + token. The most secure technique is still the default: to open the |
382 | + user's web browser to the appropriate Launchpad page. |
383 | + |
384 | + - Introduced a command-line script bin/launchpad-credentials-console, |
385 | + which takes the user's Launchpad login and password, and authorizes |
386 | + a request token on their behalf. |
387 | + |
388 | + - Introduced a command-line script bin/launchpad-request-token, which |
389 | + creates a request token on any Launchpad installation and dumps the |
390 | + JSON description of that token to standard output. |
391 | + |
392 | + - Shorthand service names like 'edge' should now be respected |
393 | + everywhere in launchpadlib. |
394 | + |
395 | + 1.5.3 (2009-10-22) |
396 | + ================== |
397 | + |
398 | + - Moved some more code from launchpadlib into the more generic |
399 | + lazr.restfulclient. |
400 | + |
401 | + 1.5.2 (2009-10-01) |
402 | + ================== |
403 | + |
404 | + - Added a number of new sample scripts from elsewhere. |
405 | + |
406 | + - Added a reference to the production Launchpad instance. |
407 | + |
408 | + - Made it easier to specify a Launchpad instance to run against. |
409 | + |
410 | + 1.5.1 (2009-07-16) |
411 | + ================== |
412 | + |
413 | + - Added a sample script for uploading a release tarball to Launchpad. |
414 | + |
415 | + 1.5.0 (2009-07-09) |
416 | + ================== |
417 | + |
418 | + - Most of launchpadlib's code has been moved to the generic |
419 | + lazr.restfulclient library. launchpadlib now contains only code |
420 | + specific to Launchpad. There should be no changes in functionality. |
421 | + |
422 | + - Moved bootstrap.py into the top-level directory. Having it in a |
423 | + subdirectory with a top-level symlink was breaking installation on |
424 | + Windows. |
425 | + |
426 | + - The notice to the end-user (that we're opening their web |
427 | + browser) is now better formatted. |
428 | + |
429 | + 1.0.1 (2009-05-30) |
430 | + ================== |
431 | + |
432 | + - Correct tests for new launchpad cache behavior in librarian |
433 | + |
434 | + - Remove build dependency on setuptools_bzr because it was causing bzr to be |
435 | + downloaded during installation of the package, which was unnecessary and |
436 | + annoying. |
437 | + |
438 | + 1.0 (2009-03-24) |
439 | + ================ |
440 | + |
441 | + - Initial release on PyPI |
442 | + |
443 | +Platform: UNKNOWN |
444 | +Classifier: Development Status :: 5 - Production/Stable |
445 | +Classifier: Intended Audience :: Developers |
446 | +Classifier: License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL) |
447 | +Classifier: Operating System :: OS Independent |
448 | +Classifier: Programming Language :: Python |
449 | |
450 | === modified file 'src/launchpadlib.egg-info/SOURCES.txt' |
451 | --- src/launchpadlib.egg-info/SOURCES.txt 2010-12-07 19:30:29 +0000 |
452 | +++ src/launchpadlib.egg-info/SOURCES.txt 2011-02-14 21:31:44 +0000 |
453 | @@ -11,22 +11,22 @@ |
454 | src/launchpadlib/errors.py |
455 | src/launchpadlib/launchpad.py |
456 | src/launchpadlib/uris.py |
457 | -src/launchpadlib/wadl-to-refhtml.xsl |
458 | src/launchpadlib.egg-info/PKG-INFO |
459 | src/launchpadlib.egg-info/SOURCES.txt |
460 | src/launchpadlib.egg-info/dependency_links.txt |
461 | src/launchpadlib.egg-info/not-zip-safe |
462 | src/launchpadlib.egg-info/requires.txt |
463 | src/launchpadlib.egg-info/top_level.txt |
464 | -src/launchpadlib/docs/browser.txt |
465 | src/launchpadlib/docs/command-line.txt |
466 | src/launchpadlib/docs/hosted-files.txt |
467 | src/launchpadlib/docs/introduction.txt |
468 | +src/launchpadlib/docs/operations.txt |
469 | src/launchpadlib/docs/people.txt |
470 | src/launchpadlib/docs/toplevel.txt |
471 | -src/launchpadlib/docs/trusted-client.txt |
472 | src/launchpadlib/docs/files/mugshot.png |
473 | src/launchpadlib/testing/__init__.py |
474 | src/launchpadlib/testing/helpers.py |
475 | src/launchpadlib/tests/__init__.py |
476 | +src/launchpadlib/tests/test_credential_store.py |
477 | +src/launchpadlib/tests/test_http.py |
478 | src/launchpadlib/tests/test_launchpad.py |
479 | \ No newline at end of file |
480 | |
481 | === added file 'src/launchpadlib.egg-info/not-zip-safe' |
482 | --- src/launchpadlib.egg-info/not-zip-safe 1970-01-01 00:00:00 +0000 |
483 | +++ src/launchpadlib.egg-info/not-zip-safe 2011-02-14 21:31:44 +0000 |
484 | @@ -0,0 +1,1 @@ |
485 | + |
486 | |
487 | === modified file 'src/launchpadlib.egg-info/requires.txt' |
488 | --- src/launchpadlib.egg-info/requires.txt 2010-12-07 19:30:29 +0000 |
489 | +++ src/launchpadlib.egg-info/requires.txt 2011-02-14 21:31:44 +0000 |
490 | @@ -1,5 +1,6 @@ |
491 | httplib2 |
492 | -lazr.restfulclient>=0.9.11 |
493 | +keyring |
494 | +lazr.restfulclient>=0.9.19 |
495 | lazr.uri |
496 | oauth |
497 | setuptools |
498 | |
499 | === modified file 'src/launchpadlib/NEWS.txt' |
500 | --- src/launchpadlib/NEWS.txt 2010-12-07 19:30:29 +0000 |
501 | +++ src/launchpadlib/NEWS.txt 2011-02-14 21:31:44 +0000 |
502 | @@ -2,6 +2,110 @@ |
503 | NEWS for launchpadlib |
504 | ===================== |
505 | |
506 | +1.9.6 (2011-02-14) |
507 | +================== |
508 | + |
509 | +- Added EDGE_SERVICE_ROOT and the 'edge' alias back, though they both |
510 | + operate on production behind the scenes. Using the 'edge' alias will |
511 | + cause a deprecation warning. |
512 | + |
513 | +1.9.5 (2011-02-08) |
514 | +================== |
515 | + |
516 | +- Fixed a bug that prevented the deprecated get_token_and_login code |
517 | + from working, and that required that users of get_token_and_login |
518 | + get a new token on every usage. |
519 | + |
520 | +1.9.4 (2011-01-18) |
521 | +================== |
522 | + |
523 | +- Removed references to the 'edge' service root, which is being phased out. |
524 | + |
525 | +- Fixed a minor bug in the upload_release_tarball contrib script which |
526 | + was causing tarballs to be uploaded with the wrong media type. |
527 | + |
528 | +- The XSLT stylesheet for converting the Launchpad WADL into HTML |
529 | + documentation has been moved back into Launchpad. |
530 | + |
531 | +1.9.3 (2011-01-10) |
532 | +================== |
533 | + |
534 | +- The keyring package import is now delayed until the keyring needs to be |
535 | + accessed. This reduces launchapdlib users' exposure to unintended side |
536 | + effects of importing keyring (KWallet authorization dialogs and the |
537 | + registration of a SIGCHLD handler). |
538 | + |
539 | +1.9.2 (2011-01-07) |
540 | +================== |
541 | + |
542 | +- Added a missing import. |
543 | + |
544 | +1.9.1 (2011-01-06) |
545 | +================== |
546 | + |
547 | +- Corrected a test failure. |
548 | + |
549 | +1.9.0 (2011-01-05) |
550 | +================== |
551 | + |
552 | +- When an authorization token expires or becomes invalid, attempt to |
553 | + acquire a new one, even in the middle of a session, rather than |
554 | + crashing. |
555 | + |
556 | +- The HTML generated by wadl-to-refhtml.xsl now validates. |
557 | + |
558 | +- Most of the helper login methods have been deprecated. There are now |
559 | + only two helper methods: |
560 | + |
561 | + * Launchpad.login_anonymously, for anonymous credential-free access. |
562 | + * Launchpad.login_with, for programs that need a credential. |
563 | + |
564 | + |
565 | +1.8.0 (2010-11-15) |
566 | +================== |
567 | + |
568 | +- Store authorization tokens in the Gnome keyring or KDE wallet, when |
569 | + available. The credentials_file parameter of Launchpad.login_with() is now |
570 | + ignored. |
571 | + |
572 | +- By default, Launchpad.login_with() now asks Launchpad for |
573 | + desktop-wide integration. This removes the need for each individual |
574 | + application to get its own OAuth token. |
575 | + |
576 | +1.7.0 (2010-09-23) |
577 | +================== |
578 | + |
579 | +- Removed "fake Launchpad browser" code that didn't work and was |
580 | + misleading developers. |
581 | + |
582 | +- Added support for http://qastaging.launchpad.net by adding |
583 | + astaging to the uris. |
584 | + |
585 | +1.6.5 (2010-08-23) |
586 | +================== |
587 | + |
588 | +- Make launchpadlib compatible with the latest lazr.restfulclient. |
589 | + |
590 | +1.6.4 (2010-08-18) |
591 | +================== |
592 | + |
593 | +- Test fixes. |
594 | + |
595 | +1.6.3 (2010-08-12) |
596 | +================== |
597 | + |
598 | +- Instead of making the end-user hit Enter after authorizing an |
599 | + application to access their Launchpad account, launchpadlib will |
600 | + automatically poll Launchpad until the user makes a decision. |
601 | + |
602 | +- launchpadlib now raises a more helpful exception when the end-user |
603 | + explicitly denies access to a launchpadlib application. |
604 | + |
605 | +- Improved the XSLT stylesheet to reflect Launchpad's more complex |
606 | + top-level structure. [bug=286941] |
607 | + |
608 | +- Test fixes. [bug=488448,616055] |
609 | + |
610 | 1.6.2 (2010-06-21) |
611 | ================== |
612 | |
613 | |
614 | === modified file 'src/launchpadlib/__init__.py' |
615 | --- src/launchpadlib/__init__.py 2010-12-07 19:30:29 +0000 |
616 | +++ src/launchpadlib/__init__.py 2011-02-14 21:31:44 +0000 |
617 | @@ -14,4 +14,4 @@ |
618 | # You should have received a copy of the GNU Lesser General Public License |
619 | # along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
620 | |
621 | -__version__ = '1.6.2' |
622 | +__version__ = '1.9.6' |
623 | |
624 | === modified file 'src/launchpadlib/apps.py' |
625 | --- src/launchpadlib/apps.py 2010-12-07 19:30:29 +0000 |
626 | +++ src/launchpadlib/apps.py 2011-02-14 21:31:44 +0000 |
627 | @@ -52,122 +52,3 @@ |
628 | return simplejson.dumps(token) |
629 | |
630 | |
631 | -class TrustedTokenAuthorizationConsoleApp(RequestTokenAuthorizationEngine): |
632 | - """An application that authorizes request tokens.""" |
633 | - |
634 | - def __init__(self, web_root, consumer_name, request_token, |
635 | - access_levels='', input_method=raw_input): |
636 | - """Constructor. |
637 | - |
638 | - :param access_levels: A string of comma-separated access level |
639 | - values. To get an up-to-date list of access levels, pass |
640 | - token_format=Credentials.DICT_TOKEN_FORMAT into |
641 | - Credentials.get_request_token, load the dict as JSON, and look |
642 | - in 'access_levels'. |
643 | - """ |
644 | - access_levels = [level.strip() for level in access_levels.split(',')] |
645 | - super(TrustedTokenAuthorizationConsoleApp, self).__init__( |
646 | - web_root, consumer_name, request_token, access_levels) |
647 | - |
648 | - self.input_method = input_method |
649 | - |
650 | - def run(self): |
651 | - """Try to authorize a request token from user input.""" |
652 | - self.error_code = -1 # Start off assuming failure. |
653 | - start = "Launchpad credential client (console)" |
654 | - self.output(start) |
655 | - self.output("-" * len(start)) |
656 | - |
657 | - try: |
658 | - self() |
659 | - except TokenAuthorizationException, e: |
660 | - print str(e) |
661 | - self.error_code = -1 |
662 | - return self.press_enter_to_exit() |
663 | - |
664 | - def exit_with(self, code): |
665 | - """Exit the app with the specified error code.""" |
666 | - sys.exit(code) |
667 | - |
668 | - def get_single_char_input(self, prompt, valid): |
669 | - """Retrieve a single-character line from the input stream.""" |
670 | - valid = valid.upper() |
671 | - input = None |
672 | - while input is None: |
673 | - input = self.input_method(prompt).upper() |
674 | - if len(input) != 1 or input not in valid: |
675 | - input = None |
676 | - return input |
677 | - |
678 | - def press_enter_to_exit(self): |
679 | - """Make the user hit enter, and then exit with an error code.""" |
680 | - prompt = '\nPress enter to go back to "%s". ' % self.consumer_name |
681 | - self.input_method(prompt) |
682 | - self.exit_with(self.error_code) |
683 | - |
684 | - def input_username(self, cached_username, suggested_message): |
685 | - """Collect the Launchpad username from the end-user. |
686 | - |
687 | - :param cached_username: A username from a previous entry attempt, |
688 | - to be presented as the default. |
689 | - """ |
690 | - if cached_username is not None: |
691 | - extra = " [%s] " % cached_username |
692 | - else: |
693 | - extra = "\n(No Launchpad account? Just hit enter.) " |
694 | - username = self.input_method(suggested_message + extra) |
695 | - if username == '': |
696 | - return cached_username |
697 | - return username |
698 | - |
699 | - def input_password(self, suggested_message): |
700 | - """Collect the Launchpad password from the end-user.""" |
701 | - if self.input_method is raw_input: |
702 | - password = getpass.getpass(suggested_message + " ") |
703 | - else: |
704 | - password = self.input_method(suggested_message) |
705 | - return password |
706 | - |
707 | - def input_access_level(self, available_levels, suggested_message, |
708 | - only_one_option=None): |
709 | - """Collect the desired level of access from the end-user.""" |
710 | - if only_one_option is not None: |
711 | - self.output(suggested_message) |
712 | - prompt = self.message( |
713 | - 'Do you want to give "%(app)s" this level of access? [YN] ') |
714 | - allow = self.get_single_char_input(prompt, "YN") |
715 | - if allow == "Y": |
716 | - return only_one_option['value'] |
717 | - else: |
718 | - return self.UNAUTHORIZED_ACCESS_LEVEL |
719 | - else: |
720 | - levels_except_unauthorized = [ |
721 | - level for level in available_levels |
722 | - if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL] |
723 | - options = [] |
724 | - for i in range(0, len(levels_except_unauthorized)): |
725 | - options.append( |
726 | - "%d: %s" % (i+1, levels_except_unauthorized[i]['title'])) |
727 | - self.output(suggested_message) |
728 | - for option in options: |
729 | - self.output(option) |
730 | - allowed = ("".join(map(str, range(1, i+2)))) + "Q" |
731 | - prompt = self.message( |
732 | - 'What should "%(app)s" be allowed to do using your ' |
733 | - 'Launchpad account? [1-%(max)d or Q] ', |
734 | - extra_variables = {'max' : i+1}) |
735 | - allow = self.get_single_char_input(prompt, allowed) |
736 | - if allow == "Q": |
737 | - return self.UNAUTHORIZED_ACCESS_LEVEL |
738 | - else: |
739 | - return levels_except_unauthorized[int(allow)-1]['value'] |
740 | - |
741 | - def user_refused_to_authorize(self, suggested_message): |
742 | - """The user refused to authorize a request token.""" |
743 | - self.output(suggested_message) |
744 | - self.error_code = -2 |
745 | - |
746 | - def user_authorized(self, access_level, suggested_message): |
747 | - """The user authorized a request token with some access level.""" |
748 | - self.output(suggested_message) |
749 | - self.error_code = 0 |
750 | |
751 | === modified file 'src/launchpadlib/credentials.py' |
752 | --- src/launchpadlib/credentials.py 2010-12-07 19:30:29 +0000 |
753 | +++ src/launchpadlib/credentials.py 2011-02-14 21:31:44 +0000 |
754 | @@ -20,17 +20,20 @@ |
755 | __all__ = [ |
756 | 'AccessToken', |
757 | 'AnonymousAccessToken', |
758 | + 'AuthorizeRequestTokenWithBrowser', |
759 | + 'CredentialStore', |
760 | 'RequestTokenAuthorizationEngine', |
761 | 'Consumer', |
762 | 'Credentials', |
763 | ] |
764 | |
765 | -import base64 |
766 | import cgi |
767 | +from cStringIO import StringIO |
768 | import httplib2 |
769 | -import sys |
770 | -import textwrap |
771 | -from urllib import urlencode, quote |
772 | +import os |
773 | +import stat |
774 | +import time |
775 | +from urllib import urlencode |
776 | from urlparse import urljoin |
777 | import webbrowser |
778 | |
779 | @@ -38,13 +41,20 @@ |
780 | |
781 | from lazr.restfulclient.errors import HTTPError |
782 | from lazr.restfulclient.authorize.oauth import ( |
783 | - AccessToken as _AccessToken, Consumer, OAuthAuthorizer) |
784 | + AccessToken as _AccessToken, |
785 | + Consumer, |
786 | + OAuthAuthorizer, |
787 | + SystemWideConsumer # Not used directly, just re-imported into here. |
788 | + ) |
789 | |
790 | from launchpadlib import uris |
791 | |
792 | request_token_page = '+request-token' |
793 | access_token_page = '+access-token' |
794 | authorize_token_page = '+authorize-token' |
795 | +access_token_poll_time = 1 |
796 | + |
797 | +EXPLOSIVE_ERRORS = (MemoryError, KeyboardInterrupt, SystemExit) |
798 | |
799 | |
800 | class Credentials(OAuthAuthorizer): |
801 | @@ -60,6 +70,25 @@ |
802 | URI_TOKEN_FORMAT = "uri" |
803 | DICT_TOKEN_FORMAT = "dict" |
804 | |
805 | + def serialize(self): |
806 | + """Turn this object into a string. |
807 | + |
808 | + This should probably be moved into OAuthAuthorizer. |
809 | + """ |
810 | + sio = StringIO() |
811 | + self.save(sio) |
812 | + return sio.getvalue() |
813 | + |
814 | + @classmethod |
815 | + def from_string(cls, value): |
816 | + """Create a `Credentials` object from a serialized string. |
817 | + |
818 | + This should probably be moved into OAuthAuthorizer. |
819 | + """ |
820 | + credentials = cls() |
821 | + credentials.load(StringIO(value)) |
822 | + return credentials |
823 | + |
824 | def get_request_token(self, context=None, web_root=uris.STAGING_WEB_ROOT, |
825 | token_format=URI_TOKEN_FORMAT): |
826 | """Request an OAuth token to Launchpad. |
827 | @@ -182,362 +211,351 @@ |
828 | super(AnonymousAccessToken, self).__init__('','') |
829 | |
830 | |
831 | -class SimulatedLaunchpadBrowser(object): |
832 | - """A programmable substitute for a human-operated web browser. |
833 | - |
834 | - Used by client programs to interact with Launchpad's credential |
835 | - pages, without opening them in the user's actual web browser. |
836 | - """ |
837 | - |
838 | - def __init__(self, web_root=uris.STAGING_WEB_ROOT): |
839 | - self.web_root = uris.lookup_web_root(web_root) |
840 | - self.http = httplib2.Http() |
841 | - |
842 | - def _auth_header(self, username, password): |
843 | - """Utility method to generate a Basic auth header.""" |
844 | - auth = base64.encodestring("%s:%s" % (username, password))[:-1] |
845 | - return "Basic " + auth |
846 | - |
847 | - def get_token_info(self, username, password, request_token, |
848 | - access_levels=''): |
849 | - """Retrieve a JSON representation of a request token. |
850 | - |
851 | - This is useful for verifying that the end-user gave a valid |
852 | - username and password, and for reconciling the client's |
853 | - allowable access levels with the access levels defined in |
854 | - Launchpad. |
855 | - """ |
856 | - if access_levels != '': |
857 | - s = "&allow_permission=" |
858 | - access_levels = s + s.join(access_levels) |
859 | - page = "%s?oauth_token=%s%s" % ( |
860 | - authorize_token_page, request_token, access_levels) |
861 | - url = urljoin(self.web_root, page) |
862 | - # We can't use httplib2's add_credentials, because Launchpad |
863 | - # doesn't respond to credential-less access with a 401 |
864 | - # response code. |
865 | - headers = {'Accept' : 'application/json', |
866 | - 'Referer' : self.web_root} |
867 | - headers['Authorization'] = self._auth_header(username, password) |
868 | - response, content = self.http.request(url, headers=headers) |
869 | - # Detect common error conditions and set the response code |
870 | - # appropriately. This lets code that uses |
871 | - # SimulatedLaunchpadBrowser detect standard response codes |
872 | - # instead of having Launchpad-specific knowledge. |
873 | - location = response.get('content-location') |
874 | - if response.status == 200 and '+login' in location: |
875 | - response.status = 401 |
876 | - elif response.get('content-type') != 'application/json': |
877 | - response.status = 500 |
878 | - return response, content |
879 | - |
880 | - def grant_access(self, username, password, request_token, access_level, |
881 | - context=None): |
882 | - """Grant a level of access to an application on behalf of a user.""" |
883 | - headers = {'Content-type' : 'application/x-www-form-urlencoded', |
884 | - 'Referer' : self.web_root} |
885 | - headers['Authorization'] = self._auth_header(username, password) |
886 | - body = "oauth_token=%s&field.actions.%s=True" % ( |
887 | - quote(request_token), quote(access_level)) |
888 | - if context is not None: |
889 | - body += "&lp.context=%s" % quote(context) |
890 | - url = urljoin(self.web_root, "+authorize-token") |
891 | - response, content = self.http.request( |
892 | - url, method="POST", headers=headers, body=body) |
893 | - # This would be much less fragile if Launchpad gave us an |
894 | - # error code to work with. |
895 | - if "Unauthenticated user POSTing to page" in content: |
896 | - response.status = 401 # Unauthorized |
897 | - elif 'Request already reviewed' in content: |
898 | - response.status = 409 # Conflict |
899 | - elif 'What level of access' in content: |
900 | - response.status = 400 # Bad Request |
901 | - elif 'Unable to identify application' in content: |
902 | - response.status = 400 # Bad Request |
903 | - elif not 'Almost finished' in content: |
904 | - response.status = 500 # Internal Server Error |
905 | - return response, content |
906 | +class CredentialStore(object): |
907 | + """Store OAuth credentials locally. |
908 | + |
909 | + This is a generic superclass. To implement a specific way of |
910 | + storing credentials locally you'll need to subclass this class, |
911 | + and implement `do_save` and `do_load`. |
912 | + """ |
913 | + |
914 | + def __init__(self, credential_save_failed=None): |
915 | + """Constructor. |
916 | + |
917 | + :param credential_save_failed: A callback to be invoked if the |
918 | + save to local storage fails. You should never invoke this |
919 | + callback yourself! Instead, you should raise an exception |
920 | + from do_save(). |
921 | + """ |
922 | + self.credential_save_failed = credential_save_failed |
923 | + |
924 | + def save(self, credentials, unique_consumer_id): |
925 | + """Save the credentials and invoke the callback on failure. |
926 | + |
927 | + Do not override this method when subclassing. Override |
928 | + do_save() instead. |
929 | + """ |
930 | + try: |
931 | + self.do_save(credentials, unique_consumer_id) |
932 | + except EXPLOSIVE_ERRORS: |
933 | + raise |
934 | + except Exception, e: |
935 | + if self.credential_save_failed is None: |
936 | + raise e |
937 | + self.credential_save_failed() |
938 | + return credentials |
939 | + |
940 | + def do_save(self, credentials, unique_consumer_id): |
941 | + """Store newly-authorized credentials locally for later use. |
942 | + |
943 | + :param credentials: A Credentials object to save. |
944 | + :param unique_consumer_id: A string uniquely identifying an |
945 | + OAuth consumer on a Launchpad instance. |
946 | + """ |
947 | + raise NotImplementedError() |
948 | + |
949 | + def load(self, unique_key): |
950 | + """Retrieve credentials from a local store. |
951 | + |
952 | + This method is the inverse of `save`. |
953 | + |
954 | + There's no special behavior in this method--it just calls |
955 | + `do_load`. There _is_ special behavior in `save`, and this |
956 | + way, developers can remember to implement `do_save` and |
957 | + `do_load`, not `do_save` and `load`. |
958 | + |
959 | + :param unique_key: A string uniquely identifying an OAuth consumer |
960 | + on a Launchpad instance. |
961 | + |
962 | + :return: A `Credentials` object if one is found in the local |
963 | + store, and None otherise. |
964 | + """ |
965 | + return self.do_load(unique_key) |
966 | + |
967 | + def do_load(self, unique_key): |
968 | + """Retrieve credentials from a local store. |
969 | + |
970 | + This method is the inverse of `do_save`. |
971 | + |
972 | + :param unique_key: A string uniquely identifying an OAuth consumer |
973 | + on a Launchpad instance. |
974 | + |
975 | + :return: A `Credentials` object if one is found in the local |
976 | + store, and None otherise. |
977 | + """ |
978 | + raise NotImplementedError() |
979 | + |
980 | + |
981 | +class KeyringCredentialStore(CredentialStore): |
982 | + """Store credentials in the GNOME keyring or KDE wallet. |
983 | + |
984 | + This is a good solution for desktop applications and interactive |
985 | + scripts. It doesn't work for non-interactive scripts, or for |
986 | + integrating third-party websites into Launchpad. |
987 | + """ |
988 | + |
989 | + @staticmethod |
990 | + def _ensure_keyring_imported(): |
991 | + """Ensure the keyring module is imported (postponing side effects). |
992 | + |
993 | + The keyring module initializes the environment-dependent backend at |
994 | + import time (nasty). We want to avoid that initialization because it |
995 | + may do things like prompt the user to unlock their password store |
996 | + (e.g., KWallet). |
997 | + """ |
998 | + if 'keyring' not in globals(): |
999 | + global keyring |
1000 | + import keyring |
1001 | + |
1002 | + def do_save(self, credentials, unique_key): |
1003 | + """Store newly-authorized credentials in the keyring.""" |
1004 | + self._ensure_keyring_imported() |
1005 | + keyring.set_password( |
1006 | + 'launchpadlib', unique_key, credentials.serialize()) |
1007 | + |
1008 | + def do_load(self, unique_key): |
1009 | + """Retrieve credentials from the keyring.""" |
1010 | + self._ensure_keyring_imported() |
1011 | + credential_string = keyring.get_password( |
1012 | + 'launchpadlib', unique_key) |
1013 | + if credential_string is not None: |
1014 | + return Credentials.from_string(credential_string) |
1015 | + return None |
1016 | + |
1017 | + |
1018 | +class UnencryptedFileCredentialStore(CredentialStore): |
1019 | + """Store credentials unencrypted in a file on disk. |
1020 | + |
1021 | + This is a good solution for scripts that need to run without any |
1022 | + user interaction. |
1023 | + """ |
1024 | + |
1025 | + def __init__(self, filename, credential_save_failed=None): |
1026 | + super(UnencryptedFileCredentialStore, self).__init__( |
1027 | + credential_save_failed) |
1028 | + self.filename = filename |
1029 | + |
1030 | + def do_save(self, credentials, unique_key): |
1031 | + """Save the credentials to disk.""" |
1032 | + credentials.save_to_path(self.filename) |
1033 | + |
1034 | + def do_load(self, unique_key): |
1035 | + """Load the credentials from disk.""" |
1036 | + if (os.path.exists(self.filename) |
1037 | + and not os.stat(self.filename)[stat.ST_SIZE] == 0): |
1038 | + return Credentials.load_from_path(self.filename) |
1039 | + return None |
1040 | |
1041 | |
1042 | class RequestTokenAuthorizationEngine(object): |
1043 | + """The superclass of all request token authorizers. |
1044 | + |
1045 | + This base class does not implement request token authorization, |
1046 | + since that varies depending on how you want the end-user to |
1047 | + authorize a request token. You'll need to subclass this class and |
1048 | + implement `make_end_user_authorize_token`. |
1049 | + """ |
1050 | |
1051 | UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED" |
1052 | |
1053 | - # Suggested messages for clients to display in common situations. |
1054 | - |
1055 | - AUTHENTICATION_FAILURE = "I can't log in with the credentials you gave me. Let's try again." |
1056 | - |
1057 | - CHOOSE_ACCESS_LEVEL = """Now it's time for you to decide how much power to give "%(app)s" over your Launchpad account.""" |
1058 | - |
1059 | - CHOOSE_ACCESS_LEVEL_ONE = CHOOSE_ACCESS_LEVEL + """ |
1060 | - |
1061 | -"%(app)s" says it needs the following level of access to your Launchpad account: "%(level)s". It can't work with any other level of access, so denying this level of access means prohibiting "%(app)s" from using your Launchpad account at all.""" |
1062 | - |
1063 | - USER_AUTHORIZED = """Okay, I'm telling Launchpad to grant "%(app)s" access to your account.""" |
1064 | - |
1065 | - USER_REFUSED_TO_AUTHORIZE = """Okay, I'm going to cancel the request that "%(app)s" made for access to your account. You can always set this up again later.""" |
1066 | - |
1067 | - CLIENT_ERROR = """Sorry, but Launchpad is behaving in a way this client doesn't understand. There might be a bug in the client, a bug in the server, or this client might just be out of date.""" |
1068 | - |
1069 | - CONSUMER_MISMATCH = """WARNING: The application you're using told me its name was "%(old_consumer)s", but it told Launchpad its name was "%(real_consumer)s". This is probably not a problem, but it's a little suspicious, so you might want to look into this before continuing. I'll refer to the application as "%(real_consumer)s" from this point on.""" |
1070 | - |
1071 | - INPUT_USERNAME = "What email address do you use on Launchpad?" |
1072 | - |
1073 | - INPUT_PASSWORD = "What's your Launchpad password? " |
1074 | - |
1075 | - NONEXISTENT_REQUEST_TOKEN = """Launchpad couldn't find an outstanding request for integration between "%(app)s" and your Launchpad account. Either someone (hopefully you) already set up the integration, or else "%(app)s" is simply wrong and didn't actually set this up with Launchpad. If you still can't use "%(app)s" with Launchpad, try this process again from the beginning.""" |
1076 | - |
1077 | - REQUEST_TOKEN_ALREADY_AUTHORIZED = """It looks like you already approved this request to grant "%(app)s" access to your Launchpad account. You shouldn't need to do anything more.""" |
1078 | - |
1079 | - SERVER_ERROR = """There seems to be something wrong on the Launchpad server side, and I can't continue. Hopefully this is a temporary problem, but if it persists, it's probably because of a bug in Lauchpad or (less likely) a bug in "%(app)s".""" |
1080 | - |
1081 | - STARTUP_MESSAGE = """An application identified as "%(app)s" wants to access Launchpad on your behalf. I'm the Launchpad credential client and I'm here to ask for your Launchpad username and password.""" |
1082 | - |
1083 | - STARTUP_MESSAGE_2 = """I'll use your Launchpad password to give "%(app)s" limited access to your Launchpad account. I will not show your password to "%(app)s" itself.""" |
1084 | - |
1085 | - SUCCESS = """You're all done! You should now be able to use Launchpad integration features of "%(app)s." """ |
1086 | - |
1087 | - SUCCESS_UNAUTHORIZED = """You're all done! "%(app)s" still doesn't have access to your Launchpad account.""" |
1088 | - |
1089 | - TOO_MANY_AUTHENTICATION_FAILURES = """You've failed the password entry too many times. I'm going to exit back to "%(app)s." Try again once you've solved the problem with your Launchpad account.""" |
1090 | - |
1091 | - YOU_NEED_A_LAUNCHPAD_ACCOUNT = """OK, you'll need to get yourself a Launchpad account before you can integrate Launchpad into "%(app)s." |
1092 | - |
1093 | -I'm opening the Launchpad registration page in your web browser so you can create an account. Once you've created an account, you can try this again.""" |
1094 | - |
1095 | - def __init__(self, web_root, consumer_name, request_token, |
1096 | - allow_access_levels=[], max_failed_attempts=3): |
1097 | - self.web_root = uris.lookup_web_root(web_root) |
1098 | - self.consumer_name = consumer_name |
1099 | - self.request_token = request_token |
1100 | - self.browser = SimulatedLaunchpadBrowser(self.web_root) |
1101 | - self.max_failed_attempts = max_failed_attempts |
1102 | - self.allow_access_levels = allow_access_levels |
1103 | - self.text_wrapper = textwrap.TextWrapper( |
1104 | - replace_whitespace=False, width=78) |
1105 | - |
1106 | - def __call__(self): |
1107 | - |
1108 | - self.startup( |
1109 | - [self.message(self.STARTUP_MESSAGE), |
1110 | - self.message(self.STARTUP_MESSAGE_2)]) |
1111 | - |
1112 | - # Have the end-user enter their Launchpad username and password. |
1113 | - # Make sure the credentials are valid, and get information |
1114 | - # about the request token as a side effect. |
1115 | - username, password, token_info = self.get_http_credentials() |
1116 | - |
1117 | - # Update this object with fresh information about the request token. |
1118 | - self.token_info = token_info |
1119 | - self.reconciled_access_levels = token_info['access_levels'] |
1120 | - self._check_consumer() |
1121 | - |
1122 | - # Have the end-user choose an access level from the fresh list. |
1123 | - if len(self.reconciled_access_levels) == 2: |
1124 | - # There's only one choice: allow access at a certain level |
1125 | - # or don't allow access at all. |
1126 | - message = self.CHOOSE_ACCESS_LEVEL_ONE |
1127 | - level = [level for level in self.reconciled_access_levels |
1128 | - if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL][0] |
1129 | - extra = {'level' : level['title']} |
1130 | - only_one_option = level |
1131 | - else: |
1132 | - message = self.CHOOSE_ACCESS_LEVEL |
1133 | - extra = None |
1134 | - only_one_option = None |
1135 | - access_level = self.input_access_level( |
1136 | - self.reconciled_access_levels, self.message(message, extra), |
1137 | - only_one_option) |
1138 | - |
1139 | - # Notify the program of the user's choice. |
1140 | - if access_level == self.UNAUTHORIZED_ACCESS_LEVEL: |
1141 | - self.user_refused_to_authorize( |
1142 | - self.message(self.USER_REFUSED_TO_AUTHORIZE)) |
1143 | - else: |
1144 | - self.user_authorized( |
1145 | - access_level, self.message(self.USER_AUTHORIZED)) |
1146 | - |
1147 | - # Try to grant the specified level of access to the request token. |
1148 | - response, content = self.browser.grant_access( |
1149 | - username, password, self.request_token, access_level) |
1150 | - if response.status == 409: |
1151 | - raise RequestTokenAlreadyAuthorized( |
1152 | - self.message(self.REQUEST_TOKEN_ALREADY_AUTHORIZED)) |
1153 | - elif response.status == 400: |
1154 | - raise ClientError(self.message(self.CLIENT_ERROR)) |
1155 | - elif response.status == 500: |
1156 | - raise ServerError(self.message(self.SERVER_ERROR)) |
1157 | - if access_level == self.UNAUTHORIZED_ACCESS_LEVEL: |
1158 | - message = self.SUCCESS_UNAUTHORIZED |
1159 | - else: |
1160 | - message = self.SUCCESS |
1161 | - self.success(self.message(message)) |
1162 | - |
1163 | - def get_http_credentials(self, cached_username=None, failed_attempts=0): |
1164 | - """Authenticate the user to Launchpad, or raise an exception trying. |
1165 | - |
1166 | - :return: A 3-tuple (username, password, |
1167 | - token_info). 'username' and 'password' are the validated |
1168 | - Launchpad username and password. 'token_info' is a dict of |
1169 | - validated information about the request token, including |
1170 | - Launchpad's reconciled list of its available access levels |
1171 | - with the access levels the third-party client will accept. |
1172 | - |
1173 | - :param cached_username: If the user has tried to enter their |
1174 | - credentials before and failed, this variable will contain the |
1175 | - username they entered the first time. This can be presented as |
1176 | - a default, since users are more likely to enter the wrong |
1177 | - password than the wrong username. |
1178 | - |
1179 | - :param failed_attempts: This method calls itself recursively |
1180 | - until failed_attempts equals self.max_failed_attempts. |
1181 | - """ |
1182 | - username = self.input_username( |
1183 | - cached_username, self.message(self.INPUT_USERNAME)) |
1184 | - if username is None: |
1185 | - self.open_page_in_user_browser( |
1186 | - urljoin(self.web_root, "+login")) |
1187 | - raise NoLaunchpadAccount( |
1188 | - self.message(self.YOU_NEED_A_LAUNCHPAD_ACCOUNT)) |
1189 | - password = self.input_password(self.message(self.INPUT_PASSWORD)) |
1190 | - response, content = self.browser.get_token_info( |
1191 | - username, password, self.request_token, self.allow_access_levels) |
1192 | - if response.status == 500: |
1193 | - raise ServerError(self.message(self.SERVER_ERROR)) |
1194 | - elif response.status == 401: |
1195 | - failed_attempts += 1 |
1196 | - if failed_attempts == self.max_failed_attempts: |
1197 | - raise TooManyAuthenticationFailures( |
1198 | - self.message(self.TOO_MANY_AUTHENTICATION_FAILURES)) |
1199 | - else: |
1200 | - self.authentication_failure( |
1201 | - self.message(self.AUTHENTICATION_FAILURE)) |
1202 | - return self.get_http_credentials(username, failed_attempts) |
1203 | - token_info = simplejson.loads(content) |
1204 | - # If Launchpad provides no information about the request token, |
1205 | - # that means the request token doesn't exist. |
1206 | - if 'oauth_token' not in token_info: |
1207 | - raise RequestTokenAlreadyAuthorized( |
1208 | - self.message(self.NONEXISTENT_REQUEST_TOKEN)) |
1209 | - return username, password, token_info |
1210 | - |
1211 | - def _check_consumer(self): |
1212 | - """Sanity-check the server consumer against the client consumer.""" |
1213 | - real_consumer = self.token_info['oauth_token_consumer'] |
1214 | - if real_consumer != self.consumer_name: |
1215 | - message = self.message( |
1216 | - self.CONSUMER_MISMATCH, { 'old_consumer' : self.consumer_name, |
1217 | - 'real_consumer' : real_consumer }) |
1218 | - self.server_consumer_differs_from_client_consumer( |
1219 | - self.consumer_name, real_consumer, message) |
1220 | - self.consumer_name = real_consumer |
1221 | - |
1222 | - def message(self, raw_message, extra_variables=None): |
1223 | - """Prepare a message by plugging in the app name.""" |
1224 | - variables = { 'app' : self.consumer_name } |
1225 | - if extra_variables is not None: |
1226 | - variables.update(extra_variables) |
1227 | - return raw_message % variables |
1228 | - |
1229 | - def open_page_in_user_browser(self, url): |
1230 | - """Open a web page in the user's web browser.""" |
1231 | - webbrowser.open(url) |
1232 | - |
1233 | - # You should define these methods in your subclass. |
1234 | - |
1235 | - def output(self, message): |
1236 | - print self.text_wrapper.fill(message) |
1237 | - |
1238 | - def input_username(self, cached_username, suggested_message): |
1239 | - """Collect the Launchpad username from the end-user. |
1240 | - |
1241 | - :param cached_username: A username from a previous entry attempt, |
1242 | - to be presented as the default. |
1243 | - """ |
1244 | - raise NotImplementedError() |
1245 | - |
1246 | - def input_password(self, suggested_message): |
1247 | - """Collect the Launchpad password from the end-user.""" |
1248 | - raise NotImplementedError() |
1249 | - |
1250 | - def input_access_level(self, available_levels, suggested_message, |
1251 | - only_one_option=None): |
1252 | - """Collect the desired level of access from the end-user.""" |
1253 | - raise NotImplementedError() |
1254 | - |
1255 | - def startup(self, suggested_messages): |
1256 | - """Hook method called on startup.""" |
1257 | - for message in suggested_messages: |
1258 | - self.output(message) |
1259 | - self.output("\n") |
1260 | - |
1261 | - def authentication_failure(self, suggested_message): |
1262 | - """The user entered invalid credentials.""" |
1263 | - self.output(suggested_message) |
1264 | - self.output("\n") |
1265 | - |
1266 | - def user_refused_to_authorize(self, suggested_message): |
1267 | - """The user refused to authorize a request token.""" |
1268 | - self.output(suggested_message) |
1269 | - self.output("\n") |
1270 | - |
1271 | - def user_authorized(self, access_level, suggested_message): |
1272 | - """The user authorized a request token with some access level.""" |
1273 | - self.output(suggested_message) |
1274 | - self.output("\n") |
1275 | - |
1276 | - def server_consumer_differs_from_client_consumer( |
1277 | - self, client_name, real_name, suggested_message): |
1278 | - """The client seems to be lying or mistaken about its name. |
1279 | - |
1280 | - When requesting a request token, the client told Launchpad |
1281 | - that its consumer name was "foo". Now the client is telling the |
1282 | - end-user that its name is "bar". Something is fishy and at the very |
1283 | - least the end-user should be warned about this. |
1284 | - """ |
1285 | - self.output("\n") |
1286 | - self.output(suggested_message) |
1287 | - self.output("\n") |
1288 | - |
1289 | - def success(self, suggested_message): |
1290 | - """The token was successfully authorized.""" |
1291 | - self.output(suggested_message) |
1292 | + def __init__(self, service_root, application_name=None, |
1293 | + consumer_name=None, allow_access_levels=None): |
1294 | + """Base class initialization. |
1295 | + |
1296 | + :param service_root: The root of the Launchpad instance being |
1297 | + used. |
1298 | + |
1299 | + :param application_name: The name of the application that |
1300 | + wants to use launchpadlib. This is used in conjunction |
1301 | + with a desktop-wide integration. |
1302 | + |
1303 | + If you specify this argument, your values for |
1304 | + consumer_name and allow_access_levels are ignored. |
1305 | + |
1306 | + :param consumer_name: The OAuth consumer name, for an |
1307 | + application that wants its own point of integration into |
1308 | + Launchpad. In almost all cases, you want to specify |
1309 | + application_name instead and do a desktop-wide |
1310 | + integration. The exception is when you're integrating a |
1311 | + third-party website into Launchpad. |
1312 | + |
1313 | + :param allow_access_levels: A list of the Launchpad access |
1314 | + levels to present to the user. ('READ_PUBLIC' and so on.) |
1315 | + Your value for this argument will be ignored during a |
1316 | + desktop-wide integration. |
1317 | + :type allow_access_levels: A list of strings. |
1318 | + """ |
1319 | + self.service_root = uris.lookup_service_root(service_root) |
1320 | + self.web_root = uris.web_root_for_service_root(service_root) |
1321 | + |
1322 | + if application_name is None and consumer_name is None: |
1323 | + raise ValueError( |
1324 | + "You must provide either application_name or consumer_name.") |
1325 | + |
1326 | + if application_name is not None and consumer_name is not None: |
1327 | + raise ValueError( |
1328 | + "You must provide only one of application_name and " |
1329 | + "consumer_name. (You provided %r and %r.)" % ( |
1330 | + application_name, consumer_name)) |
1331 | + |
1332 | + if consumer_name is None: |
1333 | + # System-wide integration. Create a system-wide consumer |
1334 | + # and identify the application using a separate |
1335 | + # application name. |
1336 | + allow_access_levels = ["DESKTOP_INTEGRATION"] |
1337 | + consumer = SystemWideConsumer(application_name) |
1338 | + else: |
1339 | + # Application-specific integration. Use the provided |
1340 | + # consumer name to create a consumer automatically. |
1341 | + consumer = Consumer(consumer_name) |
1342 | + application_name = consumer_name |
1343 | + |
1344 | + self.consumer = consumer |
1345 | + self.application_name = application_name |
1346 | + |
1347 | + self.allow_access_levels = allow_access_levels or [] |
1348 | + |
1349 | + @property |
1350 | + def unique_consumer_id(self): |
1351 | + """Return a string identifying this consumer on this host.""" |
1352 | + return self.consumer.key + '@' + self.service_root |
1353 | + |
1354 | + def authorization_url(self, request_token): |
1355 | + """Return the authorization URL for a request token. |
1356 | + |
1357 | + This is the URL the end-user must visit to authorize the |
1358 | + token. How exactly does this happen? That depends on the |
1359 | + subclass implementation. |
1360 | + """ |
1361 | + page = "%s?oauth_token=%s" % (authorize_token_page, request_token) |
1362 | + allow_permission = "&allow_permission=" |
1363 | + if len(self.allow_access_levels) > 0: |
1364 | + page += ( |
1365 | + allow_permission |
1366 | + + allow_permission.join(self.allow_access_levels)) |
1367 | + return urljoin(self.web_root, page) |
1368 | + |
1369 | + def __call__(self, credentials, credential_store): |
1370 | + """Authorize a token and associate it with the given credentials. |
1371 | + |
1372 | + If the credential store runs into a problem storing the |
1373 | + credential locally, the `credential_save_failed` callback will |
1374 | + be invoked. The callback will not be invoked if there's a |
1375 | + problem authorizing the credentials. |
1376 | + |
1377 | + :param credentials: A `Credentials` object. If the end-user |
1378 | + authorizes these credentials, this object will have its |
1379 | + .access_token property set. |
1380 | + |
1381 | + :param credential_store: A `CredentialStore` object. If the |
1382 | + end-user authorizes the credentials, they will be |
1383 | + persisted locally using this object. |
1384 | + |
1385 | + :return: If the credentials are successfully authorized, the |
1386 | + return value is the `Credentials` object originally passed |
1387 | + in. Otherwise the return value is None. |
1388 | + """ |
1389 | + request_token_string = self.get_request_token(credentials) |
1390 | + # Hand off control to the end-user. |
1391 | + self.make_end_user_authorize_token(credentials, request_token_string) |
1392 | + if credentials.access_token is None: |
1393 | + # The end-user refused to authorize the application. |
1394 | + return None |
1395 | + # save() invokes the callback on failure. |
1396 | + credential_store.save(credentials, self.unique_consumer_id) |
1397 | + return credentials |
1398 | + |
1399 | + def get_request_token(self, credentials): |
1400 | + """Get a new request token from the server. |
1401 | + |
1402 | + :param return: The request token. |
1403 | + """ |
1404 | + authorization_json = credentials.get_request_token( |
1405 | + web_root=self.web_root, |
1406 | + token_format=Credentials.DICT_TOKEN_FORMAT) |
1407 | + return authorization_json['oauth_token'] |
1408 | + |
1409 | + def make_end_user_authorize_token(self, credentials, request_token): |
1410 | + """Authorize the given request token using the given credentials. |
1411 | + |
1412 | + Your subclass must implement this method: it has no default |
1413 | + implementation. |
1414 | + |
1415 | + Because an access token may expire or be revoked in the middle |
1416 | + of a session, this method may be called at arbitrary points in |
1417 | + a launchpadlib session, or even multiple times during a single |
1418 | + session (with a different request token each time). |
1419 | + |
1420 | + In most cases, however, this method will be called at the |
1421 | + beginning of a launchpadlib session, or not at all. |
1422 | + """ |
1423 | + raise NotImplementedError() |
1424 | |
1425 | |
1426 | class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine): |
1427 | - """The simplest and most secure request token authorizer. |
1428 | + """The simplest (and, right now, the only) request token authorizer. |
1429 | |
1430 | This authorizer simply opens up the end-user's web browser to a |
1431 | Launchpad URL and lets the end-user authorize the request token |
1432 | themselves. |
1433 | """ |
1434 | |
1435 | - def __init__(self, web_root, consumer_name, request_token, |
1436 | - allow_access_levels=[], max_failed_attempts=3): |
1437 | - web_root = uris.lookup_web_root(web_root) |
1438 | - page = "+authorize-token?oauth_token=%s" % request_token |
1439 | - if len(allow_access_levels) > 0: |
1440 | - page += ("&allow_permission=" + |
1441 | - "&allow_permission=".join(allow_access_levels)) |
1442 | - self.authorization_url = urljoin(web_root, page) |
1443 | - |
1444 | + 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..." |
1445 | + |
1446 | + def __init__(self, service_root, application_name, consumer_name=None, |
1447 | + credential_save_failed=None, allow_access_levels=None): |
1448 | + """Constructor. |
1449 | + |
1450 | + :param service_root: See `RequestTokenAuthorizationEngine`. |
1451 | + :param application_name: See `RequestTokenAuthorizationEngine`. |
1452 | + :param consumer_name: The value of this argument is |
1453 | + ignored. If we have the capability to open the end-user's |
1454 | + web browser, we must be running on the end-user's computer, |
1455 | + so we should do a full desktop integration. |
1456 | + :param credential_save_failed: See `RequestTokenAuthorizationEngine`. |
1457 | + :param allow_access_levels: The value of this argument is |
1458 | + ignored, for the same reason as consumer_name. |
1459 | + """ |
1460 | + # It doesn't look like we're doing anything here, but we |
1461 | + # are discarding the passed-in values for consumer_name and |
1462 | + # allow_access_levels. |
1463 | super(AuthorizeRequestTokenWithBrowser, self).__init__( |
1464 | - web_root, consumer_name, request_token, |
1465 | - allow_access_levels, max_failed_attempts) |
1466 | - |
1467 | - def __call__(self): |
1468 | - self.open_page_in_user_browser(self.authorization_url) |
1469 | - print "The authorization page:" |
1470 | - print " (%s)" % self.authorization_url |
1471 | - print "should be opening in your browser. After you have authorized" |
1472 | - print "this program to access Launchpad on your behalf you should come" |
1473 | - print ("back here and press <Enter> to finish the authentication " |
1474 | - "process.") |
1475 | - self.wait_for_request_token_authorization() |
1476 | - |
1477 | - def wait_for_request_token_authorization(self): |
1478 | - """Get the end-user to hit enter.""" |
1479 | - sys.stdin.readline() |
1480 | + service_root, application_name, None, |
1481 | + credential_save_failed) |
1482 | + |
1483 | + def output(self, message): |
1484 | + """Display a message. |
1485 | + |
1486 | + By default, prints the message to standard output. The message |
1487 | + does not require any user interaction--it's solely |
1488 | + informative. |
1489 | + """ |
1490 | + print message |
1491 | + |
1492 | + def make_end_user_authorize_token(self, credentials, request_token): |
1493 | + """Have the end-user authorize the token in their browser.""" |
1494 | + |
1495 | + authorization_url = self.authorization_url(request_token) |
1496 | + webbrowser.open(authorization_url) |
1497 | + self.output(self.WAITING_FOR_USER % authorization_url) |
1498 | + while credentials.access_token is None: |
1499 | + time.sleep(access_token_poll_time) |
1500 | + try: |
1501 | + credentials.exchange_request_token_for_access_token( |
1502 | + self.web_root) |
1503 | + break |
1504 | + except HTTPError, e: |
1505 | + if e.response.status == 403: |
1506 | + # The user decided not to authorize this |
1507 | + # application. |
1508 | + raise EndUserDeclinedAuthorization(e.content) |
1509 | + elif e.response.status == 401: |
1510 | + # The user has not made a decision yet. |
1511 | + pass |
1512 | + else: |
1513 | + # There was an error accessing the server. |
1514 | + print "Unexpected response from Launchpad:" |
1515 | + print e |
1516 | |
1517 | |
1518 | class TokenAuthorizationException(Exception): |
1519 | @@ -548,6 +566,10 @@ |
1520 | pass |
1521 | |
1522 | |
1523 | +class EndUserDeclinedAuthorization(TokenAuthorizationException): |
1524 | + pass |
1525 | + |
1526 | + |
1527 | class ClientError(TokenAuthorizationException): |
1528 | pass |
1529 | |
1530 | |
1531 | === removed file 'src/launchpadlib/docs/browser.txt' |
1532 | --- src/launchpadlib/docs/browser.txt 2010-12-07 19:30:29 +0000 |
1533 | +++ src/launchpadlib/docs/browser.txt 1970-01-01 00:00:00 +0000 |
1534 | @@ -1,151 +0,0 @@ |
1535 | -******************************* |
1536 | -The simulated Launchpad browser |
1537 | -******************************* |
1538 | - |
1539 | -The SimulatedLaunchpadBrowser class is a scriptable browser-like class |
1540 | -that can be trusted with the end-user's username and password. It |
1541 | -fulfils the same function as the user's web browser, but because it's |
1542 | -scriptable can be used to create non-browser trusted clients. |
1543 | - |
1544 | - >>> username = 'salgado@ubuntu.com' |
1545 | - >>> password = 'zeca' |
1546 | - >>> web_root = 'http://launchpad.dev:8085/' |
1547 | - |
1548 | -Before showing how SimulatedLaunchpadBrowser can authorize a request |
1549 | -token, let's create a request token to authorize. |
1550 | - |
1551 | - >>> from launchpadlib.credentials import Credentials |
1552 | - >>> credentials = Credentials("doctest consumer") |
1553 | - >>> context="firefox" |
1554 | - >>> validate_url = credentials.get_request_token( |
1555 | - ... web_root=web_root, context=context) |
1556 | - >>> request_token = credentials._request_token.key |
1557 | - |
1558 | -get_token_info() |
1559 | -================ |
1560 | - |
1561 | -If you have the end-user's username and password, you can use |
1562 | -get_token_info() to get information about one of the user's request |
1563 | -tokens. It's useful for confirming that the end-user gave the correct |
1564 | -username and password, and for reconciling the list of access levels a |
1565 | -client will accept with Launchpad's master list. |
1566 | - |
1567 | - >>> from launchpadlib.credentials import SimulatedLaunchpadBrowser |
1568 | - >>> from launchpadlib.testing.helpers import TestableLaunchpad |
1569 | - |
1570 | - >>> browser = SimulatedLaunchpadBrowser(web_root) |
1571 | - |
1572 | -If you make an unauthorized request, you'll get a 401 error. |
1573 | -(Launchpad returns 200, but SimulatedLaunchpadBrowser sniffs it and |
1574 | -changes it to a 401.) |
1575 | - |
1576 | - >>> response, content = browser.get_token_info( |
1577 | - ... "baduser", "badpasword", request_token) |
1578 | - >>> print response.status |
1579 | - 401 |
1580 | - |
1581 | -If you provide the right authorization, you'll get back information |
1582 | -about your request token. |
1583 | - |
1584 | - >>> response, content = browser.get_token_info( |
1585 | - ... username, password, request_token) |
1586 | - >>> print response['content-type'] |
1587 | - application/json |
1588 | - |
1589 | - # XXX leonardr 2009-10-28 bug=462773 These two URLs should be |
1590 | - # exactly the same, but the Content-Location is missing the |
1591 | - # lp.context. |
1592 | - >>> validate_url.startswith(response['content-location']) |
1593 | - True |
1594 | - |
1595 | - >>> import simplejson |
1596 | - >>> json = simplejson.loads(content) |
1597 | - >>> json['oauth_token'] == request_token |
1598 | - True |
1599 | - |
1600 | - >>> print json['oauth_token_consumer'] |
1601 | - doctest consumer |
1602 | - |
1603 | -You'll also get information about the available access |
1604 | -levels. |
1605 | - |
1606 | - >>> print sorted([level['value'] for level in json['access_levels']]) |
1607 | - ['READ_PRIVATE', ... 'UNAUTHORIZED', ...] |
1608 | - |
1609 | -If you provide a list of possible access levels, you'll |
1610 | -get back a list that reconciles the list you gave with |
1611 | -Launchpad's access levels. |
1612 | - |
1613 | - >>> response, content = browser.get_token_info( |
1614 | - ... username, password, request_token, |
1615 | - ... ["READ_PUBLIC", "READ_PRIVATE", "NO_SUCH_ACCESS_LEVEL"]) |
1616 | - |
1617 | - >>> print response['content-type'] |
1618 | - application/json |
1619 | - |
1620 | - >>> json = simplejson.loads(content) |
1621 | - >>> print sorted( |
1622 | - ... [level['value'] for level in json['access_levels']]) |
1623 | - ['READ_PRIVATE', 'READ_PUBLIC', 'UNAUTHORIZED'] |
1624 | - |
1625 | -Note that the nonexistent access level has been removed from the |
1626 | -reconciled list, and the "Unauthorized" access level (which must |
1627 | -always be an option) has been added. |
1628 | - |
1629 | -grant_access() |
1630 | -============== |
1631 | - |
1632 | -If you have the end-user's username and password, you can use |
1633 | -grant_access() to authorize a request token. |
1634 | - |
1635 | -If you make an unauthorized request, you'll get a 401 error. (As with |
1636 | -get_token_info(), Launchpad returns 200, but SimulatedLaunchpadBrowser |
1637 | -sniffs it and changes it to a 401.) |
1638 | - |
1639 | - >>> access_level = "READ_PRIVATE" |
1640 | - |
1641 | - >>> response, content = browser.grant_access( |
1642 | - ... "baduser", "badpasword", request_token, access_level, context) |
1643 | - >>> print response.status |
1644 | - 401 |
1645 | - |
1646 | -If you try to grant an invalid level of access, you'll get a |
1647 | -400 error. |
1648 | - |
1649 | - >>> response, content = browser.grant_access( |
1650 | - ... username, password, request_token, |
1651 | - ... "NO_SUCH_ACCESS_LEVEL") |
1652 | - >>> print response.status |
1653 | - 400 |
1654 | - |
1655 | -If you provide all the necessary information, you'll get a 200 |
1656 | -response code and the request token will be authorized. |
1657 | - |
1658 | - >>> response, content = browser.grant_access( |
1659 | - ... username, password, request_token, access_level) |
1660 | - >>> print response.status |
1661 | - 200 |
1662 | - |
1663 | -If you try to grant access to a request token that's already |
1664 | -been authorized, you'll get a 409 error. |
1665 | - |
1666 | - >>> response, content = browser.grant_access( |
1667 | - ... username, password, request_token, access_level) |
1668 | - >>> print response.status |
1669 | - 409 |
1670 | - |
1671 | -Now that the request token is authorized, we can exchange it for an |
1672 | -access token. |
1673 | - |
1674 | - >>> credentials.exchange_request_token_for_access_token( |
1675 | - ... web_root=web_root) |
1676 | - >>> credentials.access_token.key is None |
1677 | - False |
1678 | - |
1679 | -If you try to grant access to a request token that's already been |
1680 | -exchanged for an access token, you'll get a 400 error. |
1681 | - |
1682 | - >>> response, content = browser.grant_access( |
1683 | - ... username, password, request_token, access_level) |
1684 | - >>> print response.status |
1685 | - 400 |
1686 | |
1687 | === modified file 'src/launchpadlib/docs/command-line.txt' |
1688 | --- src/launchpadlib/docs/command-line.txt 2010-12-07 19:30:29 +0000 |
1689 | +++ src/launchpadlib/docs/command-line.txt 2011-02-14 21:31:44 +0000 |
1690 | @@ -2,12 +2,11 @@ |
1691 | Command-line scripts |
1692 | ******************** |
1693 | |
1694 | -Launchpad includes some command-line scripts to make Launchpad |
1695 | +Launchpad includes one command-line script to make Launchpad |
1696 | integration easier for third-party libraries that aren't written in |
1697 | -Python or that can't do token authorization by opening a user's web |
1698 | -browser. |
1699 | +Python. |
1700 | |
1701 | -This file tests the workflow underlying the command-line scripts as |
1702 | +This file tests the workflow underlying the command-line script as |
1703 | best it can. |
1704 | |
1705 | RequestTokenApp |
1706 | @@ -36,170 +35,3 @@ |
1707 | >>> print json['oauth_token_consumer'] |
1708 | consumer |
1709 | |
1710 | -TrustedTokenAuthorizationConsoleApp |
1711 | -=================================== |
1712 | - |
1713 | -This class is called by the command-line script |
1714 | -launchpad-credentials-console. It asks for the user's Launchpad |
1715 | -username and password, and authorizes a request token on their |
1716 | -behalf. |
1717 | - |
1718 | - >>> from launchpadlib.apps import TrustedTokenAuthorizationConsoleApp |
1719 | - >>> from launchpadlib.testing.helpers import UserInput |
1720 | - |
1721 | -This class does not create the request token, or exchange it for the |
1722 | -access token--that's the job of the program that calls |
1723 | -launchpad-credentials-console. So we'll use the request token created |
1724 | -earlier by RequestTokenApp. |
1725 | - |
1726 | - >>> request_token = json['oauth_token'] |
1727 | - |
1728 | -Since this is a test, we don't want the application to call sys.exit() |
1729 | -or try to open up pages in a web browser. This subclass of |
1730 | -TrustedTokenAuthorizationConsoleApp will print messages instead of |
1731 | -performing such un-doctest-like actions. |
1732 | - |
1733 | - >>> class ConsoleApp(TrustedTokenAuthorizationConsoleApp): |
1734 | - ... def open_page_in_user_browser(self, url): |
1735 | - ... """Print a status message.""" |
1736 | - ... self.output("[If this were a real application, the " |
1737 | - ... "end-user's web browser would be opened " |
1738 | - ... "to %s]" % url) |
1739 | - ... |
1740 | - ... def exit_with(self, code): |
1741 | - ... print "Application exited with code %d" % code |
1742 | - |
1743 | -We'll use a UserInput object to simulate a user typing things in at |
1744 | -the prompt and hitting enter. This UserInput runs the program |
1745 | -correctly, entering the Launchpad username and password, choosing an |
1746 | -access level, and hitting Enter to exit. |
1747 | - |
1748 | - >>> username = "salgado@ubuntu.com" |
1749 | - >>> password = "zeca" |
1750 | - >>> fake_input = UserInput([username, password, "1", ""]) |
1751 | - |
1752 | -Here's a successful run of the application. When the request token is |
1753 | -authorized, the script's response code is 0. |
1754 | - |
1755 | - >>> app = ConsoleApp( |
1756 | - ... web_root, consumer_name, request_token, |
1757 | - ... 'READ_PRIVATE, READ_PUBLIC', input_method=fake_input) |
1758 | - >>> app.run() |
1759 | - Launchpad credential client (console) |
1760 | - ------------------------------------- |
1761 | - An application identified as "consumer" wants to access Launchpad... |
1762 | - What email address do you use on Launchpad? |
1763 | - (No Launchpad account? Just hit enter.) [User input: salgado@ubuntu.com] |
1764 | - What's your Launchpad password? [User input: zeca] |
1765 | - Now it's time for you to decide how much power to give "consumer"... |
1766 | - 1: Read Non-Private Data |
1767 | - 2: Read Anything |
1768 | - What should "consumer" be allowed to do...? [1-2 or Q] [User input: 1] |
1769 | - Okay, I'm telling Launchpad to grant "consumer" access to your account. |
1770 | - You're all done!... |
1771 | - Press enter to go back to "consumer". [User input: ] |
1772 | - Application exited with code 0 |
1773 | - |
1774 | -Now that the request token has been authorized, we'll need to create |
1775 | -another one to continue the test. |
1776 | - |
1777 | - >>> json = simplejson.loads(token_app.run()) |
1778 | - >>> request_token = json['oauth_token'] |
1779 | - >>> app.request_token = request_token |
1780 | - |
1781 | -Invalid input is ignored. The user may enter 'Q' instead of a number |
1782 | -to refuse to authorize the request token. When the user denies access, |
1783 | -the exit code is -2. |
1784 | - |
1785 | - >>> fake_input = UserInput([username, password, "A", "99", "Q", ""]) |
1786 | - >>> app.input_method = fake_input |
1787 | - >>> app.run() |
1788 | - Launchpad credential client (console) |
1789 | - ------------------------------------- |
1790 | - An application identified as "consumer"... |
1791 | - What should "consumer" be allowed to do...? [1-2 or Q] [User input: A] |
1792 | - What should "consumer" be allowed to do...? [1-2 or Q] [User input: 99] |
1793 | - What should "consumer" be allowed to do...? [1-2 or Q] [User input: Q] |
1794 | - Okay, I'm going to cancel the request... |
1795 | - You're all done! "consumer" still doesn't have access... |
1796 | - <BLANKLINE> |
1797 | - Press enter to go back to "consumer". [User input: ] |
1798 | - Application exited with code -2 |
1799 | - |
1800 | -When the third-party application will allow only one level of access, |
1801 | -the end-user is presented with a yes-or-no choice instead of a list to |
1802 | -choose from. Again, invalid input is ignored. |
1803 | - |
1804 | - >>> json = simplejson.loads(token_app.run()) |
1805 | - >>> request_token = json['oauth_token'] |
1806 | - >>> fake_input = UserInput([username, password, "1", "Q", "Y", ""]) |
1807 | - |
1808 | - >>> app = ConsoleApp( |
1809 | - ... web_root, consumer_name, request_token, |
1810 | - ... 'READ_PRIVATE', input_method=fake_input) |
1811 | - |
1812 | - >>> app.run() |
1813 | - Launchpad credential client (console) |
1814 | - ------------------------------------- |
1815 | - An application identified as "consumer"... |
1816 | - Do you want to give "consumer" this level of access? [YN] [User input: 1] |
1817 | - Do you want to give "consumer" this level of access? [YN] [User input: Q] |
1818 | - Do you want to give "consumer" this level of access? [YN] [User input: Y] |
1819 | - ... |
1820 | - Application exited with code 0 |
1821 | - |
1822 | - |
1823 | -Error handling |
1824 | --------------- |
1825 | - |
1826 | -When the end-user refuses to authorize the request token, the app |
1827 | -exits with a return code of -2, as seen above. When any other error |
1828 | -gets in the way of the authorization of the request token, the app's |
1829 | -return code is -1. |
1830 | - |
1831 | -If the user hits enter when asked for their email address, indicating |
1832 | -that they don't have a Launchpad account, the app opens their browser |
1833 | -to the Launchpad login page. |
1834 | - |
1835 | - >>> json = simplejson.loads(token_app.run()) |
1836 | - >>> app.request_token = json['oauth_token'] |
1837 | - |
1838 | - >>> input_nothing = UserInput(["", ""]) |
1839 | - >>> app.input_method = input_nothing |
1840 | - |
1841 | - >>> app.run() |
1842 | - Launchpad credential client (console) |
1843 | - ------------------------------------- |
1844 | - An application identified as "consumer"... |
1845 | - [If this were a real application, the end-user's web browser...] |
1846 | - OK, you'll need to get yourself a Launchpad account before... |
1847 | - <BLANKLINE> |
1848 | - I'm opening the Launchpad registration page in your web browser... |
1849 | - Press enter to go back to "consumer". [User input: ] |
1850 | - Application exited with code -1 |
1851 | - |
1852 | -If the user keeps entering bad passwords, the app eventually gives up. |
1853 | - |
1854 | - >>> input_bad_password = UserInput( |
1855 | - ... [username, "badpw", "", "badpw", "", "badpw", ""]) |
1856 | - >>> json = simplejson.loads(token_app.run()) |
1857 | - >>> request_token = json['oauth_token'] |
1858 | - >>> app.request_token = request_token |
1859 | - >>> app.input_method = input_bad_password |
1860 | - >>> app.run() |
1861 | - Launchpad credential client (console) |
1862 | - ------------------------------------- |
1863 | - An application identified as "consumer"... |
1864 | - What email address do you use on Launchpad? |
1865 | - (No Launchpad account? Just hit enter.) [User input: salgado@ubuntu.com] |
1866 | - What's your Launchpad password? [User input: badpw] |
1867 | - I can't log in with the credentials you gave me. Let's try again. |
1868 | - What email address do you use on Launchpad? [salgado@ubuntu.com] [User input: ] |
1869 | - What's your Launchpad password? [User input: badpw] |
1870 | - I can't log in with the credentials you gave me. Let's try again. |
1871 | - What email address do you use on Launchpad? [salgado@ubuntu.com] [User input: ] |
1872 | - What's your Launchpad password? [User input: badpw] |
1873 | - You've failed the password entry too many times... |
1874 | - Press enter to go back to "consumer". [User input: ] |
1875 | - Application exited with code -1 |
1876 | - |
1877 | |
1878 | === modified file 'src/launchpadlib/docs/hosted-files.txt' |
1879 | --- src/launchpadlib/docs/hosted-files.txt 2010-12-07 19:30:29 +0000 |
1880 | +++ src/launchpadlib/docs/hosted-files.txt 2011-02-14 21:31:44 +0000 |
1881 | @@ -55,7 +55,7 @@ |
1882 | >>> file_handle.close() |
1883 | Traceback (most recent call last): |
1884 | ... |
1885 | - HTTPError: HTTP Error 400: Bad Request |
1886 | + BadRequest: HTTP Error 400: Bad Request |
1887 | ... |
1888 | |
1889 | == Caching == |
1890 | @@ -90,7 +90,7 @@ |
1891 | >>> len(mugshot.open().read()) |
1892 | send: ... |
1893 | reply: 'HTTP/1.1 303 See Other... |
1894 | - header: Location: http://localhost:58000/.../image.png |
1895 | + header: Location: http://.../image.png |
1896 | ... |
1897 | header: Content-Type: text/plain |
1898 | 2260 |
1899 | |
1900 | === modified file 'src/launchpadlib/docs/introduction.txt' |
1901 | --- src/launchpadlib/docs/introduction.txt 2010-12-07 19:30:29 +0000 |
1902 | +++ src/launchpadlib/docs/introduction.txt 2011-02-14 21:31:44 +0000 |
1903 | @@ -66,9 +66,10 @@ |
1904 | >>> sorted(launchpad.bugs) |
1905 | [...] |
1906 | |
1907 | -For convenience, the application may store the credentials on the file system, |
1908 | -so that the next time Salgado interacts with the application, he won't have |
1909 | -to go through the whole OAuth request dance. |
1910 | +If available, the Gnome keyring or KDE wallet will be used to store access |
1911 | +tokens. If a keyring/wallet is not available, the application can store the |
1912 | +credentials on the file system, so that the next time Salgado interacts with |
1913 | +the application, he won't have to go through the whole OAuth request dance. |
1914 | |
1915 | >>> import os |
1916 | >>> import tempfile |
1917 | @@ -172,14 +173,14 @@ |
1918 | >>> launchpad.me |
1919 | Traceback (most recent call last): |
1920 | ... |
1921 | - HTTPError: HTTP Error 401: Unauthorized |
1922 | + Unauthorized: HTTP Error 401: Unauthorized |
1923 | ... |
1924 | |
1925 | >>> salgado.display_name = "This won't work." |
1926 | >>> salgado.lp_save() |
1927 | Traceback (most recent call last): |
1928 | ... |
1929 | - HTTPError: HTTP Error 401: Unauthorized |
1930 | + Unauthorized: HTTP Error 401: Unauthorized |
1931 | ... |
1932 | |
1933 | Convenience |
1934 | @@ -189,34 +190,24 @@ |
1935 | setting up a web service connection in one function call. All you have |
1936 | to provide is the consumer name. |
1937 | |
1938 | - >>> launchpad = Launchpad.login_anonymously('launchpad-library') |
1939 | + >>> launchpad = Launchpad.login_anonymously( |
1940 | + ... 'launchpad-library', service_root="test_dev") |
1941 | >>> sorted(launchpad.people) |
1942 | [...] |
1943 | |
1944 | >>> launchpad.me |
1945 | Traceback (most recent call last): |
1946 | ... |
1947 | - HTTPError: HTTP Error 401: Unauthorized |
1948 | + Unauthorized: HTTP Error 401: Unauthorized |
1949 | ... |
1950 | |
1951 | -Another function call is useful when the consumer name, access token |
1952 | -and access secret are all known up-front. |
1953 | - |
1954 | - >>> launchpad = Launchpad.login( |
1955 | - ... 'launchpad-library', 'salgado-change-anything', 'test') |
1956 | - >>> sorted(launchpad.people) |
1957 | - [...] |
1958 | - |
1959 | - >>> print launchpad.me.name |
1960 | - salgado |
1961 | - |
1962 | Otherwise, the application should obtain authorization from the user |
1963 | -and get a new set of credentials directly from Launchpad. |
1964 | +and get a new set of credentials directly from |
1965 | +Launchpad. |
1966 | |
1967 | -First we must get a request token. We use 'test_dev' as a shorthand |
1968 | -for the root URL of the Launchpad installation. It's defined in the |
1969 | -'uris' module as 'http://launchpad.dev:8085/', and the launchpadlib |
1970 | -code knows how to dereference it before using it as a URL. |
1971 | +Unfortunately, we can't test this entire process because it requires |
1972 | +opening up a web browser, but we can test the first step, which is to |
1973 | +get a request token. |
1974 | |
1975 | >>> import launchpadlib.credentials |
1976 | >>> credentials = Credentials('consumer') |
1977 | @@ -226,6 +217,11 @@ |
1978 | >>> authorization_url |
1979 | 'http://launchpad.dev:8085/+authorize-token?oauth_token=...&lp.context=firefox' |
1980 | |
1981 | +We use 'test_dev' as a shorthand for the root URL of the Launchpad |
1982 | +installation. It's defined in the 'uris' module as |
1983 | +'http://launchpad.dev:8085/', and the launchpadlib code knows how to |
1984 | +dereference it before using it as a URL. |
1985 | + |
1986 | Information about the request token is kept in the _request_token |
1987 | attribute of the Credentials object. |
1988 | |
1989 | @@ -236,117 +232,10 @@ |
1990 | >>> print credentials._request_token.context |
1991 | firefox |
1992 | |
1993 | -Now the user must authorize that token, so we'll use the |
1994 | -SimulatedLaunchpadBrowser to pretend the user is authorizing it. |
1995 | - |
1996 | - >>> from launchpadlib.credentials import SimulatedLaunchpadBrowser |
1997 | - >>> browser = SimulatedLaunchpadBrowser(web_root='test_dev') |
1998 | - >>> response, content = browser.grant_access( |
1999 | - ... "foo.bar@canonical.com", "test", |
2000 | - ... credentials._request_token.key, "WRITE_PRIVATE", |
2001 | - ... credentials._request_token.context) |
2002 | - >>> response['status'] |
2003 | - '200' |
2004 | - |
2005 | -After that we can exchange that request token for an access token. |
2006 | - |
2007 | - >>> credentials.exchange_request_token_for_access_token( |
2008 | - ... web_root='test_dev') |
2009 | - |
2010 | -Once that's done, our credentials will be complete and ready to use. |
2011 | - |
2012 | - >>> credentials.consumer.key |
2013 | - 'consumer' |
2014 | - >>> credentials.access_token |
2015 | - <launchpadlib.credentials.AccessToken... |
2016 | - >>> credentials.access_token.key is not None |
2017 | - True |
2018 | - >>> credentials.access_token.secret is not None |
2019 | - True |
2020 | - >>> credentials.access_token.context |
2021 | - 'firefox' |
2022 | - |
2023 | -Authorizing the request token |
2024 | ------------------------------ |
2025 | - |
2026 | -There are also two convenience method which do the access token |
2027 | -negotiation and log into the web service: get_token_and_login() and |
2028 | -login_with(). These convenience methods use the methods documented |
2029 | -above to get a request token, and once it has the request token's |
2030 | -authorization information, it makes the end-user authorize the request |
2031 | -token by entering their Launchpad username and password. |
2032 | - |
2033 | -There are several ways of having the end-user authorize a request |
2034 | -token, but the most secure is to open up the user's own web browser |
2035 | -(other ways are described in trusted-client.txt). Because we don't |
2036 | -want to actually open a web browser during this test, we'll create a |
2037 | -fake authorizer that uses the SimulatedLaunchpadBrowser to authorize |
2038 | -the request token. |
2039 | - |
2040 | - >>> from launchpadlib.testing.helpers import ( |
2041 | - ... DummyAuthorizeRequestTokenWithBrowser) |
2042 | - |
2043 | - >>> class AuthorizeAsSalgado(DummyAuthorizeRequestTokenWithBrowser): |
2044 | - ... def wait_for_request_token_authorization(self): |
2045 | - ... """Simulate the authorizing user with their web browser.""" |
2046 | - ... username = 'salgado@ubuntu.com' |
2047 | - ... password = 'zeca' |
2048 | - ... browser = SimulatedLaunchpadBrowser(self.web_root) |
2049 | - ... browser.grant_access(username, password, self.request_token, |
2050 | - ... 'READ_PUBLIC') |
2051 | - |
2052 | -Here, we're using 'test_dev' as shorthand for the root URL of the web |
2053 | -service. Earlier we used 'test_dev' as shorthand for the website URL, |
2054 | -and like in that earlier case, launchpadlib will internally |
2055 | -dereference 'test_dev' into the service root URL, defined in the |
2056 | -'uris' module as "http://api.launchpad.dev:8085/". |
2057 | - |
2058 | - >>> consumer_name = 'launchpadlib' |
2059 | - >>> launchpad = Launchpad.get_token_and_login( |
2060 | - ... consumer_name, service_root="test_dev", |
2061 | - ... authorizer_class=AuthorizeAsSalgado) |
2062 | - [If this were a real application, the end-user's web browser would |
2063 | - be opened to http://launchpad.dev:8085/+authorize-token?oauth_token=...] |
2064 | - The authorization page: |
2065 | - (http://launchpad.dev:8085/+authorize-token?oauth_token=...) |
2066 | - should be opening in your browser. After you have authorized |
2067 | - this program to access Launchpad on your behalf you should come |
2068 | - back here and press <Enter> to finish the authentication process. |
2069 | - |
2070 | -The login_with method will cache an access token once it gets one, so |
2071 | -that the end-user doesn't have to authorize a request token every time |
2072 | -they run the program. |
2073 | - |
2074 | - >>> import tempfile |
2075 | - >>> cache_dir = tempfile.mkdtemp() |
2076 | - >>> launchpad = Launchpad.login_with( |
2077 | - ... consumer_name, service_root="test_dev", |
2078 | - ... launchpadlib_dir=cache_dir, |
2079 | - ... authorizer_class=AuthorizeAsSalgado) |
2080 | - [If this were a real application...] |
2081 | - The authorization page: |
2082 | - ... |
2083 | - >>> print launchpad.me.name |
2084 | - salgado |
2085 | - |
2086 | -Now that the access token is authorized, we can call login_with() |
2087 | -again and pass in a null authorizer. If there was no access token, |
2088 | -this would fail, because there would be no way to authorize the |
2089 | -request token. But since there's an access token cached in the |
2090 | -cache directory, login_with() will succeed without even trying to |
2091 | -authorize a request token. |
2092 | - |
2093 | - >>> launchpad = Launchpad.login_with( |
2094 | - ... consumer_name, service_root="test_dev", |
2095 | - ... launchpadlib_dir=cache_dir, |
2096 | - ... authorizer_class=None) |
2097 | - >>> print launchpad.me.name |
2098 | - salgado |
2099 | - |
2100 | -A bit of clean-up: removing the cache directory. |
2101 | - |
2102 | - >>> import shutil |
2103 | - >>> shutil.rmtree(cache_dir) |
2104 | +Now the user must authorize that token, and this is the part we can't |
2105 | +test--it requires opening a web browser. Once the token is authorized |
2106 | +on the server side, we can call exchange_request_token_for_access_token() |
2107 | +on our Credentials object, which will then be ready to use. |
2108 | |
2109 | The dictionary request token |
2110 | ============================ |
2111 | @@ -470,7 +359,7 @@ |
2112 | >>> launchpad = Launchpad(credentials=credentials) |
2113 | Traceback (most recent call last): |
2114 | ... |
2115 | - HTTPError: HTTP Error 401: Unauthorized |
2116 | + Unauthorized: HTTP Error 401: Unauthorized |
2117 | ... |
2118 | |
2119 | The application is not allowed to access Launchpad with a consumer |
2120 | @@ -483,7 +372,7 @@ |
2121 | >>> launchpad = Launchpad(credentials=credentials) |
2122 | Traceback (most recent call last): |
2123 | ... |
2124 | - HTTPError: HTTP Error 401: Unauthorized |
2125 | + Unauthorized: HTTP Error 401: Unauthorized |
2126 | ... |
2127 | |
2128 | The application is not allowed to access Launchpad with a bad access secret. |
2129 | @@ -495,7 +384,7 @@ |
2130 | >>> launchpad = Launchpad(credentials=credentials) |
2131 | Traceback (most recent call last): |
2132 | ... |
2133 | - HTTPError: HTTP Error 401: Unauthorized |
2134 | + Unauthorized: HTTP Error 401: Unauthorized |
2135 | ... |
2136 | |
2137 | Clean up |
2138 | |
2139 | === added file 'src/launchpadlib/docs/operations.txt' |
2140 | --- src/launchpadlib/docs/operations.txt 1970-01-01 00:00:00 +0000 |
2141 | +++ src/launchpadlib/docs/operations.txt 2011-02-14 21:31:44 +0000 |
2142 | @@ -0,0 +1,27 @@ |
2143 | +**************** |
2144 | +Named operations |
2145 | +**************** |
2146 | + |
2147 | +launchpadlib can transparently determine the size of the list even |
2148 | +when the size is not directly provided, but is only available through |
2149 | +a link. |
2150 | + |
2151 | + >>> from launchpadlib.testing.helpers import salgado_with_full_permissions |
2152 | + >>> launchpad = salgado_with_full_permissions.login(version="devel") |
2153 | + |
2154 | + >>> results = launchpad.people.find(text='s') |
2155 | + >>> 'total_size' in results._wadl_resource.representation.keys() |
2156 | + False |
2157 | + >>> 'total_size_link' in results._wadl_resource.representation.keys() |
2158 | + True |
2159 | + >>> len(results) > 1 |
2160 | + True |
2161 | + |
2162 | +Of course, launchpadlib can also determine the size when the size _is_ |
2163 | +directly provided. |
2164 | + |
2165 | + >>> results = launchpad.people.find(text='salgado') |
2166 | + >>> 'total_size' in results._wadl_resource.representation.keys() |
2167 | + True |
2168 | + >>> len(results) == 1 |
2169 | + True |
2170 | |
2171 | === modified file 'src/launchpadlib/docs/people.txt' |
2172 | --- src/launchpadlib/docs/people.txt 2010-12-07 19:30:29 +0000 |
2173 | +++ src/launchpadlib/docs/people.txt 2011-02-14 21:31:44 +0000 |
2174 | @@ -159,7 +159,7 @@ |
2175 | >>> launchpad.people.newTeam(name='bassists', display_name='Bass Gods') |
2176 | Traceback (most recent call last): |
2177 | ... |
2178 | - HTTPError: HTTP Error 400: Bad Request |
2179 | + BadRequest: HTTP Error 400: Bad Request |
2180 | ... |
2181 | |
2182 | Actually, the exception contains other useful information. |
2183 | |
2184 | === modified file 'src/launchpadlib/docs/toplevel.txt' |
2185 | --- src/launchpadlib/docs/toplevel.txt 2010-12-07 19:30:29 +0000 |
2186 | +++ src/launchpadlib/docs/toplevel.txt 2011-02-14 21:31:44 +0000 |
2187 | @@ -13,10 +13,16 @@ |
2188 | collections. The bug collection does lookups by bug ID. |
2189 | |
2190 | >>> bug = launchpad.bugs[1] |
2191 | - |
2192 | -For most top-level collections, simply looking up an object will not |
2193 | -trigger an HTTP request. The HTTP request happens when you try to |
2194 | -access one of the object's properties. |
2195 | + send: 'GET /.../bugs/1 ...' |
2196 | + ... |
2197 | + |
2198 | +To avoid triggering an HTTP request when simply looking up an object, |
2199 | +you can use a different syntax: |
2200 | + |
2201 | + >>> bug = launchpad.bugs(1) |
2202 | + |
2203 | +The HTTP request will happen when you need information that can only |
2204 | +be obtained from the web service. |
2205 | |
2206 | >>> print bug.id |
2207 | send: 'GET /.../bugs/1 ...' |
2208 | @@ -26,7 +32,7 @@ |
2209 | Let's look at some more collections. The project collection does |
2210 | lookups by project name. |
2211 | |
2212 | - >>> project = launchpad.projects['firefox'] |
2213 | + >>> project = launchpad.projects('firefox') |
2214 | >>> print project.name |
2215 | send: 'GET /.../firefox ...' |
2216 | ... |
2217 | @@ -34,7 +40,7 @@ |
2218 | |
2219 | The project group collection does lookups by project group name. |
2220 | |
2221 | - >>> group = launchpad.project_groups['gnome'] |
2222 | + >>> group = launchpad.project_groups('gnome') |
2223 | >>> print group.name |
2224 | send: 'GET /.../gnome ...' |
2225 | ... |
2226 | @@ -42,7 +48,7 @@ |
2227 | |
2228 | The distribution collection does lookups by distribution name. |
2229 | |
2230 | - >>> distribution = launchpad.distributions['ubuntu'] |
2231 | + >>> distribution = launchpad.distributions('ubuntu') |
2232 | >>> print distribution.name |
2233 | send: 'GET /.../ubuntu ...' |
2234 | ... |
2235 | @@ -51,13 +57,13 @@ |
2236 | The person collection does lookups by a person's Launchpad |
2237 | name. |
2238 | |
2239 | - >>> person = launchpad.people['salgado'] |
2240 | + >>> person = launchpad.people('salgado') |
2241 | >>> print person.name |
2242 | send: 'GET /.../~salgado ...' |
2243 | ... |
2244 | salgado |
2245 | |
2246 | - >>> team = launchpad.people['rosetta-admins'] |
2247 | + >>> team = launchpad.people('rosetta-admins') |
2248 | >>> print team.name |
2249 | send: 'GET /1.0/~rosetta-admins ...' |
2250 | ... |
2251 | @@ -77,11 +83,11 @@ |
2252 | True |
2253 | |
2254 | The truth is that it doesn't know, not before making that HTTP |
2255 | -request. Until the HTTP request is made, launchpadlib assumes |
2256 | +request. Until an HTTP request is made, launchpadlib assumes |
2257 | everything in launchpad.people[] is a team (since a team has strictly |
2258 | more capabilities than a person). |
2259 | |
2260 | - >>> person2 = launchpad.people['salgado'] |
2261 | + >>> person2 = launchpad.people('salgado') |
2262 | >>> 'default_membership_period' in person2.lp_attributes |
2263 | True |
2264 | |
2265 | @@ -101,7 +107,7 @@ |
2266 | the HTTP request, and then cause an error if the object turns out not |
2267 | to be a team. |
2268 | |
2269 | - >>> person3 = launchpad.people['salgado'] |
2270 | + >>> person3 = launchpad.people('salgado') |
2271 | >>> person3.default_membership_period |
2272 | Traceback (most recent call last): |
2273 | AttributeError: 'Entry' object has no attribute 'default_membership_period' |
2274 | |
2275 | === removed file 'src/launchpadlib/docs/trusted-client.txt' |
2276 | --- src/launchpadlib/docs/trusted-client.txt 2010-12-07 19:30:29 +0000 |
2277 | +++ src/launchpadlib/docs/trusted-client.txt 1970-01-01 00:00:00 +0000 |
2278 | @@ -1,224 +0,0 @@ |
2279 | -*********************** |
2280 | -Making a trusted client |
2281 | -*********************** |
2282 | - |
2283 | -To authorize a request token, the end-user must type in their |
2284 | -Launchpad username and password. Obviously, typing your password into |
2285 | -a random program is a bad idea. The best case is to use a program you |
2286 | -already trust with your Launchpad password: your web browser. |
2287 | - |
2288 | -But if you're writing an application that can't open the end-user's |
2289 | -web browser, or you just really want a token authorization client that |
2290 | -has the same UI as the rest of your application, you should use one of |
2291 | -the trusted clients packaged with launchpadlib, rather than writing |
2292 | -your own client. |
2293 | - |
2294 | -All the trusted clients are based on the same core code and implement |
2295 | -the same workflow. This test implements a scriptable trusted client |
2296 | -and uses it to test the behavior of the standard workflow. |
2297 | - |
2298 | - >>> from launchpadlib.testing.helpers import ( |
2299 | - ... ScriptableRequestTokenAuthorization) |
2300 | - |
2301 | -Here we see the normal workflow, in which the user inputs all the |
2302 | -correct data to authorize a request token. |
2303 | - |
2304 | - >>> auth = ScriptableRequestTokenAuthorization( |
2305 | - ... "consumer", "salgado@ubuntu.com", "zeca", |
2306 | - ... "WRITE_PRIVATE", |
2307 | - ... allow_access_levels = ["WRITE_PUBLIC", "WRITE_PRIVATE"]) |
2308 | - >>> access_token = auth() |
2309 | - An application identified as "consumer" wants to access Launchpad... |
2310 | - <BLANKLINE> |
2311 | - I'll use your Launchpad password to give "consumer" limited access... |
2312 | - What email address do you use on Launchpad? |
2313 | - What's your Launchpad password? |
2314 | - Now it's time for you to decide how much power to give "consumer" ... |
2315 | - ['UNAUTHORIZED', 'WRITE_PUBLIC', 'WRITE_PRIVATE'] |
2316 | - Okay, I'm telling Launchpad to grant "consumer" access to your account. |
2317 | - You're all done! You should now be able to use Launchpad ... |
2318 | - |
2319 | -Ordinarily, the third-party program will create a request token and |
2320 | -pass it into the trusted client. The test class is a little unusual: |
2321 | -it takes care of creating the request token and, after the end-user |
2322 | -has authorized it, exchanges the request token for an access |
2323 | -token. This way we can verify that the entire end-to-end process |
2324 | -works. |
2325 | - |
2326 | - >>> access_token.key is not None |
2327 | - True |
2328 | - |
2329 | -Denying access |
2330 | -============== |
2331 | - |
2332 | -It's always possible for the end-user to deny access to the |
2333 | -application. This will make it impossible to convert the request token |
2334 | -into an access token. |
2335 | - |
2336 | - >>> auth = ScriptableRequestTokenAuthorization( |
2337 | - ... "consumer", "salgado@ubuntu.com", "zeca", "UNAUTHORIZED") |
2338 | - >>> access_token = auth() |
2339 | - An application identified as "consumer" wants to access Launchpad... |
2340 | - What email address do you use on Launchpad? |
2341 | - ... |
2342 | - Okay, I'm going to cancel the request that "consumer" made... |
2343 | - You're all done! "consumer" still doesn't have access... |
2344 | - |
2345 | - >>> access_token is None |
2346 | - True |
2347 | - |
2348 | -Only one allowable access level |
2349 | -=============================== |
2350 | - |
2351 | -When the application being authenticated only allows one access level, |
2352 | -the authorizer creates a special message for display to the end-user. |
2353 | - |
2354 | - >>> auth = ScriptableRequestTokenAuthorization( |
2355 | - ... "consumer", "salgado@ubuntu.com", "zeca", |
2356 | - ... "WRITE_PRIVATE", allow_access_levels=["WRITE_PRIVATE"]) |
2357 | - |
2358 | - >>> auth() |
2359 | - An application identified as "consumer" wants to access Launchpad ... |
2360 | - ... |
2361 | - "consumer" says it needs the following level of access to your Launchpad |
2362 | - account: "Change Anything". It can't work with any other level of access, |
2363 | - so denying this level of access means prohibiting "consumer" from |
2364 | - using your Launchpad account at all. |
2365 | - ... |
2366 | - |
2367 | -Error handling |
2368 | -============== |
2369 | - |
2370 | -Things can go wrong in many ways, most of which we can test with our |
2371 | -scriptable authorizer. Here's a utility method to run the |
2372 | -authorization process with a badly-scripted authorizer and print the |
2373 | -resulting exception. |
2374 | - |
2375 | - >>> from launchpadlib.credentials import TokenAuthorizationException |
2376 | - >>> def print_error(auth): |
2377 | - ... try: |
2378 | - ... auth() |
2379 | - ... except TokenAuthorizationException, e: |
2380 | - ... print str(e) |
2381 | - |
2382 | -Authentication failures |
2383 | ------------------------ |
2384 | - |
2385 | -If the user doesn't have a Launchpad account, or refuses to type in |
2386 | -their email address, the authorizer will open their web browser to the |
2387 | -login page, and raise an exception. |
2388 | - |
2389 | - >>> auth = ScriptableRequestTokenAuthorization( |
2390 | - ... "consumer", None, "zeca", "WRITE_PRIVATE") |
2391 | - >>> print_error(auth) |
2392 | - An application identified as "consumer" wants to access Launchpad ... |
2393 | - [If this were a real application, ... opened to http://launchpad.dev:8085/+login] |
2394 | - OK, you'll need to get yourself a Launchpad account before you can ... |
2395 | - <BLANKLINE> |
2396 | - I'm opening the Launchpad registration page in your web browser ... |
2397 | - |
2398 | -If the user enters the wrong username/password combination too many |
2399 | -times, the authorizer will give up and raise an exception. |
2400 | - |
2401 | - >>> auth = ScriptableRequestTokenAuthorization( |
2402 | - ... "consumer", "salgado@ubuntu.com", "baddpassword", |
2403 | - ... "WRITE_PRIVATE") |
2404 | - >>> print_error(auth) |
2405 | - An application identified as "consumer" wants to access Launchpad... |
2406 | - ... |
2407 | - What email address do you use on Launchpad? |
2408 | - What's your Launchpad password? |
2409 | - I can't log in with the credentials you gave me. Let's try again. |
2410 | - What email address do you use on Launchpad? |
2411 | - Cached email address: salgado@ubuntu.com |
2412 | - What's your Launchpad password? |
2413 | - You've failed the password entry too many times... |
2414 | - |
2415 | -The max_failed_attempts argument controls how many attempts the user |
2416 | -is given to enter their username and password. |
2417 | - |
2418 | - >>> auth = ScriptableRequestTokenAuthorization( |
2419 | - ... "consumer", "bad username", "zeca", |
2420 | - ... "WRITE_PRIVATE", max_failed_attempts=1) |
2421 | - >>> print_error(auth) |
2422 | - An application identified as "consumer" wants to access Launchpad ... |
2423 | - What email address do you use on Launchpad? |
2424 | - What's your Launchpad password? |
2425 | - You've failed the password entry too many times... |
2426 | - |
2427 | -Approving a token that was already approved |
2428 | -------------------------------------------- |
2429 | - |
2430 | -To set this up, let's approve a request token but not exchange it for |
2431 | -an access token. |
2432 | - |
2433 | - >>> auth = ScriptableRequestTokenAuthorization( |
2434 | - ... "consumer", "salgado@ubuntu.com", "zeca", |
2435 | - ... "WRITE_PRIVATE") |
2436 | - >>> auth(exchange_for_access_token=False) |
2437 | - An application identified as "consumer" wants to access Launchpad ... |
2438 | - ... |
2439 | - |
2440 | -Now let's try to approve the request token again: |
2441 | - |
2442 | - >>> print_error(auth) |
2443 | - An application identified as "consumer" wants to access Launchpad ... |
2444 | - ... |
2445 | - It looks like you already approved this request... |
2446 | - |
2447 | -Once the request token is exchanged for an access token, it's |
2448 | -deleted. An attempt to approve a request token that's already been |
2449 | -exchanged for an access token gives an error message. |
2450 | - |
2451 | - >>> auth.credentials.exchange_request_token_for_access_token( |
2452 | - ... web_root=auth.web_root) |
2453 | - |
2454 | - >>> print_error(auth) |
2455 | - An application identified as "consumer" wants to access Launchpad ... |
2456 | - ... |
2457 | - Launchpad couldn't find an outstanding request for integration... |
2458 | - |
2459 | -An attempt to approve a nonexistent request token gives the same error |
2460 | -message. |
2461 | - |
2462 | - >>> auth = ScriptableRequestTokenAuthorization( |
2463 | - ... "consumer", "salgado@ubuntu.com", "zeca", |
2464 | - ... "WRITE_PRIVATE") |
2465 | - >>> auth.request_token = "nosuchrequesttoken" |
2466 | - >>> print_error(auth) |
2467 | - An application identified as "consumer" wants to access Launchpad ... |
2468 | - ... |
2469 | - Launchpad couldn't find an outstanding request for integration... |
2470 | - |
2471 | -Miscellaneous error |
2472 | -------------------- |
2473 | - |
2474 | -Random errors on the server side or (occasionally) the client side |
2475 | -will result in a generic error message. |
2476 | - |
2477 | - >>> auth.request_token = "this token will confuse launchpad badly" |
2478 | - >>> print_error(auth) |
2479 | - An application identified as "consumer" wants to access Launchpad ... |
2480 | - ... |
2481 | - There seems to be something wrong on the Launchpad server side... |
2482 | - |
2483 | -Client duplicity |
2484 | ----------------- |
2485 | - |
2486 | -If the third-party client gives one consumer name to Launchpad, and a |
2487 | -different consumer name to the authorizer, the authorizer will detect |
2488 | -this possible duplicity and print a warning. |
2489 | - |
2490 | - >>> auth = ScriptableRequestTokenAuthorization( |
2491 | - ... "consumer1", "salgado@ubuntu.com", "zeca", |
2492 | - ... "WRITE_PRIVATE") |
2493 | - |
2494 | -We'll simulate this by changing the authorizer's .consumer_name after |
2495 | -it obtained a request token from Launchpad. |
2496 | - |
2497 | - >>> auth.consumer_name = "consumer2" |
2498 | - >>> auth() |
2499 | - An application identified as "consumer2" wants to access Launchpad ... |
2500 | - ... |
2501 | - WARNING: The application you're using told me its name was "consumer2", but it told Launchpad its name was "consumer1"... |
2502 | - ... |
2503 | |
2504 | === modified file 'src/launchpadlib/launchpad.py' |
2505 | --- src/launchpadlib/launchpad.py 2010-12-07 19:30:29 +0000 |
2506 | +++ src/launchpadlib/launchpad.py 2011-02-14 21:31:44 +0000 |
2507 | @@ -22,21 +22,34 @@ |
2508 | ] |
2509 | |
2510 | import os |
2511 | -import stat |
2512 | import urlparse |
2513 | +import warnings |
2514 | |
2515 | -from lazr.uri import URI |
2516 | from lazr.restfulclient.resource import ( |
2517 | - CollectionWithKeyBasedLookup, HostedFile, ServiceRoot) |
2518 | + CollectionWithKeyBasedLookup, |
2519 | + HostedFile, # Re-import for client convenience |
2520 | + ScalarValue, # Re-import for client convenience |
2521 | + ServiceRoot, |
2522 | + ) |
2523 | +from lazr.restfulclient.authorize.oauth import SystemWideConsumer |
2524 | +from lazr.restfulclient._browser import RestfulHttp |
2525 | from launchpadlib.credentials import ( |
2526 | - AccessToken, AnonymousAccessToken, Credentials, |
2527 | - AuthorizeRequestTokenWithBrowser) |
2528 | + AccessToken, |
2529 | + AnonymousAccessToken, |
2530 | + AuthorizeRequestTokenWithBrowser, |
2531 | + Consumer, |
2532 | + Credentials, |
2533 | + KeyringCredentialStore, |
2534 | + UnencryptedFileCredentialStore, |
2535 | + ) |
2536 | from launchpadlib import uris |
2537 | |
2538 | -# Import some constants for backwards compatibility. This way, old |
2539 | -# scripts that have 'from launchpad import EDGE_SERVICE_ROOT' will still |
2540 | + |
2541 | +# Set some constants for backwards compatibility. This way, old |
2542 | +# scripts that have 'from launchpad import STAGING_SERVICE_ROOT' will still |
2543 | # work. |
2544 | -from launchpadlib.uris import EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT |
2545 | +STAGING_SERVICE_ROOT = 'staging' |
2546 | +EDGE_SERVICE_ROOT = 'edge' |
2547 | OAUTH_REALM = 'https://api.launchpad.net' |
2548 | |
2549 | |
2550 | @@ -96,6 +109,41 @@ |
2551 | collection_of = 'distribution' |
2552 | |
2553 | |
2554 | +class LaunchpadOAuthAwareHttp(RestfulHttp): |
2555 | + """Detects expired/invalid OAuth tokens and tries to get a new token.""" |
2556 | + |
2557 | + def __init__(self, launchpad, authorization_engine, *args): |
2558 | + self.launchpad = launchpad |
2559 | + self.authorization_engine = authorization_engine |
2560 | + super(LaunchpadOAuthAwareHttp, self).__init__(*args) |
2561 | + |
2562 | + def _bad_oauth_token(self, response, content): |
2563 | + """Helper method to detect an error caused by a bad OAuth token.""" |
2564 | + return (response.status == 401 and |
2565 | + (content.startswith("Expired token") |
2566 | + or content.startswith("Invalid token"))) |
2567 | + |
2568 | + def _request(self, *args): |
2569 | + response, content = super( |
2570 | + LaunchpadOAuthAwareHttp, self)._request(*args) |
2571 | + return self.retry_on_bad_token(response, content, *args) |
2572 | + |
2573 | + def retry_on_bad_token(self, response, content, *args): |
2574 | + """If the response indicates a bad token, get a new token and retry. |
2575 | + |
2576 | + Otherwise, just return the response. |
2577 | + """ |
2578 | + if (self._bad_oauth_token(response, content) |
2579 | + and self.authorization_engine is not None): |
2580 | + # This access token is bad. Scrap it and create a new one. |
2581 | + self.launchpad.credentials.access_token = None |
2582 | + self.authorization_engine( |
2583 | + self.launchpad.credentials, self.launchpad.credential_store) |
2584 | + # Retry the request with the new credentials. |
2585 | + return self._request(*args) |
2586 | + return response, content |
2587 | + |
2588 | + |
2589 | class Launchpad(ServiceRoot): |
2590 | """Root Launchpad API class. |
2591 | |
2592 | @@ -108,19 +156,26 @@ |
2593 | RESOURCE_TYPE_CLASSES = { |
2594 | 'bugs': BugSet, |
2595 | 'distributions': DistributionSet, |
2596 | - 'HostedFile': HostedFile, |
2597 | 'people': PersonSet, |
2598 | 'project_groups': ProjectGroupSet, |
2599 | 'projects': ProjectSet, |
2600 | } |
2601 | + RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES) |
2602 | |
2603 | - def __init__(self, credentials, service_root=uris.STAGING_SERVICE_ROOT, |
2604 | + def __init__(self, credentials, authorization_engine, |
2605 | + credential_store, service_root=uris.STAGING_SERVICE_ROOT, |
2606 | cache=None, timeout=None, proxy_info=None, |
2607 | version=DEFAULT_VERSION): |
2608 | """Root access to the Launchpad API. |
2609 | |
2610 | :param credentials: The credentials used to access Launchpad. |
2611 | :type credentials: `Credentials` |
2612 | + :param authorization_engine: The object used to get end-user input |
2613 | + for authorizing OAuth request tokens. Used when an OAuth |
2614 | + access token expires or becomes invalid during a |
2615 | + session, or is discovered to be invalid once launchpadlib |
2616 | + starts up. |
2617 | + :type authorization_engine: `RequestTokenAuthorizationEngine` |
2618 | :param service_root: The URL to the root of the web service. |
2619 | :type service_root: string |
2620 | """ |
2621 | @@ -134,22 +189,48 @@ |
2622 | "the version name from the root URI." % version) |
2623 | raise ValueError(error) |
2624 | |
2625 | + self.credential_store = credential_store |
2626 | + |
2627 | + # We already have an access token, but it might expire or |
2628 | + # become invalid during use. Store the authorization engine in |
2629 | + # case we need to authorize a new token during use. |
2630 | + self.authorization_engine = authorization_engine |
2631 | + |
2632 | super(Launchpad, self).__init__( |
2633 | credentials, service_root, cache, timeout, proxy_info, version) |
2634 | |
2635 | + def httpFactory(self, credentials, cache, timeout, proxy_info): |
2636 | + return LaunchpadOAuthAwareHttp( |
2637 | + self, self.authorization_engine, credentials, cache, timeout, |
2638 | + proxy_info) |
2639 | + |
2640 | + @classmethod |
2641 | + def authorization_engine_factory(cls, *args): |
2642 | + return AuthorizeRequestTokenWithBrowser(*args) |
2643 | + |
2644 | + @classmethod |
2645 | + def credential_store_factory(cls, credential_save_failed): |
2646 | + return KeyringCredentialStore(credential_save_failed) |
2647 | + |
2648 | @classmethod |
2649 | def login(cls, consumer_name, token_string, access_secret, |
2650 | service_root=uris.STAGING_SERVICE_ROOT, |
2651 | cache=None, timeout=None, proxy_info=None, |
2652 | - version=DEFAULT_VERSION): |
2653 | - """Convenience for setting up access credentials. |
2654 | + authorization_engine=None, allow_access_levels=None, |
2655 | + max_failed_attempts=None, credential_store=None, |
2656 | + credential_save_failed=None, version=DEFAULT_VERSION): |
2657 | + """Convenience method for setting up access credentials. |
2658 | |
2659 | When all three pieces of credential information (the consumer |
2660 | name, the access token and the access secret) are available, this |
2661 | method can be used to quickly log into the service root. |
2662 | |
2663 | - :param consumer_name: the consumer name, as appropriate for the |
2664 | - `Consumer` constructor |
2665 | + This method is deprecated as of launchpadlib version |
2666 | + 1.9.0. You should use Launchpad.login_anonymously() for |
2667 | + anonymous access, and Launchpad.login_with() for all other |
2668 | + purposes. |
2669 | + |
2670 | + :param consumer_name: the application name. |
2671 | :type consumer_name: string |
2672 | :param token_string: the access token, as appropriate for the |
2673 | `AccessToken` constructor |
2674 | @@ -159,61 +240,125 @@ |
2675 | :type access_secret: string |
2676 | :param service_root: The URL to the root of the web service. |
2677 | :type service_root: string |
2678 | + :param authorization_engine: See `Launchpad.__init__`. If you don't |
2679 | + provide an authorization engine, a default engine will be |
2680 | + constructed using your values for `service_root` and |
2681 | + `credential_save_failed`. |
2682 | + :param allow_access_levels: This argument is ignored, and only |
2683 | + present to preserve backwards compatibility. |
2684 | + :param max_failed_attempts: This argument is ignored, and only |
2685 | + present to preserve backwards compatibility. |
2686 | :return: The web service root |
2687 | :rtype: `Launchpad` |
2688 | """ |
2689 | + cls._warn_of_deprecated_login_method("login") |
2690 | access_token = AccessToken(token_string, access_secret) |
2691 | credentials = Credentials( |
2692 | consumer_name=consumer_name, access_token=access_token) |
2693 | - return cls(credentials, service_root, cache, timeout, proxy_info, |
2694 | - version) |
2695 | + if authorization_engine is None: |
2696 | + authorization_engine = cls.authorization_engine_factory( |
2697 | + service_root, consumer_name, allow_access_levels) |
2698 | + if credential_store is None: |
2699 | + credential_store = cls.credential_store_factory( |
2700 | + credential_save_failed) |
2701 | + return cls(credentials, authorization_engine, credential_store, |
2702 | + service_root, cache, timeout, proxy_info, version) |
2703 | |
2704 | @classmethod |
2705 | def get_token_and_login(cls, consumer_name, |
2706 | service_root=uris.STAGING_SERVICE_ROOT, |
2707 | cache=None, timeout=None, proxy_info=None, |
2708 | - authorizer_class=AuthorizeRequestTokenWithBrowser, |
2709 | - allow_access_levels=[], max_failed_attempts=3, |
2710 | + authorization_engine=None, allow_access_levels=[], |
2711 | + max_failed_attempts=None, credential_store=None, |
2712 | + credential_save_failed=None, |
2713 | version=DEFAULT_VERSION): |
2714 | """Get credentials from Launchpad and log into the service root. |
2715 | |
2716 | - This is a convenience method which will open up the user's preferred |
2717 | - web browser and thus should not be used by most applications. |
2718 | - Applications should, instead, use Credentials.get_request_token() to |
2719 | - obtain the authorization URL and |
2720 | - Credentials.exchange_request_token_for_access_token() to obtain the |
2721 | - actual OAuth access token. |
2722 | - |
2723 | - This method will negotiate an OAuth access token with the service |
2724 | - provider, but to complete it we will need the user to log into |
2725 | - Launchpad and authorize us, so we'll open the authorization page in |
2726 | - a web browser and ask the user to come back here and tell us when they |
2727 | - finished the authorization process. |
2728 | - |
2729 | - :param consumer_name: The consumer name, as appropriate for the |
2730 | - `Consumer` constructor |
2731 | + This method is deprecated as of launchpadlib version |
2732 | + 1.9.0. You should use Launchpad.login_anonymously() for |
2733 | + anonymous access and Launchpad.login_with() for all other |
2734 | + purposes. |
2735 | + |
2736 | + :param consumer_name: Either a consumer name, as appropriate for |
2737 | + the `Consumer` constructor, or a premade Consumer object. |
2738 | :type consumer_name: string |
2739 | :param service_root: The URL to the root of the web service. |
2740 | :type service_root: string |
2741 | + :param authorization_engine: See `Launchpad.__init__`. If you don't |
2742 | + provide an authorization engine, a default engine will be |
2743 | + constructed using your values for `service_root` and |
2744 | + `credential_save_failed`. |
2745 | + :param allow_access_levels: This argument is ignored, and only |
2746 | + present to preserve backwards compatibility. |
2747 | :return: The web service root |
2748 | :rtype: `Launchpad` |
2749 | """ |
2750 | - credentials = Credentials(consumer_name) |
2751 | - service_root = uris.lookup_service_root(service_root) |
2752 | - web_root_uri = URI(service_root) |
2753 | - web_root_uri.path = "" |
2754 | - web_root_uri.host = web_root_uri.host.replace("api.", "", 1) |
2755 | - web_root = str(web_root_uri.ensureSlash()) |
2756 | - authorization_json = credentials.get_request_token( |
2757 | - web_root=web_root, token_format=Credentials.DICT_TOKEN_FORMAT) |
2758 | - authorizer = authorizer_class( |
2759 | - web_root, authorization_json['oauth_token_consumer'], |
2760 | - authorization_json['oauth_token'], allow_access_levels, |
2761 | - max_failed_attempts) |
2762 | - authorizer() |
2763 | - credentials.exchange_request_token_for_access_token(web_root) |
2764 | - return cls(credentials, service_root, cache, timeout, proxy_info, |
2765 | - version) |
2766 | + cls._warn_of_deprecated_login_method("get_token_and_login") |
2767 | + return cls._authorize_token_and_login( |
2768 | + consumer_name, service_root, cache, timeout, proxy_info, |
2769 | + authorization_engine, allow_access_levels, |
2770 | + credential_store, credential_save_failed, version) |
2771 | + |
2772 | + @classmethod |
2773 | + def _authorize_token_and_login( |
2774 | + cls, consumer_name, service_root, cache, timeout, proxy_info, |
2775 | + authorization_engine, allow_access_levels, credential_store, |
2776 | + credential_save_failed, version): |
2777 | + """Authorize a request token. Log in with the resulting access token. |
2778 | + |
2779 | + This is the private, non-deprecated implementation of the |
2780 | + deprecated method get_token_and_login(). Once |
2781 | + get_token_and_login() is removed, this code can be streamlined |
2782 | + and moved into its other call site, login_with(). |
2783 | + """ |
2784 | + if isinstance(consumer_name, Consumer): |
2785 | + consumer = consumer_name |
2786 | + else: |
2787 | + # Create a system-wide consumer. lazr.restfulclient won't |
2788 | + # do this automatically, but launchpadlib's default is to |
2789 | + # do a desktop-wide integration. |
2790 | + consumer = SystemWideConsumer(consumer_name) |
2791 | + |
2792 | + # Create the credentials with no Consumer, then set its .consumer |
2793 | + # property directly. |
2794 | + credentials = Credentials(None) |
2795 | + credentials.consumer = consumer |
2796 | + if authorization_engine is None: |
2797 | + authorization_engine = cls.authorization_engine_factory( |
2798 | + service_root, consumer_name, None, allow_access_levels) |
2799 | + if credential_store is None: |
2800 | + credential_store = cls.credential_store_factory( |
2801 | + credential_save_failed) |
2802 | + else: |
2803 | + # A credential store was passed in, so we won't be using |
2804 | + # any provided value for credential_save_failed. But at |
2805 | + # least make sure we weren't given a conflicting value, |
2806 | + # since that makes the calling code look confusing. |
2807 | + cls._assert_login_argument_consistency( |
2808 | + "credential_save_failed", credential_save_failed, |
2809 | + credential_store.credential_save_failed, |
2810 | + "credential_store") |
2811 | + |
2812 | + # Try to get the credentials out of the credential store. |
2813 | + cached_credentials = credential_store.load( |
2814 | + authorization_engine.unique_consumer_id) |
2815 | + if cached_credentials is None: |
2816 | + # They're not there. Acquire new credentials using the |
2817 | + # authorization engine. |
2818 | + credentials = authorization_engine(credentials, credential_store) |
2819 | + else: |
2820 | + # We acquired credentials. But, the application name |
2821 | + # wasn't stored along with the credentials, because in a |
2822 | + # desktop integration scenario, a single set of |
2823 | + # credentials may be shared by many applications. We need |
2824 | + # to set the application name for this specific instance |
2825 | + # of the credentials. |
2826 | + credentials = cached_credentials |
2827 | + credentials.consumer.application_name = ( |
2828 | + authorization_engine.application_name) |
2829 | + |
2830 | + return cls(credentials, authorization_engine, credential_store, |
2831 | + service_root, cache, timeout, proxy_info, version) |
2832 | |
2833 | @classmethod |
2834 | def login_anonymously( |
2835 | @@ -225,76 +370,207 @@ |
2836 | service_root_dir) = cls._get_paths(service_root, launchpadlib_dir) |
2837 | token = AnonymousAccessToken() |
2838 | credentials = Credentials(consumer_name, access_token=token) |
2839 | - return cls(credentials, service_root=service_root, cache=cache_path, |
2840 | - timeout=timeout, proxy_info=proxy_info, version=version) |
2841 | + return cls(credentials, None, None, service_root=service_root, |
2842 | + cache=cache_path, timeout=timeout, proxy_info=proxy_info, |
2843 | + version=version) |
2844 | |
2845 | @classmethod |
2846 | - def login_with(cls, consumer_name, |
2847 | + def login_with(cls, application_name=None, |
2848 | service_root=uris.STAGING_SERVICE_ROOT, |
2849 | launchpadlib_dir=None, timeout=None, proxy_info=None, |
2850 | - authorizer_class=AuthorizeRequestTokenWithBrowser, |
2851 | - allow_access_levels=[], max_failed_attempts=3, |
2852 | - credentials_file=None, version=DEFAULT_VERSION): |
2853 | - """Log in to Launchpad with possibly cached credentials. |
2854 | - |
2855 | - This is a convenience method for either setting up new login |
2856 | - credentials, or re-using existing ones. When a login token is generated |
2857 | - using this method, the resulting credentials will be saved in |
2858 | - `credentials_file`, or if not given, into the `launchpadlib_dir` |
2859 | - directory. If the same `credentials_file`/`launchpadlib_dir` is passed |
2860 | - in a second time, the credentials in for the consumer will be used |
2861 | - automatically. |
2862 | - |
2863 | - Each consumer has their own credentials per service root in |
2864 | - `launchpadlib_dir`. `launchpadlib_dir` is also used for caching |
2865 | - fetched objects. The cache is per service root, and shared by |
2866 | - all consumers. |
2867 | - |
2868 | - See `Launchpad.get_token_and_login()` for more information about |
2869 | - how new tokens are generated. |
2870 | - |
2871 | - :param consumer_name: The consumer name, as appropriate for the |
2872 | - `Consumer` constructor |
2873 | - :type consumer_name: string |
2874 | + authorization_engine=None, allow_access_levels=None, |
2875 | + max_failed_attempts=None, credentials_file=None, |
2876 | + version=DEFAULT_VERSION, consumer_name=None, |
2877 | + credential_save_failed=None, credential_store=None): |
2878 | + """Log in to Launchpad, possibly acquiring and storing credentials. |
2879 | + |
2880 | + Use this method to get a `Launchpad` object. If the end-user |
2881 | + has no cached Launchpad credential, their browser will open |
2882 | + and they'll be asked to log in and authorize a desktop |
2883 | + integration. The authorized Launchpad credential will be |
2884 | + stored securely: in the GNOME keyring, the KDE Wallet, or in |
2885 | + an encrypted file on disk. |
2886 | + |
2887 | + The next time your program (or any other program run by that |
2888 | + user on the same computer) invokes this method, the end-user |
2889 | + will be prompted to unlock their keyring (or equivalent), and |
2890 | + the credential will be retrieved from local storage and |
2891 | + reused. |
2892 | + |
2893 | + You can customize this behavior in three ways: |
2894 | + |
2895 | + 1. Pass in a filename to `credentials_file`. The end-user's |
2896 | + credential will be written to that file, and on subsequent |
2897 | + runs read from that file. |
2898 | + |
2899 | + 2. Subclass `CredentialStore` and pass in an instance of the |
2900 | + subclass as `credential_store`. This lets you change how |
2901 | + the end-user's credential is stored and retrieved locally. |
2902 | + |
2903 | + 3. Subclass `RequestTokenAuthorizationEngine` and pass in an |
2904 | + instance of the subclass as `authorization_engine`. This |
2905 | + lets you change change what happens when the end-user needs |
2906 | + to authorize the Launchpad credential. |
2907 | + |
2908 | + :param application_name: The application name. This is *not* |
2909 | + the OAuth consumer name. Unless a consumer_name is also |
2910 | + provided, the OAuth consumer will be a system-wide |
2911 | + consumer representing the end-user's computer as a whole. |
2912 | + :type application_name: string |
2913 | + |
2914 | :param service_root: The URL to the root of the web service. |
2915 | :type service_root: string. Can either be the full URL to a service |
2916 | or one of the short service names. |
2917 | - :param launchpadlib_dir: The directory where the cache and |
2918 | - credentials are stored. |
2919 | + |
2920 | + :param launchpadlib_dir: The directory used to store cached |
2921 | + data obtained from Launchpad. The cache is shared by all |
2922 | + consumers, and each Launchpad service root has its own |
2923 | + cache. |
2924 | :type launchpadlib_dir: string |
2925 | - :param credentials_file: If given, the credentials are stored in that |
2926 | - file instead in `launchpadlib_dir`. |
2927 | - :type credentials_file: string |
2928 | - :return: The web service root |
2929 | + |
2930 | + :param authorization_engine: A strategy for getting the |
2931 | + end-user to authorize an OAuth request token, for |
2932 | + exchanging the request token for an access token, and for |
2933 | + storing the access token locally so that it can be |
2934 | + reused. By default, launchpadlib will open the end-user's |
2935 | + web browser to have them authorize the request token. |
2936 | + :type authorization_engine: `RequestTokenAuthorizationEngine` |
2937 | + |
2938 | + :param allow_access_levels: The acceptable access levels for |
2939 | + this application. |
2940 | + |
2941 | + This argument is used to construct the default |
2942 | + `authorization_engine`, so if you pass in your own |
2943 | + `authorization_engine` any value for this argument will be |
2944 | + ignored. This argument will also be ignored unless you |
2945 | + also specify `consumer_name`. |
2946 | + |
2947 | + :type allow_access_levels: list of strings |
2948 | + |
2949 | + :param max_failed_attempts: Ignored; only present for |
2950 | + backwards compatibility. |
2951 | + |
2952 | + :param credentials_file: The path to a file in which to store |
2953 | + this user's OAuth access token. |
2954 | + |
2955 | + :param version: The version of the Launchpad web service to use. |
2956 | + |
2957 | + :param consumer_name: The consumer name, as appropriate for |
2958 | + the `Consumer` constructor. You probably don't want to |
2959 | + provide this, since providing it will prevent you from |
2960 | + taking advantage of desktop-wide integration. |
2961 | + :type consumer_name: string |
2962 | + |
2963 | + :param credential_save_failed: a callback that is called upon |
2964 | + a failure to save the credentials locally. This argument is |
2965 | + used to construct the default `credential_store`, so if |
2966 | + you pass in your own `credential_store` any value for |
2967 | + this argument will be ignored. |
2968 | + :type credential_save_failed: A callable |
2969 | + |
2970 | + :param credential_store: A strategy for storing an OAuth |
2971 | + access token locally. By default, tokens are stored in the |
2972 | + GNOME keyring (or equivalent). If `credentials_file` is |
2973 | + provided, then tokens are stored unencrypted in that file. |
2974 | + :type credential_store: `CredentialStore` |
2975 | + |
2976 | + :return: A web service root authorized as the end-user. |
2977 | :rtype: `Launchpad` |
2978 | |
2979 | """ |
2980 | (service_root, launchpadlib_dir, cache_path, |
2981 | service_root_dir) = cls._get_paths(service_root, launchpadlib_dir) |
2982 | - credentials_path = os.path.join(service_root_dir, 'credentials') |
2983 | - if not os.path.exists(credentials_path): |
2984 | - os.makedirs(credentials_path) |
2985 | - if credentials_file is None: |
2986 | - consumer_credentials_path = os.path.join(credentials_path, |
2987 | - consumer_name) |
2988 | - else: |
2989 | - consumer_credentials_path = credentials_file |
2990 | - if os.path.exists(consumer_credentials_path): |
2991 | - credentials = Credentials.load_from_path( |
2992 | - consumer_credentials_path) |
2993 | - launchpad = cls( |
2994 | - credentials, service_root=service_root, cache=cache_path, |
2995 | - timeout=timeout, proxy_info=proxy_info, version=version) |
2996 | - else: |
2997 | - launchpad = cls.get_token_and_login( |
2998 | - consumer_name, service_root=service_root, cache=cache_path, |
2999 | - timeout=timeout, proxy_info=proxy_info, |
3000 | - authorizer_class=authorizer_class, |
3001 | - allow_access_levels=allow_access_levels, |
3002 | - max_failed_attempts=max_failed_attempts, version=version) |
3003 | - launchpad.credentials.save_to_path(consumer_credentials_path) |
3004 | - os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE) |
3005 | - return launchpad |
3006 | + |
3007 | + if (application_name is None and consumer_name is None and |
3008 | + authorization_engine is None): |
3009 | + raise ValueError( |
3010 | + "At least one of application_name, consumer_name, or " |
3011 | + "authorization_engine must be provided.") |
3012 | + |
3013 | + if credentials_file is not None and credential_store is not None: |
3014 | + raise ValueError( |
3015 | + "At most one of credentials_file and credential_store " |
3016 | + "must be provided.") |
3017 | + |
3018 | + if credential_store is None: |
3019 | + if credentials_file is not None: |
3020 | + # The end-user wants credentials stored in an |
3021 | + # unencrypted file. |
3022 | + credential_store = UnencryptedFileCredentialStore( |
3023 | + credentials_file, credential_save_failed) |
3024 | + else: |
3025 | + credential_store = cls.credential_store_factory( |
3026 | + credential_save_failed) |
3027 | + else: |
3028 | + # A credential store was passed in, so we won't be using |
3029 | + # any provided value for credential_save_failed. But at |
3030 | + # least make sure we weren't given a conflicting value, |
3031 | + # since that makes the calling code look confusing. |
3032 | + cls._assert_login_argument_consistency( |
3033 | + 'credential_save_failed', credential_save_failed, |
3034 | + credential_store.credential_save_failed, |
3035 | + "credential_store") |
3036 | + credential_store = credential_store |
3037 | + |
3038 | + if authorization_engine is None: |
3039 | + authorization_engine = cls.authorization_engine_factory( |
3040 | + service_root, application_name, consumer_name, |
3041 | + allow_access_levels) |
3042 | + else: |
3043 | + # An authorization engine was passed in, so we won't be |
3044 | + # using any provided values for application_name, |
3045 | + # consumer_name, or allow_access_levels. But at least make |
3046 | + # sure we weren't given conflicting values, since that |
3047 | + # makes the calling code look confusing. |
3048 | + cls._assert_login_argument_consistency( |
3049 | + "application_name", application_name, |
3050 | + authorization_engine.application_name) |
3051 | + |
3052 | + cls._assert_login_argument_consistency( |
3053 | + "consumer_name", consumer_name, |
3054 | + authorization_engine.consumer.key) |
3055 | + |
3056 | + cls._assert_login_argument_consistency( |
3057 | + "allow_access_levels", allow_access_levels, |
3058 | + authorization_engine.allow_access_levels) |
3059 | + |
3060 | + return cls._authorize_token_and_login( |
3061 | + authorization_engine.consumer, service_root, |
3062 | + cache_path, timeout, proxy_info, authorization_engine, |
3063 | + allow_access_levels, credential_store, |
3064 | + credential_save_failed, version) |
3065 | + |
3066 | + @classmethod |
3067 | + def _warn_of_deprecated_login_method(cls, name): |
3068 | + warnings.warn( |
3069 | + ("The Launchpad.%s() method is deprecated. You should use " |
3070 | + "Launchpad.login_anonymous() for anonymous access and " |
3071 | + "Launchpad.login_with() for all other purposes.") % name, |
3072 | + DeprecationWarning) |
3073 | + |
3074 | + @classmethod |
3075 | + def _assert_login_argument_consistency( |
3076 | + cls, argument_name, argument_value, object_value, |
3077 | + object_name="authorization engine"): |
3078 | + """Helper to find conflicting values passed into the login methods. |
3079 | + |
3080 | + Many of the arguments to login_with are used to build other |
3081 | + objects--the authorization engine or the credential store. If |
3082 | + these objects are provided directly, many of the arguments |
3083 | + become redundant. We'll allow redundant arguments through, but |
3084 | + if a argument *conflicts* with the corresponding value in the |
3085 | + provided object, we raise an error. |
3086 | + """ |
3087 | + inconsistent_value_message = ( |
3088 | + "Inconsistent values given for %s: " |
3089 | + "(%r passed in, versus %r in %s). " |
3090 | + "You don't need to pass in %s if you pass in %s, " |
3091 | + "so just omit that argument.") |
3092 | + if (argument_value is not None and argument_value != object_value): |
3093 | + raise ValueError(inconsistent_value_message % ( |
3094 | + argument_name, argument_value, object_value, |
3095 | + object_name, argument_name, object_name)) |
3096 | + |
3097 | |
3098 | @classmethod |
3099 | def _get_paths(cls, service_root, launchpadlib_dir=None): |
3100 | @@ -320,8 +596,8 @@ |
3101 | launchpadlib_dir = os.path.join(home_dir, '.launchpadlib') |
3102 | launchpadlib_dir = os.path.expanduser(launchpadlib_dir) |
3103 | if not os.path.exists(launchpadlib_dir): |
3104 | - os.makedirs(launchpadlib_dir,0700) |
3105 | - os.chmod(launchpadlib_dir,0700) |
3106 | + os.makedirs(launchpadlib_dir, 0700) |
3107 | + os.chmod(launchpadlib_dir, 0700) |
3108 | # Determine the real service root. |
3109 | service_root = uris.lookup_service_root(service_root) |
3110 | # Each service root has its own cache and credential dirs. |
3111 | @@ -330,5 +606,6 @@ |
3112 | service_root_dir = os.path.join(launchpadlib_dir, host_name) |
3113 | cache_path = os.path.join(service_root_dir, 'cache') |
3114 | if not os.path.exists(cache_path): |
3115 | - os.makedirs(cache_path) |
3116 | + os.makedirs(cache_path, 0700) |
3117 | return (service_root, launchpadlib_dir, cache_path, service_root_dir) |
3118 | + |
3119 | |
3120 | === modified file 'src/launchpadlib/testing/helpers.py' |
3121 | --- src/launchpadlib/testing/helpers.py 2010-12-07 19:30:29 +0000 |
3122 | +++ src/launchpadlib/testing/helpers.py 2011-02-14 21:31:44 +0000 |
3123 | @@ -21,29 +21,152 @@ |
3124 | |
3125 | __metaclass__ = type |
3126 | __all__ = [ |
3127 | + 'BadSaveKeyring', |
3128 | + 'fake_keyring', |
3129 | + 'FauxSocketModule', |
3130 | + 'InMemoryKeyring', |
3131 | + 'NoNetworkAuthorizationEngine', |
3132 | + 'NoNetworkLaunchpad', |
3133 | 'TestableLaunchpad', |
3134 | 'nopriv_read_nonprivate', |
3135 | 'salgado_read_nonprivate', |
3136 | 'salgado_with_full_permissions', |
3137 | ] |
3138 | |
3139 | -import simplejson |
3140 | +from contextlib import contextmanager |
3141 | |
3142 | +import launchpadlib |
3143 | from launchpadlib.launchpad import Launchpad |
3144 | from launchpadlib.credentials import ( |
3145 | - AuthorizeRequestTokenWithBrowser, Credentials, |
3146 | - RequestTokenAuthorizationEngine, SimulatedLaunchpadBrowser) |
3147 | + AccessToken, |
3148 | + Credentials, |
3149 | + RequestTokenAuthorizationEngine, |
3150 | + ) |
3151 | + |
3152 | + |
3153 | +missing = object() |
3154 | + |
3155 | + |
3156 | +def assert_keyring_not_imported(): |
3157 | + assert getattr(launchpadlib.credentials, 'keyring', missing) is missing, ( |
3158 | + 'During tests the real keyring module should never be imported.') |
3159 | + |
3160 | + |
3161 | +class NoNetworkAuthorizationEngine(RequestTokenAuthorizationEngine): |
3162 | + """An authorization engine that doesn't open a web browser. |
3163 | + |
3164 | + You can use this to test the creation of Launchpad objects and the |
3165 | + storing of credentials. You can't use it to interact with the web |
3166 | + service, since it only pretends to authorize its OAuth request tokens. |
3167 | + """ |
3168 | + ACCESS_TOKEN_KEY = "access_key:84" |
3169 | + |
3170 | + def __init__(self, *args, **kwargs): |
3171 | + super(NoNetworkAuthorizationEngine, self).__init__(*args, **kwargs) |
3172 | + # Set up some instrumentation. |
3173 | + self.request_tokens_obtained = 0 |
3174 | + self.access_tokens_obtained = 0 |
3175 | + |
3176 | + def get_request_token(self, credentials): |
3177 | + """Pretend to get a request token from the server. |
3178 | + |
3179 | + We do this by simply returning a static token ID. |
3180 | + """ |
3181 | + self.request_tokens_obtained += 1 |
3182 | + return "request_token:42" |
3183 | + |
3184 | + def make_end_user_authorize_token(self, credentials, request_token): |
3185 | + """Pretend to exchange a request token for an access token. |
3186 | + |
3187 | + We do this by simply setting the access_token property. |
3188 | + """ |
3189 | + credentials.access_token = AccessToken( |
3190 | + self.ACCESS_TOKEN_KEY, 'access_secret:168') |
3191 | + self.access_tokens_obtained += 1 |
3192 | + |
3193 | + |
3194 | +class NoNetworkLaunchpad(Launchpad): |
3195 | + """A Launchpad instance for tests with no network access. |
3196 | + |
3197 | + It's only useful for making sure that certain methods were called. |
3198 | + It can't be used to interact with the API. |
3199 | + """ |
3200 | + |
3201 | + def __init__(self, credentials, authorization_engine, credential_store, |
3202 | + service_root, cache, timeout, proxy_info, version): |
3203 | + self.credentials = credentials |
3204 | + self.authorization_engine = authorization_engine |
3205 | + self.credential_store = credential_store |
3206 | + self.passed_in_args = dict( |
3207 | + service_root=service_root, cache=cache, timeout=timeout, |
3208 | + proxy_info=proxy_info, version=version) |
3209 | + |
3210 | + @classmethod |
3211 | + def authorization_engine_factory(cls, *args): |
3212 | + return NoNetworkAuthorizationEngine(*args) |
3213 | |
3214 | |
3215 | class TestableLaunchpad(Launchpad): |
3216 | """A base class for talking to the testing root service.""" |
3217 | |
3218 | - def __init__(self, credentials, service_root=None, |
3219 | + def __init__(self, credentials, authorization_engine=None, |
3220 | + credential_store=None, service_root="test_dev", |
3221 | cache=None, timeout=None, proxy_info=None, |
3222 | version=Launchpad.DEFAULT_VERSION): |
3223 | + """Provide test-friendly defaults. |
3224 | + |
3225 | + :param authorization_engine: Defaults to None, since a test |
3226 | + environment can't use an authorization engine. |
3227 | + :param credential_store: Defaults to None, since tests |
3228 | + generally pass in fully-formed Credentials objects. |
3229 | + :param service_root: Defaults to 'test_dev'. |
3230 | + """ |
3231 | super(TestableLaunchpad, self).__init__( |
3232 | - credentials, 'test_dev', cache, timeout, proxy_info, |
3233 | - version=version) |
3234 | + credentials, authorization_engine, credential_store, |
3235 | + service_root=service_root, cache=cache, timeout=timeout, |
3236 | + proxy_info=proxy_info, version=version) |
3237 | + |
3238 | + |
3239 | +@contextmanager |
3240 | +def fake_keyring(fake): |
3241 | + """A context manager which injects a testing keyring implementation.""" |
3242 | + # The real keyring package should never be imported during tests. |
3243 | + assert_keyring_not_imported() |
3244 | + launchpadlib.credentials.keyring = fake |
3245 | + try: |
3246 | + yield |
3247 | + finally: |
3248 | + del launchpadlib.credentials.keyring |
3249 | + |
3250 | + |
3251 | +class FauxSocketModule: |
3252 | + """A socket module replacement that provides a fake hostname.""" |
3253 | + |
3254 | + def gethostname(self): |
3255 | + return 'HOSTNAME' |
3256 | + |
3257 | + |
3258 | +class BadSaveKeyring: |
3259 | + """A keyring that generates errors when saving passwords.""" |
3260 | + |
3261 | + def get_password(self, service, username): |
3262 | + return None |
3263 | + |
3264 | + def set_password(self, service, username, password): |
3265 | + raise RuntimeError |
3266 | + |
3267 | + |
3268 | +class InMemoryKeyring: |
3269 | + """A keyring that saves passwords only in memory.""" |
3270 | + |
3271 | + def __init__(self): |
3272 | + self.data = {} |
3273 | + |
3274 | + def set_password(self, service, username, password): |
3275 | + self.data[service, username] = password |
3276 | + |
3277 | + def get_password(self, service, username): |
3278 | + return self.data.get((service, username)) |
3279 | |
3280 | |
3281 | class KnownTokens: |
3282 | @@ -52,119 +175,18 @@ |
3283 | def __init__(self, token_string, access_secret): |
3284 | self.token_string = token_string |
3285 | self.access_secret = access_secret |
3286 | + self.token = AccessToken(token_string, access_secret) |
3287 | + self.credentials = Credentials( |
3288 | + consumer_name="launchpad-library", access_token=self.token) |
3289 | |
3290 | def login(self, cache=None, timeout=None, proxy_info=None, |
3291 | version=Launchpad.DEFAULT_VERSION): |
3292 | - """Login using these credentials.""" |
3293 | - return TestableLaunchpad.login( |
3294 | - 'launchpad-library', self.token_string, self.access_secret, |
3295 | - cache=cache, timeout=timeout, proxy_info=proxy_info, |
3296 | - version=version) |
3297 | + """Create a Launchpad object using these credentials.""" |
3298 | + return TestableLaunchpad( |
3299 | + self.credentials, cache=cache, timeout=timeout, |
3300 | + proxy_info=proxy_info, version=version) |
3301 | |
3302 | |
3303 | salgado_with_full_permissions = KnownTokens('salgado-change-anything', 'test') |
3304 | salgado_read_nonprivate = KnownTokens('salgado-read-nonprivate', 'secret') |
3305 | nopriv_read_nonprivate = KnownTokens('nopriv-read-nonprivate', 'mystery') |
3306 | - |
3307 | - |
3308 | -class ScriptableRequestTokenAuthorization(RequestTokenAuthorizationEngine): |
3309 | - """A request token process that doesn't need any user input. |
3310 | - |
3311 | - The RequestTokenAuthorizationEngine is supposed to be hooked up to a |
3312 | - user interface, but that makes it difficult to test. This subclass |
3313 | - is designed to be easy to test. |
3314 | - """ |
3315 | - |
3316 | - def __init__(self, consumer_name, username, password, choose_access_level, |
3317 | - allow_access_levels=[], max_failed_attempts=2, |
3318 | - web_root="http://launchpad.dev:8085/"): |
3319 | - |
3320 | - # Get a request token. |
3321 | - self.credentials = Credentials(consumer_name) |
3322 | - self.credentials.get_request_token(web_root=web_root) |
3323 | - |
3324 | - # Initialize the superclass with the new request token. |
3325 | - super(ScriptableRequestTokenAuthorization, self).__init__( |
3326 | - web_root, consumer_name, self.credentials._request_token.key, |
3327 | - allow_access_levels, max_failed_attempts) |
3328 | - |
3329 | - self.username = username |
3330 | - self.password = password |
3331 | - self.choose_access_level = choose_access_level |
3332 | - |
3333 | - def __call__(self, exchange_for_access_token=True): |
3334 | - super(ScriptableRequestTokenAuthorization, self).__call__() |
3335 | - |
3336 | - # Now verify that it worked by exchanging the authorized |
3337 | - # request token for an access token. |
3338 | - if (exchange_for_access_token and |
3339 | - self.choose_access_level != self.UNAUTHORIZED_ACCESS_LEVEL): |
3340 | - self.credentials.exchange_request_token_for_access_token( |
3341 | - web_root=self.web_root) |
3342 | - return self.credentials.access_token |
3343 | - return None |
3344 | - |
3345 | - def open_page_in_user_browser(self, url): |
3346 | - """Print a status message.""" |
3347 | - print ("[If this were a real application, the end-user's web " |
3348 | - "browser would be opened to %s]" % url) |
3349 | - |
3350 | - def input_username(self, cached_username, suggested_message): |
3351 | - """Collect the Launchpad username from the end-user.""" |
3352 | - print suggested_message |
3353 | - if cached_username is not None: |
3354 | - print "Cached email address: " + cached_username |
3355 | - return self.username |
3356 | - |
3357 | - def input_password(self, suggested_message): |
3358 | - """Collect the Launchpad password from the end-user.""" |
3359 | - print suggested_message |
3360 | - return self.password |
3361 | - |
3362 | - def input_access_level(self, available_levels, suggested_message, |
3363 | - only_one_option): |
3364 | - """Collect the desired level of access from the end-user.""" |
3365 | - print suggested_message |
3366 | - print [level['value'] for level in available_levels] |
3367 | - return self.choose_access_level |
3368 | - |
3369 | - def startup(self, suggested_messages): |
3370 | - for message in suggested_messages: |
3371 | - print message |
3372 | - |
3373 | - |
3374 | -class DummyAuthorizeRequestTokenWithBrowser(AuthorizeRequestTokenWithBrowser): |
3375 | - |
3376 | - def __init__(self, web_root, consumer_name, request_token, username, |
3377 | - password, allow_access_levels=[], max_failed_attempts=3): |
3378 | - super(DummyAuthorizeRequestTokenWithBrowser, self).__init__( |
3379 | - web_root, consumer_name, request_token, allow_access_levels, |
3380 | - max_failed_attempts) |
3381 | - |
3382 | - def open_page_in_user_browser(self, url): |
3383 | - """Print a status message.""" |
3384 | - print ("[If this were a real application, the end-user's web " |
3385 | - "browser would be opened to %s]" % url) |
3386 | - |
3387 | - |
3388 | -class UserInput(object): |
3389 | - """A class to store fake user input in a readable way. |
3390 | - |
3391 | - An instance of this class can be used as a substitute for the |
3392 | - raw_input() function. |
3393 | - """ |
3394 | - |
3395 | - def __init__(self, inputs): |
3396 | - """Initialize with a line of user inputs.""" |
3397 | - self.stream = iter(inputs) |
3398 | - |
3399 | - def __call__(self, prompt): |
3400 | - """Print and return the next line of input.""" |
3401 | - line = self.readline() |
3402 | - print prompt + "[User input: %s]" % line |
3403 | - return line |
3404 | - |
3405 | - def readline(self): |
3406 | - """Return the next line of input.""" |
3407 | - next_input = self.stream.next() |
3408 | - return str(next_input) |
3409 | |
3410 | === added file 'src/launchpadlib/tests/test_credential_store.py' |
3411 | --- src/launchpadlib/tests/test_credential_store.py 1970-01-01 00:00:00 +0000 |
3412 | +++ src/launchpadlib/tests/test_credential_store.py 2011-02-14 21:31:44 +0000 |
3413 | @@ -0,0 +1,138 @@ |
3414 | +# Copyright 2010 Canonical Ltd. |
3415 | + |
3416 | +# This file is part of launchpadlib. |
3417 | +# |
3418 | +# launchpadlib is free software: you can redistribute it and/or modify it |
3419 | +# under the terms of the GNU Lesser General Public License as published by the |
3420 | +# Free Software Foundation, version 3 of the License. |
3421 | +# |
3422 | +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
3423 | +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
3424 | +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
3425 | +# for more details. |
3426 | +# |
3427 | +# You should have received a copy of the GNU Lesser General Public License |
3428 | +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
3429 | + |
3430 | +"""Tests for the credential store classes.""" |
3431 | + |
3432 | +import os |
3433 | +import tempfile |
3434 | +import unittest |
3435 | + |
3436 | +from launchpadlib.testing.helpers import ( |
3437 | + fake_keyring, |
3438 | + InMemoryKeyring, |
3439 | +) |
3440 | + |
3441 | +from launchpadlib.credentials import ( |
3442 | + AccessToken, |
3443 | + Credentials, |
3444 | + KeyringCredentialStore, |
3445 | + UnencryptedFileCredentialStore, |
3446 | +) |
3447 | + |
3448 | + |
3449 | +class CredentialStoreTestCase(unittest.TestCase): |
3450 | + |
3451 | + def make_credential(self, consumer_key): |
3452 | + """Helper method to make a fake credential.""" |
3453 | + return Credentials( |
3454 | + "app name", consumer_secret='consumer_secret:42', |
3455 | + access_token=AccessToken(consumer_key, 'access_secret:168')) |
3456 | + |
3457 | + |
3458 | +class TestUnencryptedFileCredentialStore(CredentialStoreTestCase): |
3459 | + """Tests for the UnencryptedFileCredentialStore class.""" |
3460 | + |
3461 | + def setUp(self): |
3462 | + ignore, self.filename = tempfile.mkstemp() |
3463 | + self.store = UnencryptedFileCredentialStore(self.filename) |
3464 | + |
3465 | + def tearDown(self): |
3466 | + if os.path.exists(self.filename): |
3467 | + os.remove(self.filename) |
3468 | + |
3469 | + def test_save_and_load(self): |
3470 | + # Make sure you can save and load credentials to a file. |
3471 | + credential = self.make_credential("consumer key") |
3472 | + self.store.save(credential, "unique key") |
3473 | + credential2 = self.store.load("unique key") |
3474 | + self.assertEquals(credential.consumer.key, credential2.consumer.key) |
3475 | + |
3476 | + def test_unique_id_doesnt_matter(self): |
3477 | + # If a file contains a credential, that credential will be |
3478 | + # accessed no matter what unique ID you specify. |
3479 | + credential = self.make_credential("consumer key") |
3480 | + self.store.save(credential, "some key") |
3481 | + credential2 = self.store.load("some other key") |
3482 | + self.assertEquals(credential.consumer.key, credential2.consumer.key) |
3483 | + |
3484 | + def test_file_only_contains_one_credential(self): |
3485 | + # A credential file may contain only one credential. If you |
3486 | + # write two credentials with different unique IDs to the same |
3487 | + # file, the first credential will be overwritten with the |
3488 | + # second. |
3489 | + credential1 = self.make_credential("consumer key") |
3490 | + credential2 = self.make_credential("consumer key2") |
3491 | + self.store.save(credential1, "unique key 1") |
3492 | + self.store.save(credential1, "unique key 2") |
3493 | + loaded = self.store.load("unique key 1") |
3494 | + self.assertEquals(loaded.consumer.key, credential2.consumer.key) |
3495 | + |
3496 | + |
3497 | +class TestKeyringCredentialStore(CredentialStoreTestCase): |
3498 | + """Tests for the KeyringCredentialStore class.""" |
3499 | + |
3500 | + def setUp(self): |
3501 | + self.keyring = InMemoryKeyring() |
3502 | + self.store = KeyringCredentialStore() |
3503 | + |
3504 | + def test_save_and_load(self): |
3505 | + # Make sure you can save and load credentials to a keyring. |
3506 | + with fake_keyring(self.keyring): |
3507 | + credential = self.make_credential("consumer key") |
3508 | + self.store.save(credential, "unique key") |
3509 | + credential2 = self.store.load("unique key") |
3510 | + self.assertEquals( |
3511 | + credential.consumer.key, credential2.consumer.key) |
3512 | + |
3513 | + def test_lookup_by_unique_key(self): |
3514 | + # Credentials in the keyring are looked up by the unique ID |
3515 | + # under which they were stored. |
3516 | + with fake_keyring(self.keyring): |
3517 | + credential1 = self.make_credential("consumer key1") |
3518 | + self.store.save(credential1, "key 1") |
3519 | + |
3520 | + credential2 = self.make_credential("consumer key2") |
3521 | + self.store.save(credential2, "key 2") |
3522 | + |
3523 | + loaded1 = self.store.load("key 1") |
3524 | + self.assertEquals( |
3525 | + credential1.consumer.key, loaded1.consumer.key) |
3526 | + |
3527 | + loaded2 = self.store.load("key 2") |
3528 | + self.assertEquals( |
3529 | + credential2.consumer.key, loaded2.consumer.key) |
3530 | + |
3531 | + def test_reused_unique_id_overwrites_old_credential(self): |
3532 | + # Writing a credential to the keyring with a given unique ID |
3533 | + # will overwrite any credential stored under that ID. |
3534 | + |
3535 | + with fake_keyring(self.keyring): |
3536 | + credential1 = self.make_credential("consumer key1") |
3537 | + self.store.save(credential1, "the only key") |
3538 | + |
3539 | + credential2 = self.make_credential("consumer key2") |
3540 | + self.store.save(credential2, "the only key") |
3541 | + |
3542 | + loaded = self.store.load("the only key") |
3543 | + self.assertEquals( |
3544 | + credential2.consumer.key, loaded.consumer.key) |
3545 | + |
3546 | + def test_bad_unique_id_returns_none(self): |
3547 | + # Trying to load a credential without providing a good unique |
3548 | + # ID will get you None. |
3549 | + with fake_keyring(self.keyring): |
3550 | + self.assertEquals(None, self.store.load("no such key")) |
3551 | + |
3552 | |
3553 | === added file 'src/launchpadlib/tests/test_http.py' |
3554 | --- src/launchpadlib/tests/test_http.py 1970-01-01 00:00:00 +0000 |
3555 | +++ src/launchpadlib/tests/test_http.py 2011-02-14 21:31:44 +0000 |
3556 | @@ -0,0 +1,236 @@ |
3557 | +# Copyright 2010 Canonical Ltd. |
3558 | + |
3559 | +# This file is part of launchpadlib. |
3560 | +# |
3561 | +# launchpadlib is free software: you can redistribute it and/or modify it |
3562 | +# under the terms of the GNU Lesser General Public License as published by the |
3563 | +# Free Software Foundation, version 3 of the License. |
3564 | +# |
3565 | +# launchpadlib is distributed in the hope that it will be useful, but WITHOUT |
3566 | +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
3567 | +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License |
3568 | +# for more details. |
3569 | +# |
3570 | +# You should have received a copy of the GNU Lesser General Public License |
3571 | +# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>. |
3572 | + |
3573 | +"""Tests for the LaunchpadOAuthAwareHTTP class.""" |
3574 | + |
3575 | +from collections import deque |
3576 | +import tempfile |
3577 | +import unittest |
3578 | + |
3579 | +from simplejson import dumps, JSONDecodeError |
3580 | + |
3581 | +from launchpadlib.errors import Unauthorized |
3582 | +from launchpadlib.credentials import UnencryptedFileCredentialStore |
3583 | +from launchpadlib.launchpad import ( |
3584 | + Launchpad, |
3585 | + LaunchpadOAuthAwareHttp, |
3586 | + ) |
3587 | +from launchpadlib.testing.helpers import ( |
3588 | + NoNetworkAuthorizationEngine, |
3589 | + NoNetworkLaunchpad, |
3590 | + ) |
3591 | + |
3592 | + |
3593 | +# The simplest WADL that looks like a representation of the service root. |
3594 | +SIMPLE_WADL = '''<?xml version="1.0"?> |
3595 | +<application xmlns="http://research.sun.com/wadl/2006/10"> |
3596 | + <resources base="http://www.example.com/"> |
3597 | + <resource path="" type="#service-root"/> |
3598 | + </resources> |
3599 | + |
3600 | + <resource_type id="service-root"> |
3601 | + <method name="GET" id="service-root-get"> |
3602 | + <response> |
3603 | + <representation href="#service-root-json"/> |
3604 | + </response> |
3605 | + </method> |
3606 | + </resource_type> |
3607 | + |
3608 | + <representation id="service-root-json" mediaType="application/json"/> |
3609 | +</application> |
3610 | +''' |
3611 | + |
3612 | +# The simplest JSON that looks like a representation of the service root. |
3613 | +SIMPLE_JSON = dumps({}) |
3614 | + |
3615 | + |
3616 | +class Response: |
3617 | + """A fake HTTP response object.""" |
3618 | + def __init__(self, status, content): |
3619 | + self.status = status |
3620 | + self.content = content |
3621 | + |
3622 | + |
3623 | +class SimulatedResponsesHttp(LaunchpadOAuthAwareHttp): |
3624 | + """Responds to HTTP requests by shifting responses off a stack.""" |
3625 | + |
3626 | + def __init__(self, responses, *args): |
3627 | + """Constructor. |
3628 | + |
3629 | + :param responses: A list of HttpResponse objects to use |
3630 | + in response to requests. |
3631 | + """ |
3632 | + super(SimulatedResponsesHttp, self).__init__(*args) |
3633 | + self.sent_responses = [] |
3634 | + self.unsent_responses = responses |
3635 | + self.cache = None |
3636 | + |
3637 | + def _request(self, *args): |
3638 | + response = self.unsent_responses.popleft() |
3639 | + self.sent_responses.append(response) |
3640 | + return self.retry_on_bad_token(response, response.content, *args) |
3641 | + |
3642 | + |
3643 | +class SimulatedResponsesLaunchpad(Launchpad): |
3644 | + |
3645 | + # Every Http object generated by this class will return these |
3646 | + # responses, in order. |
3647 | + responses = [] |
3648 | + |
3649 | + def httpFactory(self, *args): |
3650 | + return SimulatedResponsesHttp( |
3651 | + deque(self.responses), self, self.authorization_engine, *args) |
3652 | + |
3653 | + @classmethod |
3654 | + def credential_store_factory(cls, credential_save_failed): |
3655 | + return UnencryptedFileCredentialStore( |
3656 | + tempfile.mkstemp()[1], credential_save_failed) |
3657 | + |
3658 | + |
3659 | +class SimulatedResponsesTestCase(unittest.TestCase): |
3660 | + """Test cases that give fake responses to launchpad's HTTP requests.""" |
3661 | + |
3662 | + def setUp(self): |
3663 | + """Clear out the list of simulated responses.""" |
3664 | + SimulatedResponsesLaunchpad.responses = [] |
3665 | + self.engine = NoNetworkAuthorizationEngine( |
3666 | + 'http://api.example.com/', 'application name') |
3667 | + |
3668 | + def launchpad_with_responses(self, *responses): |
3669 | + """Use simulated HTTP responses to get a Launchpad object. |
3670 | + |
3671 | + The given Response objects will be sent, in order, in response |
3672 | + to launchpadlib's requests. |
3673 | + |
3674 | + :param responses: Some number of Response objects. |
3675 | + :return: The Launchpad object, assuming that errors in the |
3676 | + simulated requests didn't prevent one from being created. |
3677 | + """ |
3678 | + SimulatedResponsesLaunchpad.responses = responses |
3679 | + return SimulatedResponsesLaunchpad.login_with( |
3680 | + 'application name', authorization_engine=self.engine) |
3681 | + |
3682 | + |
3683 | +class TestAbilityToParseData(SimulatedResponsesTestCase): |
3684 | + """Test launchpadlib's ability to handle the sample data. |
3685 | + |
3686 | + To create a Launchpad object, two HTTP requests must succeed and |
3687 | + return usable data: the requests for the WADL and JSON |
3688 | + representations of the service root. This test shows that the |
3689 | + minimal data in SIMPLE_WADL and SIMPLE_JSON is good enough to |
3690 | + create a Launchpad object. |
3691 | + """ |
3692 | + |
3693 | + def test_minimal_data(self): |
3694 | + """Make sure that launchpadlib can use the minimal data.""" |
3695 | + launchpad = self.launchpad_with_responses( |
3696 | + Response(200, SIMPLE_WADL), |
3697 | + Response(200, SIMPLE_JSON)) |
3698 | + |
3699 | + def test_bad_wadl(self): |
3700 | + """Show that bad WADL causes an exception.""" |
3701 | + self.assertRaises( |
3702 | + SyntaxError, self.launchpad_with_responses, |
3703 | + Response(200, "This is not WADL."), |
3704 | + Response(200, SIMPLE_JSON)) |
3705 | + |
3706 | + def test_bad_json(self): |
3707 | + """Show that bad JSON causes an exception.""" |
3708 | + self.assertRaises( |
3709 | + JSONDecodeError, self.launchpad_with_responses, |
3710 | + Response(200, SIMPLE_WADL), |
3711 | + Response(200, "This is not JSON.")) |
3712 | + |
3713 | + |
3714 | +class TestTokenFailureDuringRequest(SimulatedResponsesTestCase): |
3715 | + """Test access token failures during a request. |
3716 | + |
3717 | + launchpadlib makes two HTTP requests on startup, to get the WADL |
3718 | + and JSON representations of the service root. If Launchpad |
3719 | + receives a 401 error during this process, it will acquire a fresh |
3720 | + access token and try again. |
3721 | + """ |
3722 | + |
3723 | + def test_good_token(self): |
3724 | + """If our token is good, we never get another one.""" |
3725 | + SimulatedResponsesLaunchpad.responses = [ |
3726 | + Response(200, SIMPLE_WADL), |
3727 | + Response(200, SIMPLE_JSON)] |
3728 | + |
3729 | + self.assertEquals(self.engine.access_tokens_obtained, 0) |
3730 | + launchpad = SimulatedResponsesLaunchpad.login_with( |
3731 | + 'application name', authorization_engine=self.engine) |
3732 | + self.assertEquals(self.engine.access_tokens_obtained, 1) |
3733 | + |
3734 | + def test_bad_token(self): |
3735 | + """If our token is bad, we get another one.""" |
3736 | + SimulatedResponsesLaunchpad.responses = [ |
3737 | + Response(401, "Invalid token."), |
3738 | + Response(200, SIMPLE_WADL), |
3739 | + Response(200, SIMPLE_JSON)] |
3740 | + |
3741 | + self.assertEquals(self.engine.access_tokens_obtained, 0) |
3742 | + launchpad = SimulatedResponsesLaunchpad.login_with( |
3743 | + 'application name', authorization_engine=self.engine) |
3744 | + self.assertEquals(self.engine.access_tokens_obtained, 2) |
3745 | + |
3746 | + def test_expired_token(self): |
3747 | + """If our token is expired, we get another one.""" |
3748 | + |
3749 | + SimulatedResponsesLaunchpad.responses = [ |
3750 | + Response(401, "Expired token."), |
3751 | + Response(200, SIMPLE_WADL), |
3752 | + Response(200, SIMPLE_JSON)] |
3753 | + |
3754 | + self.assertEquals(self.engine.access_tokens_obtained, 0) |
3755 | + launchpad = SimulatedResponsesLaunchpad.login_with( |
3756 | + 'application name', authorization_engine=self.engine) |
3757 | + self.assertEquals(self.engine.access_tokens_obtained, 2) |
3758 | + |
3759 | + def test_delayed_error(self): |
3760 | + """We get another token no matter when the error happens.""" |
3761 | + SimulatedResponsesLaunchpad.responses = [ |
3762 | + Response(200, SIMPLE_WADL), |
3763 | + Response(401, "Expired token."), |
3764 | + Response(200, SIMPLE_JSON)] |
3765 | + |
3766 | + self.assertEquals(self.engine.access_tokens_obtained, 0) |
3767 | + launchpad = SimulatedResponsesLaunchpad.login_with( |
3768 | + 'application name', authorization_engine=self.engine) |
3769 | + self.assertEquals(self.engine.access_tokens_obtained, 2) |
3770 | + |
3771 | + def test_many_errors(self): |
3772 | + """We'll keep getting new tokens as long as tokens are the problem.""" |
3773 | + SimulatedResponsesLaunchpad.responses = [ |
3774 | + Response(401, "Invalid token."), |
3775 | + Response(200, SIMPLE_WADL), |
3776 | + Response(401, "Expired token."), |
3777 | + Response(401, "Invalid token."), |
3778 | + Response(200, SIMPLE_JSON)] |
3779 | + self.assertEquals(self.engine.access_tokens_obtained, 0) |
3780 | + launchpad = SimulatedResponsesLaunchpad.login_with( |
3781 | + 'application name', authorization_engine=self.engine) |
3782 | + self.assertEquals(self.engine.access_tokens_obtained, 4) |
3783 | + |
3784 | + def test_other_unauthorized(self): |
3785 | + """If the token is not at fault, a 401 error raises an exception.""" |
3786 | + |
3787 | + SimulatedResponsesLaunchpad.responses = [ |
3788 | + Response(401, "Some other error.")] |
3789 | + |
3790 | + self.assertRaises( |
3791 | + Unauthorized, SimulatedResponsesLaunchpad.login_with, |
3792 | + 'application name', authorization_engine=self.engine) |
3793 | |
3794 | === modified file 'src/launchpadlib/tests/test_launchpad.py' |
3795 | --- src/launchpadlib/tests/test_launchpad.py 2010-12-07 19:30:29 +0000 |
3796 | +++ src/launchpadlib/tests/test_launchpad.py 2011-02-14 21:31:44 +0000 |
3797 | @@ -20,42 +20,47 @@ |
3798 | |
3799 | import os |
3800 | import shutil |
3801 | +import socket |
3802 | import stat |
3803 | import tempfile |
3804 | import unittest |
3805 | +import warnings |
3806 | + |
3807 | +from lazr.restfulclient.resource import ServiceRoot |
3808 | |
3809 | from launchpadlib.credentials import ( |
3810 | - AccessToken, AuthorizeRequestTokenWithBrowser, Credentials) |
3811 | + AccessToken, |
3812 | + Credentials, |
3813 | + ) |
3814 | + |
3815 | +from launchpadlib import uris |
3816 | +import launchpadlib.launchpad |
3817 | from launchpadlib.launchpad import Launchpad |
3818 | -from launchpadlib import uris |
3819 | - |
3820 | -class NoNetworkLaunchpad(Launchpad): |
3821 | - """A Launchpad instance for tests with no network access. |
3822 | - |
3823 | - It's only useful for making sure that certain methods were called. |
3824 | - It can't be used to interact with the API. |
3825 | - """ |
3826 | - |
3827 | - consumer_name = None |
3828 | - passed_in_kwargs = None |
3829 | - credentials = None |
3830 | - get_token_and_login_called = False |
3831 | - |
3832 | - def __init__(self, credentials, **kw): |
3833 | - self.credentials = credentials |
3834 | - self.passed_in_kwargs = kw |
3835 | - |
3836 | - @classmethod |
3837 | - def get_token_and_login(cls, consumer_name, **kw): |
3838 | - """Create fake credentials and record that we were called.""" |
3839 | - credentials = Credentials( |
3840 | - consumer_name, consumer_secret='consumer_secret:42', |
3841 | - access_token=AccessToken('access_key:84', 'access_secret:168')) |
3842 | - launchpad = cls(credentials, **kw) |
3843 | - launchpad.get_token_and_login_called = True |
3844 | - launchpad.consumer_name = consumer_name |
3845 | - launchpad.passed_in_kwargs = kw |
3846 | - return launchpad |
3847 | +from launchpadlib.testing.helpers import ( |
3848 | + assert_keyring_not_imported, |
3849 | + BadSaveKeyring, |
3850 | + fake_keyring, |
3851 | + FauxSocketModule, |
3852 | + InMemoryKeyring, |
3853 | + NoNetworkAuthorizationEngine, |
3854 | + NoNetworkLaunchpad, |
3855 | + ) |
3856 | +from launchpadlib.credentials import ( |
3857 | + KeyringCredentialStore, |
3858 | + UnencryptedFileCredentialStore, |
3859 | + ) |
3860 | + |
3861 | +# A dummy service root for use in tests |
3862 | +SERVICE_ROOT = "http://api.example.com/" |
3863 | + |
3864 | +class TestResourceTypeClasses(unittest.TestCase): |
3865 | + """launchpadlib must know about restfulclient's resource types.""" |
3866 | + |
3867 | + def test_resource_types(self): |
3868 | + # Make sure that Launchpad knows about every special resource |
3869 | + # class defined by lazr.restfulclient. |
3870 | + for name, cls in ServiceRoot.RESOURCE_TYPE_CLASSES.items(): |
3871 | + self.assertEqual(Launchpad.RESOURCE_TYPE_CLASSES[name], cls) |
3872 | |
3873 | |
3874 | class TestNameLookups(unittest.TestCase): |
3875 | @@ -63,23 +68,54 @@ |
3876 | |
3877 | def setUp(self): |
3878 | self.aliases = sorted( |
3879 | - ['production', 'edge', 'staging', 'dogfood', 'dev', 'test_dev']) |
3880 | + ['production', 'qastaging', 'staging', 'dogfood', 'dev', |
3881 | + 'test_dev', 'edge']) |
3882 | |
3883 | def test_short_names(self): |
3884 | # Ensure the short service names are all supported. |
3885 | self.assertEqual(sorted(uris.service_roots.keys()), self.aliases) |
3886 | self.assertEqual(sorted(uris.web_roots.keys()), self.aliases) |
3887 | |
3888 | + def test_edge_service_root_is_production(self): |
3889 | + # The edge server no longer exists, so if the client wants |
3890 | + # edge we give them production. |
3891 | + with warnings.catch_warnings(record=True) as caught: |
3892 | + warnings.simplefilter("always") |
3893 | + self.assertEqual(uris.lookup_service_root('edge'), |
3894 | + uris.lookup_service_root('production')) |
3895 | + |
3896 | + # The lookup caused a deprecation warning. |
3897 | + self.assertEqual(len(caught), 1) |
3898 | + warning, = caught |
3899 | + self.assertTrue(issubclass(warning.category, DeprecationWarning)) |
3900 | + self.assertTrue("no longer exists" in warning.message.message) |
3901 | + |
3902 | + def test_edge_service_root_is_production(self): |
3903 | + # The edge server no longer exists, so if the client wants |
3904 | + # edge we give them production. |
3905 | + with warnings.catch_warnings(record=True) as caught: |
3906 | + warnings.simplefilter("always") |
3907 | + self.assertEqual(uris.lookup_web_root('edge'), |
3908 | + uris.lookup_web_root('production')) |
3909 | + |
3910 | + # The lookup caused a deprecation warning. |
3911 | + self.assertEqual(len(caught), 1) |
3912 | + warning, = caught |
3913 | + self.assertTrue(issubclass(warning.category, DeprecationWarning)) |
3914 | + self.assertTrue("no longer exists" in warning.message.message) |
3915 | + |
3916 | def test_lookups(self): |
3917 | """Ensure that short service names turn into long service names.""" |
3918 | |
3919 | # If the service name is a known alias, lookup methods convert |
3920 | # it to a URL. |
3921 | - for alias in self.aliases: |
3922 | - self.assertEqual( |
3923 | - uris.lookup_service_root(alias), uris.service_roots[alias]) |
3924 | - self.assertEqual( |
3925 | - uris.lookup_web_root(alias), uris.web_roots[alias]) |
3926 | + with warnings.catch_warnings(): |
3927 | + warnings.simplefilter("ignore") |
3928 | + for alias in self.aliases: |
3929 | + self.assertEqual( |
3930 | + uris.lookup_service_root(alias), uris.service_roots[alias]) |
3931 | + self.assertEqual( |
3932 | + uris.lookup_web_root(alias), uris.web_roots[alias]) |
3933 | |
3934 | # If the service name is a valid URL, lookup methods let it |
3935 | # through. |
3936 | @@ -115,7 +151,7 @@ |
3937 | version = "version-foo" |
3938 | root = uris.service_roots['staging'] + version |
3939 | try: |
3940 | - Launchpad(None, root, version=version) |
3941 | + Launchpad(None, None, None, service_root=root, version=version) |
3942 | except ValueError, e: |
3943 | self.assertTrue(str(e).startswith( |
3944 | "It looks like you're using a service root that incorporates " |
3945 | @@ -127,42 +163,115 @@ |
3946 | # Make sure the problematic URL is caught even if it has a |
3947 | # slash on the end. |
3948 | root += '/' |
3949 | - self.assertRaises(ValueError, Launchpad, None, root, version=version) |
3950 | + self.assertRaises(ValueError, Launchpad, None, None, None, |
3951 | + service_root=root, version=version) |
3952 | |
3953 | # Test that the default version has the same problem |
3954 | # when no explicit version is specified |
3955 | default_version = NoNetworkLaunchpad.DEFAULT_VERSION |
3956 | root = uris.service_roots['staging'] + default_version + '/' |
3957 | - self.assertRaises(ValueError, Launchpad, None, root) |
3958 | - |
3959 | - |
3960 | -class TestLaunchpadLoginWith(unittest.TestCase): |
3961 | + self.assertRaises(ValueError, Launchpad, None, None, None, |
3962 | + service_root=root) |
3963 | + |
3964 | + |
3965 | +class TestRequestTokenAuthorizationEngine(unittest.TestCase): |
3966 | + """Tests for the RequestTokenAuthorizationEngine class.""" |
3967 | + |
3968 | + def test_app_must_be_identified(self): |
3969 | + self.assertRaises( |
3970 | + ValueError, NoNetworkAuthorizationEngine, SERVICE_ROOT) |
3971 | + |
3972 | + def test_application_name_identifies_app(self): |
3973 | + NoNetworkAuthorizationEngine(SERVICE_ROOT, application_name='name') |
3974 | + |
3975 | + def test_consumer_name_identifies_app(self): |
3976 | + NoNetworkAuthorizationEngine(SERVICE_ROOT, consumer_name='name') |
3977 | + |
3978 | + def test_conflicting_app_identification(self): |
3979 | + # You can't specify both application_name and consumer_name. |
3980 | + self.assertRaises( |
3981 | + ValueError, NoNetworkAuthorizationEngine, |
3982 | + SERVICE_ROOT, application_name='name1', consumer_name='name2') |
3983 | + |
3984 | + # This holds true even if you specify the same value for |
3985 | + # both. They're not the same thing. |
3986 | + self.assertRaises( |
3987 | + ValueError, NoNetworkAuthorizationEngine, |
3988 | + SERVICE_ROOT, application_name='name', consumer_name='name') |
3989 | + |
3990 | + |
3991 | +class TestLaunchpadLoginWithCredentialsFile(unittest.TestCase): |
3992 | + """Tests for Launchpad.login_with() with a credentials file.""" |
3993 | + |
3994 | + def test_filename(self): |
3995 | + ignore, filename = tempfile.mkstemp() |
3996 | + launchpad = NoNetworkLaunchpad.login_with( |
3997 | + application_name='not important', credentials_file=filename) |
3998 | + |
3999 | + # The credentials are stored unencrypted in the file you |
4000 | + # specify. |
4001 | + credentials = Credentials.load_from_path(filename) |
4002 | + self.assertEquals(credentials.consumer.key, |
4003 | + launchpad.credentials.consumer.key) |
4004 | + os.remove(filename) |
4005 | + |
4006 | + def test_cannot_specify_both_filename_and_store(self): |
4007 | + ignore, filename = tempfile.mkstemp() |
4008 | + store = KeyringCredentialStore() |
4009 | + self.assertRaises( |
4010 | + ValueError, NoNetworkLaunchpad.login_with, |
4011 | + application_name='not important', credentials_file=filename, |
4012 | + credential_store=store) |
4013 | + os.remove(filename) |
4014 | + |
4015 | + |
4016 | +class KeyringTest(unittest.TestCase): |
4017 | + """Base class for tests that use the keyring.""" |
4018 | + |
4019 | + def setUp(self): |
4020 | + # The real keyring package should never be imported during tests. |
4021 | + assert_keyring_not_imported() |
4022 | + # For these tests we want to use a dummy keyring implementation |
4023 | + # that only stores data in memory. |
4024 | + launchpadlib.credentials.keyring = InMemoryKeyring() |
4025 | + |
4026 | + def tearDown(self): |
4027 | + # Remove the fake keyring module we injected during setUp. |
4028 | + del launchpadlib.credentials.keyring |
4029 | + |
4030 | + |
4031 | +class TestLaunchpadLoginWith(KeyringTest): |
4032 | """Tests for Launchpad.login_with().""" |
4033 | |
4034 | def setUp(self): |
4035 | + super(TestLaunchpadLoginWith, self).setUp() |
4036 | self.temp_dir = tempfile.mkdtemp() |
4037 | |
4038 | def tearDown(self): |
4039 | + super(TestLaunchpadLoginWith, self).tearDown() |
4040 | shutil.rmtree(self.temp_dir) |
4041 | |
4042 | def test_dirs_created(self): |
4043 | # The path we pass into login_with() is the directory where |
4044 | - # cache and credentials for all service roots are stored. |
4045 | + # cache for all service roots are stored. |
4046 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4047 | - launchpad = NoNetworkLaunchpad.login_with( |
4048 | - 'not important', service_root='http://api.example.com/beta', |
4049 | + NoNetworkLaunchpad.login_with( |
4050 | + 'not important', service_root=SERVICE_ROOT, |
4051 | launchpadlib_dir=launchpadlib_dir) |
4052 | # The 'launchpadlib' dir got created. |
4053 | self.assertTrue(os.path.isdir(launchpadlib_dir)) |
4054 | # A directory for the passed in service root was created. |
4055 | service_path = os.path.join(launchpadlib_dir, 'api.example.com') |
4056 | self.assertTrue(os.path.isdir(service_path)) |
4057 | - # Inside the service root directory, there is a 'cache' and a |
4058 | - # 'credentials' directory. |
4059 | + # Inside the service root directory, there is a 'cache' |
4060 | + # directory. |
4061 | self.assertTrue( |
4062 | os.path.isdir(os.path.join(service_path, 'cache'))) |
4063 | + |
4064 | + # In older versions there was also a 'credentials' directory, |
4065 | + # but no longer. |
4066 | credentials_path = os.path.join(service_path, 'credentials') |
4067 | - self.assertTrue(os.path.isdir(credentials_path)) |
4068 | + self.assertFalse(os.path.isdir(credentials_path)) |
4069 | |
4070 | def test_dirs_created_are_changed_to_secure(self): |
4071 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4072 | @@ -173,8 +282,8 @@ |
4073 | statinfo = os.stat(launchpadlib_dir) |
4074 | mode = stat.S_IMODE(statinfo.st_mode) |
4075 | self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) |
4076 | - launchpad = NoNetworkLaunchpad.login_with( |
4077 | - 'not important', service_root='http://api.example.com/beta', |
4078 | + NoNetworkLaunchpad.login_with( |
4079 | + 'not important', service_root=SERVICE_ROOT, |
4080 | launchpadlib_dir=launchpadlib_dir) |
4081 | # Verify the mode has been changed to 0700 |
4082 | statinfo = os.stat(launchpadlib_dir) |
4083 | @@ -183,8 +292,8 @@ |
4084 | |
4085 | def test_dirs_created_are_secure(self): |
4086 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4087 | - launchpad = NoNetworkLaunchpad.login_with( |
4088 | - 'not important', service_root='http://api.example.com/beta', |
4089 | + NoNetworkLaunchpad.login_with( |
4090 | + 'not important', service_root=SERVICE_ROOT, |
4091 | launchpadlib_dir=launchpadlib_dir) |
4092 | self.assertTrue(os.path.isdir(launchpadlib_dir)) |
4093 | # Verify the mode is safe |
4094 | @@ -198,44 +307,159 @@ |
4095 | # credentials will be cached to disk. |
4096 | launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4097 | launchpad = NoNetworkLaunchpad.login_with( |
4098 | - 'not important', service_root='http://api.example.com/', |
4099 | + 'not important', service_root=SERVICE_ROOT, |
4100 | launchpadlib_dir=launchpadlib_dir, version="foo") |
4101 | - self.assertEquals(launchpad.passed_in_kwargs['version'], 'foo') |
4102 | + self.assertEquals(launchpad.passed_in_args['version'], 'foo') |
4103 | |
4104 | # Now execute the same test a second time. This time, the |
4105 | # credentials are loaded from disk and a different code path |
4106 | # is executed. We want to make sure this code path propagates |
4107 | # the 'version' argument. |
4108 | launchpad = NoNetworkLaunchpad.login_with( |
4109 | - 'not important', service_root='http://api.example.com/', |
4110 | + 'not important', service_root=SERVICE_ROOT, |
4111 | launchpadlib_dir=launchpadlib_dir, version="bar") |
4112 | - self.assertEquals(launchpad.passed_in_kwargs['version'], 'bar') |
4113 | - |
4114 | - def test_no_credentials_calls_get_token_and_login(self): |
4115 | - # If no credentials are found, get_token_and_login() is called. |
4116 | - service_root = 'http://api.example.com/beta' |
4117 | + self.assertEquals(launchpad.passed_in_args['version'], 'bar') |
4118 | + |
4119 | + def test_application_name_is_propagated(self): |
4120 | + # Create a Launchpad instance for a given application name. |
4121 | + # Credentials are stored, but they don't include the |
4122 | + # application name, since multiple applications may share a |
4123 | + # single system-wide credential. |
4124 | + launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4125 | + launchpad = NoNetworkLaunchpad.login_with( |
4126 | + 'very important', service_root=SERVICE_ROOT, |
4127 | + launchpadlib_dir=launchpadlib_dir) |
4128 | + self.assertEquals( |
4129 | + launchpad.credentials.consumer.application_name, 'very important') |
4130 | + |
4131 | + # Now execute the same test a second time. This time, the |
4132 | + # credentials are loaded from disk and a different code path |
4133 | + # is executed. We want to make sure this code path propagates |
4134 | + # the application name, instead of picking an empty one from |
4135 | + # disk. |
4136 | + launchpad = NoNetworkLaunchpad.login_with( |
4137 | + 'very important', service_root=SERVICE_ROOT, |
4138 | + launchpadlib_dir=launchpadlib_dir) |
4139 | + self.assertEquals( |
4140 | + launchpad.credentials.consumer.application_name, 'very important') |
4141 | + |
4142 | + def test_authorization_engine_is_propagated(self): |
4143 | + # You can pass in a custom authorization engine, which will be |
4144 | + # used to get a request token and exchange it for an access |
4145 | + # token. |
4146 | + engine = NoNetworkAuthorizationEngine( |
4147 | + SERVICE_ROOT, 'application name') |
4148 | + NoNetworkLaunchpad.login_with(authorization_engine=engine) |
4149 | + self.assertEquals(engine.request_tokens_obtained, 1) |
4150 | + self.assertEquals(engine.access_tokens_obtained, 1) |
4151 | + |
4152 | + def test_login_with_must_identify_application(self): |
4153 | + # If you call login_with without identifying your application |
4154 | + # you'll get an error. |
4155 | + self.assertRaises(ValueError, NoNetworkLaunchpad.login_with) |
4156 | + |
4157 | + def test_application_name_identifies_app(self): |
4158 | + # If you pass in application_name, that's good enough to identify |
4159 | + # your application. |
4160 | + NoNetworkLaunchpad.login_with(application_name="name") |
4161 | + |
4162 | + def test_consumer_name_identifies_app(self): |
4163 | + # If you pass in consumer_name, that's good enough to identify |
4164 | + # your application. |
4165 | + NoNetworkLaunchpad.login_with(consumer_name="name") |
4166 | + |
4167 | + def test_inconsistent_application_name_rejected(self): |
4168 | + """Catch an attempt to specify inconsistent application_names.""" |
4169 | + engine = NoNetworkAuthorizationEngine( |
4170 | + SERVICE_ROOT, 'application name1') |
4171 | + self.assertRaises(ValueError, NoNetworkLaunchpad.login_with, |
4172 | + "application name2", |
4173 | + authorization_engine=engine) |
4174 | + |
4175 | + def test_inconsistent_consumer_name_rejected(self): |
4176 | + """Catch an attempt to specify inconsistent application_names.""" |
4177 | + engine = NoNetworkAuthorizationEngine( |
4178 | + SERVICE_ROOT, None, consumer_name="consumer_name1") |
4179 | + |
4180 | + self.assertRaises(ValueError, NoNetworkLaunchpad.login_with, |
4181 | + "consumer_name2", |
4182 | + authorization_engine=engine) |
4183 | + |
4184 | + def test_inconsistent_allow_access_levels_rejected(self): |
4185 | + """Catch an attempt to specify inconsistent allow_access_levels.""" |
4186 | + engine = NoNetworkAuthorizationEngine( |
4187 | + SERVICE_ROOT, consumer_name="consumer", |
4188 | + allow_access_levels=['FOO']) |
4189 | + |
4190 | + self.assertRaises(ValueError, NoNetworkLaunchpad.login_with, |
4191 | + None, consumer_name="consumer", |
4192 | + allow_access_levels=['BAR'], |
4193 | + authorization_engine=engine) |
4194 | + |
4195 | + def test_inconsistent_credential_save_failed(self): |
4196 | + # Catch an attempt to specify inconsistent callbacks for |
4197 | + # credential save failure. |
4198 | + def callback1(): |
4199 | + pass |
4200 | + store = KeyringCredentialStore(credential_save_failed=callback1) |
4201 | + |
4202 | + def callback2(): |
4203 | + pass |
4204 | + self.assertRaises(ValueError, NoNetworkLaunchpad.login_with, |
4205 | + "app name", credential_store=store, |
4206 | + credential_save_failed=callback2) |
4207 | + |
4208 | + def test_non_desktop_integration(self): |
4209 | + # When doing a non-desktop integration, you must specify a |
4210 | + # consumer_name. You can pass a list of allowable access |
4211 | + # levels into login_with(). |
4212 | + launchpad = NoNetworkLaunchpad.login_with( |
4213 | + consumer_name="consumer", allow_access_levels=['FOO']) |
4214 | + self.assertEquals(launchpad.credentials.consumer.key, "consumer") |
4215 | + self.assertEquals(launchpad.credentials.consumer.application_name, |
4216 | + None) |
4217 | + self.assertEquals(launchpad.authorization_engine.allow_access_levels, |
4218 | + ['FOO']) |
4219 | + |
4220 | + def test_desktop_integration_doesnt_happen_without_consumer_name(self): |
4221 | + # The only way to do a non-desktop integration is to specify a |
4222 | + # consumer_name. If you specify application_name instead, your |
4223 | + # value for allow_access_levels is ignored, and a desktop |
4224 | + # integration is performed. |
4225 | + launchpad = NoNetworkLaunchpad.login_with( |
4226 | + 'application name', allow_access_levels=['FOO']) |
4227 | + self.assertEquals(launchpad.authorization_engine.allow_access_levels, |
4228 | + ['DESKTOP_INTEGRATION']) |
4229 | + |
4230 | + def test_no_credentials_creates_new_credential(self): |
4231 | + # If no credentials are found, a desktop-wide credential is created. |
4232 | timeout = object() |
4233 | proxy_info = object() |
4234 | launchpad = NoNetworkLaunchpad.login_with( |
4235 | 'app name', launchpadlib_dir=self.temp_dir, |
4236 | - service_root=service_root, timeout=timeout, proxy_info=proxy_info) |
4237 | - self.assertEqual(launchpad.consumer_name, 'app name') |
4238 | + service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info) |
4239 | + # Here's the new credential. |
4240 | + self.assertEqual(launchpad.credentials.access_token.key, |
4241 | + NoNetworkAuthorizationEngine.ACCESS_TOKEN_KEY) |
4242 | + self.assertEqual(launchpad.credentials.consumer.application_name, |
4243 | + 'app name') |
4244 | + self.assertEquals(launchpad.authorization_engine.allow_access_levels, |
4245 | + ['DESKTOP_INTEGRATION']) |
4246 | + # The expected arguments were passed in to the Launchpad |
4247 | + # constructor. |
4248 | expected_arguments = dict( |
4249 | - allow_access_levels=[], |
4250 | - authorizer_class=AuthorizeRequestTokenWithBrowser, |
4251 | - max_failed_attempts=3, |
4252 | - service_root=service_root, |
4253 | - timeout=timeout, |
4254 | - proxy_info=proxy_info, |
4255 | + service_root=SERVICE_ROOT, |
4256 | cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'), |
4257 | - version='beta') |
4258 | - self.assertEqual(launchpad.passed_in_kwargs, expected_arguments) |
4259 | + timeout=timeout, |
4260 | + proxy_info=proxy_info, |
4261 | + version=NoNetworkLaunchpad.DEFAULT_VERSION) |
4262 | + self.assertEqual(launchpad.passed_in_args, expected_arguments) |
4263 | |
4264 | def test_anonymous_login(self): |
4265 | """Test the anonymous login helper function.""" |
4266 | launchpad = NoNetworkLaunchpad.login_anonymously( |
4267 | 'anonymous access', launchpadlib_dir=self.temp_dir, |
4268 | - service_root='http://api.example.com/beta') |
4269 | + service_root=SERVICE_ROOT) |
4270 | self.assertEqual(launchpad.credentials.access_token.key, '') |
4271 | self.assertEqual(launchpad.credentials.access_token.secret, '') |
4272 | |
4273 | @@ -245,62 +469,6 @@ |
4274 | 'anonymous access') |
4275 | self.assertFalse(os.path.exists(credentials_path)) |
4276 | |
4277 | - def test_new_credentials_are_saved(self): |
4278 | - # After get_token_and_login() have been called, the created |
4279 | - # credentials are saved. |
4280 | - launchpad = NoNetworkLaunchpad.login_with( |
4281 | - 'app name', launchpadlib_dir=self.temp_dir, |
4282 | - service_root='http://api.example.com/beta') |
4283 | - credentials_path = os.path.join( |
4284 | - self.temp_dir, 'api.example.com', 'credentials', 'app name') |
4285 | - self.assertTrue(os.path.exists(credentials_path)) |
4286 | - # Make sure that the credentials can be loaded, thus were |
4287 | - # written correctly. |
4288 | - loaded_credentials = Credentials.load_from_path(credentials_path) |
4289 | - self.assertEqual(loaded_credentials.consumer.key, 'app name') |
4290 | - self.assertEqual( |
4291 | - loaded_credentials.consumer.secret, 'consumer_secret:42') |
4292 | - self.assertEqual( |
4293 | - loaded_credentials.access_token.key, 'access_key:84') |
4294 | - self.assertEqual( |
4295 | - loaded_credentials.access_token.secret, 'access_secret:168') |
4296 | - |
4297 | - def test_new_credentials_are_secure(self): |
4298 | - # The newly created credentials file is only readable and |
4299 | - # writable by the user. |
4300 | - launchpad = NoNetworkLaunchpad.login_with( |
4301 | - 'app name', launchpadlib_dir=self.temp_dir, |
4302 | - service_root='http://api.example.com/beta') |
4303 | - credentials_path = os.path.join( |
4304 | - self.temp_dir, 'api.example.com', 'credentials', 'app name') |
4305 | - statinfo = os.stat(credentials_path) |
4306 | - mode = stat.S_IMODE(statinfo.st_mode) |
4307 | - self.assertEqual(mode, stat.S_IWRITE | stat.S_IREAD) |
4308 | - |
4309 | - def test_existing_credentials_are_reused(self): |
4310 | - # If a credential file for the application already exists, that |
4311 | - # one is used. |
4312 | - os.makedirs( |
4313 | - os.path.join(self.temp_dir, 'api.example.com', 'credentials')) |
4314 | - credentials_file_path = os.path.join( |
4315 | - self.temp_dir, 'api.example.com', 'credentials', 'app name') |
4316 | - credentials = Credentials( |
4317 | - 'app name', consumer_secret='consumer_secret:42', |
4318 | - access_token=AccessToken('access_key:84', 'access_secret:168')) |
4319 | - credentials.save_to_path(credentials_file_path) |
4320 | - |
4321 | - launchpad = NoNetworkLaunchpad.login_with( |
4322 | - 'app name', launchpadlib_dir=self.temp_dir, |
4323 | - service_root='http://api.example.com/beta') |
4324 | - self.assertFalse(launchpad.get_token_and_login_called) |
4325 | - self.assertEqual(launchpad.credentials.consumer.key, 'app name') |
4326 | - self.assertEqual( |
4327 | - launchpad.credentials.consumer.secret, 'consumer_secret:42') |
4328 | - self.assertEqual( |
4329 | - launchpad.credentials.access_token.key, 'access_key:84') |
4330 | - self.assertEqual( |
4331 | - launchpad.credentials.access_token.secret, 'access_secret:168') |
4332 | - |
4333 | def test_existing_credentials_arguments_passed_on(self): |
4334 | # When re-using existing credentials, the arguments login_with |
4335 | # is called with are passed on the the __init__() method. |
4336 | @@ -313,21 +481,22 @@ |
4337 | access_token=AccessToken('access_key:84', 'access_secret:168')) |
4338 | credentials.save_to_path(credentials_file_path) |
4339 | |
4340 | - service_root = 'http://api.example.com/' |
4341 | timeout = object() |
4342 | proxy_info = object() |
4343 | version = "foo" |
4344 | launchpad = NoNetworkLaunchpad.login_with( |
4345 | 'app name', launchpadlib_dir=self.temp_dir, |
4346 | - service_root=service_root, timeout=timeout, proxy_info=proxy_info, |
4347 | + service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info, |
4348 | version=version) |
4349 | expected_arguments = dict( |
4350 | - service_root=service_root, |
4351 | + service_root=SERVICE_ROOT, |
4352 | timeout=timeout, |
4353 | proxy_info=proxy_info, |
4354 | version=version, |
4355 | cache=os.path.join(self.temp_dir, 'api.example.com', 'cache')) |
4356 | - self.assertEqual(launchpad.passed_in_kwargs, expected_arguments) |
4357 | + for key, expected in expected_arguments.items(): |
4358 | + actual = launchpad.passed_in_args[key] |
4359 | + self.assertEqual(actual, expected) |
4360 | |
4361 | def test_None_launchpadlib_dir(self): |
4362 | # If no launchpadlib_dir is passed in to login_with, |
4363 | @@ -335,32 +504,30 @@ |
4364 | old_home = os.environ['HOME'] |
4365 | os.environ['HOME'] = self.temp_dir |
4366 | launchpad = NoNetworkLaunchpad.login_with( |
4367 | - 'app name', service_root='http://api.example.com/beta') |
4368 | + 'app name', service_root=SERVICE_ROOT) |
4369 | # Reset the environment to the old value. |
4370 | os.environ['HOME'] = old_home |
4371 | |
4372 | - cache_dir = launchpad.passed_in_kwargs['cache'] |
4373 | + cache_dir = launchpad.passed_in_args['cache'] |
4374 | launchpadlib_dir = os.path.abspath( |
4375 | os.path.join(cache_dir, '..', '..')) |
4376 | self.assertEqual( |
4377 | launchpadlib_dir, os.path.join(self.temp_dir, '.launchpadlib')) |
4378 | self.assertTrue(os.path.exists( |
4379 | os.path.join(launchpadlib_dir, 'api.example.com', 'cache'))) |
4380 | - self.assertTrue(os.path.exists( |
4381 | - os.path.join(launchpadlib_dir, 'api.example.com', 'credentials'))) |
4382 | |
4383 | def test_short_service_name(self): |
4384 | # A short service name is converted to the full service root URL. |
4385 | launchpad = NoNetworkLaunchpad.login_with('app name', 'staging') |
4386 | self.assertEqual( |
4387 | - launchpad.passed_in_kwargs['service_root'], |
4388 | + launchpad.passed_in_args['service_root'], |
4389 | 'https://api.staging.launchpad.net/') |
4390 | |
4391 | # A full URL as the service name is left alone. |
4392 | launchpad = NoNetworkLaunchpad.login_with( |
4393 | 'app name', uris.service_roots['staging']) |
4394 | self.assertEqual( |
4395 | - launchpad.passed_in_kwargs['service_root'], |
4396 | + launchpad.passed_in_args['service_root'], |
4397 | uris.service_roots['staging']) |
4398 | |
4399 | # A short service name that does not match one of the |
4400 | @@ -370,27 +537,143 @@ |
4401 | self.assertRaises( |
4402 | ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo') |
4403 | |
4404 | - def test_separate_credentials_file(self): |
4405 | - my_credentials_path = os.path.join(self.temp_dir, 'my_creds') |
4406 | - launchpad = NoNetworkLaunchpad.login_with( |
4407 | - 'app name', launchpadlib_dir=self.temp_dir, |
4408 | - credentials_file=my_credentials_path, |
4409 | - service_root='http://api.example.com/beta') |
4410 | - default_credentials_path = os.path.join( |
4411 | - self.temp_dir, 'api.example.com', 'credentials', 'app name') |
4412 | - self.assertFalse(os.path.exists(default_credentials_path)) |
4413 | - self.assertTrue(os.path.exists(my_credentials_path)) |
4414 | - |
4415 | - self.assertTrue(launchpad.get_token_and_login_called) |
4416 | - |
4417 | - # gets reused, too |
4418 | - launchpad = NoNetworkLaunchpad.login_with( |
4419 | - 'app name', launchpadlib_dir=self.temp_dir, |
4420 | - credentials_file=my_credentials_path, |
4421 | - service_root='http://api.example.com/beta') |
4422 | - self.assertFalse(os.path.exists(default_credentials_path)) |
4423 | - self.assertTrue(os.path.exists(my_credentials_path)) |
4424 | - self.assertFalse(launchpad.get_token_and_login_called) |
4425 | + def test_max_failed_attempts_accepted(self): |
4426 | + # You can pass in a value for the 'max_failed_attempts' |
4427 | + # argument, even though that argument doesn't do anything. |
4428 | + NoNetworkLaunchpad.login_with( |
4429 | + 'not important', max_failed_attempts=5) |
4430 | + |
4431 | + |
4432 | +class TestDeprecatedLoginMethods(KeyringTest): |
4433 | + """Make sure the deprecated login methods still work.""" |
4434 | + |
4435 | + def test_login_is_deprecated(self): |
4436 | + # login() works but triggers a deprecation warning. |
4437 | + with warnings.catch_warnings(record=True) as caught: |
4438 | + warnings.simplefilter("always") |
4439 | + launchpad = NoNetworkLaunchpad.login( |
4440 | + 'consumer', 'token', 'secret') |
4441 | + self.assertEquals(len(caught), 1) |
4442 | + self.assertEquals(caught[0].category, DeprecationWarning) |
4443 | + |
4444 | + def test_get_token_and_login_is_deprecated(self): |
4445 | + # get_token_and_login() works but triggers a deprecation warning. |
4446 | + with warnings.catch_warnings(record=True) as caught: |
4447 | + warnings.simplefilter("always") |
4448 | + launchpad = NoNetworkLaunchpad.get_token_and_login('consumer') |
4449 | + self.assertEquals(len(caught), 1) |
4450 | + self.assertEquals(caught[0].category, DeprecationWarning) |
4451 | + |
4452 | + |
4453 | +class TestCredenitialSaveFailedCallback(unittest.TestCase): |
4454 | + # There is a callback which will be called if saving the credentials |
4455 | + # fails. |
4456 | + |
4457 | + def setUp(self): |
4458 | + # launchpadlib.launchpad uses the socket module to look up the |
4459 | + # hostname, obviously that can vary so we replace the socket module |
4460 | + # with a fake that returns a fake hostname. |
4461 | + launchpadlib.launchpad.socket = FauxSocketModule() |
4462 | + self.temp_dir = tempfile.mkdtemp() |
4463 | + |
4464 | + def tearDown(self): |
4465 | + launchpadlib.launchpad.socket = socket |
4466 | + shutil.rmtree(self.temp_dir) |
4467 | + |
4468 | + def test_credentials_save_failed(self): |
4469 | + # If saving the credentials did not succeed and a callback was |
4470 | + # provided, it is called. |
4471 | + |
4472 | + callback_called = [] |
4473 | + def callback(): |
4474 | + # Since we can't rebind "callback_called" here, we'll have to |
4475 | + # settle for mutating it to signal success. |
4476 | + callback_called.append(None) |
4477 | + |
4478 | + launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4479 | + service_root = "http://api.example.com/" |
4480 | + with fake_keyring(BadSaveKeyring()): |
4481 | + NoNetworkLaunchpad.login_with( |
4482 | + 'not important', service_root=service_root, |
4483 | + launchpadlib_dir=launchpadlib_dir, |
4484 | + credential_save_failed=callback) |
4485 | + self.assertEquals(len(callback_called), 1) |
4486 | + |
4487 | + def test_default_credentials_save_failed_is_to_raise_exception(self): |
4488 | + # If saving the credentials did not succeed and no callback was |
4489 | + # provided, the underlying exception is raised. |
4490 | + launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4491 | + service_root = "http://api.example.com/" |
4492 | + with fake_keyring(BadSaveKeyring()): |
4493 | + self.assertRaises( |
4494 | + RuntimeError, |
4495 | + NoNetworkLaunchpad.login_with, |
4496 | + 'not important', service_root=service_root, |
4497 | + launchpadlib_dir=launchpadlib_dir) |
4498 | + |
4499 | + |
4500 | +class TestMultipleSites(unittest.TestCase): |
4501 | + # If the same application name (consumer name) is used to access more than |
4502 | + # one site, the credentials need to be stored seperately. Therefore, the |
4503 | + # "username" passed ot the keyring includes the service root. |
4504 | + |
4505 | + def setUp(self): |
4506 | + # launchpadlib.launchpad uses the socket module to look up the |
4507 | + # hostname, obviously that can vary so we replace the socket module |
4508 | + # with a fake that returns a fake hostname. |
4509 | + launchpadlib.launchpad.socket = FauxSocketModule() |
4510 | + self.temp_dir = tempfile.mkdtemp() |
4511 | + |
4512 | + def tearDown(self): |
4513 | + launchpadlib.launchpad.socket = socket |
4514 | + shutil.rmtree(self.temp_dir) |
4515 | + |
4516 | + def test_components_of_application_key(self): |
4517 | + launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4518 | + keyring = InMemoryKeyring() |
4519 | + service_root = 'http://api.example.com/' |
4520 | + application_name = 'Super App 3000' |
4521 | + with fake_keyring(keyring): |
4522 | + launchpad = NoNetworkLaunchpad.login_with( |
4523 | + application_name, service_root=service_root, |
4524 | + launchpadlib_dir=launchpadlib_dir) |
4525 | + consumer_name = launchpad.credentials.consumer.key |
4526 | + |
4527 | + application_key = keyring.data.keys()[0][1] |
4528 | + |
4529 | + # Both the consumer name (normally the name of the application) and |
4530 | + # the service root (the URL of the service being accessed) are |
4531 | + # included in the key when storing credentials. |
4532 | + self.assert_(service_root in application_key) |
4533 | + self.assert_(consumer_name in application_key) |
4534 | + |
4535 | + # The key used to store the credentials is of this structure (and |
4536 | + # shouldn't change between releases or stored credentials will be |
4537 | + # "forgotten"). |
4538 | + self.assertEquals(application_key, consumer_name + '@' + service_root) |
4539 | + |
4540 | + def test_same_app_different_servers(self): |
4541 | + launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib') |
4542 | + keyring = InMemoryKeyring() |
4543 | + # Be paranoid about the keyring starting out empty. |
4544 | + assert not keyring.data, 'oops, a fresh keyring has data in it' |
4545 | + with fake_keyring(keyring): |
4546 | + # Create stored credentials for the same application but against |
4547 | + # two different sites (service roots). |
4548 | + NoNetworkLaunchpad.login_with( |
4549 | + 'application name', service_root='http://alpha.example.com/', |
4550 | + launchpadlib_dir=launchpadlib_dir) |
4551 | + NoNetworkLaunchpad.login_with( |
4552 | + 'application name', service_root='http://beta.example.com/', |
4553 | + launchpadlib_dir=launchpadlib_dir) |
4554 | + |
4555 | + # There should only be two sets of stored credentials (this assertion |
4556 | + # is of the test mechanism, not a test assertion). |
4557 | + assert len(keyring.data.keys()) == 2 |
4558 | + |
4559 | + application_key_1 = keyring.data.keys()[0][1] |
4560 | + application_key_2 = keyring.data.keys()[1][1] |
4561 | + self.assertNotEqual(application_key_1, application_key_2) |
4562 | |
4563 | |
4564 | def test_suite(): |
4565 | |
4566 | === modified file 'src/launchpadlib/uris.py' |
4567 | --- src/launchpadlib/uris.py 2010-12-07 19:30:29 +0000 |
4568 | +++ src/launchpadlib/uris.py 2011-02-14 21:31:44 +0000 |
4569 | @@ -16,27 +16,33 @@ |
4570 | |
4571 | """Launchpad-specific URIs and convenience lookup functions. |
4572 | |
4573 | -The code in this module lets users say "edge" when they mean |
4574 | -"https://api.edge.launchpad.net/". |
4575 | +The code in this module lets users say "staging" when they mean |
4576 | +"https://api.staging.launchpad.net/". |
4577 | """ |
4578 | |
4579 | __metaclass__ = type |
4580 | __all__ = [ |
4581 | 'lookup_service_root', |
4582 | 'lookup_web_root', |
4583 | + 'web_root_for_service_root', |
4584 | ] |
4585 | |
4586 | from urlparse import urlparse |
4587 | +import warnings |
4588 | +from lazr.uri import URI |
4589 | |
4590 | LPNET_SERVICE_ROOT = 'https://api.launchpad.net/' |
4591 | -EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/' |
4592 | +# Allow code that uses EDGE_SERVICE_ROOT to keep running, |
4593 | +# even though edge is gone. |
4594 | +EDGE_SERVICE_ROOT = LPNET_SERVICE_ROOT |
4595 | +QASTAGING_SERVICE_ROOT = 'https://api.qastaging.launchpad.net/' |
4596 | STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/' |
4597 | DEV_SERVICE_ROOT = 'https://api.launchpad.dev/' |
4598 | DOGFOOD_SERVICE_ROOT = 'https://api.dogfood.launchpad.net/' |
4599 | TEST_DEV_SERVICE_ROOT = 'http://api.launchpad.dev:8085/' |
4600 | |
4601 | LPNET_WEB_ROOT = 'https://launchpad.net/' |
4602 | -EDGE_WEB_ROOT = 'https://edge.launchpad.net/' |
4603 | +QASTAGING_WEB_ROOT = 'https://qastaging.launchpad.net/' |
4604 | STAGING_WEB_ROOT = 'https://staging.launchpad.net/' |
4605 | DEV_WEB_ROOT = 'https://launchpad.dev/' |
4606 | DOGFOOD_WEB_ROOT = 'https://dogfood.launchpad.net/' |
4607 | @@ -45,7 +51,8 @@ |
4608 | |
4609 | service_roots = dict( |
4610 | production=LPNET_SERVICE_ROOT, |
4611 | - edge=EDGE_SERVICE_ROOT, |
4612 | + edge=LPNET_SERVICE_ROOT, |
4613 | + qastaging=QASTAGING_SERVICE_ROOT, |
4614 | staging=STAGING_SERVICE_ROOT, |
4615 | dogfood=DOGFOOD_SERVICE_ROOT, |
4616 | dev=DEV_SERVICE_ROOT, |
4617 | @@ -55,7 +62,8 @@ |
4618 | |
4619 | web_roots = dict( |
4620 | production=LPNET_WEB_ROOT, |
4621 | - edge=EDGE_WEB_ROOT, |
4622 | + edge = LPNET_WEB_ROOT, |
4623 | + qastaging=QASTAGING_WEB_ROOT, |
4624 | staging=STAGING_WEB_ROOT, |
4625 | dogfood=DOGFOOD_WEB_ROOT, |
4626 | dev=DEV_WEB_ROOT, |
4627 | @@ -65,6 +73,9 @@ |
4628 | |
4629 | def _dereference_alias(root, aliases): |
4630 | """Dereference what might a URL or an alias for a URL.""" |
4631 | + if root == 'edge': |
4632 | + warnings.warn(("Launchpad edge server no longer exists. " |
4633 | + "Using 'production' instead."), DeprecationWarning) |
4634 | if root in aliases: |
4635 | return aliases[root] |
4636 | |
4637 | @@ -81,7 +92,7 @@ |
4638 | def lookup_service_root(service_root): |
4639 | """Dereference an alias to a service root. |
4640 | |
4641 | - A recognized server alias such as "edge" gets turned into the |
4642 | + A recognized server alias such as "staging" gets turned into the |
4643 | appropriate URI. A URI gets returned as is. Any other string raises a |
4644 | ValueError. |
4645 | """ |
4646 | @@ -91,9 +102,21 @@ |
4647 | def lookup_web_root(web_root): |
4648 | """Dereference an alias to a website root. |
4649 | |
4650 | - A recognized server alias such as "edge" gets turned into the |
4651 | + A recognized server alias such as "staging" gets turned into the |
4652 | appropriate URI. A URI gets returned as is. Any other string raises a |
4653 | ValueError. |
4654 | """ |
4655 | return _dereference_alias(web_root, web_roots) |
4656 | |
4657 | + |
4658 | +def web_root_for_service_root(service_root): |
4659 | + """Turn a service root URL into a web root URL. |
4660 | + |
4661 | + This is done heuristically, not with a lookup. |
4662 | + """ |
4663 | + service_root = lookup_service_root(service_root) |
4664 | + web_root_uri = URI(service_root) |
4665 | + web_root_uri.path = "" |
4666 | + web_root_uri.host = web_root_uri.host.replace("api.", "", 1) |
4667 | + web_root = str(web_root_uri.ensureSlash()) |
4668 | + return web_root |
4669 | |
4670 | === removed file 'src/launchpadlib/wadl-to-refhtml.xsl' |
4671 | --- src/launchpadlib/wadl-to-refhtml.xsl 2010-12-07 19:30:29 +0000 |
4672 | +++ src/launchpadlib/wadl-to-refhtml.xsl 1970-01-01 00:00:00 +0000 |
4673 | @@ -1,1045 +0,0 @@ |
4674 | -<?xml version="1.0" encoding="UTF-8"?> |
4675 | -<!-- |
4676 | - wadl-to-refhtml.xsl |
4677 | - |
4678 | - Generate HTML documentation for a webservice described in a WADL file. |
4679 | - This is tailored to WADL generated by Launchpad's web service. |
4680 | - |
4681 | - Based on wadl_documentaion.xsl from Mark Nottingham <mnot@yahoo-inc.com> |
4682 | - that can be found at http://www.mnot.net/webdesc/ |
4683 | - Copyright (c) 2006-2007 Yahoo! Inc. |
4684 | - Copyright (c) 2008 Canonical Ltd. |
4685 | - |
4686 | - This work is licensed under the Creative Commons Attribution-ShareAlike 2.5 |
4687 | - License. To view a copy of this license, visit |
4688 | - http://creativecommons.org/licenses/by-sa/2.5/ |
4689 | - or send a letter to |
4690 | - Creative Commons |
4691 | - 543 Howard Street, 5th Floor |
4692 | - San Francisco, California, 94105, USA |
4693 | ---> |
4694 | - |
4695 | -<xsl:stylesheet |
4696 | - xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0" |
4697 | - xmlns:wadl="http://research.sun.com/wadl/2006/10" |
4698 | - xmlns:html="http://www.w3.org/1999/xhtml" |
4699 | - xmlns="http://www.w3.org/1999/xhtml" |
4700 | - exclude-result-prefixes="xsl wadl html" |
4701 | -> |
4702 | - <xsl:output |
4703 | - method="xml" |
4704 | - encoding="UTF-8" |
4705 | - indent="yes" |
4706 | - doctype-public="-//W3C//DTD XHTML 1.0 Transitional//EN" |
4707 | - doctype-system="http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" |
4708 | - /> |
4709 | - |
4710 | - |
4711 | - <!-- Allow using key('id', 'people') to identify unique elements, since |
4712 | - the document doesn't have a parsed DTD. |
4713 | - --> |
4714 | - <xsl:key name="id" match="*[@id]" use="@id"/> |
4715 | - |
4716 | - <!-- Embedded stylesheet. --> |
4717 | - <xsl:template name="css-stylesheet"> |
4718 | - <style type="text/css"> |
4719 | - body { |
4720 | - font-family: sans-serif; |
4721 | - font-size: 0.85em; |
4722 | - margin: 2em 8em; |
4723 | - } |
4724 | - .methods { |
4725 | - background-color: #eef; |
4726 | - padding: 1em; |
4727 | - margin-bottom: 0.5em; |
4728 | - } |
4729 | - .method { |
4730 | - padding-left: 4em; |
4731 | - } |
4732 | - h1 { |
4733 | - font-size: 2.5em; |
4734 | - } |
4735 | - h2 { |
4736 | - border-bottom: 1px solid black; |
4737 | - margin-top: 1em; |
4738 | - margin-bottom: 0.5em; |
4739 | - font-size: 2em; |
4740 | - } |
4741 | - h3 { |
4742 | - color: orange; |
4743 | - font-size: 1.75em; |
4744 | - margin-top: 1.25em; |
4745 | - margin-bottom: 0em; |
4746 | - } |
4747 | - h4 { |
4748 | - font-size: 1.50em; |
4749 | - margin: 0em; |
4750 | - padding: 0em; |
4751 | - border-bottom: 2px solid white; |
4752 | - } |
4753 | - h5 { |
4754 | - font-size: 1.25em; |
4755 | - margin-left: -3em; |
4756 | - } |
4757 | - h6 { |
4758 | - font-size: 1.1em; |
4759 | - color: #99a; |
4760 | - margin: 0.5em 0em 0.25em 0em; |
4761 | - } |
4762 | - dd { |
4763 | - margin-left: 1em; |
4764 | - } |
4765 | - tt, code { |
4766 | - font-size: 1.2em; |
4767 | - } |
4768 | - table { |
4769 | - margin-bottom: 0.5em; |
4770 | - } |
4771 | - th { |
4772 | - text-align: left; |
4773 | - font-weight: normal; |
4774 | - color: black; |
4775 | - border-bottom: 1px solid black; |
4776 | - padding: 3px 6px; |
4777 | - } |
4778 | - td { |
4779 | - padding: 3px 6px; |
4780 | - vertical-align: top; |
4781 | - background-color: #f6f6ff; |
4782 | - font-size: 0.85em; |
4783 | - } |
4784 | - td p { |
4785 | - margin: 0px; |
4786 | - } |
4787 | - ul { |
4788 | - padding-left: 1.75em; |
4789 | - } |
4790 | - p + ul, p + ol, p + dl { |
4791 | - margin-top: 0em; |
4792 | - } |
4793 | - label { |
4794 | - font-weight: bold; |
4795 | - } |
4796 | - .optional { |
4797 | - font-weight: normal; |
4798 | - opacity: 0.75; |
4799 | - } |
4800 | - .toc-link { |
4801 | - font-size: 0.85em; |
4802 | - } |
4803 | - </style> |
4804 | - </xsl:template> |
4805 | - |
4806 | - <!-- Contains the base URL for the webservice without a trailing |
4807 | - slash. --> |
4808 | - <xsl:variable name="base"> |
4809 | - <xsl:variable name="uri" select="//wadl:resources/@base"/> |
4810 | - <xsl:choose> |
4811 | - <xsl:when |
4812 | - test="substring($uri, string-length($uri) , 1) = '/'"> |
4813 | - <xsl:value-of |
4814 | - select="substring($uri, 1, string-length($uri) - 1)"/> |
4815 | - </xsl:when> |
4816 | - <xsl:otherwise> |
4817 | - <xsl:value-of select="$uri"/> |
4818 | - </xsl:otherwise> |
4819 | - </xsl:choose> |
4820 | - </xsl:variable> |
4821 | - |
4822 | - <!-- Generate the URL to the top-level collection. --> |
4823 | - <xsl:template name="resource-uri-doc"> |
4824 | - <xsl:param name="url"><xsl:value-of |
4825 | - select="$base"/>/<xsl:value-of select="@id"/></xsl:param> |
4826 | - <p><label>URL:</label> |
4827 | - <code><xsl:copy-of select="$url" /></code></p> |
4828 | - </xsl:template> |
4829 | - |
4830 | - <xsl:template name="entry-uri-doc"> |
4831 | - <xsl:call-template name="resource-uri-doc"> |
4832 | - <xsl:with-param name="url"> |
4833 | - <xsl:choose> |
4834 | - <xsl:when test="@id = 'has_milestones' |
4835 | - or @id = 'bug_target' |
4836 | - or @id = 'has_bugs'"> |
4837 | - <em>depends on the underlying entry</em> |
4838 | - </xsl:when> |
4839 | - <xsl:otherwise> |
4840 | - <xsl:call-template name="find-entry-uri"/> |
4841 | - </xsl:otherwise> |
4842 | - </xsl:choose> |
4843 | - </xsl:with-param> |
4844 | - </xsl:call-template> |
4845 | - </xsl:template> |
4846 | - |
4847 | - <xsl:template name="find-entry-uri"> |
4848 | - <xsl:value-of select="$base"/> |
4849 | - <xsl:choose> |
4850 | - <xsl:when test="@id = 'archive'"> |
4851 | - <xsl:text>/</xsl:text> |
4852 | - <var><distribution></var> |
4853 | - <xsl:text>/+archive/</xsl:text> |
4854 | - <var><archive.name></var> |
4855 | - </xsl:when> |
4856 | - <xsl:when test="@id = 'archive_permission'"> |
4857 | - <xsl:text>/</xsl:text> |
4858 | - <var><archive.distribution></var> |
4859 | - <xsl:text>/+archive/</xsl:text> |
4860 | - <var><archive.name></var> |
4861 | - <xsl:text>/+</xsl:text> |
4862 | - <xsl:text>name</xsl:text> |
4863 | - <xsl:text>/</xsl:text> |
4864 | - <xsl:text>person.name</xsl:text> |
4865 | - <xsl:text>.</xsl:text> |
4866 | - <xsl:text>[component or source package].name</xsl:text> |
4867 | - </xsl:when> |
4868 | - <xsl:when test="@id = 'binary_package_publishing_history'"> |
4869 | - <xsl:text>/</xsl:text> |
4870 | - <var><distribution.name></var> |
4871 | - <xsl:text>/+archive/</xsl:text> |
4872 | - <var><binary_package.name></var> |
4873 | - <xsl:text>/+binarypub/</xsl:text> |
4874 | - <var><id></var> |
4875 | - </xsl:when> |
4876 | - <xsl:when test="@id = 'branch'"> |
4877 | - <xsl:text>/~</xsl:text> |
4878 | - <var><author.name></var> |
4879 | - <xsl:text>/</xsl:text> |
4880 | - <var><project.name></var> |
4881 | - <xsl:text>/</xsl:text> |
4882 | - <var><name></var> |
4883 | - </xsl:when> |
4884 | - <xsl:when test="@id = 'branch_merge_proposal'"> |
4885 | - <xsl:text>/~</xsl:text> |
4886 | - <var><author.name></var> |
4887 | - <xsl:text>/</xsl:text> |
4888 | - <var><project.name></var> |
4889 | - <xsl:text>/</xsl:text> |
4890 | - <var><branch.name></var> |
4891 | - <xsl:text>/+merge/</xsl:text> |
4892 | - <var><id></var> |
4893 | - </xsl:when> |
4894 | - <xsl:when test="@id = 'bug'"> |
4895 | - <xsl:text>/bugs/</xsl:text><var><id></var> |
4896 | - </xsl:when> |
4897 | - <xsl:when test="@id = 'bug_attachment'"> |
4898 | - <xsl:text>/bugs/</xsl:text> |
4899 | - <var><bug.id></var> |
4900 | - <xsl:text>/attachments/</xsl:text> |
4901 | - <var><id></var> |
4902 | - </xsl:when> |
4903 | - <xsl:when test="@id = 'bug_subscription'"> |
4904 | - <xsl:text>/bugs/</xsl:text> |
4905 | - <var><bug.id></var> |
4906 | - <xsl:text>/subscriptions/</xsl:text> |
4907 | - <var><subscriber.name></var> |
4908 | - </xsl:when> |
4909 | - <xsl:when test="@id = 'bug_task'"> |
4910 | - <xsl:text>/</xsl:text> |
4911 | - <var><target.name></var> |
4912 | - <xsl:text>/+bug/</xsl:text> |
4913 | - <var ><bug.id></var> |
4914 | - </xsl:when> |
4915 | - <xsl:when test="@id = 'bug_watch'"> |
4916 | - <xsl:text>/bugs/</xsl:text> |
4917 | - <var><bug.id></var> |
4918 | - <xsl:text>/watch/</xsl:text> |
4919 | - <var><id></var> |
4920 | - </xsl:when> |
4921 | - <xsl:when test="@id = 'bug_tracker'"> |
4922 | - <xsl:text>/bugs/bugtrackers/</xsl:text> |
4923 | - <var><name></var> |
4924 | - </xsl:when> |
4925 | - <xsl:when test="@id = 'build'"> |
4926 | - <xsl:text>/</xsl:text> |
4927 | - <var><distribution.name></var> |
4928 | - <xsl:text>/+source/</xsl:text> |
4929 | - <var><source_package.name></var> |
4930 | - <xsl:text>/+build/</xsl:text> |
4931 | - <var><id></var> |
4932 | - </xsl:when> |
4933 | - <xsl:when test="@id = 'cve'"> |
4934 | - <xsl:text>/bugs/cve/</xsl:text> |
4935 | - <var><sequence></var> |
4936 | - </xsl:when> |
4937 | - <xsl:when test="@id = 'distribution_source_package'"> |
4938 | - <xsl:text>/</xsl:text> |
4939 | - <var><distribution.name></var> |
4940 | - <xsl:text>/+source/</xsl:text> |
4941 | - <var><name></var> |
4942 | - </xsl:when> |
4943 | - <xsl:when test="@id = 'distro_arch_series'"> |
4944 | - <xsl:text>/</xsl:text> |
4945 | - <var><distribution.name></var> |
4946 | - <xsl:text>/</xsl:text> |
4947 | - <var><distroseries.name></var> |
4948 | - <xsl:text>/</xsl:text> |
4949 | - <var><architecture_tag></var> |
4950 | - </xsl:when> |
4951 | - <xsl:when test="@id = 'distro_series'"> |
4952 | - <xsl:text>/</xsl:text> |
4953 | - <var><distribution.name></var> |
4954 | - <xsl:text>/</xsl:text> |
4955 | - <var><name></var> |
4956 | - </xsl:when> |
4957 | - <xsl:when test="@id = 'email_address'"> |
4958 | - <xsl:text>/</xsl:text> |
4959 | - <var><person.name></var> |
4960 | - <xsl:text>/+email/</xsl:text> |
4961 | - <var><email></var> |
4962 | - </xsl:when> |
4963 | - <xsl:when test="@id = 'h_w_device'"> |
4964 | - <xsl:text>/+hwdb/+device/</xsl:text> |
4965 | - <var><id></var> |
4966 | - </xsl:when> |
4967 | - <xsl:when test="@id = 'h_w_device_class'"> |
4968 | - <xsl:text>/+hwdb/+deviceclass/</xsl:text> |
4969 | - <var><id></var> |
4970 | - </xsl:when> |
4971 | - <xsl:when test="@id = 'h_w_driver'"> |
4972 | - <xsl:text>/+hwdb/+driver/</xsl:text> |
4973 | - <var><id></var> |
4974 | - </xsl:when> |
4975 | - <xsl:when test="@id = 'h_w_submission'"> |
4976 | - <xsl:text>/+hwdb/+submission/</xsl:text> |
4977 | - <var><submission-key></var> |
4978 | - </xsl:when> |
4979 | - <xsl:when test="@id = 'h_w_submission_device'"> |
4980 | - <xsl:text>/+hwdb/+submissiondevice/</xsl:text> |
4981 | - <var><id></var> |
4982 | - </xsl:when> |
4983 | - <xsl:when test="@id = 'h_w_vendor_i_d'"> |
4984 | - <xsl:text>/+hwdb/+hwvendorid/</xsl:text> |
4985 | - <var><id></var> |
4986 | - </xsl:when> |
4987 | - <xsl:when test="@id = 'jabber_id'"> |
4988 | - <xsl:text>/</xsl:text> |
4989 | - <var><person.name></var> |
4990 | - <xsl:text>/+jabberid/</xsl:text> |
4991 | - <var><id></var> |
4992 | - </xsl:when> |
4993 | - <xsl:when test="@id = 'irc_id'"> |
4994 | - <xsl:text>/</xsl:text> |
4995 | - <var><person.name></var> |
4996 | - <xsl:text>/+ircnick/</xsl:text> |
4997 | - <var><id></var> |
4998 | - </xsl:when> |
4999 | - <xsl:when test="@id = 'language'"> |
5000 | - <xsl:text>/+languages/</xsl:text> |
the change looks good, in any case, you have a better knowledge than I on the subject :)
I just wondered as you get a lot of API break in that upgrade if you tested it through the archives? Maybe as well blog about it before the upload so that people having scripts which will be broken scripts or tools (I'm pretty sure Quickly will break reading the changelog ;)) can take some advanced tests toward it.
As it seems the (long) discussion on bug #686690 found an agreement, if the first case is done, people can sponsor it.