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

Proposed by Barry Warsaw
Status: Superseded
Proposed branch: lp:~barry/ubuntu/natty/python-launchpadlib/bug-702375
Merge into: lp:ubuntu/natty/python-launchpadlib
Diff against target: 5627 lines (+2241/-2597)
28 files modified
PKG-INFO (+98/-1)
README.txt (+11/-1)
debian/changelog (+6/-0)
setup.cfg (+5/-0)
setup.py (+1/-0)
src/launchpadlib.egg-info/PKG-INFO (+251/-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 (+97/-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 (+384/-108)
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 (+405/-153)
src/launchpadlib/uris.py (+22/-8)
src/launchpadlib/wadl-to-refhtml.xsl (+0/-1045)
To merge this branch: bzr merge lp:~barry/ubuntu/natty/python-launchpadlib/bug-702375
Reviewer Review Type Date Requested Status
Leonard Richardson (community) Approve
Francis J. Lacoste Pending
Ubuntu branches Pending
Review via email: mp+48965@code.launchpad.net

This proposal has been superseded by a proposal from 2011-02-14.

Description of the change

Update to upstream 1.9.5 as requested by Leonard Richardson.

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

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 :

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 :

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 :

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 :

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 :

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 :

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.

Unmerged revisions

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

Subscribers

People subscribed via source and target branches

to all changes: