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
=== modified file 'PKG-INFO'
--- PKG-INFO 2010-12-07 19:30:29 +0000
+++ PKG-INFO 2011-02-08 19:01:02 +0000
@@ -1,6 +1,6 @@
1Metadata-Version: 1.01Metadata-Version: 1.0
2Name: launchpadlib2Name: launchpadlib
3Version: 1.6.23Version: 1.9.5
4Summary: Script Launchpad through its web services interfaces. Officially supported.4Summary: Script Launchpad through its web services interfaces. Officially supported.
5Home-page: https://help.launchpad.net/API/launchpadlib5Home-page: https://help.launchpad.net/API/launchpadlib
6Author: LAZR Developers6Author: LAZR Developers
@@ -31,6 +31,103 @@
31 NEWS for launchpadlib31 NEWS for launchpadlib
32 =====================32 =====================
33 33
34 1.9.5 (2010-02-08)
35 ==================
36
37 - Fixed a bug that prevented the deprecated get_token_and_login code
38 from working, and that required that users of get_token_and_login
39 get a new token on every usage.
40
41 1.9.4 (2010-01-18)
42 ==================
43
44 - Removed references to the 'edge' service root, which is being phased out.
45
46 - Fixed a minor bug in the upload_release_tarball contrib script which
47 was causing tarballs to be uploaded with the wrong media type.
48
49 - The XSLT stylesheet for converting the Launchpad WADL into HTML
50 documentation has been moved back into Launchpad.
51
52 1.9.3 (2011-01-10)
53 ==================
54
55 - The keyring package import is now delayed until the keyring needs to be
56 accessed. This reduces launchapdlib users' exposure to unintended side
57 effects of importing keyring (KWallet authorization dialogs and the
58 registration of a SIGCHLD handler).
59
60 1.9.2 (2011-01-07)
61 ==================
62
63 - Added a missing import.
64
65 1.9.1 (2011-01-06)
66 ==================
67
68 - Corrected a test failure.
69
70 1.9.0 (2011-01-05)
71 ==================
72
73 - When an authorization token expires or becomes invalid, attempt to
74 acquire a new one, even in the middle of a session, rather than
75 crashing.
76
77 - The HTML generated by wadl-to-refhtml.xsl now validates.
78
79 - Most of the helper login methods have been deprecated. There are now
80 only two helper methods:
81
82 * Launchpad.login_anonymously, for anonymous credential-free access.
83 * Launchpad.login_with, for programs that need a credential.
84
85
86 1.8.0 (2010-11-15)
87 ==================
88
89 - Store authorization tokens in the Gnome keyring or KDE wallet, when
90 available. The credentials_file parameter of Launchpad.login_with() is now
91 ignored.
92
93 - By default, Launchpad.login_with() now asks Launchpad for
94 desktop-wide integration. This removes the need for each individual
95 application to get its own OAuth token.
96
97 1.7.0 (2010-09-23)
98 ==================
99
100 - Removed "fake Launchpad browser" code that didn't work and was
101 misleading developers.
102
103 - Added support for http://qastaging.launchpad.net by adding
104 astaging to the uris.
105
106 1.6.5 (2010-08-23)
107 ==================
108
109 - Make launchpadlib compatible with the latest lazr.restfulclient.
110
111 1.6.4 (2010-08-18)
112 ==================
113
114 - Test fixes.
115
116 1.6.3 (2010-08-12)
117 ==================
118
119 - Instead of making the end-user hit Enter after authorizing an
120 application to access their Launchpad account, launchpadlib will
121 automatically poll Launchpad until the user makes a decision.
122
123 - launchpadlib now raises a more helpful exception when the end-user
124 explicitly denies access to a launchpadlib application.
125
126 - Improved the XSLT stylesheet to reflect Launchpad's more complex
127 top-level structure. [bug=286941]
128
129 - Test fixes. [bug=488448,616055]
130
34 1.6.2 (2010-06-21)131 1.6.2 (2010-06-21)
35 ==================132 ==================
36 133
37134
=== modified file 'README.txt'
--- README.txt 2010-12-07 19:30:29 +0000
+++ README.txt 2011-02-08 19:01:02 +0000
@@ -19,7 +19,7 @@
1919
2020
21Overview21Overview
22********22========
2323
24launchpadlib is a standalone Python library for scripting Launchpad through24launchpadlib is a standalone Python library for scripting Launchpad through
25its web services interface. It is the officially supported bindings to the25its web services interface. It is the officially supported bindings to the
@@ -44,3 +44,13 @@
44Please submit bug reports to44Please submit bug reports to
4545
46 https://bugs.launchpad.net/launchpadlib46 https://bugs.launchpad.net/launchpadlib
47
48
49Credential storage
50==================
51
52After authorizing an application to access Launchpad on a user's behalf,
53launchpadlib will attempt to store those credentials in the most
54system-appropriate way. That is, on Gnome it will store them in the Gnome
55keyring, on KDE it will store them in the KDE wallet and if neither of those
56is available it will store them on disk.
4757
=== modified file 'debian/changelog'
--- debian/changelog 2010-12-07 19:30:29 +0000
+++ debian/changelog 2011-02-08 19:01:02 +0000
@@ -1,3 +1,9 @@
1python-launchpadlib (1.9.5-0ubuntu1) natty; urgency=low
2
3 * New upstream release. (LP: #702375)
4
5 -- Barry Warsaw <barry@ubuntu.com> Tue, 08 Feb 2011 13:53:36 -0500
6
1python-launchpadlib (1.8.0.is.1.6.2-0ubuntu1) natty; urgency=low7python-launchpadlib (1.8.0.is.1.6.2-0ubuntu1) natty; urgency=low
28
3 * Revert to 1.6.2 for the time being. 1.8.0 completely breaks the login_with9 * Revert to 1.6.2 for the time being. 1.8.0 completely breaks the login_with
410
=== added file 'setup.cfg'
--- setup.cfg 1970-01-01 00:00:00 +0000
+++ setup.cfg 2011-02-08 19:01:02 +0000
@@ -0,0 +1,5 @@
1[egg_info]
2tag_build =
3tag_date = 0
4tag_svn_revision = 0
5
06
=== modified file 'setup.py'
--- setup.py 2010-12-07 19:30:29 +0000
+++ setup.py 2011-02-08 19:01:02 +0000
@@ -60,6 +60,7 @@
60 license='LGPL v3',60 license='LGPL v3',
61 install_requires=[61 install_requires=[
62 'httplib2',62 'httplib2',
63 'keyring',
63 'lazr.restfulclient>=0.9.19',64 'lazr.restfulclient>=0.9.19',
64 'lazr.uri',65 'lazr.uri',
65 'oauth',66 'oauth',
6667
=== added file 'src/launchpadlib.egg-info/PKG-INFO'
--- src/launchpadlib.egg-info/PKG-INFO 1970-01-01 00:00:00 +0000
+++ src/launchpadlib.egg-info/PKG-INFO 2011-02-08 19:01:02 +0000
@@ -0,0 +1,251 @@
1Metadata-Version: 1.0
2Name: launchpadlib
3Version: 1.9.5
4Summary: Script Launchpad through its web services interfaces. Officially supported.
5Home-page: https://help.launchpad.net/API/launchpadlib
6Author: LAZR Developers
7Author-email: lazr-developers@lists.launchpad.net
8License: LGPL v3
9Download-URL: https://launchpad.net/launchpadlib/+download
10Description: ..
11 This file is part of launchpadlib.
12
13 launchpadlib is free software: you can redistribute it and/or modify it
14 under the terms of the GNU Lesser General Public License as published by
15 the Free Software Foundation, version 3 of the License.
16
17 launchpadlib is distributed in the hope that it will be useful, but
18 WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
19 or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
20 License for more details.
21
22 You should have received a copy of the GNU Lesser General Public License
23 along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
24
25 launchpadlib
26 ************
27
28 See https://help.launchpad.net/API/launchpadlib .
29
30 =====================
31 NEWS for launchpadlib
32 =====================
33
34 1.9.5 (2010-02-08)
35 ==================
36
37 - Fixed a bug that prevented the deprecated get_token_and_login code
38 from working, and that required that users of get_token_and_login
39 get a new token on every usage.
40
41 1.9.4 (2010-01-18)
42 ==================
43
44 - Removed references to the 'edge' service root, which is being phased out.
45
46 - Fixed a minor bug in the upload_release_tarball contrib script which
47 was causing tarballs to be uploaded with the wrong media type.
48
49 - The XSLT stylesheet for converting the Launchpad WADL into HTML
50 documentation has been moved back into Launchpad.
51
52 1.9.3 (2011-01-10)
53 ==================
54
55 - The keyring package import is now delayed until the keyring needs to be
56 accessed. This reduces launchapdlib users' exposure to unintended side
57 effects of importing keyring (KWallet authorization dialogs and the
58 registration of a SIGCHLD handler).
59
60 1.9.2 (2011-01-07)
61 ==================
62
63 - Added a missing import.
64
65 1.9.1 (2011-01-06)
66 ==================
67
68 - Corrected a test failure.
69
70 1.9.0 (2011-01-05)
71 ==================
72
73 - When an authorization token expires or becomes invalid, attempt to
74 acquire a new one, even in the middle of a session, rather than
75 crashing.
76
77 - The HTML generated by wadl-to-refhtml.xsl now validates.
78
79 - Most of the helper login methods have been deprecated. There are now
80 only two helper methods:
81
82 * Launchpad.login_anonymously, for anonymous credential-free access.
83 * Launchpad.login_with, for programs that need a credential.
84
85
86 1.8.0 (2010-11-15)
87 ==================
88
89 - Store authorization tokens in the Gnome keyring or KDE wallet, when
90 available. The credentials_file parameter of Launchpad.login_with() is now
91 ignored.
92
93 - By default, Launchpad.login_with() now asks Launchpad for
94 desktop-wide integration. This removes the need for each individual
95 application to get its own OAuth token.
96
97 1.7.0 (2010-09-23)
98 ==================
99
100 - Removed "fake Launchpad browser" code that didn't work and was
101 misleading developers.
102
103 - Added support for http://qastaging.launchpad.net by adding
104 astaging to the uris.
105
106 1.6.5 (2010-08-23)
107 ==================
108
109 - Make launchpadlib compatible with the latest lazr.restfulclient.
110
111 1.6.4 (2010-08-18)
112 ==================
113
114 - Test fixes.
115
116 1.6.3 (2010-08-12)
117 ==================
118
119 - Instead of making the end-user hit Enter after authorizing an
120 application to access their Launchpad account, launchpadlib will
121 automatically poll Launchpad until the user makes a decision.
122
123 - launchpadlib now raises a more helpful exception when the end-user
124 explicitly denies access to a launchpadlib application.
125
126 - Improved the XSLT stylesheet to reflect Launchpad's more complex
127 top-level structure. [bug=286941]
128
129 - Test fixes. [bug=488448,616055]
130
131 1.6.2 (2010-06-21)
132 ==================
133
134 - Extended the optimization from version 1.6.1 to apply to Launchpad's
135 top-level collection of people.
136
137 1.6.1 (2010-06-16)
138 ==================
139
140 - Added an optimization that lets launchpadlib avoid making an HTTP
141 request in some situations.
142
143 1.6.0 (2010-04-07)
144 ==================
145
146 - Fixed a test to work against the latest version of Launchpad.
147
148 1.5.8 (2010-03-25)
149 ==================
150
151 - Use version 1.0 of the Launchpad web service by default.
152
153 1.5.7 (2010-03-16)
154 ==================
155
156 - Send a Referer header whenever making requests to the Launchpad
157 website (as opposed to the web service) to avoid falling afoul of
158 new cross-site-request-forgery countermeasures.
159
160 1.5.6 (2010-03-04)
161 ==================
162
163 - Fixed a minor bug when using login_with() to access a version of the
164 Launchpad web service other than the default.
165
166 - Added a check to catch old client code that would cause newer
167 versions of launchpadlib to make nonsensical requests to
168 https://api.launchpad.dev/beta/beta/, and raise a helpful exception
169 telling the developer how to fix it.
170
171 1.5.5
172 =====
173
174 - Added the ability to access different versions of the Launchpad web
175 service.
176
177 1.5.4 (2009-12-17)
178 ==================
179
180 - Made it easy to get anonymous access to a Launchpad instance.
181
182 - Made it easy to plug in different clients that take the user's
183 Launchpad login and password for purposes of authorizing a request
184 token. The most secure technique is still the default: to open the
185 user's web browser to the appropriate Launchpad page.
186
187 - Introduced a command-line script bin/launchpad-credentials-console,
188 which takes the user's Launchpad login and password, and authorizes
189 a request token on their behalf.
190
191 - Introduced a command-line script bin/launchpad-request-token, which
192 creates a request token on any Launchpad installation and dumps the
193 JSON description of that token to standard output.
194
195 - Shorthand service names like 'edge' should now be respected
196 everywhere in launchpadlib.
197
198 1.5.3 (2009-10-22)
199 ==================
200
201 - Moved some more code from launchpadlib into the more generic
202 lazr.restfulclient.
203
204 1.5.2 (2009-10-01)
205 ==================
206
207 - Added a number of new sample scripts from elsewhere.
208
209 - Added a reference to the production Launchpad instance.
210
211 - Made it easier to specify a Launchpad instance to run against.
212
213 1.5.1 (2009-07-16)
214 ==================
215
216 - Added a sample script for uploading a release tarball to Launchpad.
217
218 1.5.0 (2009-07-09)
219 ==================
220
221 - Most of launchpadlib's code has been moved to the generic
222 lazr.restfulclient library. launchpadlib now contains only code
223 specific to Launchpad. There should be no changes in functionality.
224
225 - Moved bootstrap.py into the top-level directory. Having it in a
226 subdirectory with a top-level symlink was breaking installation on
227 Windows.
228
229 - The notice to the end-user (that we're opening their web
230 browser) is now better formatted.
231
232 1.0.1 (2009-05-30)
233 ==================
234
235 - Correct tests for new launchpad cache behavior in librarian
236
237 - Remove build dependency on setuptools_bzr because it was causing bzr to be
238 downloaded during installation of the package, which was unnecessary and
239 annoying.
240
241 1.0 (2009-03-24)
242 ================
243
244 - Initial release on PyPI
245
246Platform: UNKNOWN
247Classifier: Development Status :: 5 - Production/Stable
248Classifier: Intended Audience :: Developers
249Classifier: License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)
250Classifier: Operating System :: OS Independent
251Classifier: Programming Language :: Python
0252
=== modified file 'src/launchpadlib.egg-info/SOURCES.txt'
--- src/launchpadlib.egg-info/SOURCES.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib.egg-info/SOURCES.txt 2011-02-08 19:01:02 +0000
@@ -11,22 +11,22 @@
11src/launchpadlib/errors.py11src/launchpadlib/errors.py
12src/launchpadlib/launchpad.py12src/launchpadlib/launchpad.py
13src/launchpadlib/uris.py13src/launchpadlib/uris.py
14src/launchpadlib/wadl-to-refhtml.xsl
15src/launchpadlib.egg-info/PKG-INFO14src/launchpadlib.egg-info/PKG-INFO
16src/launchpadlib.egg-info/SOURCES.txt15src/launchpadlib.egg-info/SOURCES.txt
17src/launchpadlib.egg-info/dependency_links.txt16src/launchpadlib.egg-info/dependency_links.txt
18src/launchpadlib.egg-info/not-zip-safe17src/launchpadlib.egg-info/not-zip-safe
19src/launchpadlib.egg-info/requires.txt18src/launchpadlib.egg-info/requires.txt
20src/launchpadlib.egg-info/top_level.txt19src/launchpadlib.egg-info/top_level.txt
21src/launchpadlib/docs/browser.txt
22src/launchpadlib/docs/command-line.txt20src/launchpadlib/docs/command-line.txt
23src/launchpadlib/docs/hosted-files.txt21src/launchpadlib/docs/hosted-files.txt
24src/launchpadlib/docs/introduction.txt22src/launchpadlib/docs/introduction.txt
23src/launchpadlib/docs/operations.txt
25src/launchpadlib/docs/people.txt24src/launchpadlib/docs/people.txt
26src/launchpadlib/docs/toplevel.txt25src/launchpadlib/docs/toplevel.txt
27src/launchpadlib/docs/trusted-client.txt
28src/launchpadlib/docs/files/mugshot.png26src/launchpadlib/docs/files/mugshot.png
29src/launchpadlib/testing/__init__.py27src/launchpadlib/testing/__init__.py
30src/launchpadlib/testing/helpers.py28src/launchpadlib/testing/helpers.py
31src/launchpadlib/tests/__init__.py29src/launchpadlib/tests/__init__.py
30src/launchpadlib/tests/test_credential_store.py
31src/launchpadlib/tests/test_http.py
32src/launchpadlib/tests/test_launchpad.py32src/launchpadlib/tests/test_launchpad.py
33\ No newline at end of file33\ No newline at end of file
3434
=== added file 'src/launchpadlib.egg-info/not-zip-safe'
--- src/launchpadlib.egg-info/not-zip-safe 1970-01-01 00:00:00 +0000
+++ src/launchpadlib.egg-info/not-zip-safe 2011-02-08 19:01:02 +0000
@@ -0,0 +1,1 @@
1
02
=== modified file 'src/launchpadlib.egg-info/requires.txt'
--- src/launchpadlib.egg-info/requires.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib.egg-info/requires.txt 2011-02-08 19:01:02 +0000
@@ -1,5 +1,6 @@
1httplib21httplib2
2lazr.restfulclient>=0.9.112keyring
3lazr.restfulclient>=0.9.19
3lazr.uri4lazr.uri
4oauth5oauth
5setuptools6setuptools
67
=== modified file 'src/launchpadlib/NEWS.txt'
--- src/launchpadlib/NEWS.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/NEWS.txt 2011-02-08 19:01:02 +0000
@@ -2,6 +2,103 @@
2NEWS for launchpadlib2NEWS for launchpadlib
3=====================3=====================
44
51.9.5 (2010-02-08)
6==================
7
8- Fixed a bug that prevented the deprecated get_token_and_login code
9 from working, and that required that users of get_token_and_login
10 get a new token on every usage.
11
121.9.4 (2010-01-18)
13==================
14
15- Removed references to the 'edge' service root, which is being phased out.
16
17- Fixed a minor bug in the upload_release_tarball contrib script which
18 was causing tarballs to be uploaded with the wrong media type.
19
20- The XSLT stylesheet for converting the Launchpad WADL into HTML
21 documentation has been moved back into Launchpad.
22
231.9.3 (2011-01-10)
24==================
25
26- The keyring package import is now delayed until the keyring needs to be
27 accessed. This reduces launchapdlib users' exposure to unintended side
28 effects of importing keyring (KWallet authorization dialogs and the
29 registration of a SIGCHLD handler).
30
311.9.2 (2011-01-07)
32==================
33
34- Added a missing import.
35
361.9.1 (2011-01-06)
37==================
38
39- Corrected a test failure.
40
411.9.0 (2011-01-05)
42==================
43
44- When an authorization token expires or becomes invalid, attempt to
45 acquire a new one, even in the middle of a session, rather than
46 crashing.
47
48- The HTML generated by wadl-to-refhtml.xsl now validates.
49
50- Most of the helper login methods have been deprecated. There are now
51 only two helper methods:
52
53 * Launchpad.login_anonymously, for anonymous credential-free access.
54 * Launchpad.login_with, for programs that need a credential.
55
56
571.8.0 (2010-11-15)
58==================
59
60- Store authorization tokens in the Gnome keyring or KDE wallet, when
61 available. The credentials_file parameter of Launchpad.login_with() is now
62 ignored.
63
64- By default, Launchpad.login_with() now asks Launchpad for
65 desktop-wide integration. This removes the need for each individual
66 application to get its own OAuth token.
67
681.7.0 (2010-09-23)
69==================
70
71- Removed "fake Launchpad browser" code that didn't work and was
72 misleading developers.
73
74- Added support for http://qastaging.launchpad.net by adding
75 astaging to the uris.
76
771.6.5 (2010-08-23)
78==================
79
80- Make launchpadlib compatible with the latest lazr.restfulclient.
81
821.6.4 (2010-08-18)
83==================
84
85- Test fixes.
86
871.6.3 (2010-08-12)
88==================
89
90- Instead of making the end-user hit Enter after authorizing an
91 application to access their Launchpad account, launchpadlib will
92 automatically poll Launchpad until the user makes a decision.
93
94- launchpadlib now raises a more helpful exception when the end-user
95 explicitly denies access to a launchpadlib application.
96
97- Improved the XSLT stylesheet to reflect Launchpad's more complex
98 top-level structure. [bug=286941]
99
100- Test fixes. [bug=488448,616055]
101
51.6.2 (2010-06-21)1021.6.2 (2010-06-21)
6==================103==================
7104
8105
=== modified file 'src/launchpadlib/__init__.py'
--- src/launchpadlib/__init__.py 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/__init__.py 2011-02-08 19:01:02 +0000
@@ -14,4 +14,4 @@
14# You should have received a copy of the GNU Lesser General Public License14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
1616
17__version__ = '1.6.2'17__version__ = '1.9.5'
1818
=== modified file 'src/launchpadlib/apps.py'
--- src/launchpadlib/apps.py 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/apps.py 2011-02-08 19:01:02 +0000
@@ -52,122 +52,3 @@
52 return simplejson.dumps(token)52 return simplejson.dumps(token)
5353
5454
55class TrustedTokenAuthorizationConsoleApp(RequestTokenAuthorizationEngine):
56 """An application that authorizes request tokens."""
57
58 def __init__(self, web_root, consumer_name, request_token,
59 access_levels='', input_method=raw_input):
60 """Constructor.
61
62 :param access_levels: A string of comma-separated access level
63 values. To get an up-to-date list of access levels, pass
64 token_format=Credentials.DICT_TOKEN_FORMAT into
65 Credentials.get_request_token, load the dict as JSON, and look
66 in 'access_levels'.
67 """
68 access_levels = [level.strip() for level in access_levels.split(',')]
69 super(TrustedTokenAuthorizationConsoleApp, self).__init__(
70 web_root, consumer_name, request_token, access_levels)
71
72 self.input_method = input_method
73
74 def run(self):
75 """Try to authorize a request token from user input."""
76 self.error_code = -1 # Start off assuming failure.
77 start = "Launchpad credential client (console)"
78 self.output(start)
79 self.output("-" * len(start))
80
81 try:
82 self()
83 except TokenAuthorizationException, e:
84 print str(e)
85 self.error_code = -1
86 return self.press_enter_to_exit()
87
88 def exit_with(self, code):
89 """Exit the app with the specified error code."""
90 sys.exit(code)
91
92 def get_single_char_input(self, prompt, valid):
93 """Retrieve a single-character line from the input stream."""
94 valid = valid.upper()
95 input = None
96 while input is None:
97 input = self.input_method(prompt).upper()
98 if len(input) != 1 or input not in valid:
99 input = None
100 return input
101
102 def press_enter_to_exit(self):
103 """Make the user hit enter, and then exit with an error code."""
104 prompt = '\nPress enter to go back to "%s". ' % self.consumer_name
105 self.input_method(prompt)
106 self.exit_with(self.error_code)
107
108 def input_username(self, cached_username, suggested_message):
109 """Collect the Launchpad username from the end-user.
110
111 :param cached_username: A username from a previous entry attempt,
112 to be presented as the default.
113 """
114 if cached_username is not None:
115 extra = " [%s] " % cached_username
116 else:
117 extra = "\n(No Launchpad account? Just hit enter.) "
118 username = self.input_method(suggested_message + extra)
119 if username == '':
120 return cached_username
121 return username
122
123 def input_password(self, suggested_message):
124 """Collect the Launchpad password from the end-user."""
125 if self.input_method is raw_input:
126 password = getpass.getpass(suggested_message + " ")
127 else:
128 password = self.input_method(suggested_message)
129 return password
130
131 def input_access_level(self, available_levels, suggested_message,
132 only_one_option=None):
133 """Collect the desired level of access from the end-user."""
134 if only_one_option is not None:
135 self.output(suggested_message)
136 prompt = self.message(
137 'Do you want to give "%(app)s" this level of access? [YN] ')
138 allow = self.get_single_char_input(prompt, "YN")
139 if allow == "Y":
140 return only_one_option['value']
141 else:
142 return self.UNAUTHORIZED_ACCESS_LEVEL
143 else:
144 levels_except_unauthorized = [
145 level for level in available_levels
146 if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL]
147 options = []
148 for i in range(0, len(levels_except_unauthorized)):
149 options.append(
150 "%d: %s" % (i+1, levels_except_unauthorized[i]['title']))
151 self.output(suggested_message)
152 for option in options:
153 self.output(option)
154 allowed = ("".join(map(str, range(1, i+2)))) + "Q"
155 prompt = self.message(
156 'What should "%(app)s" be allowed to do using your '
157 'Launchpad account? [1-%(max)d or Q] ',
158 extra_variables = {'max' : i+1})
159 allow = self.get_single_char_input(prompt, allowed)
160 if allow == "Q":
161 return self.UNAUTHORIZED_ACCESS_LEVEL
162 else:
163 return levels_except_unauthorized[int(allow)-1]['value']
164
165 def user_refused_to_authorize(self, suggested_message):
166 """The user refused to authorize a request token."""
167 self.output(suggested_message)
168 self.error_code = -2
169
170 def user_authorized(self, access_level, suggested_message):
171 """The user authorized a request token with some access level."""
172 self.output(suggested_message)
173 self.error_code = 0
17455
=== modified file 'src/launchpadlib/credentials.py'
--- src/launchpadlib/credentials.py 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/credentials.py 2011-02-08 19:01:02 +0000
@@ -20,17 +20,20 @@
20__all__ = [20__all__ = [
21 'AccessToken',21 'AccessToken',
22 'AnonymousAccessToken',22 'AnonymousAccessToken',
23 'AuthorizeRequestTokenWithBrowser',
24 'CredentialStore',
23 'RequestTokenAuthorizationEngine',25 'RequestTokenAuthorizationEngine',
24 'Consumer',26 'Consumer',
25 'Credentials',27 'Credentials',
26 ]28 ]
2729
28import base64
29import cgi30import cgi
31from cStringIO import StringIO
30import httplib232import httplib2
31import sys33import os
32import textwrap34import stat
33from urllib import urlencode, quote35import time
36from urllib import urlencode
34from urlparse import urljoin37from urlparse import urljoin
35import webbrowser38import webbrowser
3639
@@ -38,13 +41,20 @@
3841
39from lazr.restfulclient.errors import HTTPError42from lazr.restfulclient.errors import HTTPError
40from lazr.restfulclient.authorize.oauth import (43from lazr.restfulclient.authorize.oauth import (
41 AccessToken as _AccessToken, Consumer, OAuthAuthorizer)44 AccessToken as _AccessToken,
45 Consumer,
46 OAuthAuthorizer,
47 SystemWideConsumer # Not used directly, just re-imported into here.
48 )
4249
43from launchpadlib import uris50from launchpadlib import uris
4451
45request_token_page = '+request-token'52request_token_page = '+request-token'
46access_token_page = '+access-token'53access_token_page = '+access-token'
47authorize_token_page = '+authorize-token'54authorize_token_page = '+authorize-token'
55access_token_poll_time = 1
56
57EXPLOSIVE_ERRORS = (MemoryError, KeyboardInterrupt, SystemExit)
4858
4959
50class Credentials(OAuthAuthorizer):60class Credentials(OAuthAuthorizer):
@@ -60,6 +70,25 @@
60 URI_TOKEN_FORMAT = "uri"70 URI_TOKEN_FORMAT = "uri"
61 DICT_TOKEN_FORMAT = "dict"71 DICT_TOKEN_FORMAT = "dict"
6272
73 def serialize(self):
74 """Turn this object into a string.
75
76 This should probably be moved into OAuthAuthorizer.
77 """
78 sio = StringIO()
79 self.save(sio)
80 return sio.getvalue()
81
82 @classmethod
83 def from_string(cls, value):
84 """Create a `Credentials` object from a serialized string.
85
86 This should probably be moved into OAuthAuthorizer.
87 """
88 credentials = cls()
89 credentials.load(StringIO(value))
90 return credentials
91
63 def get_request_token(self, context=None, web_root=uris.STAGING_WEB_ROOT,92 def get_request_token(self, context=None, web_root=uris.STAGING_WEB_ROOT,
64 token_format=URI_TOKEN_FORMAT):93 token_format=URI_TOKEN_FORMAT):
65 """Request an OAuth token to Launchpad.94 """Request an OAuth token to Launchpad.
@@ -182,362 +211,351 @@
182 super(AnonymousAccessToken, self).__init__('','')211 super(AnonymousAccessToken, self).__init__('','')
183212
184213
185class SimulatedLaunchpadBrowser(object):214class CredentialStore(object):
186 """A programmable substitute for a human-operated web browser.215 """Store OAuth credentials locally.
187216
188 Used by client programs to interact with Launchpad's credential217 This is a generic superclass. To implement a specific way of
189 pages, without opening them in the user's actual web browser.218 storing credentials locally you'll need to subclass this class,
190 """219 and implement `do_save` and `do_load`.
191220 """
192 def __init__(self, web_root=uris.STAGING_WEB_ROOT):221
193 self.web_root = uris.lookup_web_root(web_root)222 def __init__(self, credential_save_failed=None):
194 self.http = httplib2.Http()223 """Constructor.
195224
196 def _auth_header(self, username, password):225 :param credential_save_failed: A callback to be invoked if the
197 """Utility method to generate a Basic auth header."""226 save to local storage fails. You should never invoke this
198 auth = base64.encodestring("%s:%s" % (username, password))[:-1]227 callback yourself! Instead, you should raise an exception
199 return "Basic " + auth228 from do_save().
200229 """
201 def get_token_info(self, username, password, request_token,230 self.credential_save_failed = credential_save_failed
202 access_levels=''):231
203 """Retrieve a JSON representation of a request token.232 def save(self, credentials, unique_consumer_id):
204233 """Save the credentials and invoke the callback on failure.
205 This is useful for verifying that the end-user gave a valid234
206 username and password, and for reconciling the client's235 Do not override this method when subclassing. Override
207 allowable access levels with the access levels defined in236 do_save() instead.
208 Launchpad.237 """
209 """238 try:
210 if access_levels != '':239 self.do_save(credentials, unique_consumer_id)
211 s = "&allow_permission="240 except EXPLOSIVE_ERRORS:
212 access_levels = s + s.join(access_levels)241 raise
213 page = "%s?oauth_token=%s%s" % (242 except Exception, e:
214 authorize_token_page, request_token, access_levels)243 if self.credential_save_failed is None:
215 url = urljoin(self.web_root, page)244 raise e
216 # We can't use httplib2's add_credentials, because Launchpad245 self.credential_save_failed()
217 # doesn't respond to credential-less access with a 401246 return credentials
218 # response code.247
219 headers = {'Accept' : 'application/json',248 def do_save(self, credentials, unique_consumer_id):
220 'Referer' : self.web_root}249 """Store newly-authorized credentials locally for later use.
221 headers['Authorization'] = self._auth_header(username, password)250
222 response, content = self.http.request(url, headers=headers)251 :param credentials: A Credentials object to save.
223 # Detect common error conditions and set the response code252 :param unique_consumer_id: A string uniquely identifying an
224 # appropriately. This lets code that uses253 OAuth consumer on a Launchpad instance.
225 # SimulatedLaunchpadBrowser detect standard response codes254 """
226 # instead of having Launchpad-specific knowledge.255 raise NotImplementedError()
227 location = response.get('content-location')256
228 if response.status == 200 and '+login' in location:257 def load(self, unique_key):
229 response.status = 401258 """Retrieve credentials from a local store.
230 elif response.get('content-type') != 'application/json':259
231 response.status = 500260 This method is the inverse of `save`.
232 return response, content261
233262 There's no special behavior in this method--it just calls
234 def grant_access(self, username, password, request_token, access_level,263 `do_load`. There _is_ special behavior in `save`, and this
235 context=None):264 way, developers can remember to implement `do_save` and
236 """Grant a level of access to an application on behalf of a user."""265 `do_load`, not `do_save` and `load`.
237 headers = {'Content-type' : 'application/x-www-form-urlencoded',266
238 'Referer' : self.web_root}267 :param unique_key: A string uniquely identifying an OAuth consumer
239 headers['Authorization'] = self._auth_header(username, password)268 on a Launchpad instance.
240 body = "oauth_token=%s&field.actions.%s=True" % (269
241 quote(request_token), quote(access_level))270 :return: A `Credentials` object if one is found in the local
242 if context is not None:271 store, and None otherise.
243 body += "&lp.context=%s" % quote(context)272 """
244 url = urljoin(self.web_root, "+authorize-token")273 return self.do_load(unique_key)
245 response, content = self.http.request(274
246 url, method="POST", headers=headers, body=body)275 def do_load(self, unique_key):
247 # This would be much less fragile if Launchpad gave us an276 """Retrieve credentials from a local store.
248 # error code to work with.277
249 if "Unauthenticated user POSTing to page" in content:278 This method is the inverse of `do_save`.
250 response.status = 401 # Unauthorized279
251 elif 'Request already reviewed' in content:280 :param unique_key: A string uniquely identifying an OAuth consumer
252 response.status = 409 # Conflict281 on a Launchpad instance.
253 elif 'What level of access' in content:282
254 response.status = 400 # Bad Request283 :return: A `Credentials` object if one is found in the local
255 elif 'Unable to identify application' in content:284 store, and None otherise.
256 response.status = 400 # Bad Request285 """
257 elif not 'Almost finished' in content:286 raise NotImplementedError()
258 response.status = 500 # Internal Server Error287
259 return response, content288
289class KeyringCredentialStore(CredentialStore):
290 """Store credentials in the GNOME keyring or KDE wallet.
291
292 This is a good solution for desktop applications and interactive
293 scripts. It doesn't work for non-interactive scripts, or for
294 integrating third-party websites into Launchpad.
295 """
296
297 @staticmethod
298 def _ensure_keyring_imported():
299 """Ensure the keyring module is imported (postponing side effects).
300
301 The keyring module initializes the environment-dependent backend at
302 import time (nasty). We want to avoid that initialization because it
303 may do things like prompt the user to unlock their password store
304 (e.g., KWallet).
305 """
306 if 'keyring' not in globals():
307 global keyring
308 import keyring
309
310 def do_save(self, credentials, unique_key):
311 """Store newly-authorized credentials in the keyring."""
312 self._ensure_keyring_imported()
313 keyring.set_password(
314 'launchpadlib', unique_key, credentials.serialize())
315
316 def do_load(self, unique_key):
317 """Retrieve credentials from the keyring."""
318 self._ensure_keyring_imported()
319 credential_string = keyring.get_password(
320 'launchpadlib', unique_key)
321 if credential_string is not None:
322 return Credentials.from_string(credential_string)
323 return None
324
325
326class UnencryptedFileCredentialStore(CredentialStore):
327 """Store credentials unencrypted in a file on disk.
328
329 This is a good solution for scripts that need to run without any
330 user interaction.
331 """
332
333 def __init__(self, filename, credential_save_failed=None):
334 super(UnencryptedFileCredentialStore, self).__init__(
335 credential_save_failed)
336 self.filename = filename
337
338 def do_save(self, credentials, unique_key):
339 """Save the credentials to disk."""
340 credentials.save_to_path(self.filename)
341
342 def do_load(self, unique_key):
343 """Load the credentials from disk."""
344 if (os.path.exists(self.filename)
345 and not os.stat(self.filename)[stat.ST_SIZE] == 0):
346 return Credentials.load_from_path(self.filename)
347 return None
260348
261349
262class RequestTokenAuthorizationEngine(object):350class RequestTokenAuthorizationEngine(object):
351 """The superclass of all request token authorizers.
352
353 This base class does not implement request token authorization,
354 since that varies depending on how you want the end-user to
355 authorize a request token. You'll need to subclass this class and
356 implement `make_end_user_authorize_token`.
357 """
263358
264 UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"359 UNAUTHORIZED_ACCESS_LEVEL = "UNAUTHORIZED"
265360
266 # Suggested messages for clients to display in common situations.361 def __init__(self, service_root, application_name=None,
267362 consumer_name=None, allow_access_levels=None):
268 AUTHENTICATION_FAILURE = "I can't log in with the credentials you gave me. Let's try again."363 """Base class initialization.
269364
270 CHOOSE_ACCESS_LEVEL = """Now it's time for you to decide how much power to give "%(app)s" over your Launchpad account."""365 :param service_root: The root of the Launchpad instance being
271366 used.
272 CHOOSE_ACCESS_LEVEL_ONE = CHOOSE_ACCESS_LEVEL + """367
273368 :param application_name: The name of the application that
274"%(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."""369 wants to use launchpadlib. This is used in conjunction
275370 with a desktop-wide integration.
276 USER_AUTHORIZED = """Okay, I'm telling Launchpad to grant "%(app)s" access to your account."""371
277372 If you specify this argument, your values for
278 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."""373 consumer_name and allow_access_levels are ignored.
279374
280 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."""375 :param consumer_name: The OAuth consumer name, for an
281376 application that wants its own point of integration into
282 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."""377 Launchpad. In almost all cases, you want to specify
283378 application_name instead and do a desktop-wide
284 INPUT_USERNAME = "What email address do you use on Launchpad?"379 integration. The exception is when you're integrating a
285380 third-party website into Launchpad.
286 INPUT_PASSWORD = "What's your Launchpad password? "381
287382 :param allow_access_levels: A list of the Launchpad access
288 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."""383 levels to present to the user. ('READ_PUBLIC' and so on.)
289384 Your value for this argument will be ignored during a
290 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."""385 desktop-wide integration.
291386 :type allow_access_levels: A list of strings.
292 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"."""387 """
293388 self.service_root = uris.lookup_service_root(service_root)
294 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."""389 self.web_root = uris.web_root_for_service_root(service_root)
295390
296 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."""391 if application_name is None and consumer_name is None:
297392 raise ValueError(
298 SUCCESS = """You're all done! You should now be able to use Launchpad integration features of "%(app)s." """393 "You must provide either application_name or consumer_name.")
299394
300 SUCCESS_UNAUTHORIZED = """You're all done! "%(app)s" still doesn't have access to your Launchpad account."""395 if application_name is not None and consumer_name is not None:
301396 raise ValueError(
302 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."""397 "You must provide only one of application_name and "
303398 "consumer_name. (You provided %r and %r.)" % (
304 YOU_NEED_A_LAUNCHPAD_ACCOUNT = """OK, you'll need to get yourself a Launchpad account before you can integrate Launchpad into "%(app)s."399 application_name, consumer_name))
305400
306I'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."""401 if consumer_name is None:
307402 # System-wide integration. Create a system-wide consumer
308 def __init__(self, web_root, consumer_name, request_token,403 # and identify the application using a separate
309 allow_access_levels=[], max_failed_attempts=3):404 # application name.
310 self.web_root = uris.lookup_web_root(web_root)405 allow_access_levels = ["DESKTOP_INTEGRATION"]
311 self.consumer_name = consumer_name406 consumer = SystemWideConsumer(application_name)
312 self.request_token = request_token407 else:
313 self.browser = SimulatedLaunchpadBrowser(self.web_root)408 # Application-specific integration. Use the provided
314 self.max_failed_attempts = max_failed_attempts409 # consumer name to create a consumer automatically.
315 self.allow_access_levels = allow_access_levels410 consumer = Consumer(consumer_name)
316 self.text_wrapper = textwrap.TextWrapper(411 application_name = consumer_name
317 replace_whitespace=False, width=78)412
318413 self.consumer = consumer
319 def __call__(self):414 self.application_name = application_name
320415
321 self.startup(416 self.allow_access_levels = allow_access_levels or []
322 [self.message(self.STARTUP_MESSAGE),417
323 self.message(self.STARTUP_MESSAGE_2)])418 @property
324419 def unique_consumer_id(self):
325 # Have the end-user enter their Launchpad username and password.420 """Return a string identifying this consumer on this host."""
326 # Make sure the credentials are valid, and get information421 return self.consumer.key + '@' + self.service_root
327 # about the request token as a side effect.422
328 username, password, token_info = self.get_http_credentials()423 def authorization_url(self, request_token):
329424 """Return the authorization URL for a request token.
330 # Update this object with fresh information about the request token.425
331 self.token_info = token_info426 This is the URL the end-user must visit to authorize the
332 self.reconciled_access_levels = token_info['access_levels']427 token. How exactly does this happen? That depends on the
333 self._check_consumer()428 subclass implementation.
334429 """
335 # Have the end-user choose an access level from the fresh list.430 page = "%s?oauth_token=%s" % (authorize_token_page, request_token)
336 if len(self.reconciled_access_levels) == 2:431 allow_permission = "&allow_permission="
337 # There's only one choice: allow access at a certain level432 if len(self.allow_access_levels) > 0:
338 # or don't allow access at all.433 page += (
339 message = self.CHOOSE_ACCESS_LEVEL_ONE434 allow_permission
340 level = [level for level in self.reconciled_access_levels435 + allow_permission.join(self.allow_access_levels))
341 if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL][0]436 return urljoin(self.web_root, page)
342 extra = {'level' : level['title']}437
343 only_one_option = level438 def __call__(self, credentials, credential_store):
344 else:439 """Authorize a token and associate it with the given credentials.
345 message = self.CHOOSE_ACCESS_LEVEL440
346 extra = None441 If the credential store runs into a problem storing the
347 only_one_option = None442 credential locally, the `credential_save_failed` callback will
348 access_level = self.input_access_level(443 be invoked. The callback will not be invoked if there's a
349 self.reconciled_access_levels, self.message(message, extra),444 problem authorizing the credentials.
350 only_one_option)445
351446 :param credentials: A `Credentials` object. If the end-user
352 # Notify the program of the user's choice.447 authorizes these credentials, this object will have its
353 if access_level == self.UNAUTHORIZED_ACCESS_LEVEL:448 .access_token property set.
354 self.user_refused_to_authorize(449
355 self.message(self.USER_REFUSED_TO_AUTHORIZE))450 :param credential_store: A `CredentialStore` object. If the
356 else:451 end-user authorizes the credentials, they will be
357 self.user_authorized(452 persisted locally using this object.
358 access_level, self.message(self.USER_AUTHORIZED))453
359454 :return: If the credentials are successfully authorized, the
360 # Try to grant the specified level of access to the request token.455 return value is the `Credentials` object originally passed
361 response, content = self.browser.grant_access(456 in. Otherwise the return value is None.
362 username, password, self.request_token, access_level)457 """
363 if response.status == 409:458 request_token_string = self.get_request_token(credentials)
364 raise RequestTokenAlreadyAuthorized(459 # Hand off control to the end-user.
365 self.message(self.REQUEST_TOKEN_ALREADY_AUTHORIZED))460 self.make_end_user_authorize_token(credentials, request_token_string)
366 elif response.status == 400:461 if credentials.access_token is None:
367 raise ClientError(self.message(self.CLIENT_ERROR))462 # The end-user refused to authorize the application.
368 elif response.status == 500:463 return None
369 raise ServerError(self.message(self.SERVER_ERROR))464 # save() invokes the callback on failure.
370 if access_level == self.UNAUTHORIZED_ACCESS_LEVEL:465 credential_store.save(credentials, self.unique_consumer_id)
371 message = self.SUCCESS_UNAUTHORIZED466 return credentials
372 else:467
373 message = self.SUCCESS468 def get_request_token(self, credentials):
374 self.success(self.message(message))469 """Get a new request token from the server.
375470
376 def get_http_credentials(self, cached_username=None, failed_attempts=0):471 :param return: The request token.
377 """Authenticate the user to Launchpad, or raise an exception trying.472 """
378473 authorization_json = credentials.get_request_token(
379 :return: A 3-tuple (username, password,474 web_root=self.web_root,
380 token_info). 'username' and 'password' are the validated475 token_format=Credentials.DICT_TOKEN_FORMAT)
381 Launchpad username and password. 'token_info' is a dict of476 return authorization_json['oauth_token']
382 validated information about the request token, including477
383 Launchpad's reconciled list of its available access levels478 def make_end_user_authorize_token(self, credentials, request_token):
384 with the access levels the third-party client will accept.479 """Authorize the given request token using the given credentials.
385480
386 :param cached_username: If the user has tried to enter their481 Your subclass must implement this method: it has no default
387 credentials before and failed, this variable will contain the482 implementation.
388 username they entered the first time. This can be presented as483
389 a default, since users are more likely to enter the wrong484 Because an access token may expire or be revoked in the middle
390 password than the wrong username.485 of a session, this method may be called at arbitrary points in
391486 a launchpadlib session, or even multiple times during a single
392 :param failed_attempts: This method calls itself recursively487 session (with a different request token each time).
393 until failed_attempts equals self.max_failed_attempts.488
394 """489 In most cases, however, this method will be called at the
395 username = self.input_username(490 beginning of a launchpadlib session, or not at all.
396 cached_username, self.message(self.INPUT_USERNAME))491 """
397 if username is None:492 raise NotImplementedError()
398 self.open_page_in_user_browser(
399 urljoin(self.web_root, "+login"))
400 raise NoLaunchpadAccount(
401 self.message(self.YOU_NEED_A_LAUNCHPAD_ACCOUNT))
402 password = self.input_password(self.message(self.INPUT_PASSWORD))
403 response, content = self.browser.get_token_info(
404 username, password, self.request_token, self.allow_access_levels)
405 if response.status == 500:
406 raise ServerError(self.message(self.SERVER_ERROR))
407 elif response.status == 401:
408 failed_attempts += 1
409 if failed_attempts == self.max_failed_attempts:
410 raise TooManyAuthenticationFailures(
411 self.message(self.TOO_MANY_AUTHENTICATION_FAILURES))
412 else:
413 self.authentication_failure(
414 self.message(self.AUTHENTICATION_FAILURE))
415 return self.get_http_credentials(username, failed_attempts)
416 token_info = simplejson.loads(content)
417 # If Launchpad provides no information about the request token,
418 # that means the request token doesn't exist.
419 if 'oauth_token' not in token_info:
420 raise RequestTokenAlreadyAuthorized(
421 self.message(self.NONEXISTENT_REQUEST_TOKEN))
422 return username, password, token_info
423
424 def _check_consumer(self):
425 """Sanity-check the server consumer against the client consumer."""
426 real_consumer = self.token_info['oauth_token_consumer']
427 if real_consumer != self.consumer_name:
428 message = self.message(
429 self.CONSUMER_MISMATCH, { 'old_consumer' : self.consumer_name,
430 'real_consumer' : real_consumer })
431 self.server_consumer_differs_from_client_consumer(
432 self.consumer_name, real_consumer, message)
433 self.consumer_name = real_consumer
434
435 def message(self, raw_message, extra_variables=None):
436 """Prepare a message by plugging in the app name."""
437 variables = { 'app' : self.consumer_name }
438 if extra_variables is not None:
439 variables.update(extra_variables)
440 return raw_message % variables
441
442 def open_page_in_user_browser(self, url):
443 """Open a web page in the user's web browser."""
444 webbrowser.open(url)
445
446 # You should define these methods in your subclass.
447
448 def output(self, message):
449 print self.text_wrapper.fill(message)
450
451 def input_username(self, cached_username, suggested_message):
452 """Collect the Launchpad username from the end-user.
453
454 :param cached_username: A username from a previous entry attempt,
455 to be presented as the default.
456 """
457 raise NotImplementedError()
458
459 def input_password(self, suggested_message):
460 """Collect the Launchpad password from the end-user."""
461 raise NotImplementedError()
462
463 def input_access_level(self, available_levels, suggested_message,
464 only_one_option=None):
465 """Collect the desired level of access from the end-user."""
466 raise NotImplementedError()
467
468 def startup(self, suggested_messages):
469 """Hook method called on startup."""
470 for message in suggested_messages:
471 self.output(message)
472 self.output("\n")
473
474 def authentication_failure(self, suggested_message):
475 """The user entered invalid credentials."""
476 self.output(suggested_message)
477 self.output("\n")
478
479 def user_refused_to_authorize(self, suggested_message):
480 """The user refused to authorize a request token."""
481 self.output(suggested_message)
482 self.output("\n")
483
484 def user_authorized(self, access_level, suggested_message):
485 """The user authorized a request token with some access level."""
486 self.output(suggested_message)
487 self.output("\n")
488
489 def server_consumer_differs_from_client_consumer(
490 self, client_name, real_name, suggested_message):
491 """The client seems to be lying or mistaken about its name.
492
493 When requesting a request token, the client told Launchpad
494 that its consumer name was "foo". Now the client is telling the
495 end-user that its name is "bar". Something is fishy and at the very
496 least the end-user should be warned about this.
497 """
498 self.output("\n")
499 self.output(suggested_message)
500 self.output("\n")
501
502 def success(self, suggested_message):
503 """The token was successfully authorized."""
504 self.output(suggested_message)
505493
506494
507class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):495class AuthorizeRequestTokenWithBrowser(RequestTokenAuthorizationEngine):
508 """The simplest and most secure request token authorizer.496 """The simplest (and, right now, the only) request token authorizer.
509497
510 This authorizer simply opens up the end-user's web browser to a498 This authorizer simply opens up the end-user's web browser to a
511 Launchpad URL and lets the end-user authorize the request token499 Launchpad URL and lets the end-user authorize the request token
512 themselves.500 themselves.
513 """501 """
514502
515 def __init__(self, web_root, consumer_name, request_token,503 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..."
516 allow_access_levels=[], max_failed_attempts=3):504
517 web_root = uris.lookup_web_root(web_root)505 def __init__(self, service_root, application_name, consumer_name=None,
518 page = "+authorize-token?oauth_token=%s" % request_token506 credential_save_failed=None, allow_access_levels=None):
519 if len(allow_access_levels) > 0:507 """Constructor.
520 page += ("&allow_permission=" +508
521 "&allow_permission=".join(allow_access_levels))509 :param service_root: See `RequestTokenAuthorizationEngine`.
522 self.authorization_url = urljoin(web_root, page)510 :param application_name: See `RequestTokenAuthorizationEngine`.
523511 :param consumer_name: The value of this argument is
512 ignored. If we have the capability to open the end-user's
513 web browser, we must be running on the end-user's computer,
514 so we should do a full desktop integration.
515 :param credential_save_failed: See `RequestTokenAuthorizationEngine`.
516 :param allow_access_levels: The value of this argument is
517 ignored, for the same reason as consumer_name.
518 """
519 # It doesn't look like we're doing anything here, but we
520 # are discarding the passed-in values for consumer_name and
521 # allow_access_levels.
524 super(AuthorizeRequestTokenWithBrowser, self).__init__(522 super(AuthorizeRequestTokenWithBrowser, self).__init__(
525 web_root, consumer_name, request_token,523 service_root, application_name, None,
526 allow_access_levels, max_failed_attempts)524 credential_save_failed)
527525
528 def __call__(self):526 def output(self, message):
529 self.open_page_in_user_browser(self.authorization_url)527 """Display a message.
530 print "The authorization page:"528
531 print " (%s)" % self.authorization_url529 By default, prints the message to standard output. The message
532 print "should be opening in your browser. After you have authorized"530 does not require any user interaction--it's solely
533 print "this program to access Launchpad on your behalf you should come"531 informative.
534 print ("back here and press <Enter> to finish the authentication "532 """
535 "process.")533 print message
536 self.wait_for_request_token_authorization()534
537535 def make_end_user_authorize_token(self, credentials, request_token):
538 def wait_for_request_token_authorization(self):536 """Have the end-user authorize the token in their browser."""
539 """Get the end-user to hit enter."""537
540 sys.stdin.readline()538 authorization_url = self.authorization_url(request_token)
539 webbrowser.open(authorization_url)
540 self.output(self.WAITING_FOR_USER % authorization_url)
541 while credentials.access_token is None:
542 time.sleep(access_token_poll_time)
543 try:
544 credentials.exchange_request_token_for_access_token(
545 self.web_root)
546 break
547 except HTTPError, e:
548 if e.response.status == 403:
549 # The user decided not to authorize this
550 # application.
551 raise EndUserDeclinedAuthorization(e.content)
552 elif e.response.status == 401:
553 # The user has not made a decision yet.
554 pass
555 else:
556 # There was an error accessing the server.
557 print "Unexpected response from Launchpad:"
558 print e
541559
542560
543class TokenAuthorizationException(Exception):561class TokenAuthorizationException(Exception):
@@ -548,6 +566,10 @@
548 pass566 pass
549567
550568
569class EndUserDeclinedAuthorization(TokenAuthorizationException):
570 pass
571
572
551class ClientError(TokenAuthorizationException):573class ClientError(TokenAuthorizationException):
552 pass574 pass
553575
554576
=== removed file 'src/launchpadlib/docs/browser.txt'
--- src/launchpadlib/docs/browser.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/docs/browser.txt 1970-01-01 00:00:00 +0000
@@ -1,151 +0,0 @@
1*******************************
2The simulated Launchpad browser
3*******************************
4
5The SimulatedLaunchpadBrowser class is a scriptable browser-like class
6that can be trusted with the end-user's username and password. It
7fulfils the same function as the user's web browser, but because it's
8scriptable can be used to create non-browser trusted clients.
9
10 >>> username = 'salgado@ubuntu.com'
11 >>> password = 'zeca'
12 >>> web_root = 'http://launchpad.dev:8085/'
13
14Before showing how SimulatedLaunchpadBrowser can authorize a request
15token, let's create a request token to authorize.
16
17 >>> from launchpadlib.credentials import Credentials
18 >>> credentials = Credentials("doctest consumer")
19 >>> context="firefox"
20 >>> validate_url = credentials.get_request_token(
21 ... web_root=web_root, context=context)
22 >>> request_token = credentials._request_token.key
23
24get_token_info()
25================
26
27If you have the end-user's username and password, you can use
28get_token_info() to get information about one of the user's request
29tokens. It's useful for confirming that the end-user gave the correct
30username and password, and for reconciling the list of access levels a
31client will accept with Launchpad's master list.
32
33 >>> from launchpadlib.credentials import SimulatedLaunchpadBrowser
34 >>> from launchpadlib.testing.helpers import TestableLaunchpad
35
36 >>> browser = SimulatedLaunchpadBrowser(web_root)
37
38If you make an unauthorized request, you'll get a 401 error.
39(Launchpad returns 200, but SimulatedLaunchpadBrowser sniffs it and
40changes it to a 401.)
41
42 >>> response, content = browser.get_token_info(
43 ... "baduser", "badpasword", request_token)
44 >>> print response.status
45 401
46
47If you provide the right authorization, you'll get back information
48about your request token.
49
50 >>> response, content = browser.get_token_info(
51 ... username, password, request_token)
52 >>> print response['content-type']
53 application/json
54
55 # XXX leonardr 2009-10-28 bug=462773 These two URLs should be
56 # exactly the same, but the Content-Location is missing the
57 # lp.context.
58 >>> validate_url.startswith(response['content-location'])
59 True
60
61 >>> import simplejson
62 >>> json = simplejson.loads(content)
63 >>> json['oauth_token'] == request_token
64 True
65
66 >>> print json['oauth_token_consumer']
67 doctest consumer
68
69You'll also get information about the available access
70levels.
71
72 >>> print sorted([level['value'] for level in json['access_levels']])
73 ['READ_PRIVATE', ... 'UNAUTHORIZED', ...]
74
75If you provide a list of possible access levels, you'll
76get back a list that reconciles the list you gave with
77Launchpad's access levels.
78
79 >>> response, content = browser.get_token_info(
80 ... username, password, request_token,
81 ... ["READ_PUBLIC", "READ_PRIVATE", "NO_SUCH_ACCESS_LEVEL"])
82
83 >>> print response['content-type']
84 application/json
85
86 >>> json = simplejson.loads(content)
87 >>> print sorted(
88 ... [level['value'] for level in json['access_levels']])
89 ['READ_PRIVATE', 'READ_PUBLIC', 'UNAUTHORIZED']
90
91Note that the nonexistent access level has been removed from the
92reconciled list, and the "Unauthorized" access level (which must
93always be an option) has been added.
94
95grant_access()
96==============
97
98If you have the end-user's username and password, you can use
99grant_access() to authorize a request token.
100
101If you make an unauthorized request, you'll get a 401 error. (As with
102get_token_info(), Launchpad returns 200, but SimulatedLaunchpadBrowser
103sniffs it and changes it to a 401.)
104
105 >>> access_level = "READ_PRIVATE"
106
107 >>> response, content = browser.grant_access(
108 ... "baduser", "badpasword", request_token, access_level, context)
109 >>> print response.status
110 401
111
112If you try to grant an invalid level of access, you'll get a
113400 error.
114
115 >>> response, content = browser.grant_access(
116 ... username, password, request_token,
117 ... "NO_SUCH_ACCESS_LEVEL")
118 >>> print response.status
119 400
120
121If you provide all the necessary information, you'll get a 200
122response code and the request token will be authorized.
123
124 >>> response, content = browser.grant_access(
125 ... username, password, request_token, access_level)
126 >>> print response.status
127 200
128
129If you try to grant access to a request token that's already
130been authorized, you'll get a 409 error.
131
132 >>> response, content = browser.grant_access(
133 ... username, password, request_token, access_level)
134 >>> print response.status
135 409
136
137Now that the request token is authorized, we can exchange it for an
138access token.
139
140 >>> credentials.exchange_request_token_for_access_token(
141 ... web_root=web_root)
142 >>> credentials.access_token.key is None
143 False
144
145If you try to grant access to a request token that's already been
146exchanged for an access token, you'll get a 400 error.
147
148 >>> response, content = browser.grant_access(
149 ... username, password, request_token, access_level)
150 >>> print response.status
151 400
1520
=== modified file 'src/launchpadlib/docs/command-line.txt'
--- src/launchpadlib/docs/command-line.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/docs/command-line.txt 2011-02-08 19:01:02 +0000
@@ -2,12 +2,11 @@
2Command-line scripts2Command-line scripts
3********************3********************
44
5Launchpad includes some command-line scripts to make Launchpad5Launchpad includes one command-line script to make Launchpad
6integration easier for third-party libraries that aren't written in6integration easier for third-party libraries that aren't written in
7Python or that can't do token authorization by opening a user's web7Python.
8browser.
98
10This file tests the workflow underlying the command-line scripts as9This file tests the workflow underlying the command-line script as
11best it can.10best it can.
1211
13RequestTokenApp12RequestTokenApp
@@ -36,170 +35,3 @@
36 >>> print json['oauth_token_consumer']35 >>> print json['oauth_token_consumer']
37 consumer36 consumer
3837
39TrustedTokenAuthorizationConsoleApp
40===================================
41
42This class is called by the command-line script
43launchpad-credentials-console. It asks for the user's Launchpad
44username and password, and authorizes a request token on their
45behalf.
46
47 >>> from launchpadlib.apps import TrustedTokenAuthorizationConsoleApp
48 >>> from launchpadlib.testing.helpers import UserInput
49
50This class does not create the request token, or exchange it for the
51access token--that's the job of the program that calls
52launchpad-credentials-console. So we'll use the request token created
53earlier by RequestTokenApp.
54
55 >>> request_token = json['oauth_token']
56
57Since this is a test, we don't want the application to call sys.exit()
58or try to open up pages in a web browser. This subclass of
59TrustedTokenAuthorizationConsoleApp will print messages instead of
60performing such un-doctest-like actions.
61
62 >>> class ConsoleApp(TrustedTokenAuthorizationConsoleApp):
63 ... def open_page_in_user_browser(self, url):
64 ... """Print a status message."""
65 ... self.output("[If this were a real application, the "
66 ... "end-user's web browser would be opened "
67 ... "to %s]" % url)
68 ...
69 ... def exit_with(self, code):
70 ... print "Application exited with code %d" % code
71
72We'll use a UserInput object to simulate a user typing things in at
73the prompt and hitting enter. This UserInput runs the program
74correctly, entering the Launchpad username and password, choosing an
75access level, and hitting Enter to exit.
76
77 >>> username = "salgado@ubuntu.com"
78 >>> password = "zeca"
79 >>> fake_input = UserInput([username, password, "1", ""])
80
81Here's a successful run of the application. When the request token is
82authorized, the script's response code is 0.
83
84 >>> app = ConsoleApp(
85 ... web_root, consumer_name, request_token,
86 ... 'READ_PRIVATE, READ_PUBLIC', input_method=fake_input)
87 >>> app.run()
88 Launchpad credential client (console)
89 -------------------------------------
90 An application identified as "consumer" wants to access Launchpad...
91 What email address do you use on Launchpad?
92 (No Launchpad account? Just hit enter.) [User input: salgado@ubuntu.com]
93 What's your Launchpad password? [User input: zeca]
94 Now it's time for you to decide how much power to give "consumer"...
95 1: Read Non-Private Data
96 2: Read Anything
97 What should "consumer" be allowed to do...? [1-2 or Q] [User input: 1]
98 Okay, I'm telling Launchpad to grant "consumer" access to your account.
99 You're all done!...
100 Press enter to go back to "consumer". [User input: ]
101 Application exited with code 0
102
103Now that the request token has been authorized, we'll need to create
104another one to continue the test.
105
106 >>> json = simplejson.loads(token_app.run())
107 >>> request_token = json['oauth_token']
108 >>> app.request_token = request_token
109
110Invalid input is ignored. The user may enter 'Q' instead of a number
111to refuse to authorize the request token. When the user denies access,
112the exit code is -2.
113
114 >>> fake_input = UserInput([username, password, "A", "99", "Q", ""])
115 >>> app.input_method = fake_input
116 >>> app.run()
117 Launchpad credential client (console)
118 -------------------------------------
119 An application identified as "consumer"...
120 What should "consumer" be allowed to do...? [1-2 or Q] [User input: A]
121 What should "consumer" be allowed to do...? [1-2 or Q] [User input: 99]
122 What should "consumer" be allowed to do...? [1-2 or Q] [User input: Q]
123 Okay, I'm going to cancel the request...
124 You're all done! "consumer" still doesn't have access...
125 <BLANKLINE>
126 Press enter to go back to "consumer". [User input: ]
127 Application exited with code -2
128
129When the third-party application will allow only one level of access,
130the end-user is presented with a yes-or-no choice instead of a list to
131choose from. Again, invalid input is ignored.
132
133 >>> json = simplejson.loads(token_app.run())
134 >>> request_token = json['oauth_token']
135 >>> fake_input = UserInput([username, password, "1", "Q", "Y", ""])
136
137 >>> app = ConsoleApp(
138 ... web_root, consumer_name, request_token,
139 ... 'READ_PRIVATE', input_method=fake_input)
140
141 >>> app.run()
142 Launchpad credential client (console)
143 -------------------------------------
144 An application identified as "consumer"...
145 Do you want to give "consumer" this level of access? [YN] [User input: 1]
146 Do you want to give "consumer" this level of access? [YN] [User input: Q]
147 Do you want to give "consumer" this level of access? [YN] [User input: Y]
148 ...
149 Application exited with code 0
150
151
152Error handling
153--------------
154
155When the end-user refuses to authorize the request token, the app
156exits with a return code of -2, as seen above. When any other error
157gets in the way of the authorization of the request token, the app's
158return code is -1.
159
160If the user hits enter when asked for their email address, indicating
161that they don't have a Launchpad account, the app opens their browser
162to the Launchpad login page.
163
164 >>> json = simplejson.loads(token_app.run())
165 >>> app.request_token = json['oauth_token']
166
167 >>> input_nothing = UserInput(["", ""])
168 >>> app.input_method = input_nothing
169
170 >>> app.run()
171 Launchpad credential client (console)
172 -------------------------------------
173 An application identified as "consumer"...
174 [If this were a real application, the end-user's web browser...]
175 OK, you'll need to get yourself a Launchpad account before...
176 <BLANKLINE>
177 I'm opening the Launchpad registration page in your web browser...
178 Press enter to go back to "consumer". [User input: ]
179 Application exited with code -1
180
181If the user keeps entering bad passwords, the app eventually gives up.
182
183 >>> input_bad_password = UserInput(
184 ... [username, "badpw", "", "badpw", "", "badpw", ""])
185 >>> json = simplejson.loads(token_app.run())
186 >>> request_token = json['oauth_token']
187 >>> app.request_token = request_token
188 >>> app.input_method = input_bad_password
189 >>> app.run()
190 Launchpad credential client (console)
191 -------------------------------------
192 An application identified as "consumer"...
193 What email address do you use on Launchpad?
194 (No Launchpad account? Just hit enter.) [User input: salgado@ubuntu.com]
195 What's your Launchpad password? [User input: badpw]
196 I can't log in with the credentials you gave me. Let's try again.
197 What email address do you use on Launchpad? [salgado@ubuntu.com] [User input: ]
198 What's your Launchpad password? [User input: badpw]
199 I can't log in with the credentials you gave me. Let's try again.
200 What email address do you use on Launchpad? [salgado@ubuntu.com] [User input: ]
201 What's your Launchpad password? [User input: badpw]
202 You've failed the password entry too many times...
203 Press enter to go back to "consumer". [User input: ]
204 Application exited with code -1
205
20638
=== modified file 'src/launchpadlib/docs/hosted-files.txt'
--- src/launchpadlib/docs/hosted-files.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/docs/hosted-files.txt 2011-02-08 19:01:02 +0000
@@ -55,7 +55,7 @@
55 >>> file_handle.close()55 >>> file_handle.close()
56 Traceback (most recent call last):56 Traceback (most recent call last):
57 ...57 ...
58 HTTPError: HTTP Error 400: Bad Request58 BadRequest: HTTP Error 400: Bad Request
59 ...59 ...
6060
61== Caching ==61== Caching ==
@@ -90,7 +90,7 @@
90 >>> len(mugshot.open().read())90 >>> len(mugshot.open().read())
91 send: ...91 send: ...
92 reply: 'HTTP/1.1 303 See Other...92 reply: 'HTTP/1.1 303 See Other...
93 header: Location: http://localhost:58000/.../image.png93 header: Location: http://.../image.png
94 ...94 ...
95 header: Content-Type: text/plain95 header: Content-Type: text/plain
96 226096 2260
9797
=== modified file 'src/launchpadlib/docs/introduction.txt'
--- src/launchpadlib/docs/introduction.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/docs/introduction.txt 2011-02-08 19:01:02 +0000
@@ -66,9 +66,10 @@
66 >>> sorted(launchpad.bugs)66 >>> sorted(launchpad.bugs)
67 [...]67 [...]
6868
69For convenience, the application may store the credentials on the file system,69If available, the Gnome keyring or KDE wallet will be used to store access
70so that the next time Salgado interacts with the application, he won't have70tokens. If a keyring/wallet is not available, the application can store the
71to go through the whole OAuth request dance.71credentials on the file system, so that the next time Salgado interacts with
72the application, he won't have to go through the whole OAuth request dance.
7273
73 >>> import os74 >>> import os
74 >>> import tempfile75 >>> import tempfile
@@ -172,14 +173,14 @@
172 >>> launchpad.me173 >>> launchpad.me
173 Traceback (most recent call last):174 Traceback (most recent call last):
174 ...175 ...
175 HTTPError: HTTP Error 401: Unauthorized176 Unauthorized: HTTP Error 401: Unauthorized
176 ...177 ...
177178
178 >>> salgado.display_name = "This won't work."179 >>> salgado.display_name = "This won't work."
179 >>> salgado.lp_save()180 >>> salgado.lp_save()
180 Traceback (most recent call last):181 Traceback (most recent call last):
181 ...182 ...
182 HTTPError: HTTP Error 401: Unauthorized183 Unauthorized: HTTP Error 401: Unauthorized
183 ...184 ...
184185
185Convenience186Convenience
@@ -189,34 +190,24 @@
189setting up a web service connection in one function call. All you have190setting up a web service connection in one function call. All you have
190to provide is the consumer name.191to provide is the consumer name.
191192
192 >>> launchpad = Launchpad.login_anonymously('launchpad-library')193 >>> launchpad = Launchpad.login_anonymously(
194 ... 'launchpad-library', service_root="test_dev")
193 >>> sorted(launchpad.people)195 >>> sorted(launchpad.people)
194 [...]196 [...]
195197
196 >>> launchpad.me198 >>> launchpad.me
197 Traceback (most recent call last):199 Traceback (most recent call last):
198 ...200 ...
199 HTTPError: HTTP Error 401: Unauthorized201 Unauthorized: HTTP Error 401: Unauthorized
200 ...202 ...
201203
202Another function call is useful when the consumer name, access token
203and access secret are all known up-front.
204
205 >>> launchpad = Launchpad.login(
206 ... 'launchpad-library', 'salgado-change-anything', 'test')
207 >>> sorted(launchpad.people)
208 [...]
209
210 >>> print launchpad.me.name
211 salgado
212
213Otherwise, the application should obtain authorization from the user204Otherwise, the application should obtain authorization from the user
214and get a new set of credentials directly from Launchpad.205and get a new set of credentials directly from
206Launchpad.
215207
216First we must get a request token. We use 'test_dev' as a shorthand208Unfortunately, we can't test this entire process because it requires
217for the root URL of the Launchpad installation. It's defined in the209opening up a web browser, but we can test the first step, which is to
218'uris' module as 'http://launchpad.dev:8085/', and the launchpadlib210get a request token.
219code knows how to dereference it before using it as a URL.
220211
221 >>> import launchpadlib.credentials212 >>> import launchpadlib.credentials
222 >>> credentials = Credentials('consumer')213 >>> credentials = Credentials('consumer')
@@ -226,6 +217,11 @@
226 >>> authorization_url217 >>> authorization_url
227 'http://launchpad.dev:8085/+authorize-token?oauth_token=...&lp.context=firefox'218 'http://launchpad.dev:8085/+authorize-token?oauth_token=...&lp.context=firefox'
228219
220We use 'test_dev' as a shorthand for the root URL of the Launchpad
221installation. It's defined in the 'uris' module as
222'http://launchpad.dev:8085/', and the launchpadlib code knows how to
223dereference it before using it as a URL.
224
229Information about the request token is kept in the _request_token225Information about the request token is kept in the _request_token
230attribute of the Credentials object.226attribute of the Credentials object.
231227
@@ -236,117 +232,10 @@
236 >>> print credentials._request_token.context232 >>> print credentials._request_token.context
237 firefox233 firefox
238234
239Now the user must authorize that token, so we'll use the235Now the user must authorize that token, and this is the part we can't
240SimulatedLaunchpadBrowser to pretend the user is authorizing it.236test--it requires opening a web browser. Once the token is authorized
241237on the server side, we can call exchange_request_token_for_access_token()
242 >>> from launchpadlib.credentials import SimulatedLaunchpadBrowser238on our Credentials object, which will then be ready to use.
243 >>> browser = SimulatedLaunchpadBrowser(web_root='test_dev')
244 >>> response, content = browser.grant_access(
245 ... "foo.bar@canonical.com", "test",
246 ... credentials._request_token.key, "WRITE_PRIVATE",
247 ... credentials._request_token.context)
248 >>> response['status']
249 '200'
250
251After that we can exchange that request token for an access token.
252
253 >>> credentials.exchange_request_token_for_access_token(
254 ... web_root='test_dev')
255
256Once that's done, our credentials will be complete and ready to use.
257
258 >>> credentials.consumer.key
259 'consumer'
260 >>> credentials.access_token
261 <launchpadlib.credentials.AccessToken...
262 >>> credentials.access_token.key is not None
263 True
264 >>> credentials.access_token.secret is not None
265 True
266 >>> credentials.access_token.context
267 'firefox'
268
269Authorizing the request token
270-----------------------------
271
272There are also two convenience method which do the access token
273negotiation and log into the web service: get_token_and_login() and
274login_with(). These convenience methods use the methods documented
275above to get a request token, and once it has the request token's
276authorization information, it makes the end-user authorize the request
277token by entering their Launchpad username and password.
278
279There are several ways of having the end-user authorize a request
280token, but the most secure is to open up the user's own web browser
281(other ways are described in trusted-client.txt). Because we don't
282want to actually open a web browser during this test, we'll create a
283fake authorizer that uses the SimulatedLaunchpadBrowser to authorize
284the request token.
285
286 >>> from launchpadlib.testing.helpers import (
287 ... DummyAuthorizeRequestTokenWithBrowser)
288
289 >>> class AuthorizeAsSalgado(DummyAuthorizeRequestTokenWithBrowser):
290 ... def wait_for_request_token_authorization(self):
291 ... """Simulate the authorizing user with their web browser."""
292 ... username = 'salgado@ubuntu.com'
293 ... password = 'zeca'
294 ... browser = SimulatedLaunchpadBrowser(self.web_root)
295 ... browser.grant_access(username, password, self.request_token,
296 ... 'READ_PUBLIC')
297
298Here, we're using 'test_dev' as shorthand for the root URL of the web
299service. Earlier we used 'test_dev' as shorthand for the website URL,
300and like in that earlier case, launchpadlib will internally
301dereference 'test_dev' into the service root URL, defined in the
302'uris' module as "http://api.launchpad.dev:8085/".
303
304 >>> consumer_name = 'launchpadlib'
305 >>> launchpad = Launchpad.get_token_and_login(
306 ... consumer_name, service_root="test_dev",
307 ... authorizer_class=AuthorizeAsSalgado)
308 [If this were a real application, the end-user's web browser would
309 be opened to http://launchpad.dev:8085/+authorize-token?oauth_token=...]
310 The authorization page:
311 (http://launchpad.dev:8085/+authorize-token?oauth_token=...)
312 should be opening in your browser. After you have authorized
313 this program to access Launchpad on your behalf you should come
314 back here and press <Enter> to finish the authentication process.
315
316The login_with method will cache an access token once it gets one, so
317that the end-user doesn't have to authorize a request token every time
318they run the program.
319
320 >>> import tempfile
321 >>> cache_dir = tempfile.mkdtemp()
322 >>> launchpad = Launchpad.login_with(
323 ... consumer_name, service_root="test_dev",
324 ... launchpadlib_dir=cache_dir,
325 ... authorizer_class=AuthorizeAsSalgado)
326 [If this were a real application...]
327 The authorization page:
328 ...
329 >>> print launchpad.me.name
330 salgado
331
332Now that the access token is authorized, we can call login_with()
333again and pass in a null authorizer. If there was no access token,
334this would fail, because there would be no way to authorize the
335request token. But since there's an access token cached in the
336cache directory, login_with() will succeed without even trying to
337authorize a request token.
338
339 >>> launchpad = Launchpad.login_with(
340 ... consumer_name, service_root="test_dev",
341 ... launchpadlib_dir=cache_dir,
342 ... authorizer_class=None)
343 >>> print launchpad.me.name
344 salgado
345
346A bit of clean-up: removing the cache directory.
347
348 >>> import shutil
349 >>> shutil.rmtree(cache_dir)
350239
351The dictionary request token240The dictionary request token
352============================241============================
@@ -470,7 +359,7 @@
470 >>> launchpad = Launchpad(credentials=credentials)359 >>> launchpad = Launchpad(credentials=credentials)
471 Traceback (most recent call last):360 Traceback (most recent call last):
472 ...361 ...
473 HTTPError: HTTP Error 401: Unauthorized362 Unauthorized: HTTP Error 401: Unauthorized
474 ...363 ...
475364
476The application is not allowed to access Launchpad with a consumer365The application is not allowed to access Launchpad with a consumer
@@ -483,7 +372,7 @@
483 >>> launchpad = Launchpad(credentials=credentials)372 >>> launchpad = Launchpad(credentials=credentials)
484 Traceback (most recent call last):373 Traceback (most recent call last):
485 ...374 ...
486 HTTPError: HTTP Error 401: Unauthorized375 Unauthorized: HTTP Error 401: Unauthorized
487 ...376 ...
488377
489The application is not allowed to access Launchpad with a bad access secret.378The application is not allowed to access Launchpad with a bad access secret.
@@ -495,7 +384,7 @@
495 >>> launchpad = Launchpad(credentials=credentials)384 >>> launchpad = Launchpad(credentials=credentials)
496 Traceback (most recent call last):385 Traceback (most recent call last):
497 ...386 ...
498 HTTPError: HTTP Error 401: Unauthorized387 Unauthorized: HTTP Error 401: Unauthorized
499 ...388 ...
500389
501Clean up390Clean up
502391
=== added file 'src/launchpadlib/docs/operations.txt'
--- src/launchpadlib/docs/operations.txt 1970-01-01 00:00:00 +0000
+++ src/launchpadlib/docs/operations.txt 2011-02-08 19:01:02 +0000
@@ -0,0 +1,27 @@
1****************
2Named operations
3****************
4
5launchpadlib can transparently determine the size of the list even
6when the size is not directly provided, but is only available through
7a link.
8
9 >>> from launchpadlib.testing.helpers import salgado_with_full_permissions
10 >>> launchpad = salgado_with_full_permissions.login(version="devel")
11
12 >>> results = launchpad.people.find(text='s')
13 >>> 'total_size' in results._wadl_resource.representation.keys()
14 False
15 >>> 'total_size_link' in results._wadl_resource.representation.keys()
16 True
17 >>> len(results) > 1
18 True
19
20Of course, launchpadlib can also determine the size when the size _is_
21directly provided.
22
23 >>> results = launchpad.people.find(text='salgado')
24 >>> 'total_size' in results._wadl_resource.representation.keys()
25 True
26 >>> len(results) == 1
27 True
028
=== modified file 'src/launchpadlib/docs/people.txt'
--- src/launchpadlib/docs/people.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/docs/people.txt 2011-02-08 19:01:02 +0000
@@ -159,7 +159,7 @@
159 >>> launchpad.people.newTeam(name='bassists', display_name='Bass Gods')159 >>> launchpad.people.newTeam(name='bassists', display_name='Bass Gods')
160 Traceback (most recent call last):160 Traceback (most recent call last):
161 ...161 ...
162 HTTPError: HTTP Error 400: Bad Request162 BadRequest: HTTP Error 400: Bad Request
163 ...163 ...
164164
165Actually, the exception contains other useful information.165Actually, the exception contains other useful information.
166166
=== modified file 'src/launchpadlib/docs/toplevel.txt'
--- src/launchpadlib/docs/toplevel.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/docs/toplevel.txt 2011-02-08 19:01:02 +0000
@@ -13,10 +13,16 @@
13collections. The bug collection does lookups by bug ID.13collections. The bug collection does lookups by bug ID.
1414
15 >>> bug = launchpad.bugs[1]15 >>> bug = launchpad.bugs[1]
1616 send: 'GET /.../bugs/1 ...'
17For most top-level collections, simply looking up an object will not17 ...
18trigger an HTTP request. The HTTP request happens when you try to18
19access one of the object's properties.19To avoid triggering an HTTP request when simply looking up an object,
20you can use a different syntax:
21
22 >>> bug = launchpad.bugs(1)
23
24The HTTP request will happen when you need information that can only
25be obtained from the web service.
2026
21 >>> print bug.id27 >>> print bug.id
22 send: 'GET /.../bugs/1 ...'28 send: 'GET /.../bugs/1 ...'
@@ -26,7 +32,7 @@
26Let's look at some more collections. The project collection does32Let's look at some more collections. The project collection does
27lookups by project name.33lookups by project name.
2834
29 >>> project = launchpad.projects['firefox']35 >>> project = launchpad.projects('firefox')
30 >>> print project.name36 >>> print project.name
31 send: 'GET /.../firefox ...'37 send: 'GET /.../firefox ...'
32 ...38 ...
@@ -34,7 +40,7 @@
3440
35The project group collection does lookups by project group name.41The project group collection does lookups by project group name.
3642
37 >>> group = launchpad.project_groups['gnome']43 >>> group = launchpad.project_groups('gnome')
38 >>> print group.name44 >>> print group.name
39 send: 'GET /.../gnome ...'45 send: 'GET /.../gnome ...'
40 ...46 ...
@@ -42,7 +48,7 @@
4248
43The distribution collection does lookups by distribution name.49The distribution collection does lookups by distribution name.
4450
45 >>> distribution = launchpad.distributions['ubuntu']51 >>> distribution = launchpad.distributions('ubuntu')
46 >>> print distribution.name52 >>> print distribution.name
47 send: 'GET /.../ubuntu ...'53 send: 'GET /.../ubuntu ...'
48 ...54 ...
@@ -51,13 +57,13 @@
51The person collection does lookups by a person's Launchpad57The person collection does lookups by a person's Launchpad
52name.58name.
5359
54 >>> person = launchpad.people['salgado']60 >>> person = launchpad.people('salgado')
55 >>> print person.name61 >>> print person.name
56 send: 'GET /.../~salgado ...'62 send: 'GET /.../~salgado ...'
57 ...63 ...
58 salgado64 salgado
5965
60 >>> team = launchpad.people['rosetta-admins']66 >>> team = launchpad.people('rosetta-admins')
61 >>> print team.name67 >>> print team.name
62 send: 'GET /1.0/~rosetta-admins ...'68 send: 'GET /1.0/~rosetta-admins ...'
63 ...69 ...
@@ -77,11 +83,11 @@
77 True83 True
7884
79The truth is that it doesn't know, not before making that HTTP85The truth is that it doesn't know, not before making that HTTP
80request. Until the HTTP request is made, launchpadlib assumes86request. Until an HTTP request is made, launchpadlib assumes
81everything in launchpad.people[] is a team (since a team has strictly87everything in launchpad.people[] is a team (since a team has strictly
82more capabilities than a person).88more capabilities than a person).
8389
84 >>> person2 = launchpad.people['salgado']90 >>> person2 = launchpad.people('salgado')
85 >>> 'default_membership_period' in person2.lp_attributes91 >>> 'default_membership_period' in person2.lp_attributes
86 True92 True
8793
@@ -101,7 +107,7 @@
101the HTTP request, and then cause an error if the object turns out not107the HTTP request, and then cause an error if the object turns out not
102to be a team.108to be a team.
103109
104 >>> person3 = launchpad.people['salgado']110 >>> person3 = launchpad.people('salgado')
105 >>> person3.default_membership_period111 >>> person3.default_membership_period
106 Traceback (most recent call last):112 Traceback (most recent call last):
107 AttributeError: 'Entry' object has no attribute 'default_membership_period'113 AttributeError: 'Entry' object has no attribute 'default_membership_period'
108114
=== removed file 'src/launchpadlib/docs/trusted-client.txt'
--- src/launchpadlib/docs/trusted-client.txt 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/docs/trusted-client.txt 1970-01-01 00:00:00 +0000
@@ -1,224 +0,0 @@
1***********************
2Making a trusted client
3***********************
4
5To authorize a request token, the end-user must type in their
6Launchpad username and password. Obviously, typing your password into
7a random program is a bad idea. The best case is to use a program you
8already trust with your Launchpad password: your web browser.
9
10But if you're writing an application that can't open the end-user's
11web browser, or you just really want a token authorization client that
12has the same UI as the rest of your application, you should use one of
13the trusted clients packaged with launchpadlib, rather than writing
14your own client.
15
16All the trusted clients are based on the same core code and implement
17the same workflow. This test implements a scriptable trusted client
18and uses it to test the behavior of the standard workflow.
19
20 >>> from launchpadlib.testing.helpers import (
21 ... ScriptableRequestTokenAuthorization)
22
23Here we see the normal workflow, in which the user inputs all the
24correct data to authorize a request token.
25
26 >>> auth = ScriptableRequestTokenAuthorization(
27 ... "consumer", "salgado@ubuntu.com", "zeca",
28 ... "WRITE_PRIVATE",
29 ... allow_access_levels = ["WRITE_PUBLIC", "WRITE_PRIVATE"])
30 >>> access_token = auth()
31 An application identified as "consumer" wants to access Launchpad...
32 <BLANKLINE>
33 I'll use your Launchpad password to give "consumer" limited access...
34 What email address do you use on Launchpad?
35 What's your Launchpad password?
36 Now it's time for you to decide how much power to give "consumer" ...
37 ['UNAUTHORIZED', 'WRITE_PUBLIC', 'WRITE_PRIVATE']
38 Okay, I'm telling Launchpad to grant "consumer" access to your account.
39 You're all done! You should now be able to use Launchpad ...
40
41Ordinarily, the third-party program will create a request token and
42pass it into the trusted client. The test class is a little unusual:
43it takes care of creating the request token and, after the end-user
44has authorized it, exchanges the request token for an access
45token. This way we can verify that the entire end-to-end process
46works.
47
48 >>> access_token.key is not None
49 True
50
51Denying access
52==============
53
54It's always possible for the end-user to deny access to the
55application. This will make it impossible to convert the request token
56into an access token.
57
58 >>> auth = ScriptableRequestTokenAuthorization(
59 ... "consumer", "salgado@ubuntu.com", "zeca", "UNAUTHORIZED")
60 >>> access_token = auth()
61 An application identified as "consumer" wants to access Launchpad...
62 What email address do you use on Launchpad?
63 ...
64 Okay, I'm going to cancel the request that "consumer" made...
65 You're all done! "consumer" still doesn't have access...
66
67 >>> access_token is None
68 True
69
70Only one allowable access level
71===============================
72
73When the application being authenticated only allows one access level,
74the authorizer creates a special message for display to the end-user.
75
76 >>> auth = ScriptableRequestTokenAuthorization(
77 ... "consumer", "salgado@ubuntu.com", "zeca",
78 ... "WRITE_PRIVATE", allow_access_levels=["WRITE_PRIVATE"])
79
80 >>> auth()
81 An application identified as "consumer" wants to access Launchpad ...
82 ...
83 "consumer" says it needs the following level of access to your Launchpad
84 account: "Change Anything". It can't work with any other level of access,
85 so denying this level of access means prohibiting "consumer" from
86 using your Launchpad account at all.
87 ...
88
89Error handling
90==============
91
92Things can go wrong in many ways, most of which we can test with our
93scriptable authorizer. Here's a utility method to run the
94authorization process with a badly-scripted authorizer and print the
95resulting exception.
96
97 >>> from launchpadlib.credentials import TokenAuthorizationException
98 >>> def print_error(auth):
99 ... try:
100 ... auth()
101 ... except TokenAuthorizationException, e:
102 ... print str(e)
103
104Authentication failures
105-----------------------
106
107If the user doesn't have a Launchpad account, or refuses to type in
108their email address, the authorizer will open their web browser to the
109login page, and raise an exception.
110
111 >>> auth = ScriptableRequestTokenAuthorization(
112 ... "consumer", None, "zeca", "WRITE_PRIVATE")
113 >>> print_error(auth)
114 An application identified as "consumer" wants to access Launchpad ...
115 [If this were a real application, ... opened to http://launchpad.dev:8085/+login]
116 OK, you'll need to get yourself a Launchpad account before you can ...
117 <BLANKLINE>
118 I'm opening the Launchpad registration page in your web browser ...
119
120If the user enters the wrong username/password combination too many
121times, the authorizer will give up and raise an exception.
122
123 >>> auth = ScriptableRequestTokenAuthorization(
124 ... "consumer", "salgado@ubuntu.com", "baddpassword",
125 ... "WRITE_PRIVATE")
126 >>> print_error(auth)
127 An application identified as "consumer" wants to access Launchpad...
128 ...
129 What email address do you use on Launchpad?
130 What's your Launchpad password?
131 I can't log in with the credentials you gave me. Let's try again.
132 What email address do you use on Launchpad?
133 Cached email address: salgado@ubuntu.com
134 What's your Launchpad password?
135 You've failed the password entry too many times...
136
137The max_failed_attempts argument controls how many attempts the user
138is given to enter their username and password.
139
140 >>> auth = ScriptableRequestTokenAuthorization(
141 ... "consumer", "bad username", "zeca",
142 ... "WRITE_PRIVATE", max_failed_attempts=1)
143 >>> print_error(auth)
144 An application identified as "consumer" wants to access Launchpad ...
145 What email address do you use on Launchpad?
146 What's your Launchpad password?
147 You've failed the password entry too many times...
148
149Approving a token that was already approved
150-------------------------------------------
151
152To set this up, let's approve a request token but not exchange it for
153an access token.
154
155 >>> auth = ScriptableRequestTokenAuthorization(
156 ... "consumer", "salgado@ubuntu.com", "zeca",
157 ... "WRITE_PRIVATE")
158 >>> auth(exchange_for_access_token=False)
159 An application identified as "consumer" wants to access Launchpad ...
160 ...
161
162Now let's try to approve the request token again:
163
164 >>> print_error(auth)
165 An application identified as "consumer" wants to access Launchpad ...
166 ...
167 It looks like you already approved this request...
168
169Once the request token is exchanged for an access token, it's
170deleted. An attempt to approve a request token that's already been
171exchanged for an access token gives an error message.
172
173 >>> auth.credentials.exchange_request_token_for_access_token(
174 ... web_root=auth.web_root)
175
176 >>> print_error(auth)
177 An application identified as "consumer" wants to access Launchpad ...
178 ...
179 Launchpad couldn't find an outstanding request for integration...
180
181An attempt to approve a nonexistent request token gives the same error
182message.
183
184 >>> auth = ScriptableRequestTokenAuthorization(
185 ... "consumer", "salgado@ubuntu.com", "zeca",
186 ... "WRITE_PRIVATE")
187 >>> auth.request_token = "nosuchrequesttoken"
188 >>> print_error(auth)
189 An application identified as "consumer" wants to access Launchpad ...
190 ...
191 Launchpad couldn't find an outstanding request for integration...
192
193Miscellaneous error
194-------------------
195
196Random errors on the server side or (occasionally) the client side
197will result in a generic error message.
198
199 >>> auth.request_token = "this token will confuse launchpad badly"
200 >>> print_error(auth)
201 An application identified as "consumer" wants to access Launchpad ...
202 ...
203 There seems to be something wrong on the Launchpad server side...
204
205Client duplicity
206----------------
207
208If the third-party client gives one consumer name to Launchpad, and a
209different consumer name to the authorizer, the authorizer will detect
210this possible duplicity and print a warning.
211
212 >>> auth = ScriptableRequestTokenAuthorization(
213 ... "consumer1", "salgado@ubuntu.com", "zeca",
214 ... "WRITE_PRIVATE")
215
216We'll simulate this by changing the authorizer's .consumer_name after
217it obtained a request token from Launchpad.
218
219 >>> auth.consumer_name = "consumer2"
220 >>> auth()
221 An application identified as "consumer2" wants to access Launchpad ...
222 ...
223 WARNING: The application you're using told me its name was "consumer2", but it told Launchpad its name was "consumer1"...
224 ...
2250
=== modified file 'src/launchpadlib/launchpad.py'
--- src/launchpadlib/launchpad.py 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/launchpad.py 2011-02-08 19:01:02 +0000
@@ -22,21 +22,33 @@
22 ]22 ]
2323
24import os24import os
25import stat
26import urlparse25import urlparse
26import warnings
2727
28from lazr.uri import URI
29from lazr.restfulclient.resource import (28from lazr.restfulclient.resource import (
30 CollectionWithKeyBasedLookup, HostedFile, ServiceRoot)29 CollectionWithKeyBasedLookup,
30 HostedFile, # Re-import for client convenience
31 ScalarValue, # Re-import for client convenience
32 ServiceRoot,
33 )
34from lazr.restfulclient.authorize.oauth import SystemWideConsumer
35from lazr.restfulclient._browser import RestfulHttp
31from launchpadlib.credentials import (36from launchpadlib.credentials import (
32 AccessToken, AnonymousAccessToken, Credentials,37 AccessToken,
33 AuthorizeRequestTokenWithBrowser)38 AnonymousAccessToken,
39 AuthorizeRequestTokenWithBrowser,
40 Consumer,
41 Credentials,
42 KeyringCredentialStore,
43 UnencryptedFileCredentialStore,
44 )
34from launchpadlib import uris45from launchpadlib import uris
3546
47
36# Import some constants for backwards compatibility. This way, old48# Import some constants for backwards compatibility. This way, old
37# scripts that have 'from launchpad import EDGE_SERVICE_ROOT' will still49# scripts that have 'from launchpad import STAGING_SERVICE_ROOT' will still
38# work.50# work.
39from launchpadlib.uris import EDGE_SERVICE_ROOT, STAGING_SERVICE_ROOT51from launchpadlib.uris import STAGING_SERVICE_ROOT
40OAUTH_REALM = 'https://api.launchpad.net'52OAUTH_REALM = 'https://api.launchpad.net'
4153
4254
@@ -96,6 +108,41 @@
96 collection_of = 'distribution'108 collection_of = 'distribution'
97109
98110
111class LaunchpadOAuthAwareHttp(RestfulHttp):
112 """Detects expired/invalid OAuth tokens and tries to get a new token."""
113
114 def __init__(self, launchpad, authorization_engine, *args):
115 self.launchpad = launchpad
116 self.authorization_engine = authorization_engine
117 super(LaunchpadOAuthAwareHttp, self).__init__(*args)
118
119 def _bad_oauth_token(self, response, content):
120 """Helper method to detect an error caused by a bad OAuth token."""
121 return (response.status == 401 and
122 (content.startswith("Expired token")
123 or content.startswith("Invalid token")))
124
125 def _request(self, *args):
126 response, content = super(
127 LaunchpadOAuthAwareHttp, self)._request(*args)
128 return self.retry_on_bad_token(response, content, *args)
129
130 def retry_on_bad_token(self, response, content, *args):
131 """If the response indicates a bad token, get a new token and retry.
132
133 Otherwise, just return the response.
134 """
135 if (self._bad_oauth_token(response, content)
136 and self.authorization_engine is not None):
137 # This access token is bad. Scrap it and create a new one.
138 self.launchpad.credentials.access_token = None
139 self.authorization_engine(
140 self.launchpad.credentials, self.launchpad.credential_store)
141 # Retry the request with the new credentials.
142 return self._request(*args)
143 return response, content
144
145
99class Launchpad(ServiceRoot):146class Launchpad(ServiceRoot):
100 """Root Launchpad API class.147 """Root Launchpad API class.
101148
@@ -108,19 +155,26 @@
108 RESOURCE_TYPE_CLASSES = {155 RESOURCE_TYPE_CLASSES = {
109 'bugs': BugSet,156 'bugs': BugSet,
110 'distributions': DistributionSet,157 'distributions': DistributionSet,
111 'HostedFile': HostedFile,
112 'people': PersonSet,158 'people': PersonSet,
113 'project_groups': ProjectGroupSet,159 'project_groups': ProjectGroupSet,
114 'projects': ProjectSet,160 'projects': ProjectSet,
115 }161 }
162 RESOURCE_TYPE_CLASSES.update(ServiceRoot.RESOURCE_TYPE_CLASSES)
116163
117 def __init__(self, credentials, service_root=uris.STAGING_SERVICE_ROOT,164 def __init__(self, credentials, authorization_engine,
165 credential_store, service_root=uris.STAGING_SERVICE_ROOT,
118 cache=None, timeout=None, proxy_info=None,166 cache=None, timeout=None, proxy_info=None,
119 version=DEFAULT_VERSION):167 version=DEFAULT_VERSION):
120 """Root access to the Launchpad API.168 """Root access to the Launchpad API.
121169
122 :param credentials: The credentials used to access Launchpad.170 :param credentials: The credentials used to access Launchpad.
123 :type credentials: `Credentials`171 :type credentials: `Credentials`
172 :param authorization_engine: The object used to get end-user input
173 for authorizing OAuth request tokens. Used when an OAuth
174 access token expires or becomes invalid during a
175 session, or is discovered to be invalid once launchpadlib
176 starts up.
177 :type authorization_engine: `RequestTokenAuthorizationEngine`
124 :param service_root: The URL to the root of the web service.178 :param service_root: The URL to the root of the web service.
125 :type service_root: string179 :type service_root: string
126 """180 """
@@ -134,22 +188,48 @@
134 "the version name from the root URI." % version)188 "the version name from the root URI." % version)
135 raise ValueError(error)189 raise ValueError(error)
136190
191 self.credential_store = credential_store
192
193 # We already have an access token, but it might expire or
194 # become invalid during use. Store the authorization engine in
195 # case we need to authorize a new token during use.
196 self.authorization_engine = authorization_engine
197
137 super(Launchpad, self).__init__(198 super(Launchpad, self).__init__(
138 credentials, service_root, cache, timeout, proxy_info, version)199 credentials, service_root, cache, timeout, proxy_info, version)
139200
201 def httpFactory(self, credentials, cache, timeout, proxy_info):
202 return LaunchpadOAuthAwareHttp(
203 self, self.authorization_engine, credentials, cache, timeout,
204 proxy_info)
205
206 @classmethod
207 def authorization_engine_factory(cls, *args):
208 return AuthorizeRequestTokenWithBrowser(*args)
209
210 @classmethod
211 def credential_store_factory(cls, credential_save_failed):
212 return KeyringCredentialStore(credential_save_failed)
213
140 @classmethod214 @classmethod
141 def login(cls, consumer_name, token_string, access_secret,215 def login(cls, consumer_name, token_string, access_secret,
142 service_root=uris.STAGING_SERVICE_ROOT,216 service_root=uris.STAGING_SERVICE_ROOT,
143 cache=None, timeout=None, proxy_info=None,217 cache=None, timeout=None, proxy_info=None,
144 version=DEFAULT_VERSION):218 authorization_engine=None, allow_access_levels=None,
145 """Convenience for setting up access credentials.219 max_failed_attempts=None, credential_store=None,
220 credential_save_failed=None, version=DEFAULT_VERSION):
221 """Convenience method for setting up access credentials.
146222
147 When all three pieces of credential information (the consumer223 When all three pieces of credential information (the consumer
148 name, the access token and the access secret) are available, this224 name, the access token and the access secret) are available, this
149 method can be used to quickly log into the service root.225 method can be used to quickly log into the service root.
150226
151 :param consumer_name: the consumer name, as appropriate for the227 This method is deprecated as of launchpadlib version
152 `Consumer` constructor228 1.9.0. You should use Launchpad.login_anonymously() for
229 anonymous access, and Launchpad.login_with() for all other
230 purposes.
231
232 :param consumer_name: the application name.
153 :type consumer_name: string233 :type consumer_name: string
154 :param token_string: the access token, as appropriate for the234 :param token_string: the access token, as appropriate for the
155 `AccessToken` constructor235 `AccessToken` constructor
@@ -159,61 +239,125 @@
159 :type access_secret: string239 :type access_secret: string
160 :param service_root: The URL to the root of the web service.240 :param service_root: The URL to the root of the web service.
161 :type service_root: string241 :type service_root: string
242 :param authorization_engine: See `Launchpad.__init__`. If you don't
243 provide an authorization engine, a default engine will be
244 constructed using your values for `service_root` and
245 `credential_save_failed`.
246 :param allow_access_levels: This argument is ignored, and only
247 present to preserve backwards compatibility.
248 :param max_failed_attempts: This argument is ignored, and only
249 present to preserve backwards compatibility.
162 :return: The web service root250 :return: The web service root
163 :rtype: `Launchpad`251 :rtype: `Launchpad`
164 """252 """
253 cls._warn_of_deprecated_login_method("login")
165 access_token = AccessToken(token_string, access_secret)254 access_token = AccessToken(token_string, access_secret)
166 credentials = Credentials(255 credentials = Credentials(
167 consumer_name=consumer_name, access_token=access_token)256 consumer_name=consumer_name, access_token=access_token)
168 return cls(credentials, service_root, cache, timeout, proxy_info,257 if authorization_engine is None:
169 version)258 authorization_engine = cls.authorization_engine_factory(
259 service_root, consumer_name, allow_access_levels)
260 if credential_store is None:
261 credential_store = cls.credential_store_factory(
262 credential_save_failed)
263 return cls(credentials, authorization_engine, credential_store,
264 service_root, cache, timeout, proxy_info, version)
170265
171 @classmethod266 @classmethod
172 def get_token_and_login(cls, consumer_name,267 def get_token_and_login(cls, consumer_name,
173 service_root=uris.STAGING_SERVICE_ROOT,268 service_root=uris.STAGING_SERVICE_ROOT,
174 cache=None, timeout=None, proxy_info=None,269 cache=None, timeout=None, proxy_info=None,
175 authorizer_class=AuthorizeRequestTokenWithBrowser,270 authorization_engine=None, allow_access_levels=[],
176 allow_access_levels=[], max_failed_attempts=3,271 max_failed_attempts=None, credential_store=None,
272 credential_save_failed=None,
177 version=DEFAULT_VERSION):273 version=DEFAULT_VERSION):
178 """Get credentials from Launchpad and log into the service root.274 """Get credentials from Launchpad and log into the service root.
179275
180 This is a convenience method which will open up the user's preferred276 This method is deprecated as of launchpadlib version
181 web browser and thus should not be used by most applications.277 1.9.0. You should use Launchpad.login_anonymously() for
182 Applications should, instead, use Credentials.get_request_token() to278 anonymous access and Launchpad.login_with() for all other
183 obtain the authorization URL and279 purposes.
184 Credentials.exchange_request_token_for_access_token() to obtain the280
185 actual OAuth access token.281 :param consumer_name: Either a consumer name, as appropriate for
186282 the `Consumer` constructor, or a premade Consumer object.
187 This method will negotiate an OAuth access token with the service
188 provider, but to complete it we will need the user to log into
189 Launchpad and authorize us, so we'll open the authorization page in
190 a web browser and ask the user to come back here and tell us when they
191 finished the authorization process.
192
193 :param consumer_name: The consumer name, as appropriate for the
194 `Consumer` constructor
195 :type consumer_name: string283 :type consumer_name: string
196 :param service_root: The URL to the root of the web service.284 :param service_root: The URL to the root of the web service.
197 :type service_root: string285 :type service_root: string
286 :param authorization_engine: See `Launchpad.__init__`. If you don't
287 provide an authorization engine, a default engine will be
288 constructed using your values for `service_root` and
289 `credential_save_failed`.
290 :param allow_access_levels: This argument is ignored, and only
291 present to preserve backwards compatibility.
198 :return: The web service root292 :return: The web service root
199 :rtype: `Launchpad`293 :rtype: `Launchpad`
200 """294 """
201 credentials = Credentials(consumer_name)295 cls._warn_of_deprecated_login_method("get_token_and_login")
202 service_root = uris.lookup_service_root(service_root)296 return cls._authorize_token_and_login(
203 web_root_uri = URI(service_root)297 consumer_name, service_root, cache, timeout, proxy_info,
204 web_root_uri.path = ""298 authorization_engine, allow_access_levels,
205 web_root_uri.host = web_root_uri.host.replace("api.", "", 1)299 credential_store, credential_save_failed, version)
206 web_root = str(web_root_uri.ensureSlash())300
207 authorization_json = credentials.get_request_token(301 @classmethod
208 web_root=web_root, token_format=Credentials.DICT_TOKEN_FORMAT)302 def _authorize_token_and_login(
209 authorizer = authorizer_class(303 cls, consumer_name, service_root, cache, timeout, proxy_info,
210 web_root, authorization_json['oauth_token_consumer'],304 authorization_engine, allow_access_levels, credential_store,
211 authorization_json['oauth_token'], allow_access_levels,305 credential_save_failed, version):
212 max_failed_attempts)306 """Authorize a request token. Log in with the resulting access token.
213 authorizer()307
214 credentials.exchange_request_token_for_access_token(web_root)308 This is the private, non-deprecated implementation of the
215 return cls(credentials, service_root, cache, timeout, proxy_info,309 deprecated method get_token_and_login(). Once
216 version)310 get_token_and_login() is removed, this code can be streamlined
311 and moved into its other call site, login_with().
312 """
313 if isinstance(consumer_name, Consumer):
314 consumer = consumer_name
315 else:
316 # Create a system-wide consumer. lazr.restfulclient won't
317 # do this automatically, but launchpadlib's default is to
318 # do a desktop-wide integration.
319 consumer = SystemWideConsumer(consumer_name)
320
321 # Create the credentials with no Consumer, then set its .consumer
322 # property directly.
323 credentials = Credentials(None)
324 credentials.consumer = consumer
325 if authorization_engine is None:
326 authorization_engine = cls.authorization_engine_factory(
327 service_root, consumer_name, None, allow_access_levels)
328 if credential_store is None:
329 credential_store = cls.credential_store_factory(
330 credential_save_failed)
331 else:
332 # A credential store was passed in, so we won't be using
333 # any provided value for credential_save_failed. But at
334 # least make sure we weren't given a conflicting value,
335 # since that makes the calling code look confusing.
336 cls._assert_login_argument_consistency(
337 "credential_save_failed", credential_save_failed,
338 credential_store.credential_save_failed,
339 "credential_store")
340
341 # Try to get the credentials out of the credential store.
342 cached_credentials = credential_store.load(
343 authorization_engine.unique_consumer_id)
344 if cached_credentials is None:
345 # They're not there. Acquire new credentials using the
346 # authorization engine.
347 credentials = authorization_engine(credentials, credential_store)
348 else:
349 # We acquired credentials. But, the application name
350 # wasn't stored along with the credentials, because in a
351 # desktop integration scenario, a single set of
352 # credentials may be shared by many applications. We need
353 # to set the application name for this specific instance
354 # of the credentials.
355 credentials = cached_credentials
356 credentials.consumer.application_name = (
357 authorization_engine.application_name)
358
359 return cls(credentials, authorization_engine, credential_store,
360 service_root, cache, timeout, proxy_info, version)
217361
218 @classmethod362 @classmethod
219 def login_anonymously(363 def login_anonymously(
@@ -225,76 +369,207 @@
225 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)369 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
226 token = AnonymousAccessToken()370 token = AnonymousAccessToken()
227 credentials = Credentials(consumer_name, access_token=token)371 credentials = Credentials(consumer_name, access_token=token)
228 return cls(credentials, service_root=service_root, cache=cache_path,372 return cls(credentials, None, None, service_root=service_root,
229 timeout=timeout, proxy_info=proxy_info, version=version)373 cache=cache_path, timeout=timeout, proxy_info=proxy_info,
374 version=version)
230375
231 @classmethod376 @classmethod
232 def login_with(cls, consumer_name,377 def login_with(cls, application_name=None,
233 service_root=uris.STAGING_SERVICE_ROOT,378 service_root=uris.STAGING_SERVICE_ROOT,
234 launchpadlib_dir=None, timeout=None, proxy_info=None,379 launchpadlib_dir=None, timeout=None, proxy_info=None,
235 authorizer_class=AuthorizeRequestTokenWithBrowser,380 authorization_engine=None, allow_access_levels=None,
236 allow_access_levels=[], max_failed_attempts=3,381 max_failed_attempts=None, credentials_file=None,
237 credentials_file=None, version=DEFAULT_VERSION):382 version=DEFAULT_VERSION, consumer_name=None,
238 """Log in to Launchpad with possibly cached credentials.383 credential_save_failed=None, credential_store=None):
239384 """Log in to Launchpad, possibly acquiring and storing credentials.
240 This is a convenience method for either setting up new login385
241 credentials, or re-using existing ones. When a login token is generated386 Use this method to get a `Launchpad` object. If the end-user
242 using this method, the resulting credentials will be saved in387 has no cached Launchpad credential, their browser will open
243 `credentials_file`, or if not given, into the `launchpadlib_dir`388 and they'll be asked to log in and authorize a desktop
244 directory. If the same `credentials_file`/`launchpadlib_dir` is passed389 integration. The authorized Launchpad credential will be
245 in a second time, the credentials in for the consumer will be used390 stored securely: in the GNOME keyring, the KDE Wallet, or in
246 automatically.391 an encrypted file on disk.
247392
248 Each consumer has their own credentials per service root in393 The next time your program (or any other program run by that
249 `launchpadlib_dir`. `launchpadlib_dir` is also used for caching394 user on the same computer) invokes this method, the end-user
250 fetched objects. The cache is per service root, and shared by395 will be prompted to unlock their keyring (or equivalent), and
251 all consumers.396 the credential will be retrieved from local storage and
252397 reused.
253 See `Launchpad.get_token_and_login()` for more information about398
254 how new tokens are generated.399 You can customize this behavior in three ways:
255400
256 :param consumer_name: The consumer name, as appropriate for the401 1. Pass in a filename to `credentials_file`. The end-user's
257 `Consumer` constructor402 credential will be written to that file, and on subsequent
258 :type consumer_name: string403 runs read from that file.
404
405 2. Subclass `CredentialStore` and pass in an instance of the
406 subclass as `credential_store`. This lets you change how
407 the end-user's credential is stored and retrieved locally.
408
409 3. Subclass `RequestTokenAuthorizationEngine` and pass in an
410 instance of the subclass as `authorization_engine`. This
411 lets you change change what happens when the end-user needs
412 to authorize the Launchpad credential.
413
414 :param application_name: The application name. This is *not*
415 the OAuth consumer name. Unless a consumer_name is also
416 provided, the OAuth consumer will be a system-wide
417 consumer representing the end-user's computer as a whole.
418 :type application_name: string
419
259 :param service_root: The URL to the root of the web service.420 :param service_root: The URL to the root of the web service.
260 :type service_root: string. Can either be the full URL to a service421 :type service_root: string. Can either be the full URL to a service
261 or one of the short service names.422 or one of the short service names.
262 :param launchpadlib_dir: The directory where the cache and423
263 credentials are stored.424 :param launchpadlib_dir: The directory used to store cached
425 data obtained from Launchpad. The cache is shared by all
426 consumers, and each Launchpad service root has its own
427 cache.
264 :type launchpadlib_dir: string428 :type launchpadlib_dir: string
265 :param credentials_file: If given, the credentials are stored in that429
266 file instead in `launchpadlib_dir`.430 :param authorization_engine: A strategy for getting the
267 :type credentials_file: string431 end-user to authorize an OAuth request token, for
268 :return: The web service root432 exchanging the request token for an access token, and for
433 storing the access token locally so that it can be
434 reused. By default, launchpadlib will open the end-user's
435 web browser to have them authorize the request token.
436 :type authorization_engine: `RequestTokenAuthorizationEngine`
437
438 :param allow_access_levels: The acceptable access levels for
439 this application.
440
441 This argument is used to construct the default
442 `authorization_engine`, so if you pass in your own
443 `authorization_engine` any value for this argument will be
444 ignored. This argument will also be ignored unless you
445 also specify `consumer_name`.
446
447 :type allow_access_levels: list of strings
448
449 :param max_failed_attempts: Ignored; only present for
450 backwards compatibility.
451
452 :param credentials_file: The path to a file in which to store
453 this user's OAuth access token.
454
455 :param version: The version of the Launchpad web service to use.
456
457 :param consumer_name: The consumer name, as appropriate for
458 the `Consumer` constructor. You probably don't want to
459 provide this, since providing it will prevent you from
460 taking advantage of desktop-wide integration.
461 :type consumer_name: string
462
463 :param credential_save_failed: a callback that is called upon
464 a failure to save the credentials locally. This argument is
465 used to construct the default `credential_store`, so if
466 you pass in your own `credential_store` any value for
467 this argument will be ignored.
468 :type credential_save_failed: A callable
469
470 :param credential_store: A strategy for storing an OAuth
471 access token locally. By default, tokens are stored in the
472 GNOME keyring (or equivalent). If `credentials_file` is
473 provided, then tokens are stored unencrypted in that file.
474 :type credential_store: `CredentialStore`
475
476 :return: A web service root authorized as the end-user.
269 :rtype: `Launchpad`477 :rtype: `Launchpad`
270478
271 """479 """
272 (service_root, launchpadlib_dir, cache_path,480 (service_root, launchpadlib_dir, cache_path,
273 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)481 service_root_dir) = cls._get_paths(service_root, launchpadlib_dir)
274 credentials_path = os.path.join(service_root_dir, 'credentials')482
275 if not os.path.exists(credentials_path):483 if (application_name is None and consumer_name is None and
276 os.makedirs(credentials_path)484 authorization_engine is None):
277 if credentials_file is None:485 raise ValueError(
278 consumer_credentials_path = os.path.join(credentials_path,486 "At least one of application_name, consumer_name, or "
279 consumer_name)487 "authorization_engine must be provided.")
280 else:488
281 consumer_credentials_path = credentials_file489 if credentials_file is not None and credential_store is not None:
282 if os.path.exists(consumer_credentials_path):490 raise ValueError(
283 credentials = Credentials.load_from_path(491 "At most one of credentials_file and credential_store "
284 consumer_credentials_path)492 "must be provided.")
285 launchpad = cls(493
286 credentials, service_root=service_root, cache=cache_path,494 if credential_store is None:
287 timeout=timeout, proxy_info=proxy_info, version=version)495 if credentials_file is not None:
288 else:496 # The end-user wants credentials stored in an
289 launchpad = cls.get_token_and_login(497 # unencrypted file.
290 consumer_name, service_root=service_root, cache=cache_path,498 credential_store = UnencryptedFileCredentialStore(
291 timeout=timeout, proxy_info=proxy_info,499 credentials_file, credential_save_failed)
292 authorizer_class=authorizer_class,500 else:
293 allow_access_levels=allow_access_levels,501 credential_store = cls.credential_store_factory(
294 max_failed_attempts=max_failed_attempts, version=version)502 credential_save_failed)
295 launchpad.credentials.save_to_path(consumer_credentials_path)503 else:
296 os.chmod(consumer_credentials_path, stat.S_IREAD | stat.S_IWRITE)504 # A credential store was passed in, so we won't be using
297 return launchpad505 # any provided value for credential_save_failed. But at
506 # least make sure we weren't given a conflicting value,
507 # since that makes the calling code look confusing.
508 cls._assert_login_argument_consistency(
509 'credential_save_failed', credential_save_failed,
510 credential_store.credential_save_failed,
511 "credential_store")
512 credential_store = credential_store
513
514 if authorization_engine is None:
515 authorization_engine = cls.authorization_engine_factory(
516 service_root, application_name, consumer_name,
517 allow_access_levels)
518 else:
519 # An authorization engine was passed in, so we won't be
520 # using any provided values for application_name,
521 # consumer_name, or allow_access_levels. But at least make
522 # sure we weren't given conflicting values, since that
523 # makes the calling code look confusing.
524 cls._assert_login_argument_consistency(
525 "application_name", application_name,
526 authorization_engine.application_name)
527
528 cls._assert_login_argument_consistency(
529 "consumer_name", consumer_name,
530 authorization_engine.consumer.key)
531
532 cls._assert_login_argument_consistency(
533 "allow_access_levels", allow_access_levels,
534 authorization_engine.allow_access_levels)
535
536 return cls._authorize_token_and_login(
537 authorization_engine.consumer, service_root,
538 cache_path, timeout, proxy_info, authorization_engine,
539 allow_access_levels, credential_store,
540 credential_save_failed, version)
541
542 @classmethod
543 def _warn_of_deprecated_login_method(cls, name):
544 warnings.warn(
545 ("The Launchpad.%s() method is deprecated. You should use "
546 "Launchpad.login_anonymous() for anonymous access and "
547 "Launchpad.login_with() for all other purposes.") % name,
548 DeprecationWarning)
549
550 @classmethod
551 def _assert_login_argument_consistency(
552 cls, argument_name, argument_value, object_value,
553 object_name="authorization engine"):
554 """Helper to find conflicting values passed into the login methods.
555
556 Many of the arguments to login_with are used to build other
557 objects--the authorization engine or the credential store. If
558 these objects are provided directly, many of the arguments
559 become redundant. We'll allow redundant arguments through, but
560 if a argument *conflicts* with the corresponding value in the
561 provided object, we raise an error.
562 """
563 inconsistent_value_message = (
564 "Inconsistent values given for %s: "
565 "(%r passed in, versus %r in %s). "
566 "You don't need to pass in %s if you pass in %s, "
567 "so just omit that argument.")
568 if (argument_value is not None and argument_value != object_value):
569 raise ValueError(inconsistent_value_message % (
570 argument_name, argument_value, object_value,
571 object_name, argument_name, object_name))
572
298573
299 @classmethod574 @classmethod
300 def _get_paths(cls, service_root, launchpadlib_dir=None):575 def _get_paths(cls, service_root, launchpadlib_dir=None):
@@ -320,8 +595,8 @@
320 launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')595 launchpadlib_dir = os.path.join(home_dir, '.launchpadlib')
321 launchpadlib_dir = os.path.expanduser(launchpadlib_dir)596 launchpadlib_dir = os.path.expanduser(launchpadlib_dir)
322 if not os.path.exists(launchpadlib_dir):597 if not os.path.exists(launchpadlib_dir):
323 os.makedirs(launchpadlib_dir,0700)598 os.makedirs(launchpadlib_dir, 0700)
324 os.chmod(launchpadlib_dir,0700)599 os.chmod(launchpadlib_dir, 0700)
325 # Determine the real service root.600 # Determine the real service root.
326 service_root = uris.lookup_service_root(service_root)601 service_root = uris.lookup_service_root(service_root)
327 # Each service root has its own cache and credential dirs.602 # Each service root has its own cache and credential dirs.
@@ -330,5 +605,6 @@
330 service_root_dir = os.path.join(launchpadlib_dir, host_name)605 service_root_dir = os.path.join(launchpadlib_dir, host_name)
331 cache_path = os.path.join(service_root_dir, 'cache')606 cache_path = os.path.join(service_root_dir, 'cache')
332 if not os.path.exists(cache_path):607 if not os.path.exists(cache_path):
333 os.makedirs(cache_path)608 os.makedirs(cache_path, 0700)
334 return (service_root, launchpadlib_dir, cache_path, service_root_dir)609 return (service_root, launchpadlib_dir, cache_path, service_root_dir)
610
335611
=== modified file 'src/launchpadlib/testing/helpers.py'
--- src/launchpadlib/testing/helpers.py 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/testing/helpers.py 2011-02-08 19:01:02 +0000
@@ -21,29 +21,152 @@
2121
22__metaclass__ = type22__metaclass__ = type
23__all__ = [23__all__ = [
24 'BadSaveKeyring',
25 'fake_keyring',
26 'FauxSocketModule',
27 'InMemoryKeyring',
28 'NoNetworkAuthorizationEngine',
29 'NoNetworkLaunchpad',
24 'TestableLaunchpad',30 'TestableLaunchpad',
25 'nopriv_read_nonprivate',31 'nopriv_read_nonprivate',
26 'salgado_read_nonprivate',32 'salgado_read_nonprivate',
27 'salgado_with_full_permissions',33 'salgado_with_full_permissions',
28 ]34 ]
2935
30import simplejson36from contextlib import contextmanager
3137
38import launchpadlib
32from launchpadlib.launchpad import Launchpad39from launchpadlib.launchpad import Launchpad
33from launchpadlib.credentials import (40from launchpadlib.credentials import (
34 AuthorizeRequestTokenWithBrowser, Credentials,41 AccessToken,
35 RequestTokenAuthorizationEngine, SimulatedLaunchpadBrowser)42 Credentials,
43 RequestTokenAuthorizationEngine,
44 )
45
46
47missing = object()
48
49
50def assert_keyring_not_imported():
51 assert getattr(launchpadlib.credentials, 'keyring', missing) is missing, (
52 'During tests the real keyring module should never be imported.')
53
54
55class NoNetworkAuthorizationEngine(RequestTokenAuthorizationEngine):
56 """An authorization engine that doesn't open a web browser.
57
58 You can use this to test the creation of Launchpad objects and the
59 storing of credentials. You can't use it to interact with the web
60 service, since it only pretends to authorize its OAuth request tokens.
61 """
62 ACCESS_TOKEN_KEY = "access_key:84"
63
64 def __init__(self, *args, **kwargs):
65 super(NoNetworkAuthorizationEngine, self).__init__(*args, **kwargs)
66 # Set up some instrumentation.
67 self.request_tokens_obtained = 0
68 self.access_tokens_obtained = 0
69
70 def get_request_token(self, credentials):
71 """Pretend to get a request token from the server.
72
73 We do this by simply returning a static token ID.
74 """
75 self.request_tokens_obtained += 1
76 return "request_token:42"
77
78 def make_end_user_authorize_token(self, credentials, request_token):
79 """Pretend to exchange a request token for an access token.
80
81 We do this by simply setting the access_token property.
82 """
83 credentials.access_token = AccessToken(
84 self.ACCESS_TOKEN_KEY, 'access_secret:168')
85 self.access_tokens_obtained += 1
86
87
88class NoNetworkLaunchpad(Launchpad):
89 """A Launchpad instance for tests with no network access.
90
91 It's only useful for making sure that certain methods were called.
92 It can't be used to interact with the API.
93 """
94
95 def __init__(self, credentials, authorization_engine, credential_store,
96 service_root, cache, timeout, proxy_info, version):
97 self.credentials = credentials
98 self.authorization_engine = authorization_engine
99 self.credential_store = credential_store
100 self.passed_in_args = dict(
101 service_root=service_root, cache=cache, timeout=timeout,
102 proxy_info=proxy_info, version=version)
103
104 @classmethod
105 def authorization_engine_factory(cls, *args):
106 return NoNetworkAuthorizationEngine(*args)
36107
37108
38class TestableLaunchpad(Launchpad):109class TestableLaunchpad(Launchpad):
39 """A base class for talking to the testing root service."""110 """A base class for talking to the testing root service."""
40111
41 def __init__(self, credentials, service_root=None,112 def __init__(self, credentials, authorization_engine=None,
113 credential_store=None, service_root="test_dev",
42 cache=None, timeout=None, proxy_info=None,114 cache=None, timeout=None, proxy_info=None,
43 version=Launchpad.DEFAULT_VERSION):115 version=Launchpad.DEFAULT_VERSION):
116 """Provide test-friendly defaults.
117
118 :param authorization_engine: Defaults to None, since a test
119 environment can't use an authorization engine.
120 :param credential_store: Defaults to None, since tests
121 generally pass in fully-formed Credentials objects.
122 :param service_root: Defaults to 'test_dev'.
123 """
44 super(TestableLaunchpad, self).__init__(124 super(TestableLaunchpad, self).__init__(
45 credentials, 'test_dev', cache, timeout, proxy_info,125 credentials, authorization_engine, credential_store,
46 version=version)126 service_root=service_root, cache=cache, timeout=timeout,
127 proxy_info=proxy_info, version=version)
128
129
130@contextmanager
131def fake_keyring(fake):
132 """A context manager which injects a testing keyring implementation."""
133 # The real keyring package should never be imported during tests.
134 assert_keyring_not_imported()
135 launchpadlib.credentials.keyring = fake
136 try:
137 yield
138 finally:
139 del launchpadlib.credentials.keyring
140
141
142class FauxSocketModule:
143 """A socket module replacement that provides a fake hostname."""
144
145 def gethostname(self):
146 return 'HOSTNAME'
147
148
149class BadSaveKeyring:
150 """A keyring that generates errors when saving passwords."""
151
152 def get_password(self, service, username):
153 return None
154
155 def set_password(self, service, username, password):
156 raise RuntimeError
157
158
159class InMemoryKeyring:
160 """A keyring that saves passwords only in memory."""
161
162 def __init__(self):
163 self.data = {}
164
165 def set_password(self, service, username, password):
166 self.data[service, username] = password
167
168 def get_password(self, service, username):
169 return self.data.get((service, username))
47170
48171
49class KnownTokens:172class KnownTokens:
@@ -52,119 +175,18 @@
52 def __init__(self, token_string, access_secret):175 def __init__(self, token_string, access_secret):
53 self.token_string = token_string176 self.token_string = token_string
54 self.access_secret = access_secret177 self.access_secret = access_secret
178 self.token = AccessToken(token_string, access_secret)
179 self.credentials = Credentials(
180 consumer_name="launchpad-library", access_token=self.token)
55181
56 def login(self, cache=None, timeout=None, proxy_info=None,182 def login(self, cache=None, timeout=None, proxy_info=None,
57 version=Launchpad.DEFAULT_VERSION):183 version=Launchpad.DEFAULT_VERSION):
58 """Login using these credentials."""184 """Create a Launchpad object using these credentials."""
59 return TestableLaunchpad.login(185 return TestableLaunchpad(
60 'launchpad-library', self.token_string, self.access_secret,186 self.credentials, cache=cache, timeout=timeout,
61 cache=cache, timeout=timeout, proxy_info=proxy_info,187 proxy_info=proxy_info, version=version)
62 version=version)
63188
64189
65salgado_with_full_permissions = KnownTokens('salgado-change-anything', 'test')190salgado_with_full_permissions = KnownTokens('salgado-change-anything', 'test')
66salgado_read_nonprivate = KnownTokens('salgado-read-nonprivate', 'secret')191salgado_read_nonprivate = KnownTokens('salgado-read-nonprivate', 'secret')
67nopriv_read_nonprivate = KnownTokens('nopriv-read-nonprivate', 'mystery')192nopriv_read_nonprivate = KnownTokens('nopriv-read-nonprivate', 'mystery')
68
69
70class ScriptableRequestTokenAuthorization(RequestTokenAuthorizationEngine):
71 """A request token process that doesn't need any user input.
72
73 The RequestTokenAuthorizationEngine is supposed to be hooked up to a
74 user interface, but that makes it difficult to test. This subclass
75 is designed to be easy to test.
76 """
77
78 def __init__(self, consumer_name, username, password, choose_access_level,
79 allow_access_levels=[], max_failed_attempts=2,
80 web_root="http://launchpad.dev:8085/"):
81
82 # Get a request token.
83 self.credentials = Credentials(consumer_name)
84 self.credentials.get_request_token(web_root=web_root)
85
86 # Initialize the superclass with the new request token.
87 super(ScriptableRequestTokenAuthorization, self).__init__(
88 web_root, consumer_name, self.credentials._request_token.key,
89 allow_access_levels, max_failed_attempts)
90
91 self.username = username
92 self.password = password
93 self.choose_access_level = choose_access_level
94
95 def __call__(self, exchange_for_access_token=True):
96 super(ScriptableRequestTokenAuthorization, self).__call__()
97
98 # Now verify that it worked by exchanging the authorized
99 # request token for an access token.
100 if (exchange_for_access_token and
101 self.choose_access_level != self.UNAUTHORIZED_ACCESS_LEVEL):
102 self.credentials.exchange_request_token_for_access_token(
103 web_root=self.web_root)
104 return self.credentials.access_token
105 return None
106
107 def open_page_in_user_browser(self, url):
108 """Print a status message."""
109 print ("[If this were a real application, the end-user's web "
110 "browser would be opened to %s]" % url)
111
112 def input_username(self, cached_username, suggested_message):
113 """Collect the Launchpad username from the end-user."""
114 print suggested_message
115 if cached_username is not None:
116 print "Cached email address: " + cached_username
117 return self.username
118
119 def input_password(self, suggested_message):
120 """Collect the Launchpad password from the end-user."""
121 print suggested_message
122 return self.password
123
124 def input_access_level(self, available_levels, suggested_message,
125 only_one_option):
126 """Collect the desired level of access from the end-user."""
127 print suggested_message
128 print [level['value'] for level in available_levels]
129 return self.choose_access_level
130
131 def startup(self, suggested_messages):
132 for message in suggested_messages:
133 print message
134
135
136class DummyAuthorizeRequestTokenWithBrowser(AuthorizeRequestTokenWithBrowser):
137
138 def __init__(self, web_root, consumer_name, request_token, username,
139 password, allow_access_levels=[], max_failed_attempts=3):
140 super(DummyAuthorizeRequestTokenWithBrowser, self).__init__(
141 web_root, consumer_name, request_token, allow_access_levels,
142 max_failed_attempts)
143
144 def open_page_in_user_browser(self, url):
145 """Print a status message."""
146 print ("[If this were a real application, the end-user's web "
147 "browser would be opened to %s]" % url)
148
149
150class UserInput(object):
151 """A class to store fake user input in a readable way.
152
153 An instance of this class can be used as a substitute for the
154 raw_input() function.
155 """
156
157 def __init__(self, inputs):
158 """Initialize with a line of user inputs."""
159 self.stream = iter(inputs)
160
161 def __call__(self, prompt):
162 """Print and return the next line of input."""
163 line = self.readline()
164 print prompt + "[User input: %s]" % line
165 return line
166
167 def readline(self):
168 """Return the next line of input."""
169 next_input = self.stream.next()
170 return str(next_input)
171193
=== added file 'src/launchpadlib/tests/test_credential_store.py'
--- src/launchpadlib/tests/test_credential_store.py 1970-01-01 00:00:00 +0000
+++ src/launchpadlib/tests/test_credential_store.py 2011-02-08 19:01:02 +0000
@@ -0,0 +1,138 @@
1# Copyright 2010 Canonical Ltd.
2
3# This file is part of launchpadlib.
4#
5# launchpadlib is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by the
7# Free Software Foundation, version 3 of the License.
8#
9# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
12# for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the credential store classes."""
18
19import os
20import tempfile
21import unittest
22
23from launchpadlib.testing.helpers import (
24 fake_keyring,
25 InMemoryKeyring,
26)
27
28from launchpadlib.credentials import (
29 AccessToken,
30 Credentials,
31 KeyringCredentialStore,
32 UnencryptedFileCredentialStore,
33)
34
35
36class CredentialStoreTestCase(unittest.TestCase):
37
38 def make_credential(self, consumer_key):
39 """Helper method to make a fake credential."""
40 return Credentials(
41 "app name", consumer_secret='consumer_secret:42',
42 access_token=AccessToken(consumer_key, 'access_secret:168'))
43
44
45class TestUnencryptedFileCredentialStore(CredentialStoreTestCase):
46 """Tests for the UnencryptedFileCredentialStore class."""
47
48 def setUp(self):
49 ignore, self.filename = tempfile.mkstemp()
50 self.store = UnencryptedFileCredentialStore(self.filename)
51
52 def tearDown(self):
53 if os.path.exists(self.filename):
54 os.remove(self.filename)
55
56 def test_save_and_load(self):
57 # Make sure you can save and load credentials to a file.
58 credential = self.make_credential("consumer key")
59 self.store.save(credential, "unique key")
60 credential2 = self.store.load("unique key")
61 self.assertEquals(credential.consumer.key, credential2.consumer.key)
62
63 def test_unique_id_doesnt_matter(self):
64 # If a file contains a credential, that credential will be
65 # accessed no matter what unique ID you specify.
66 credential = self.make_credential("consumer key")
67 self.store.save(credential, "some key")
68 credential2 = self.store.load("some other key")
69 self.assertEquals(credential.consumer.key, credential2.consumer.key)
70
71 def test_file_only_contains_one_credential(self):
72 # A credential file may contain only one credential. If you
73 # write two credentials with different unique IDs to the same
74 # file, the first credential will be overwritten with the
75 # second.
76 credential1 = self.make_credential("consumer key")
77 credential2 = self.make_credential("consumer key2")
78 self.store.save(credential1, "unique key 1")
79 self.store.save(credential1, "unique key 2")
80 loaded = self.store.load("unique key 1")
81 self.assertEquals(loaded.consumer.key, credential2.consumer.key)
82
83
84class TestKeyringCredentialStore(CredentialStoreTestCase):
85 """Tests for the KeyringCredentialStore class."""
86
87 def setUp(self):
88 self.keyring = InMemoryKeyring()
89 self.store = KeyringCredentialStore()
90
91 def test_save_and_load(self):
92 # Make sure you can save and load credentials to a keyring.
93 with fake_keyring(self.keyring):
94 credential = self.make_credential("consumer key")
95 self.store.save(credential, "unique key")
96 credential2 = self.store.load("unique key")
97 self.assertEquals(
98 credential.consumer.key, credential2.consumer.key)
99
100 def test_lookup_by_unique_key(self):
101 # Credentials in the keyring are looked up by the unique ID
102 # under which they were stored.
103 with fake_keyring(self.keyring):
104 credential1 = self.make_credential("consumer key1")
105 self.store.save(credential1, "key 1")
106
107 credential2 = self.make_credential("consumer key2")
108 self.store.save(credential2, "key 2")
109
110 loaded1 = self.store.load("key 1")
111 self.assertEquals(
112 credential1.consumer.key, loaded1.consumer.key)
113
114 loaded2 = self.store.load("key 2")
115 self.assertEquals(
116 credential2.consumer.key, loaded2.consumer.key)
117
118 def test_reused_unique_id_overwrites_old_credential(self):
119 # Writing a credential to the keyring with a given unique ID
120 # will overwrite any credential stored under that ID.
121
122 with fake_keyring(self.keyring):
123 credential1 = self.make_credential("consumer key1")
124 self.store.save(credential1, "the only key")
125
126 credential2 = self.make_credential("consumer key2")
127 self.store.save(credential2, "the only key")
128
129 loaded = self.store.load("the only key")
130 self.assertEquals(
131 credential2.consumer.key, loaded.consumer.key)
132
133 def test_bad_unique_id_returns_none(self):
134 # Trying to load a credential without providing a good unique
135 # ID will get you None.
136 with fake_keyring(self.keyring):
137 self.assertEquals(None, self.store.load("no such key"))
138
0139
=== added file 'src/launchpadlib/tests/test_http.py'
--- src/launchpadlib/tests/test_http.py 1970-01-01 00:00:00 +0000
+++ src/launchpadlib/tests/test_http.py 2011-02-08 19:01:02 +0000
@@ -0,0 +1,236 @@
1# Copyright 2010 Canonical Ltd.
2
3# This file is part of launchpadlib.
4#
5# launchpadlib is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by the
7# Free Software Foundation, version 3 of the License.
8#
9# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
12# for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the LaunchpadOAuthAwareHTTP class."""
18
19from collections import deque
20import tempfile
21import unittest
22
23from simplejson import dumps, JSONDecodeError
24
25from launchpadlib.errors import Unauthorized
26from launchpadlib.credentials import UnencryptedFileCredentialStore
27from launchpadlib.launchpad import (
28 Launchpad,
29 LaunchpadOAuthAwareHttp,
30 )
31from launchpadlib.testing.helpers import (
32 NoNetworkAuthorizationEngine,
33 NoNetworkLaunchpad,
34 )
35
36
37# The simplest WADL that looks like a representation of the service root.
38SIMPLE_WADL = '''<?xml version="1.0"?>
39<application xmlns="http://research.sun.com/wadl/2006/10">
40 <resources base="http://www.example.com/">
41 <resource path="" type="#service-root"/>
42 </resources>
43
44 <resource_type id="service-root">
45 <method name="GET" id="service-root-get">
46 <response>
47 <representation href="#service-root-json"/>
48 </response>
49 </method>
50 </resource_type>
51
52 <representation id="service-root-json" mediaType="application/json"/>
53</application>
54'''
55
56# The simplest JSON that looks like a representation of the service root.
57SIMPLE_JSON = dumps({})
58
59
60class Response:
61 """A fake HTTP response object."""
62 def __init__(self, status, content):
63 self.status = status
64 self.content = content
65
66
67class SimulatedResponsesHttp(LaunchpadOAuthAwareHttp):
68 """Responds to HTTP requests by shifting responses off a stack."""
69
70 def __init__(self, responses, *args):
71 """Constructor.
72
73 :param responses: A list of HttpResponse objects to use
74 in response to requests.
75 """
76 super(SimulatedResponsesHttp, self).__init__(*args)
77 self.sent_responses = []
78 self.unsent_responses = responses
79 self.cache = None
80
81 def _request(self, *args):
82 response = self.unsent_responses.popleft()
83 self.sent_responses.append(response)
84 return self.retry_on_bad_token(response, response.content, *args)
85
86
87class SimulatedResponsesLaunchpad(Launchpad):
88
89 # Every Http object generated by this class will return these
90 # responses, in order.
91 responses = []
92
93 def httpFactory(self, *args):
94 return SimulatedResponsesHttp(
95 deque(self.responses), self, self.authorization_engine, *args)
96
97 @classmethod
98 def credential_store_factory(cls, credential_save_failed):
99 return UnencryptedFileCredentialStore(
100 tempfile.mkstemp()[1], credential_save_failed)
101
102
103class SimulatedResponsesTestCase(unittest.TestCase):
104 """Test cases that give fake responses to launchpad's HTTP requests."""
105
106 def setUp(self):
107 """Clear out the list of simulated responses."""
108 SimulatedResponsesLaunchpad.responses = []
109 self.engine = NoNetworkAuthorizationEngine(
110 'http://api.example.com/', 'application name')
111
112 def launchpad_with_responses(self, *responses):
113 """Use simulated HTTP responses to get a Launchpad object.
114
115 The given Response objects will be sent, in order, in response
116 to launchpadlib's requests.
117
118 :param responses: Some number of Response objects.
119 :return: The Launchpad object, assuming that errors in the
120 simulated requests didn't prevent one from being created.
121 """
122 SimulatedResponsesLaunchpad.responses = responses
123 return SimulatedResponsesLaunchpad.login_with(
124 'application name', authorization_engine=self.engine)
125
126
127class TestAbilityToParseData(SimulatedResponsesTestCase):
128 """Test launchpadlib's ability to handle the sample data.
129
130 To create a Launchpad object, two HTTP requests must succeed and
131 return usable data: the requests for the WADL and JSON
132 representations of the service root. This test shows that the
133 minimal data in SIMPLE_WADL and SIMPLE_JSON is good enough to
134 create a Launchpad object.
135 """
136
137 def test_minimal_data(self):
138 """Make sure that launchpadlib can use the minimal data."""
139 launchpad = self.launchpad_with_responses(
140 Response(200, SIMPLE_WADL),
141 Response(200, SIMPLE_JSON))
142
143 def test_bad_wadl(self):
144 """Show that bad WADL causes an exception."""
145 self.assertRaises(
146 SyntaxError, self.launchpad_with_responses,
147 Response(200, "This is not WADL."),
148 Response(200, SIMPLE_JSON))
149
150 def test_bad_json(self):
151 """Show that bad JSON causes an exception."""
152 self.assertRaises(
153 JSONDecodeError, self.launchpad_with_responses,
154 Response(200, SIMPLE_WADL),
155 Response(200, "This is not JSON."))
156
157
158class TestTokenFailureDuringRequest(SimulatedResponsesTestCase):
159 """Test access token failures during a request.
160
161 launchpadlib makes two HTTP requests on startup, to get the WADL
162 and JSON representations of the service root. If Launchpad
163 receives a 401 error during this process, it will acquire a fresh
164 access token and try again.
165 """
166
167 def test_good_token(self):
168 """If our token is good, we never get another one."""
169 SimulatedResponsesLaunchpad.responses = [
170 Response(200, SIMPLE_WADL),
171 Response(200, SIMPLE_JSON)]
172
173 self.assertEquals(self.engine.access_tokens_obtained, 0)
174 launchpad = SimulatedResponsesLaunchpad.login_with(
175 'application name', authorization_engine=self.engine)
176 self.assertEquals(self.engine.access_tokens_obtained, 1)
177
178 def test_bad_token(self):
179 """If our token is bad, we get another one."""
180 SimulatedResponsesLaunchpad.responses = [
181 Response(401, "Invalid token."),
182 Response(200, SIMPLE_WADL),
183 Response(200, SIMPLE_JSON)]
184
185 self.assertEquals(self.engine.access_tokens_obtained, 0)
186 launchpad = SimulatedResponsesLaunchpad.login_with(
187 'application name', authorization_engine=self.engine)
188 self.assertEquals(self.engine.access_tokens_obtained, 2)
189
190 def test_expired_token(self):
191 """If our token is expired, we get another one."""
192
193 SimulatedResponsesLaunchpad.responses = [
194 Response(401, "Expired token."),
195 Response(200, SIMPLE_WADL),
196 Response(200, SIMPLE_JSON)]
197
198 self.assertEquals(self.engine.access_tokens_obtained, 0)
199 launchpad = SimulatedResponsesLaunchpad.login_with(
200 'application name', authorization_engine=self.engine)
201 self.assertEquals(self.engine.access_tokens_obtained, 2)
202
203 def test_delayed_error(self):
204 """We get another token no matter when the error happens."""
205 SimulatedResponsesLaunchpad.responses = [
206 Response(200, SIMPLE_WADL),
207 Response(401, "Expired token."),
208 Response(200, SIMPLE_JSON)]
209
210 self.assertEquals(self.engine.access_tokens_obtained, 0)
211 launchpad = SimulatedResponsesLaunchpad.login_with(
212 'application name', authorization_engine=self.engine)
213 self.assertEquals(self.engine.access_tokens_obtained, 2)
214
215 def test_many_errors(self):
216 """We'll keep getting new tokens as long as tokens are the problem."""
217 SimulatedResponsesLaunchpad.responses = [
218 Response(401, "Invalid token."),
219 Response(200, SIMPLE_WADL),
220 Response(401, "Expired token."),
221 Response(401, "Invalid token."),
222 Response(200, SIMPLE_JSON)]
223 self.assertEquals(self.engine.access_tokens_obtained, 0)
224 launchpad = SimulatedResponsesLaunchpad.login_with(
225 'application name', authorization_engine=self.engine)
226 self.assertEquals(self.engine.access_tokens_obtained, 4)
227
228 def test_other_unauthorized(self):
229 """If the token is not at fault, a 401 error raises an exception."""
230
231 SimulatedResponsesLaunchpad.responses = [
232 Response(401, "Some other error.")]
233
234 self.assertRaises(
235 Unauthorized, SimulatedResponsesLaunchpad.login_with,
236 'application name', authorization_engine=self.engine)
0237
=== modified file 'src/launchpadlib/tests/test_launchpad.py'
--- src/launchpadlib/tests/test_launchpad.py 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/tests/test_launchpad.py 2011-02-08 19:01:02 +0000
@@ -20,42 +20,47 @@
2020
21import os21import os
22import shutil22import shutil
23import socket
23import stat24import stat
24import tempfile25import tempfile
25import unittest26import unittest
27import warnings
28
29from lazr.restfulclient.resource import ServiceRoot
2630
27from launchpadlib.credentials import (31from launchpadlib.credentials import (
28 AccessToken, AuthorizeRequestTokenWithBrowser, Credentials)32 AccessToken,
33 Credentials,
34 )
35
36from launchpadlib import uris
37import launchpadlib.launchpad
29from launchpadlib.launchpad import Launchpad38from launchpadlib.launchpad import Launchpad
30from launchpadlib import uris39from launchpadlib.testing.helpers import (
3140 assert_keyring_not_imported,
32class NoNetworkLaunchpad(Launchpad):41 BadSaveKeyring,
33 """A Launchpad instance for tests with no network access.42 fake_keyring,
3443 FauxSocketModule,
35 It's only useful for making sure that certain methods were called.44 InMemoryKeyring,
36 It can't be used to interact with the API.45 NoNetworkAuthorizationEngine,
37 """46 NoNetworkLaunchpad,
3847 )
39 consumer_name = None48from launchpadlib.credentials import (
40 passed_in_kwargs = None49 KeyringCredentialStore,
41 credentials = None50 UnencryptedFileCredentialStore,
42 get_token_and_login_called = False51 )
4352
44 def __init__(self, credentials, **kw):53# A dummy service root for use in tests
45 self.credentials = credentials54SERVICE_ROOT = "http://api.example.com/"
46 self.passed_in_kwargs = kw55
4756class TestResourceTypeClasses(unittest.TestCase):
48 @classmethod57 """launchpadlib must know about restfulclient's resource types."""
49 def get_token_and_login(cls, consumer_name, **kw):58
50 """Create fake credentials and record that we were called."""59 def test_resource_types(self):
51 credentials = Credentials(60 # Make sure that Launchpad knows about every special resource
52 consumer_name, consumer_secret='consumer_secret:42',61 # class defined by lazr.restfulclient.
53 access_token=AccessToken('access_key:84', 'access_secret:168'))62 for name, cls in ServiceRoot.RESOURCE_TYPE_CLASSES.items():
54 launchpad = cls(credentials, **kw)63 self.assertEqual(Launchpad.RESOURCE_TYPE_CLASSES[name], cls)
55 launchpad.get_token_and_login_called = True
56 launchpad.consumer_name = consumer_name
57 launchpad.passed_in_kwargs = kw
58 return launchpad
5964
6065
61class TestNameLookups(unittest.TestCase):66class TestNameLookups(unittest.TestCase):
@@ -63,7 +68,7 @@
6368
64 def setUp(self):69 def setUp(self):
65 self.aliases = sorted(70 self.aliases = sorted(
66 ['production', 'edge', 'staging', 'dogfood', 'dev', 'test_dev'])71 ['production', 'qastaging', 'staging', 'dogfood', 'dev', 'test_dev'])
6772
68 def test_short_names(self):73 def test_short_names(self):
69 # Ensure the short service names are all supported.74 # Ensure the short service names are all supported.
@@ -115,7 +120,7 @@
115 version = "version-foo"120 version = "version-foo"
116 root = uris.service_roots['staging'] + version121 root = uris.service_roots['staging'] + version
117 try:122 try:
118 Launchpad(None, root, version=version)123 Launchpad(None, None, None, service_root=root, version=version)
119 except ValueError, e:124 except ValueError, e:
120 self.assertTrue(str(e).startswith(125 self.assertTrue(str(e).startswith(
121 "It looks like you're using a service root that incorporates "126 "It looks like you're using a service root that incorporates "
@@ -127,42 +132,115 @@
127 # Make sure the problematic URL is caught even if it has a132 # Make sure the problematic URL is caught even if it has a
128 # slash on the end.133 # slash on the end.
129 root += '/'134 root += '/'
130 self.assertRaises(ValueError, Launchpad, None, root, version=version)135 self.assertRaises(ValueError, Launchpad, None, None, None,
136 service_root=root, version=version)
131137
132 # Test that the default version has the same problem138 # Test that the default version has the same problem
133 # when no explicit version is specified139 # when no explicit version is specified
134 default_version = NoNetworkLaunchpad.DEFAULT_VERSION140 default_version = NoNetworkLaunchpad.DEFAULT_VERSION
135 root = uris.service_roots['staging'] + default_version + '/'141 root = uris.service_roots['staging'] + default_version + '/'
136 self.assertRaises(ValueError, Launchpad, None, root)142 self.assertRaises(ValueError, Launchpad, None, None, None,
137143 service_root=root)
138144
139class TestLaunchpadLoginWith(unittest.TestCase):145
146class TestRequestTokenAuthorizationEngine(unittest.TestCase):
147 """Tests for the RequestTokenAuthorizationEngine class."""
148
149 def test_app_must_be_identified(self):
150 self.assertRaises(
151 ValueError, NoNetworkAuthorizationEngine, SERVICE_ROOT)
152
153 def test_application_name_identifies_app(self):
154 NoNetworkAuthorizationEngine(SERVICE_ROOT, application_name='name')
155
156 def test_consumer_name_identifies_app(self):
157 NoNetworkAuthorizationEngine(SERVICE_ROOT, consumer_name='name')
158
159 def test_conflicting_app_identification(self):
160 # You can't specify both application_name and consumer_name.
161 self.assertRaises(
162 ValueError, NoNetworkAuthorizationEngine,
163 SERVICE_ROOT, application_name='name1', consumer_name='name2')
164
165 # This holds true even if you specify the same value for
166 # both. They're not the same thing.
167 self.assertRaises(
168 ValueError, NoNetworkAuthorizationEngine,
169 SERVICE_ROOT, application_name='name', consumer_name='name')
170
171
172class TestLaunchpadLoginWithCredentialsFile(unittest.TestCase):
173 """Tests for Launchpad.login_with() with a credentials file."""
174
175 def test_filename(self):
176 ignore, filename = tempfile.mkstemp()
177 launchpad = NoNetworkLaunchpad.login_with(
178 application_name='not important', credentials_file=filename)
179
180 # The credentials are stored unencrypted in the file you
181 # specify.
182 credentials = Credentials.load_from_path(filename)
183 self.assertEquals(credentials.consumer.key,
184 launchpad.credentials.consumer.key)
185 os.remove(filename)
186
187 def test_cannot_specify_both_filename_and_store(self):
188 ignore, filename = tempfile.mkstemp()
189 store = KeyringCredentialStore()
190 self.assertRaises(
191 ValueError, NoNetworkLaunchpad.login_with,
192 application_name='not important', credentials_file=filename,
193 credential_store=store)
194 os.remove(filename)
195
196
197class KeyringTest(unittest.TestCase):
198 """Base class for tests that use the keyring."""
199
200 def setUp(self):
201 # The real keyring package should never be imported during tests.
202 assert_keyring_not_imported()
203 # For these tests we want to use a dummy keyring implementation
204 # that only stores data in memory.
205 launchpadlib.credentials.keyring = InMemoryKeyring()
206
207 def tearDown(self):
208 # Remove the fake keyring module we injected during setUp.
209 del launchpadlib.credentials.keyring
210
211
212class TestLaunchpadLoginWith(KeyringTest):
140 """Tests for Launchpad.login_with()."""213 """Tests for Launchpad.login_with()."""
141214
142 def setUp(self):215 def setUp(self):
216 super(TestLaunchpadLoginWith, self).setUp()
143 self.temp_dir = tempfile.mkdtemp()217 self.temp_dir = tempfile.mkdtemp()
144218
145 def tearDown(self):219 def tearDown(self):
220 super(TestLaunchpadLoginWith, self).tearDown()
146 shutil.rmtree(self.temp_dir)221 shutil.rmtree(self.temp_dir)
147222
148 def test_dirs_created(self):223 def test_dirs_created(self):
149 # The path we pass into login_with() is the directory where224 # The path we pass into login_with() is the directory where
150 # cache and credentials for all service roots are stored.225 # cache for all service roots are stored.
151 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')226 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
152 launchpad = NoNetworkLaunchpad.login_with(227 NoNetworkLaunchpad.login_with(
153 'not important', service_root='http://api.example.com/beta',228 'not important', service_root=SERVICE_ROOT,
154 launchpadlib_dir=launchpadlib_dir)229 launchpadlib_dir=launchpadlib_dir)
155 # The 'launchpadlib' dir got created.230 # The 'launchpadlib' dir got created.
156 self.assertTrue(os.path.isdir(launchpadlib_dir))231 self.assertTrue(os.path.isdir(launchpadlib_dir))
157 # A directory for the passed in service root was created.232 # A directory for the passed in service root was created.
158 service_path = os.path.join(launchpadlib_dir, 'api.example.com')233 service_path = os.path.join(launchpadlib_dir, 'api.example.com')
159 self.assertTrue(os.path.isdir(service_path))234 self.assertTrue(os.path.isdir(service_path))
160 # Inside the service root directory, there is a 'cache' and a235 # Inside the service root directory, there is a 'cache'
161 # 'credentials' directory.236 # directory.
162 self.assertTrue(237 self.assertTrue(
163 os.path.isdir(os.path.join(service_path, 'cache')))238 os.path.isdir(os.path.join(service_path, 'cache')))
239
240 # In older versions there was also a 'credentials' directory,
241 # but no longer.
164 credentials_path = os.path.join(service_path, 'credentials')242 credentials_path = os.path.join(service_path, 'credentials')
165 self.assertTrue(os.path.isdir(credentials_path))243 self.assertFalse(os.path.isdir(credentials_path))
166244
167 def test_dirs_created_are_changed_to_secure(self):245 def test_dirs_created_are_changed_to_secure(self):
168 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')246 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
@@ -173,8 +251,8 @@
173 statinfo = os.stat(launchpadlib_dir)251 statinfo = os.stat(launchpadlib_dir)
174 mode = stat.S_IMODE(statinfo.st_mode)252 mode = stat.S_IMODE(statinfo.st_mode)
175 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)253 self.assertNotEqual(mode, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
176 launchpad = NoNetworkLaunchpad.login_with(254 NoNetworkLaunchpad.login_with(
177 'not important', service_root='http://api.example.com/beta',255 'not important', service_root=SERVICE_ROOT,
178 launchpadlib_dir=launchpadlib_dir)256 launchpadlib_dir=launchpadlib_dir)
179 # Verify the mode has been changed to 0700257 # Verify the mode has been changed to 0700
180 statinfo = os.stat(launchpadlib_dir)258 statinfo = os.stat(launchpadlib_dir)
@@ -183,8 +261,8 @@
183261
184 def test_dirs_created_are_secure(self):262 def test_dirs_created_are_secure(self):
185 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')263 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
186 launchpad = NoNetworkLaunchpad.login_with(264 NoNetworkLaunchpad.login_with(
187 'not important', service_root='http://api.example.com/beta',265 'not important', service_root=SERVICE_ROOT,
188 launchpadlib_dir=launchpadlib_dir)266 launchpadlib_dir=launchpadlib_dir)
189 self.assertTrue(os.path.isdir(launchpadlib_dir))267 self.assertTrue(os.path.isdir(launchpadlib_dir))
190 # Verify the mode is safe268 # Verify the mode is safe
@@ -198,44 +276,159 @@
198 # credentials will be cached to disk.276 # credentials will be cached to disk.
199 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')277 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
200 launchpad = NoNetworkLaunchpad.login_with(278 launchpad = NoNetworkLaunchpad.login_with(
201 'not important', service_root='http://api.example.com/',279 'not important', service_root=SERVICE_ROOT,
202 launchpadlib_dir=launchpadlib_dir, version="foo")280 launchpadlib_dir=launchpadlib_dir, version="foo")
203 self.assertEquals(launchpad.passed_in_kwargs['version'], 'foo')281 self.assertEquals(launchpad.passed_in_args['version'], 'foo')
204282
205 # Now execute the same test a second time. This time, the283 # Now execute the same test a second time. This time, the
206 # credentials are loaded from disk and a different code path284 # credentials are loaded from disk and a different code path
207 # is executed. We want to make sure this code path propagates285 # is executed. We want to make sure this code path propagates
208 # the 'version' argument.286 # the 'version' argument.
209 launchpad = NoNetworkLaunchpad.login_with(287 launchpad = NoNetworkLaunchpad.login_with(
210 'not important', service_root='http://api.example.com/',288 'not important', service_root=SERVICE_ROOT,
211 launchpadlib_dir=launchpadlib_dir, version="bar")289 launchpadlib_dir=launchpadlib_dir, version="bar")
212 self.assertEquals(launchpad.passed_in_kwargs['version'], 'bar')290 self.assertEquals(launchpad.passed_in_args['version'], 'bar')
213291
214 def test_no_credentials_calls_get_token_and_login(self):292 def test_application_name_is_propagated(self):
215 # If no credentials are found, get_token_and_login() is called.293 # Create a Launchpad instance for a given application name.
216 service_root = 'http://api.example.com/beta'294 # Credentials are stored, but they don't include the
295 # application name, since multiple applications may share a
296 # single system-wide credential.
297 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
298 launchpad = NoNetworkLaunchpad.login_with(
299 'very important', service_root=SERVICE_ROOT,
300 launchpadlib_dir=launchpadlib_dir)
301 self.assertEquals(
302 launchpad.credentials.consumer.application_name, 'very important')
303
304 # Now execute the same test a second time. This time, the
305 # credentials are loaded from disk and a different code path
306 # is executed. We want to make sure this code path propagates
307 # the application name, instead of picking an empty one from
308 # disk.
309 launchpad = NoNetworkLaunchpad.login_with(
310 'very important', service_root=SERVICE_ROOT,
311 launchpadlib_dir=launchpadlib_dir)
312 self.assertEquals(
313 launchpad.credentials.consumer.application_name, 'very important')
314
315 def test_authorization_engine_is_propagated(self):
316 # You can pass in a custom authorization engine, which will be
317 # used to get a request token and exchange it for an access
318 # token.
319 engine = NoNetworkAuthorizationEngine(
320 SERVICE_ROOT, 'application name')
321 NoNetworkLaunchpad.login_with(authorization_engine=engine)
322 self.assertEquals(engine.request_tokens_obtained, 1)
323 self.assertEquals(engine.access_tokens_obtained, 1)
324
325 def test_login_with_must_identify_application(self):
326 # If you call login_with without identifying your application
327 # you'll get an error.
328 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with)
329
330 def test_application_name_identifies_app(self):
331 # If you pass in application_name, that's good enough to identify
332 # your application.
333 NoNetworkLaunchpad.login_with(application_name="name")
334
335 def test_consumer_name_identifies_app(self):
336 # If you pass in consumer_name, that's good enough to identify
337 # your application.
338 NoNetworkLaunchpad.login_with(consumer_name="name")
339
340 def test_inconsistent_application_name_rejected(self):
341 """Catch an attempt to specify inconsistent application_names."""
342 engine = NoNetworkAuthorizationEngine(
343 SERVICE_ROOT, 'application name1')
344 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
345 "application name2",
346 authorization_engine=engine)
347
348 def test_inconsistent_consumer_name_rejected(self):
349 """Catch an attempt to specify inconsistent application_names."""
350 engine = NoNetworkAuthorizationEngine(
351 SERVICE_ROOT, None, consumer_name="consumer_name1")
352
353 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
354 "consumer_name2",
355 authorization_engine=engine)
356
357 def test_inconsistent_allow_access_levels_rejected(self):
358 """Catch an attempt to specify inconsistent allow_access_levels."""
359 engine = NoNetworkAuthorizationEngine(
360 SERVICE_ROOT, consumer_name="consumer",
361 allow_access_levels=['FOO'])
362
363 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
364 None, consumer_name="consumer",
365 allow_access_levels=['BAR'],
366 authorization_engine=engine)
367
368 def test_inconsistent_credential_save_failed(self):
369 # Catch an attempt to specify inconsistent callbacks for
370 # credential save failure.
371 def callback1():
372 pass
373 store = KeyringCredentialStore(credential_save_failed=callback1)
374
375 def callback2():
376 pass
377 self.assertRaises(ValueError, NoNetworkLaunchpad.login_with,
378 "app name", credential_store=store,
379 credential_save_failed=callback2)
380
381 def test_non_desktop_integration(self):
382 # When doing a non-desktop integration, you must specify a
383 # consumer_name. You can pass a list of allowable access
384 # levels into login_with().
385 launchpad = NoNetworkLaunchpad.login_with(
386 consumer_name="consumer", allow_access_levels=['FOO'])
387 self.assertEquals(launchpad.credentials.consumer.key, "consumer")
388 self.assertEquals(launchpad.credentials.consumer.application_name,
389 None)
390 self.assertEquals(launchpad.authorization_engine.allow_access_levels,
391 ['FOO'])
392
393 def test_desktop_integration_doesnt_happen_without_consumer_name(self):
394 # The only way to do a non-desktop integration is to specify a
395 # consumer_name. If you specify application_name instead, your
396 # value for allow_access_levels is ignored, and a desktop
397 # integration is performed.
398 launchpad = NoNetworkLaunchpad.login_with(
399 'application name', allow_access_levels=['FOO'])
400 self.assertEquals(launchpad.authorization_engine.allow_access_levels,
401 ['DESKTOP_INTEGRATION'])
402
403 def test_no_credentials_creates_new_credential(self):
404 # If no credentials are found, a desktop-wide credential is created.
217 timeout = object()405 timeout = object()
218 proxy_info = object()406 proxy_info = object()
219 launchpad = NoNetworkLaunchpad.login_with(407 launchpad = NoNetworkLaunchpad.login_with(
220 'app name', launchpadlib_dir=self.temp_dir,408 'app name', launchpadlib_dir=self.temp_dir,
221 service_root=service_root, timeout=timeout, proxy_info=proxy_info)409 service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info)
222 self.assertEqual(launchpad.consumer_name, 'app name')410 # Here's the new credential.
411 self.assertEqual(launchpad.credentials.access_token.key,
412 NoNetworkAuthorizationEngine.ACCESS_TOKEN_KEY)
413 self.assertEqual(launchpad.credentials.consumer.application_name,
414 'app name')
415 self.assertEquals(launchpad.authorization_engine.allow_access_levels,
416 ['DESKTOP_INTEGRATION'])
417 # The expected arguments were passed in to the Launchpad
418 # constructor.
223 expected_arguments = dict(419 expected_arguments = dict(
224 allow_access_levels=[],420 service_root=SERVICE_ROOT,
225 authorizer_class=AuthorizeRequestTokenWithBrowser,
226 max_failed_attempts=3,
227 service_root=service_root,
228 timeout=timeout,
229 proxy_info=proxy_info,
230 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'),421 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'),
231 version='beta')422 timeout=timeout,
232 self.assertEqual(launchpad.passed_in_kwargs, expected_arguments)423 proxy_info=proxy_info,
424 version=NoNetworkLaunchpad.DEFAULT_VERSION)
425 self.assertEqual(launchpad.passed_in_args, expected_arguments)
233426
234 def test_anonymous_login(self):427 def test_anonymous_login(self):
235 """Test the anonymous login helper function."""428 """Test the anonymous login helper function."""
236 launchpad = NoNetworkLaunchpad.login_anonymously(429 launchpad = NoNetworkLaunchpad.login_anonymously(
237 'anonymous access', launchpadlib_dir=self.temp_dir,430 'anonymous access', launchpadlib_dir=self.temp_dir,
238 service_root='http://api.example.com/beta')431 service_root=SERVICE_ROOT)
239 self.assertEqual(launchpad.credentials.access_token.key, '')432 self.assertEqual(launchpad.credentials.access_token.key, '')
240 self.assertEqual(launchpad.credentials.access_token.secret, '')433 self.assertEqual(launchpad.credentials.access_token.secret, '')
241434
@@ -245,62 +438,6 @@
245 'anonymous access')438 'anonymous access')
246 self.assertFalse(os.path.exists(credentials_path))439 self.assertFalse(os.path.exists(credentials_path))
247440
248 def test_new_credentials_are_saved(self):
249 # After get_token_and_login() have been called, the created
250 # credentials are saved.
251 launchpad = NoNetworkLaunchpad.login_with(
252 'app name', launchpadlib_dir=self.temp_dir,
253 service_root='http://api.example.com/beta')
254 credentials_path = os.path.join(
255 self.temp_dir, 'api.example.com', 'credentials', 'app name')
256 self.assertTrue(os.path.exists(credentials_path))
257 # Make sure that the credentials can be loaded, thus were
258 # written correctly.
259 loaded_credentials = Credentials.load_from_path(credentials_path)
260 self.assertEqual(loaded_credentials.consumer.key, 'app name')
261 self.assertEqual(
262 loaded_credentials.consumer.secret, 'consumer_secret:42')
263 self.assertEqual(
264 loaded_credentials.access_token.key, 'access_key:84')
265 self.assertEqual(
266 loaded_credentials.access_token.secret, 'access_secret:168')
267
268 def test_new_credentials_are_secure(self):
269 # The newly created credentials file is only readable and
270 # writable by the user.
271 launchpad = NoNetworkLaunchpad.login_with(
272 'app name', launchpadlib_dir=self.temp_dir,
273 service_root='http://api.example.com/beta')
274 credentials_path = os.path.join(
275 self.temp_dir, 'api.example.com', 'credentials', 'app name')
276 statinfo = os.stat(credentials_path)
277 mode = stat.S_IMODE(statinfo.st_mode)
278 self.assertEqual(mode, stat.S_IWRITE | stat.S_IREAD)
279
280 def test_existing_credentials_are_reused(self):
281 # If a credential file for the application already exists, that
282 # one is used.
283 os.makedirs(
284 os.path.join(self.temp_dir, 'api.example.com', 'credentials'))
285 credentials_file_path = os.path.join(
286 self.temp_dir, 'api.example.com', 'credentials', 'app name')
287 credentials = Credentials(
288 'app name', consumer_secret='consumer_secret:42',
289 access_token=AccessToken('access_key:84', 'access_secret:168'))
290 credentials.save_to_path(credentials_file_path)
291
292 launchpad = NoNetworkLaunchpad.login_with(
293 'app name', launchpadlib_dir=self.temp_dir,
294 service_root='http://api.example.com/beta')
295 self.assertFalse(launchpad.get_token_and_login_called)
296 self.assertEqual(launchpad.credentials.consumer.key, 'app name')
297 self.assertEqual(
298 launchpad.credentials.consumer.secret, 'consumer_secret:42')
299 self.assertEqual(
300 launchpad.credentials.access_token.key, 'access_key:84')
301 self.assertEqual(
302 launchpad.credentials.access_token.secret, 'access_secret:168')
303
304 def test_existing_credentials_arguments_passed_on(self):441 def test_existing_credentials_arguments_passed_on(self):
305 # When re-using existing credentials, the arguments login_with442 # When re-using existing credentials, the arguments login_with
306 # is called with are passed on the the __init__() method.443 # is called with are passed on the the __init__() method.
@@ -313,21 +450,22 @@
313 access_token=AccessToken('access_key:84', 'access_secret:168'))450 access_token=AccessToken('access_key:84', 'access_secret:168'))
314 credentials.save_to_path(credentials_file_path)451 credentials.save_to_path(credentials_file_path)
315452
316 service_root = 'http://api.example.com/'
317 timeout = object()453 timeout = object()
318 proxy_info = object()454 proxy_info = object()
319 version = "foo"455 version = "foo"
320 launchpad = NoNetworkLaunchpad.login_with(456 launchpad = NoNetworkLaunchpad.login_with(
321 'app name', launchpadlib_dir=self.temp_dir,457 'app name', launchpadlib_dir=self.temp_dir,
322 service_root=service_root, timeout=timeout, proxy_info=proxy_info,458 service_root=SERVICE_ROOT, timeout=timeout, proxy_info=proxy_info,
323 version=version)459 version=version)
324 expected_arguments = dict(460 expected_arguments = dict(
325 service_root=service_root,461 service_root=SERVICE_ROOT,
326 timeout=timeout,462 timeout=timeout,
327 proxy_info=proxy_info,463 proxy_info=proxy_info,
328 version=version,464 version=version,
329 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'))465 cache=os.path.join(self.temp_dir, 'api.example.com', 'cache'))
330 self.assertEqual(launchpad.passed_in_kwargs, expected_arguments)466 for key, expected in expected_arguments.items():
467 actual = launchpad.passed_in_args[key]
468 self.assertEqual(actual, expected)
331469
332 def test_None_launchpadlib_dir(self):470 def test_None_launchpadlib_dir(self):
333 # If no launchpadlib_dir is passed in to login_with,471 # If no launchpadlib_dir is passed in to login_with,
@@ -335,32 +473,30 @@
335 old_home = os.environ['HOME']473 old_home = os.environ['HOME']
336 os.environ['HOME'] = self.temp_dir474 os.environ['HOME'] = self.temp_dir
337 launchpad = NoNetworkLaunchpad.login_with(475 launchpad = NoNetworkLaunchpad.login_with(
338 'app name', service_root='http://api.example.com/beta')476 'app name', service_root=SERVICE_ROOT)
339 # Reset the environment to the old value.477 # Reset the environment to the old value.
340 os.environ['HOME'] = old_home478 os.environ['HOME'] = old_home
341479
342 cache_dir = launchpad.passed_in_kwargs['cache']480 cache_dir = launchpad.passed_in_args['cache']
343 launchpadlib_dir = os.path.abspath(481 launchpadlib_dir = os.path.abspath(
344 os.path.join(cache_dir, '..', '..'))482 os.path.join(cache_dir, '..', '..'))
345 self.assertEqual(483 self.assertEqual(
346 launchpadlib_dir, os.path.join(self.temp_dir, '.launchpadlib'))484 launchpadlib_dir, os.path.join(self.temp_dir, '.launchpadlib'))
347 self.assertTrue(os.path.exists(485 self.assertTrue(os.path.exists(
348 os.path.join(launchpadlib_dir, 'api.example.com', 'cache')))486 os.path.join(launchpadlib_dir, 'api.example.com', 'cache')))
349 self.assertTrue(os.path.exists(
350 os.path.join(launchpadlib_dir, 'api.example.com', 'credentials')))
351487
352 def test_short_service_name(self):488 def test_short_service_name(self):
353 # A short service name is converted to the full service root URL.489 # A short service name is converted to the full service root URL.
354 launchpad = NoNetworkLaunchpad.login_with('app name', 'staging')490 launchpad = NoNetworkLaunchpad.login_with('app name', 'staging')
355 self.assertEqual(491 self.assertEqual(
356 launchpad.passed_in_kwargs['service_root'],492 launchpad.passed_in_args['service_root'],
357 'https://api.staging.launchpad.net/')493 'https://api.staging.launchpad.net/')
358494
359 # A full URL as the service name is left alone.495 # A full URL as the service name is left alone.
360 launchpad = NoNetworkLaunchpad.login_with(496 launchpad = NoNetworkLaunchpad.login_with(
361 'app name', uris.service_roots['staging'])497 'app name', uris.service_roots['staging'])
362 self.assertEqual(498 self.assertEqual(
363 launchpad.passed_in_kwargs['service_root'],499 launchpad.passed_in_args['service_root'],
364 uris.service_roots['staging'])500 uris.service_roots['staging'])
365501
366 # A short service name that does not match one of the502 # A short service name that does not match one of the
@@ -370,27 +506,143 @@
370 self.assertRaises(506 self.assertRaises(
371 ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo')507 ValueError, NoNetworkLaunchpad.login_with, 'app name', 'foo')
372508
373 def test_separate_credentials_file(self):509 def test_max_failed_attempts_accepted(self):
374 my_credentials_path = os.path.join(self.temp_dir, 'my_creds') 510 # You can pass in a value for the 'max_failed_attempts'
375 launchpad = NoNetworkLaunchpad.login_with(511 # argument, even though that argument doesn't do anything.
376 'app name', launchpadlib_dir=self.temp_dir,512 NoNetworkLaunchpad.login_with(
377 credentials_file=my_credentials_path,513 'not important', max_failed_attempts=5)
378 service_root='http://api.example.com/beta')514
379 default_credentials_path = os.path.join(515
380 self.temp_dir, 'api.example.com', 'credentials', 'app name')516class TestDeprecatedLoginMethods(KeyringTest):
381 self.assertFalse(os.path.exists(default_credentials_path))517 """Make sure the deprecated login methods still work."""
382 self.assertTrue(os.path.exists(my_credentials_path))518
383519 def test_login_is_deprecated(self):
384 self.assertTrue(launchpad.get_token_and_login_called)520 # login() works but triggers a deprecation warning.
385521 with warnings.catch_warnings(record=True) as caught:
386 # gets reused, too522 warnings.simplefilter("always")
387 launchpad = NoNetworkLaunchpad.login_with(523 launchpad = NoNetworkLaunchpad.login(
388 'app name', launchpadlib_dir=self.temp_dir,524 'consumer', 'token', 'secret')
389 credentials_file=my_credentials_path,525 self.assertEquals(len(caught), 1)
390 service_root='http://api.example.com/beta')526 self.assertEquals(caught[0].category, DeprecationWarning)
391 self.assertFalse(os.path.exists(default_credentials_path))527
392 self.assertTrue(os.path.exists(my_credentials_path))528 def test_get_token_and_login_is_deprecated(self):
393 self.assertFalse(launchpad.get_token_and_login_called)529 # get_token_and_login() works but triggers a deprecation warning.
530 with warnings.catch_warnings(record=True) as caught:
531 warnings.simplefilter("always")
532 launchpad = NoNetworkLaunchpad.get_token_and_login('consumer')
533 self.assertEquals(len(caught), 1)
534 self.assertEquals(caught[0].category, DeprecationWarning)
535
536
537class TestCredenitialSaveFailedCallback(unittest.TestCase):
538 # There is a callback which will be called if saving the credentials
539 # fails.
540
541 def setUp(self):
542 # launchpadlib.launchpad uses the socket module to look up the
543 # hostname, obviously that can vary so we replace the socket module
544 # with a fake that returns a fake hostname.
545 launchpadlib.launchpad.socket = FauxSocketModule()
546 self.temp_dir = tempfile.mkdtemp()
547
548 def tearDown(self):
549 launchpadlib.launchpad.socket = socket
550 shutil.rmtree(self.temp_dir)
551
552 def test_credentials_save_failed(self):
553 # If saving the credentials did not succeed and a callback was
554 # provided, it is called.
555
556 callback_called = []
557 def callback():
558 # Since we can't rebind "callback_called" here, we'll have to
559 # settle for mutating it to signal success.
560 callback_called.append(None)
561
562 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
563 service_root = "http://api.example.com/"
564 with fake_keyring(BadSaveKeyring()):
565 NoNetworkLaunchpad.login_with(
566 'not important', service_root=service_root,
567 launchpadlib_dir=launchpadlib_dir,
568 credential_save_failed=callback)
569 self.assertEquals(len(callback_called), 1)
570
571 def test_default_credentials_save_failed_is_to_raise_exception(self):
572 # If saving the credentials did not succeed and no callback was
573 # provided, the underlying exception is raised.
574 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
575 service_root = "http://api.example.com/"
576 with fake_keyring(BadSaveKeyring()):
577 self.assertRaises(
578 RuntimeError,
579 NoNetworkLaunchpad.login_with,
580 'not important', service_root=service_root,
581 launchpadlib_dir=launchpadlib_dir)
582
583
584class TestMultipleSites(unittest.TestCase):
585 # If the same application name (consumer name) is used to access more than
586 # one site, the credentials need to be stored seperately. Therefore, the
587 # "username" passed ot the keyring includes the service root.
588
589 def setUp(self):
590 # launchpadlib.launchpad uses the socket module to look up the
591 # hostname, obviously that can vary so we replace the socket module
592 # with a fake that returns a fake hostname.
593 launchpadlib.launchpad.socket = FauxSocketModule()
594 self.temp_dir = tempfile.mkdtemp()
595
596 def tearDown(self):
597 launchpadlib.launchpad.socket = socket
598 shutil.rmtree(self.temp_dir)
599
600 def test_components_of_application_key(self):
601 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
602 keyring = InMemoryKeyring()
603 service_root = 'http://api.example.com/'
604 application_name = 'Super App 3000'
605 with fake_keyring(keyring):
606 launchpad = NoNetworkLaunchpad.login_with(
607 application_name, service_root=service_root,
608 launchpadlib_dir=launchpadlib_dir)
609 consumer_name = launchpad.credentials.consumer.key
610
611 application_key = keyring.data.keys()[0][1]
612
613 # Both the consumer name (normally the name of the application) and
614 # the service root (the URL of the service being accessed) are
615 # included in the key when storing credentials.
616 self.assert_(service_root in application_key)
617 self.assert_(consumer_name in application_key)
618
619 # The key used to store the credentials is of this structure (and
620 # shouldn't change between releases or stored credentials will be
621 # "forgotten").
622 self.assertEquals(application_key, consumer_name + '@' + service_root)
623
624 def test_same_app_different_servers(self):
625 launchpadlib_dir = os.path.join(self.temp_dir, 'launchpadlib')
626 keyring = InMemoryKeyring()
627 # Be paranoid about the keyring starting out empty.
628 assert not keyring.data, 'oops, a fresh keyring has data in it'
629 with fake_keyring(keyring):
630 # Create stored credentials for the same application but against
631 # two different sites (service roots).
632 NoNetworkLaunchpad.login_with(
633 'application name', service_root='http://alpha.example.com/',
634 launchpadlib_dir=launchpadlib_dir)
635 NoNetworkLaunchpad.login_with(
636 'application name', service_root='http://beta.example.com/',
637 launchpadlib_dir=launchpadlib_dir)
638
639 # There should only be two sets of stored credentials (this assertion
640 # is of the test mechanism, not a test assertion).
641 assert len(keyring.data.keys()) == 2
642
643 application_key_1 = keyring.data.keys()[0][1]
644 application_key_2 = keyring.data.keys()[1][1]
645 self.assertNotEqual(application_key_1, application_key_2)
394646
395647
396def test_suite():648def test_suite():
397649
=== modified file 'src/launchpadlib/uris.py'
--- src/launchpadlib/uris.py 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/uris.py 2011-02-08 19:01:02 +0000
@@ -16,27 +16,29 @@
1616
17"""Launchpad-specific URIs and convenience lookup functions.17"""Launchpad-specific URIs and convenience lookup functions.
1818
19The code in this module lets users say "edge" when they mean19The code in this module lets users say "staging" when they mean
20"https://api.edge.launchpad.net/".20"https://api.staging.launchpad.net/".
21"""21"""
2222
23__metaclass__ = type23__metaclass__ = type
24__all__ = [24__all__ = [
25 'lookup_service_root',25 'lookup_service_root',
26 'lookup_web_root',26 'lookup_web_root',
27 'web_root_for_service_root',
27 ]28 ]
2829
29from urlparse import urlparse30from urlparse import urlparse
31from lazr.uri import URI
3032
31LPNET_SERVICE_ROOT = 'https://api.launchpad.net/'33LPNET_SERVICE_ROOT = 'https://api.launchpad.net/'
32EDGE_SERVICE_ROOT = 'https://api.edge.launchpad.net/'34QASTAGING_SERVICE_ROOT = 'https://api.qastaging.launchpad.net/'
33STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/'35STAGING_SERVICE_ROOT = 'https://api.staging.launchpad.net/'
34DEV_SERVICE_ROOT = 'https://api.launchpad.dev/'36DEV_SERVICE_ROOT = 'https://api.launchpad.dev/'
35DOGFOOD_SERVICE_ROOT = 'https://api.dogfood.launchpad.net/'37DOGFOOD_SERVICE_ROOT = 'https://api.dogfood.launchpad.net/'
36TEST_DEV_SERVICE_ROOT = 'http://api.launchpad.dev:8085/'38TEST_DEV_SERVICE_ROOT = 'http://api.launchpad.dev:8085/'
3739
38LPNET_WEB_ROOT = 'https://launchpad.net/'40LPNET_WEB_ROOT = 'https://launchpad.net/'
39EDGE_WEB_ROOT = 'https://edge.launchpad.net/'41QASTAGING_WEB_ROOT = 'https://qastaging.launchpad.net/'
40STAGING_WEB_ROOT = 'https://staging.launchpad.net/'42STAGING_WEB_ROOT = 'https://staging.launchpad.net/'
41DEV_WEB_ROOT = 'https://launchpad.dev/'43DEV_WEB_ROOT = 'https://launchpad.dev/'
42DOGFOOD_WEB_ROOT = 'https://dogfood.launchpad.net/'44DOGFOOD_WEB_ROOT = 'https://dogfood.launchpad.net/'
@@ -45,7 +47,7 @@
4547
46service_roots = dict(48service_roots = dict(
47 production=LPNET_SERVICE_ROOT,49 production=LPNET_SERVICE_ROOT,
48 edge=EDGE_SERVICE_ROOT,50 qastaging=QASTAGING_SERVICE_ROOT,
49 staging=STAGING_SERVICE_ROOT,51 staging=STAGING_SERVICE_ROOT,
50 dogfood=DOGFOOD_SERVICE_ROOT,52 dogfood=DOGFOOD_SERVICE_ROOT,
51 dev=DEV_SERVICE_ROOT,53 dev=DEV_SERVICE_ROOT,
@@ -55,7 +57,7 @@
5557
56web_roots = dict(58web_roots = dict(
57 production=LPNET_WEB_ROOT,59 production=LPNET_WEB_ROOT,
58 edge=EDGE_WEB_ROOT,60 qastaging=QASTAGING_WEB_ROOT,
59 staging=STAGING_WEB_ROOT,61 staging=STAGING_WEB_ROOT,
60 dogfood=DOGFOOD_WEB_ROOT,62 dogfood=DOGFOOD_WEB_ROOT,
61 dev=DEV_WEB_ROOT,63 dev=DEV_WEB_ROOT,
@@ -81,7 +83,7 @@
81def lookup_service_root(service_root):83def lookup_service_root(service_root):
82 """Dereference an alias to a service root.84 """Dereference an alias to a service root.
8385
84 A recognized server alias such as "edge" gets turned into the86 A recognized server alias such as "staging" gets turned into the
85 appropriate URI. A URI gets returned as is. Any other string raises a87 appropriate URI. A URI gets returned as is. Any other string raises a
86 ValueError.88 ValueError.
87 """89 """
@@ -91,9 +93,21 @@
91def lookup_web_root(web_root):93def lookup_web_root(web_root):
92 """Dereference an alias to a website root.94 """Dereference an alias to a website root.
9395
94 A recognized server alias such as "edge" gets turned into the96 A recognized server alias such as "staging" gets turned into the
95 appropriate URI. A URI gets returned as is. Any other string raises a97 appropriate URI. A URI gets returned as is. Any other string raises a
96 ValueError.98 ValueError.
97 """99 """
98 return _dereference_alias(web_root, web_roots)100 return _dereference_alias(web_root, web_roots)
99101
102
103def web_root_for_service_root(service_root):
104 """Turn a service root URL into a web root URL.
105
106 This is done heuristically, not with a lookup.
107 """
108 service_root = lookup_service_root(service_root)
109 web_root_uri = URI(service_root)
110 web_root_uri.path = ""
111 web_root_uri.host = web_root_uri.host.replace("api.", "", 1)
112 web_root = str(web_root_uri.ensureSlash())
113 return web_root
100114
=== removed file 'src/launchpadlib/wadl-to-refhtml.xsl'
--- src/launchpadlib/wadl-to-refhtml.xsl 2010-12-07 19:30:29 +0000
+++ src/launchpadlib/wadl-to-refhtml.xsl 1970-01-01 00:00:00 +0000
@@ -1,1045 +0,0 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!--
3 wadl-to-refhtml.xsl
4
5 Generate HTML documentation for a webservice described in a WADL file.
6 This is tailored to WADL generated by Launchpad's web service.
7
8 Based on wadl_documentaion.xsl from Mark Nottingham <mnot@yahoo-inc.com>
9 that can be found at http://www.mnot.net/webdesc/
10 Copyright (c) 2006-2007 Yahoo! Inc.
11 Copyright (c) 2008 Canonical Ltd.
12
13 This work is licensed under the Creative Commons Attribution-ShareAlike 2.5
14 License. To view a copy of this license, visit
15 http://creativecommons.org/licenses/by-sa/2.5/
16 or send a letter to
17 Creative Commons
18 543 Howard Street, 5th Floor
19 San Francisco, California, 94105, USA
20-->
21
22<xsl:stylesheet
23 xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"
24 xmlns:wadl="http://research.sun.com/wadl/2006/10"
25 xmlns:html="http://www.w3.org/1999/xhtml"
26 xmlns="http://www.w3.org/1999/xhtml"
27 exclude-result-prefixes="xsl wadl html"
28>
29 <xsl:output
30 method="xml"
31 encoding="UTF-8"
32 indent="yes"
33 doctype-public="-//W3C//DTD XHTML 1.0 Transitional//EN"
34 doctype-system="http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"
35 />
36
37
38 <!-- Allow using key('id', 'people') to identify unique elements, since
39 the document doesn't have a parsed DTD.
40 -->
41 <xsl:key name="id" match="*[@id]" use="@id"/>
42
43 <!-- Embedded stylesheet. -->
44 <xsl:template name="css-stylesheet">
45 <style type="text/css">
46 body {
47 font-family: sans-serif;
48 font-size: 0.85em;
49 margin: 2em 8em;
50 }
51 .methods {
52 background-color: #eef;
53 padding: 1em;
54 margin-bottom: 0.5em;
55 }
56 .method {
57 padding-left: 4em;
58 }
59 h1 {
60 font-size: 2.5em;
61 }
62 h2 {
63 border-bottom: 1px solid black;
64 margin-top: 1em;
65 margin-bottom: 0.5em;
66 font-size: 2em;
67 }
68 h3 {
69 color: orange;
70 font-size: 1.75em;
71 margin-top: 1.25em;
72 margin-bottom: 0em;
73 }
74 h4 {
75 font-size: 1.50em;
76 margin: 0em;
77 padding: 0em;
78 border-bottom: 2px solid white;
79 }
80 h5 {
81 font-size: 1.25em;
82 margin-left: -3em;
83 }
84 h6 {
85 font-size: 1.1em;
86 color: #99a;
87 margin: 0.5em 0em 0.25em 0em;
88 }
89 dd {
90 margin-left: 1em;
91 }
92 tt, code {
93 font-size: 1.2em;
94 }
95 table {
96 margin-bottom: 0.5em;
97 }
98 th {
99 text-align: left;
100 font-weight: normal;
101 color: black;
102 border-bottom: 1px solid black;
103 padding: 3px 6px;
104 }
105 td {
106 padding: 3px 6px;
107 vertical-align: top;
108 background-color: #f6f6ff;
109 font-size: 0.85em;
110 }
111 td p {
112 margin: 0px;
113 }
114 ul {
115 padding-left: 1.75em;
116 }
117 p + ul, p + ol, p + dl {
118 margin-top: 0em;
119 }
120 label {
121 font-weight: bold;
122 }
123 .optional {
124 font-weight: normal;
125 opacity: 0.75;
126 }
127 .toc-link {
128 font-size: 0.85em;
129 }
130 </style>
131 </xsl:template>
132
133 <!-- Contains the base URL for the webservice without a trailing
134 slash. -->
135 <xsl:variable name="base">
136 <xsl:variable name="uri" select="//wadl:resources/@base"/>
137 <xsl:choose>
138 <xsl:when
139 test="substring($uri, string-length($uri) , 1) = '/'">
140 <xsl:value-of
141 select="substring($uri, 1, string-length($uri) - 1)"/>
142 </xsl:when>
143 <xsl:otherwise>
144 <xsl:value-of select="$uri"/>
145 </xsl:otherwise>
146 </xsl:choose>
147 </xsl:variable>
148
149 <!-- Generate the URL to the top-level collection. -->
150 <xsl:template name="resource-uri-doc">
151 <xsl:param name="url"><xsl:value-of
152 select="$base"/>/<xsl:value-of select="@id"/></xsl:param>
153 <p><label>URL:</label>
154 <code><xsl:copy-of select="$url" /></code></p>
155 </xsl:template>
156
157 <xsl:template name="entry-uri-doc">
158 <xsl:call-template name="resource-uri-doc">
159 <xsl:with-param name="url">
160 <xsl:choose>
161 <xsl:when test="@id = 'has_milestones'
162 or @id = 'bug_target'
163 or @id = 'has_bugs'">
164 <em>depends on the underlying entry</em>
165 </xsl:when>
166 <xsl:otherwise>
167 <xsl:call-template name="find-entry-uri"/>
168 </xsl:otherwise>
169 </xsl:choose>
170 </xsl:with-param>
171 </xsl:call-template>
172 </xsl:template>
173
174 <xsl:template name="find-entry-uri">
175 <xsl:value-of select="$base"/>
176 <xsl:choose>
177 <xsl:when test="@id = 'archive'">
178 <xsl:text>/</xsl:text>
179 <var>&lt;distribution&gt;</var>
180 <xsl:text>/+archive/</xsl:text>
181 <var>&lt;archive.name&gt;</var>
182 </xsl:when>
183 <xsl:when test="@id = 'archive_permission'">
184 <xsl:text>/</xsl:text>
185 <var>&lt;archive.distribution&gt;</var>
186 <xsl:text>/+archive/</xsl:text>
187 <var>&lt;archive.name&gt;</var>
188 <xsl:text>/+</xsl:text>
189 <xsl:text>name</xsl:text>
190 <xsl:text>/</xsl:text>
191 <xsl:text>person.name</xsl:text>
192 <xsl:text>.</xsl:text>
193 <xsl:text>[component or source package].name</xsl:text>
194 </xsl:when>
195 <xsl:when test="@id = 'binary_package_publishing_history'">
196 <xsl:text>/</xsl:text>
197 <var>&lt;distribution.name&gt;</var>
198 <xsl:text>/+archive/</xsl:text>
199 <var>&lt;binary_package.name&gt;</var>
200 <xsl:text>/+binarypub/</xsl:text>
201 <var>&lt;id&gt;</var>
202 </xsl:when>
203 <xsl:when test="@id = 'branch'">
204 <xsl:text>/~</xsl:text>
205 <var>&lt;author.name&gt;</var>
206 <xsl:text>/</xsl:text>
207 <var>&lt;project.name&gt;</var>
208 <xsl:text>/</xsl:text>
209 <var>&lt;name&gt;</var>
210 </xsl:when>
211 <xsl:when test="@id = 'branch_merge_proposal'">
212 <xsl:text>/~</xsl:text>
213 <var>&lt;author.name&gt;</var>
214 <xsl:text>/</xsl:text>
215 <var>&lt;project.name&gt;</var>
216 <xsl:text>/</xsl:text>
217 <var>&lt;branch.name&gt;</var>
218 <xsl:text>/+merge/</xsl:text>
219 <var>&lt;id&gt;</var>
220 </xsl:when>
221 <xsl:when test="@id = 'bug'">
222 <xsl:text>/bugs/</xsl:text><var>&lt;id&gt;</var>
223 </xsl:when>
224 <xsl:when test="@id = 'bug_attachment'">
225 <xsl:text>/bugs/</xsl:text>
226 <var>&lt;bug.id&gt;</var>
227 <xsl:text>/attachments/</xsl:text>
228 <var>&lt;id&gt;</var>
229 </xsl:when>
230 <xsl:when test="@id = 'bug_subscription'">
231 <xsl:text>/bugs/</xsl:text>
232 <var>&lt;bug.id&gt;</var>
233 <xsl:text>/subscriptions/</xsl:text>
234 <var>&lt;subscriber.name&gt;</var>
235 </xsl:when>
236 <xsl:when test="@id = 'bug_task'">
237 <xsl:text>/</xsl:text>
238 <var>&lt;target.name&gt;</var>
239 <xsl:text>/+bug/</xsl:text>
240 <var >&lt;bug.id&gt;</var>
241 </xsl:when>
242 <xsl:when test="@id = 'bug_watch'">
243 <xsl:text>/bugs/</xsl:text>
244 <var>&lt;bug.id&gt;</var>
245 <xsl:text>/watch/</xsl:text>
246 <var>&lt;id&gt;</var>
247 </xsl:when>
248 <xsl:when test="@id = 'bug_tracker'">
249 <xsl:text>/bugs/bugtrackers/</xsl:text>
250 <var>&lt;name&gt;</var>
251 </xsl:when>
252 <xsl:when test="@id = 'build'">
253 <xsl:text>/</xsl:text>
254 <var>&lt;distribution.name&gt;</var>
255 <xsl:text>/+source/</xsl:text>
256 <var>&lt;source_package.name&gt;</var>
257 <xsl:text>/+build/</xsl:text>
258 <var>&lt;id&gt;</var>
259 </xsl:when>
260 <xsl:when test="@id = 'cve'">
261 <xsl:text>/bugs/cve/</xsl:text>
262 <var>&lt;sequence&gt;</var>
263 </xsl:when>
264 <xsl:when test="@id = 'distribution_source_package'">
265 <xsl:text>/</xsl:text>
266 <var>&lt;distribution.name&gt;</var>
267 <xsl:text>/+source/</xsl:text>
268 <var>&lt;name&gt;</var>
269 </xsl:when>
270 <xsl:when test="@id = 'distro_arch_series'">
271 <xsl:text>/</xsl:text>
272 <var>&lt;distribution.name&gt;</var>
273 <xsl:text>/</xsl:text>
274 <var>&lt;distroseries.name&gt;</var>
275 <xsl:text>/</xsl:text>
276 <var>&lt;architecture_tag&gt;</var>
277 </xsl:when>
278 <xsl:when test="@id = 'distro_series'">
279 <xsl:text>/</xsl:text>
280 <var>&lt;distribution.name&gt;</var>
281 <xsl:text>/</xsl:text>
282 <var>&lt;name&gt;</var>
283 </xsl:when>
284 <xsl:when test="@id = 'email_address'">
285 <xsl:text>/</xsl:text>
286 <var>&lt;person.name&gt;</var>
287 <xsl:text>/+email/</xsl:text>
288 <var>&lt;email&gt;</var>
289 </xsl:when>
290 <xsl:when test="@id = 'h_w_device'">
291 <xsl:text>/+hwdb/+device/</xsl:text>
292 <var>&lt;id&gt;</var>
293 </xsl:when>
294 <xsl:when test="@id = 'h_w_device_class'">
295 <xsl:text>/+hwdb/+deviceclass/</xsl:text>
296 <var>&lt;id&gt;</var>
297 </xsl:when>
298 <xsl:when test="@id = 'h_w_driver'">
299 <xsl:text>/+hwdb/+driver/</xsl:text>
300 <var>&lt;id&gt;</var>
301 </xsl:when>
302 <xsl:when test="@id = 'h_w_submission'">
303 <xsl:text>/+hwdb/+submission/</xsl:text>
304 <var>&lt;submission-key&gt;</var>
305 </xsl:when>
306 <xsl:when test="@id = 'h_w_submission_device'">
307 <xsl:text>/+hwdb/+submissiondevice/</xsl:text>
308 <var>&lt;id&gt;</var>
309 </xsl:when>
310 <xsl:when test="@id = 'h_w_vendor_i_d'">
311 <xsl:text>/+hwdb/+hwvendorid/</xsl:text>
312 <var>&lt;id&gt;</var>
313 </xsl:when>
314 <xsl:when test="@id = 'jabber_id'">
315 <xsl:text>/</xsl:text>
316 <var>&lt;person.name&gt;</var>
317 <xsl:text>/+jabberid/</xsl:text>
318 <var>&lt;id&gt;</var>
319 </xsl:when>
320 <xsl:when test="@id = 'irc_id'">
321 <xsl:text>/</xsl:text>
322 <var>&lt;person.name&gt;</var>
323 <xsl:text>/+ircnick/</xsl:text>
324 <var>&lt;id&gt;</var>
325 </xsl:when>
326 <xsl:when test="@id = 'language'">
327 <xsl:text>/+languages/</xsl:text>
328 <var>&lt;code&gt;</var>
329 </xsl:when>
330 <xsl:when test="@id = 'message'">
331 <xsl:text>/</xsl:text>
332 <var>&lt;target.name&gt;</var>
333 <xsl:text>/+bug/</xsl:text>
334 <var>&lt;bug.id&gt;</var>
335 <xsl:text>/comments/</xsl:text>
336 <var>&lt;index&gt;</var>
337 </xsl:when>
338 <xsl:when test="@id = 'milestone'">
339 <xsl:text>/</xsl:text>
340 <var>&lt;target.name&gt;</var>
341 <xsl:text>/+milestone/</xsl:text>
342 <var>&lt;name&gt;</var>
343 </xsl:when>
344 <xsl:when test=" @id = 'distribution'
345 or @id = 'pillar'
346 or @id = 'product'
347 or @id = 'project'
348 or @id = 'project_group'">
349 <xsl:text>/</xsl:text>
350 <var>&lt;name&gt;</var>
351 </xsl:when>
352 <xsl:when test="@id = 'team' or @id = 'person'">
353 <xsl:text>/~</xsl:text>
354 <var>&lt;name&gt;</var>
355 </xsl:when>
356 <xsl:when test="@id = 'product_release'">
357 <xsl:text>/</xsl:text>
358 <var>&lt;product.name&gt;</var>
359 <xsl:text>/</xsl:text>
360 <var>&lt;product_series.name&gt;</var>
361 <xsl:text>/</xsl:text>
362 <var>&lt;name&gt;</var>
363 </xsl:when>
364 <xsl:when test="@id = 'product_series'">
365 <xsl:text>/</xsl:text>
366 <var>&lt;product.name&gt;</var>
367 <xsl:text>/</xsl:text>
368 <var>&lt;name&gt;</var>
369 </xsl:when>
370 <xsl:when test="@id = 'project_release'">
371 <xsl:text>/</xsl:text>
372 <var>&lt;project.name&gt;</var>
373 <xsl:text>/</xsl:text>
374 <var>&lt;project_series.name&gt;</var>
375 <xsl:text>/</xsl:text>
376 <var>&lt;release.version&gt;</var>
377 </xsl:when>
378 <xsl:when test="@id = 'project_release_file'">
379 <xsl:text>/</xsl:text>
380 <var>&lt;project.name&gt;</var>
381 <xsl:text>/</xsl:text>
382 <var>&lt;project_series.name&gt;</var>
383 <xsl:text>/</xsl:text>
384 <var>&lt;release.version&gt;</var>
385 <xsl:text>/+file/</xsl:text>
386 <var>&lt;hosted_file.filename&gt;</var>
387 </xsl:when>
388 <xsl:when test="@id = 'project_series'">
389 <xsl:text>/</xsl:text>
390 <var>&lt;project.name&gt;</var>
391 <xsl:text>/</xsl:text>
392 <var>&lt;name&gt;</var>
393 </xsl:when>
394 <xsl:when test="@id = 'source_package'">
395 <xsl:text>/</xsl:text>
396 <var>&lt;distribution.name&gt;</var>
397 <xsl:text>/</xsl:text>
398 <var>&lt;distro_series.name&gt;</var>
399 <xsl:text>/+source/</xsl:text>
400 <var>&lt;name&gt;</var>
401 </xsl:when>
402 <xsl:when test="@id = 'source_package_publishing_history'">
403 <xsl:text>/</xsl:text>
404 <var>&lt;distribution&gt;</var>
405 <xsl:text>/+archive/</xsl:text>
406 <var>&lt;name&gt;</var>
407 <xsl:text>/+sourcepub/</xsl:text>
408 <var>&lt;id&gt;</var>
409 </xsl:when>
410 <xsl:when test="@id = 'team_membership'">
411 <xsl:text>/~</xsl:text>
412 <var>&lt;team.name&gt;</var>
413 <xsl:text>/+member/</xsl:text>
414 <var>&lt;member.name&gt;</var>
415 </xsl:when>
416 <xsl:when test="@id = 'wiki_name'">
417 <xsl:text>/~</xsl:text>
418 <var>&lt;person.name&gt;</var>
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches

to all changes: