Merge lp:~barry/ubuntu/natty/python-launchpadlib/bug-702375-1.9.6 into lp:ubuntu/natty/python-launchpadlib

Proposed by Barry Warsaw
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
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.

Description of the change

Updated to launchpadlib 1.9.6

To post a comment you must log in.
Revision history for this message
Didier Roche-Tolomelli (didrocks) wrote : Posted in a previous version of this proposal

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.

Revision history for this message
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.

Revision history for this message
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.

Revision history for this message
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.

Revision history for this message
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.

review: Approve
Revision history for this message
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://code.launchpad.net/~leonardr/launchpadlib/fake-edge/+merge/49651

It would also be great to get my backport of this feature into Maverick:
https://code.launchpad.net/~leonardr/launchpadlib/edge-fakery-for-maverick/+merge/49653

Revision history for this message
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://wiki.ubuntu.com/StableReleaseUpdates

Leonard, if you can do the bookkeeping work on proposing the SRU, I'll prepare a package branch.

Revision history for this message
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.

Revision history for this message
Sebastien Bacher (seb128) wrote :

Since 1.9.7 is available should that merge request be updated?

review: Needs Information
Revision history for this message
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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>&lt;distribution&gt;</var>
4853- <xsl:text>/+archive/</xsl:text>
4854- <var>&lt;archive.name&gt;</var>
4855- </xsl:when>
4856- <xsl:when test="@id = 'archive_permission'">
4857- <xsl:text>/</xsl:text>
4858- <var>&lt;archive.distribution&gt;</var>
4859- <xsl:text>/+archive/</xsl:text>
4860- <var>&lt;archive.name&gt;</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>&lt;distribution.name&gt;</var>
4871- <xsl:text>/+archive/</xsl:text>
4872- <var>&lt;binary_package.name&gt;</var>
4873- <xsl:text>/+binarypub/</xsl:text>
4874- <var>&lt;id&gt;</var>
4875- </xsl:when>
4876- <xsl:when test="@id = 'branch'">
4877- <xsl:text>/~</xsl:text>
4878- <var>&lt;author.name&gt;</var>
4879- <xsl:text>/</xsl:text>
4880- <var>&lt;project.name&gt;</var>
4881- <xsl:text>/</xsl:text>
4882- <var>&lt;name&gt;</var>
4883- </xsl:when>
4884- <xsl:when test="@id = 'branch_merge_proposal'">
4885- <xsl:text>/~</xsl:text>
4886- <var>&lt;author.name&gt;</var>
4887- <xsl:text>/</xsl:text>
4888- <var>&lt;project.name&gt;</var>
4889- <xsl:text>/</xsl:text>
4890- <var>&lt;branch.name&gt;</var>
4891- <xsl:text>/+merge/</xsl:text>
4892- <var>&lt;id&gt;</var>
4893- </xsl:when>
4894- <xsl:when test="@id = 'bug'">
4895- <xsl:text>/bugs/</xsl:text><var>&lt;id&gt;</var>
4896- </xsl:when>
4897- <xsl:when test="@id = 'bug_attachment'">
4898- <xsl:text>/bugs/</xsl:text>
4899- <var>&lt;bug.id&gt;</var>
4900- <xsl:text>/attachments/</xsl:text>
4901- <var>&lt;id&gt;</var>
4902- </xsl:when>
4903- <xsl:when test="@id = 'bug_subscription'">
4904- <xsl:text>/bugs/</xsl:text>
4905- <var>&lt;bug.id&gt;</var>
4906- <xsl:text>/subscriptions/</xsl:text>
4907- <var>&lt;subscriber.name&gt;</var>
4908- </xsl:when>
4909- <xsl:when test="@id = 'bug_task'">
4910- <xsl:text>/</xsl:text>
4911- <var>&lt;target.name&gt;</var>
4912- <xsl:text>/+bug/</xsl:text>
4913- <var >&lt;bug.id&gt;</var>
4914- </xsl:when>
4915- <xsl:when test="@id = 'bug_watch'">
4916- <xsl:text>/bugs/</xsl:text>
4917- <var>&lt;bug.id&gt;</var>
4918- <xsl:text>/watch/</xsl:text>
4919- <var>&lt;id&gt;</var>
4920- </xsl:when>
4921- <xsl:when test="@id = 'bug_tracker'">
4922- <xsl:text>/bugs/bugtrackers/</xsl:text>
4923- <var>&lt;name&gt;</var>
4924- </xsl:when>
4925- <xsl:when test="@id = 'build'">
4926- <xsl:text>/</xsl:text>
4927- <var>&lt;distribution.name&gt;</var>
4928- <xsl:text>/+source/</xsl:text>
4929- <var>&lt;source_package.name&gt;</var>
4930- <xsl:text>/+build/</xsl:text>
4931- <var>&lt;id&gt;</var>
4932- </xsl:when>
4933- <xsl:when test="@id = 'cve'">
4934- <xsl:text>/bugs/cve/</xsl:text>
4935- <var>&lt;sequence&gt;</var>
4936- </xsl:when>
4937- <xsl:when test="@id = 'distribution_source_package'">
4938- <xsl:text>/</xsl:text>
4939- <var>&lt;distribution.name&gt;</var>
4940- <xsl:text>/+source/</xsl:text>
4941- <var>&lt;name&gt;</var>
4942- </xsl:when>
4943- <xsl:when test="@id = 'distro_arch_series'">
4944- <xsl:text>/</xsl:text>
4945- <var>&lt;distribution.name&gt;</var>
4946- <xsl:text>/</xsl:text>
4947- <var>&lt;distroseries.name&gt;</var>
4948- <xsl:text>/</xsl:text>
4949- <var>&lt;architecture_tag&gt;</var>
4950- </xsl:when>
4951- <xsl:when test="@id = 'distro_series'">
4952- <xsl:text>/</xsl:text>
4953- <var>&lt;distribution.name&gt;</var>
4954- <xsl:text>/</xsl:text>
4955- <var>&lt;name&gt;</var>
4956- </xsl:when>
4957- <xsl:when test="@id = 'email_address'">
4958- <xsl:text>/</xsl:text>
4959- <var>&lt;person.name&gt;</var>
4960- <xsl:text>/+email/</xsl:text>
4961- <var>&lt;email&gt;</var>
4962- </xsl:when>
4963- <xsl:when test="@id = 'h_w_device'">
4964- <xsl:text>/+hwdb/+device/</xsl:text>
4965- <var>&lt;id&gt;</var>
4966- </xsl:when>
4967- <xsl:when test="@id = 'h_w_device_class'">
4968- <xsl:text>/+hwdb/+deviceclass/</xsl:text>
4969- <var>&lt;id&gt;</var>
4970- </xsl:when>
4971- <xsl:when test="@id = 'h_w_driver'">
4972- <xsl:text>/+hwdb/+driver/</xsl:text>
4973- <var>&lt;id&gt;</var>
4974- </xsl:when>
4975- <xsl:when test="@id = 'h_w_submission'">
4976- <xsl:text>/+hwdb/+submission/</xsl:text>
4977- <var>&lt;submission-key&gt;</var>
4978- </xsl:when>
4979- <xsl:when test="@id = 'h_w_submission_device'">
4980- <xsl:text>/+hwdb/+submissiondevice/</xsl:text>
4981- <var>&lt;id&gt;</var>
4982- </xsl:when>
4983- <xsl:when test="@id = 'h_w_vendor_i_d'">
4984- <xsl:text>/+hwdb/+hwvendorid/</xsl:text>
4985- <var>&lt;id&gt;</var>
4986- </xsl:when>
4987- <xsl:when test="@id = 'jabber_id'">
4988- <xsl:text>/</xsl:text>
4989- <var>&lt;person.name&gt;</var>
4990- <xsl:text>/+jabberid/</xsl:text>
4991- <var>&lt;id&gt;</var>
4992- </xsl:when>
4993- <xsl:when test="@id = 'irc_id'">
4994- <xsl:text>/</xsl:text>
4995- <var>&lt;person.name&gt;</var>
4996- <xsl:text>/+ircnick/</xsl:text>
4997- <var>&lt;id&gt;</var>
4998- </xsl:when>
4999- <xsl:when test="@id = 'language'">
5000- <xsl:text>/+languages/</xsl:text>
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches

to all changes: