Merge lp:~leonardr/launchpadlib/trusted-workflow-tests-2 into lp:launchpadlib

Proposed by Leonard Richardson
Status: Merged
Approved by: Gary Poster
Approved revision: 78
Merged at revision: not available
Proposed branch: lp:~leonardr/launchpadlib/trusted-workflow-tests-2
Merge into: lp:launchpadlib
Diff against target: 584 lines
5 files modified
src/launchpadlib/apps.py (+129/-1)
src/launchpadlib/bin/launchpad-credentials-console (+54/-0)
src/launchpadlib/credentials.py (+37/-17)
src/launchpadlib/docs/command-line.txt (+173/-3)
src/launchpadlib/testing/helpers.py (+28/-3)
To merge this branch: bzr merge lp:~leonardr/launchpadlib/trusted-workflow-tests-2
Reviewer Review Type Date Requested Status
Gary Poster Approve
Review via email: mp+14230@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This branch creates a TrustedTokenAuthorizationConsoleApp, a console application that guides the end-user through the process of authorizing a request token. It creates a lot of doctests for the App object and a small command-line script that invokes it. The doctests use the new UserInput class which simulates a user typing things into a console application.

There are a lot of small spacing tweaks in this branch to make the application look good when run from the command line. These changes don't necessarily show up in the doctests, which are much more lenient about whitespace.

In addition to the doctests I manually ran the console application to test things that can't be automatically tested such as the use of getpass and the opening of a page in the web browser.

77. By Leonard Richardson

Merged with trunk.

Revision history for this message
Guilherme Salgado (salgado) wrote :
Download full text (7.6 KiB)

On Fri, 2009-10-30 at 17:35 +0000, Leonard Richardson wrote:
> Leonard Richardson has proposed merging lp:~leonardr/launchpadlib/trusted-workflow-tests-2 into lp:launchpadlib.
>
> Requested reviews:
> LAZR Developers (lazr-developers)
>
>
> This branch creates a TrustedTokenAuthorizationConsoleApp, a console
> application that guides the end-user through the process of
> authorizing a request token. It creates a lot of doctests for the App
> object and a small command-line script that invokes it. The doctests
> use the new UserInput class which simulates a user typing things into
> a console application.
>
> There are a lot of small spacing tweaks in this branch to make the
> application look good when run from the command line. These changes
> don't necessarily show up in the doctests, which are much more lenient
> about whitespace.

Did you consider changing your tests to run without the
NORMALIZE_WHITESPACE option, which is enabled by default for all our
tests? But I guess there's no real benefit in making the test more
fragile just to show that the app looks good when running on the command
line.

>
> In addition to the doctests I manually ran the console application to
> test things that can't be automatically tested such as the use of
> getpass and the opening of a page in the web browser.
> --

> === modified file 'src/launchpadlib/apps.py'
> --- src/launchpadlib/apps.py 2009-10-28 17:27:53 +0000
> +++ src/launchpadlib/apps.py 2009-10-30 17:35:17 +0000
> @@ -20,9 +20,18 @@
> themselves are kept in bin/.
> """
>
> +__all__ = [
> + 'RequestTokenApp',
> + 'TrustedTokenAuthorizationConsoleApp',
> + ]
> +
> +import getpass
> +import sys
> +
> import simplejson
>
> -from launchpadlib.credentials import Credentials
> +from launchpadlib.credentials import (
> + Credentials, RequestTokenAuthorizationEngine, TokenAuthorizationException)
> from launchpadlib.uris import lookup_web_root
>
>
> @@ -43,3 +52,124 @@
> return simplejson.dumps(token)
>
>
> +class TrustedTokenAuthorizationConsoleApp(RequestTokenAuthorizationEngine):
> + """An application that authorizes request tokens."""
> +
> + def __init__(self, web_root, consumer_name, request_token,
> + access_levels='', input_method=raw_input):
> + """Constructor.
> +
> + :param access_levels: A string of comma-separated access level
> + values. To get an up-to-date list of access levels, pass
> + token_format=Credentials.DICT_TOKEN_FORMAT into
> + Credentials.get_request_token, load the dict as JSON, and look
> + in 'access_levels'.
> + """
> + access_levels = [level.strip() for level in access_levels.split(',')]
> + super(TrustedTokenAuthorizationConsoleApp, self).__init__(
> + web_root, consumer_name, request_token, access_levels)
> +
> + self.input_method = input_method
> +
> + def run(self):
> + """Try to authorize a request token from user input."""
> + self.error_code = -1 # Start off assuming failure.
> + start = "Launchpad credential client (console)"
> + self.output(start)
> + self.output("-" ...

Read more...

Revision history for this message
Guilherme Salgado (salgado) wrote :

On Fri, 2009-10-30 at 17:29 -0200, Guilherme Salgado wrote:
>
> > + return self.pressEnterToExit()
> > +
> > + def exit_with(self, code):
>
> Why is this not exitWith()?

As you explained to me on IRC, this is because you're using the python
naming standard instead of the LP one. Fair enough, but the two methods
below use the LP one. Care to rename them?

And here's a review of the two remaining files, which had conflicts when
I first reviewed your branch. Just one question, in fact.

> === modified file 'src/launchpadlib/credentials.py'
> --- src/launchpadlib/credentials.py 2009-10-30 14:48:21 +0000
> +++ src/launchpadlib/credentials.py 2009-10-30 19:55:30 +0000
> @@ -289,16 +291,20 @@
>
> def __init__(self, web_root, consumer_name, request_token,
> allow_access_levels=[], max_failed_attempts=3):
> - self.web_root = web_root
> + self.web_root = uris.lookup_web_root(web_root)

What's the reason for this change?

> self.consumer_name = consumer_name
> self.request_token = request_token
> self.browser = SimulatedLaunchpadBrowser(self.web_root)
> self.max_failed_attempts = max_failed_attempts
> self.allow_access_levels = allow_access_levels
> + self.text_wrapper = textwrap.TextWrapper(
> + replace_whitespace=False, width=78)
>

Revision history for this message
Leonard Richardson (leonardr) wrote :

> > + try:
> > + self()
> > + except TokenAuthorizationException, e:
> > + print str(e)
> > + self.error_code = -1
> > + return self.pressEnterToExit()
>
> The above call is redundant because of the one below, no?
>
> > + return self.pressEnterToExit()

Yes, I've removed the redundant call.

> > + def pressEnterToExit(self):
> > + """Make the user hit enter, and then exit with an error code."""
> > + prompt = '\nPress enter to go back to "%s". ' % self.consumer_name
> > + self.input_method(prompt)
> > + self.exit_with(self.error_code)
> > + return self.error_code
>
> Since exit_with() will call sys.exit(), there's no reason to return
> anything here, is there?

The default implementation of exit_with() calls sys.exit(), but my testing version returned the error code. However I was just ignoring the error code in my tests, so it's not necessary.

Revision history for this message
Leonard Richardson (leonardr) wrote :

> As you explained to me on IRC, this is because you're using the python
> naming standard instead of the LP one. Fair enough, but the two methods
> below use the LP one. Care to rename them?

I've renamed getSingleCharInput and pressEnterToExit.

> And here's a review of the two remaining files, which had conflicts when
> I first reviewed your branch. Just one question, in fact.
>
> > === modified file 'src/launchpadlib/credentials.py'
> > --- src/launchpadlib/credentials.py 2009-10-30 14:48:21 +0000
> > +++ src/launchpadlib/credentials.py 2009-10-30 19:55:30 +0000
> > @@ -289,16 +291,20 @@
> >
> > def __init__(self, web_root, consumer_name, request_token,
> > allow_access_levels=[], max_failed_attempts=3):
> > - self.web_root = web_root
> > + self.web_root = uris.lookup_web_root(web_root)
>
> What's the reason for this change?

I think I explained this on IRC too, but for the record: lookup_web_root lets you pass in "staging" as the site root instead of "https://staging.launchpad.net/".

78. By Leonard Richardson

Response to feedback.

Revision history for this message
Gary Poster (gary) wrote :

Approving on Salgado's behalf because Leonard appears to have addressed his concerns and Salgado is not available today.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/launchpadlib/apps.py'
2--- src/launchpadlib/apps.py 2009-10-28 17:27:53 +0000
3+++ src/launchpadlib/apps.py 2009-11-02 12:35:19 +0000
4@@ -20,9 +20,18 @@
5 themselves are kept in bin/.
6 """
7
8+__all__ = [
9+ 'RequestTokenApp',
10+ 'TrustedTokenAuthorizationConsoleApp',
11+ ]
12+
13+import getpass
14+import sys
15+
16 import simplejson
17
18-from launchpadlib.credentials import Credentials
19+from launchpadlib.credentials import (
20+ Credentials, RequestTokenAuthorizationEngine, TokenAuthorizationException)
21 from launchpadlib.uris import lookup_web_root
22
23
24@@ -43,3 +52,122 @@
25 return simplejson.dumps(token)
26
27
28+class TrustedTokenAuthorizationConsoleApp(RequestTokenAuthorizationEngine):
29+ """An application that authorizes request tokens."""
30+
31+ def __init__(self, web_root, consumer_name, request_token,
32+ access_levels='', input_method=raw_input):
33+ """Constructor.
34+
35+ :param access_levels: A string of comma-separated access level
36+ values. To get an up-to-date list of access levels, pass
37+ token_format=Credentials.DICT_TOKEN_FORMAT into
38+ Credentials.get_request_token, load the dict as JSON, and look
39+ in 'access_levels'.
40+ """
41+ access_levels = [level.strip() for level in access_levels.split(',')]
42+ super(TrustedTokenAuthorizationConsoleApp, self).__init__(
43+ web_root, consumer_name, request_token, access_levels)
44+
45+ self.input_method = input_method
46+
47+ def run(self):
48+ """Try to authorize a request token from user input."""
49+ self.error_code = -1 # Start off assuming failure.
50+ start = "Launchpad credential client (console)"
51+ self.output(start)
52+ self.output("-" * len(start))
53+
54+ try:
55+ self()
56+ except TokenAuthorizationException, e:
57+ print str(e)
58+ self.error_code = -1
59+ return self.press_enter_to_exit()
60+
61+ def exit_with(self, code):
62+ """Exit the app with the specified error code."""
63+ sys.exit(code)
64+
65+ def get_single_char_input(self, prompt, valid):
66+ """Retrieve a single-character line from the input stream."""
67+ valid = valid.upper()
68+ input = None
69+ while input is None:
70+ input = self.input_method(prompt).upper()
71+ if len(input) != 1 or input not in valid:
72+ input = None
73+ return input
74+
75+ def press_enter_to_exit(self):
76+ """Make the user hit enter, and then exit with an error code."""
77+ prompt = '\nPress enter to go back to "%s". ' % self.consumer_name
78+ self.input_method(prompt)
79+ self.exit_with(self.error_code)
80+
81+ def input_username(self, cached_username, suggested_message):
82+ """Collect the Launchpad username from the end-user.
83+
84+ :param cached_username: A username from a previous entry attempt,
85+ to be presented as the default.
86+ """
87+ if cached_username is not None:
88+ extra = " [%s] " % cached_username
89+ else:
90+ extra = "\n(No Launchpad account? Just hit enter.) "
91+ username = self.input_method(suggested_message + extra)
92+ if username == '':
93+ return cached_username
94+ return username
95+
96+ def input_password(self, suggested_message):
97+ """Collect the Launchpad password from the end-user."""
98+ if self.input_method is raw_input:
99+ password = getpass.getpass(suggested_message + " ")
100+ else:
101+ password = self.input_method(suggested_message)
102+ return password
103+
104+ def input_access_level(self, available_levels, suggested_message,
105+ only_one_option=None):
106+ """Collect the desired level of access from the end-user."""
107+ if only_one_option is not None:
108+ self.output(suggested_message)
109+ prompt = self.message(
110+ 'Do you want to give "%(app)s" this level of access? [YN] ')
111+ allow = self.get_single_char_input(prompt, "YN")
112+ if allow == "Y":
113+ return only_one_option['value']
114+ else:
115+ return self.UNAUTHORIZED_ACCESS_LEVEL
116+ else:
117+ levels_except_unauthorized = [
118+ level for level in available_levels
119+ if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL]
120+ options = []
121+ for i in range(0, len(levels_except_unauthorized)):
122+ options.append(
123+ "%d: %s" % (i+1, levels_except_unauthorized[i]['title']))
124+ self.output(suggested_message)
125+ for option in options:
126+ self.output(option)
127+ allowed = ("".join(map(str, range(1, i+2)))) + "Q"
128+ prompt = self.message(
129+ 'What should "%(app)s" be allowed to do using your '
130+ 'Launchpad account? [1-%(max)d or Q] ',
131+ extra_variables = {'max' : i+1})
132+ allow = self.get_single_char_input(prompt, allowed)
133+ if allow == "Q":
134+ return self.UNAUTHORIZED_ACCESS_LEVEL
135+ else:
136+ return levels_except_unauthorized[int(allow)-1]['value']
137+
138+ def user_refused_to_authorize(self, suggested_message):
139+ """The user refused to authorize a request token."""
140+ self.output(suggested_message)
141+ self.error_code = -2
142+
143+ def user_authorized(self, access_level, suggested_message):
144+ """The user authorized a request token with some access level."""
145+ self.output(suggested_message)
146+ self.error_code = 0
147
148=== added file 'src/launchpadlib/bin/launchpad-credentials-console'
149--- src/launchpadlib/bin/launchpad-credentials-console 1970-01-01 00:00:00 +0000
150+++ src/launchpadlib/bin/launchpad-credentials-console 2009-11-02 12:35:19 +0000
151@@ -0,0 +1,54 @@
152+#!/usr/bin/python
153+
154+# Copyright 2009 Canonical Ltd.
155+
156+# This file is part of launchpadlib.
157+#
158+# launchpadlib is free software: you can redistribute it and/or modify it
159+# under the terms of the GNU Lesser General Public License as published by the
160+# Free Software Foundation, version 3 of the License.
161+#
162+# launchpadlib is distributed in the hope that it will be useful, but WITHOUT
163+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
164+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
165+# for more details.
166+#
167+# You should have received a copy of the GNU Lesser General Public License
168+# along with launchpadlib. If not, see <http://www.gnu.org/licenses/>.
169+
170+"""Take the user's Launchpad credentials and authorize a request token.
171+
172+This script will guide the user through the process of authorizing a
173+request token with their username and password. It's not as secure as
174+opening the user's web browser, but if you are writing a console
175+application and want to keep the authorization process console-based,
176+it's better to use this script than write the authorization code yourself.
177+"""
178+
179+__metaclass__ = type
180+
181+from optparse import OptionParser
182+from launchpadlib.apps import (
183+ RequestTokenApp, TrustedTokenAuthorizationConsoleApp)
184+
185+parser = OptionParser()
186+parser.usage = "%prog CONSUMER_NAME REQUEST_TOKEN [-r URL] [-a LEVEL,LEVEL,...]"
187+parser.add_option("-s", "--site", dest="web_root",
188+ help=("The name of the Launchpad site on which the request "
189+ "token can be found (default: %default). This can "
190+ "also be the URL to the root of the site."),
191+ metavar="URL", default="staging")
192+parser.add_option("-a", "--access-level", dest="access_levels",
193+ help="Any restrictions on your clients' access levels",
194+ metavar="[LEVEL,LEVEL,...]", default="")
195+
196+if __name__ == '__main__':
197+ (options, args) = parser.parse_args()
198+ if len(args) < 2:
199+ parser.error("You must provide both a consumer name and the ID of "
200+ "an OAuth request token on Launchpad.")
201+ consumer_name, token_id = args[:2]
202+ TrustedTokenAuthorizationConsoleApp(
203+ options.web_root, consumer_name, token_id,
204+ options.access_levels).run()
205+
206
207=== modified file 'src/launchpadlib/credentials.py'
208--- src/launchpadlib/credentials.py 2009-10-30 14:48:21 +0000
209+++ src/launchpadlib/credentials.py 2009-11-02 12:35:19 +0000
210@@ -27,8 +27,10 @@
211 import base64
212 import cgi
213 import httplib2
214+import textwrap
215 from urllib import urlencode, quote
216 from urlparse import urljoin
217+import webbrowser
218
219 import simplejson
220
221@@ -265,7 +267,7 @@
222
223 INPUT_USERNAME = "What email address do you use on Launchpad?"
224
225- INPUT_PASSWORD = "What's your Launchpad password?"
226+ INPUT_PASSWORD = "What's your Launchpad password? "
227
228 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."""
229
230@@ -273,9 +275,9 @@
231
232 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"."""
233
234- 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.
235+ 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."""
236
237-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."""
238+ 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."""
239
240 SUCCESS = """You're all done! You should now be able to use Launchpad integration features of "%(app)s." """
241
242@@ -289,16 +291,20 @@
243
244 def __init__(self, web_root, consumer_name, request_token,
245 allow_access_levels=[], max_failed_attempts=3):
246- self.web_root = web_root
247+ self.web_root = uris.lookup_web_root(web_root)
248 self.consumer_name = consumer_name
249 self.request_token = request_token
250 self.browser = SimulatedLaunchpadBrowser(self.web_root)
251 self.max_failed_attempts = max_failed_attempts
252 self.allow_access_levels = allow_access_levels
253+ self.text_wrapper = textwrap.TextWrapper(
254+ replace_whitespace=False, width=78)
255
256 def __call__(self):
257
258- self.startup(self.message(self.STARTUP_MESSAGE))
259+ self.startup(
260+ [self.message(self.STARTUP_MESSAGE),
261+ self.message(self.STARTUP_MESSAGE_2)])
262
263 # Have the end-user enter their Launchpad username and password.
264 # Make sure the credentials are valid, and get information
265@@ -315,14 +321,17 @@
266 # There's only one choice: allow access at a certain level
267 # or don't allow access at all.
268 message = self.CHOOSE_ACCESS_LEVEL_ONE
269- level = [level['title'] for level in self.reconciled_access_levels
270- if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL]
271- extra = {'level' : level[0]}
272+ level = [level for level in self.reconciled_access_levels
273+ if level['value'] != self.UNAUTHORIZED_ACCESS_LEVEL][0]
274+ extra = {'level' : level['title']}
275+ only_one_option = level
276 else:
277 message = self.CHOOSE_ACCESS_LEVEL
278 extra = None
279+ only_one_option = None
280 access_level = self.input_access_level(
281- self.reconciled_access_levels, self.message(message, extra))
282+ self.reconciled_access_levels, self.message(message, extra),
283+ only_one_option)
284
285 # Notify the program of the user's choice.
286 if access_level == self.UNAUTHORIZED_ACCESS_LEVEL:
287@@ -420,8 +429,8 @@
288
289 # You should define these methods in your subclass.
290
291- def startup(self, suggested_message):
292- """Hook method called on startup."""
293+ def output(self, message):
294+ print self.text_wrapper.fill(message)
295
296 def input_username(self, cached_username, suggested_message):
297 """Collect the Launchpad username from the end-user.
298@@ -435,22 +444,31 @@
299 """Collect the Launchpad password from the end-user."""
300 raise NotImplementedError()
301
302- def input_access_level(self, available_levels, suggested_message):
303+ def input_access_level(self, available_levels, suggested_message,
304+ only_one_option=None):
305 """Collect the desired level of access from the end-user."""
306 raise NotImplementedError()
307
308+ def startup(self, suggested_messages):
309+ """Hook method called on startup."""
310+ for message in suggested_messages:
311+ self.output(message)
312+ self.output("\n")
313
314 def authentication_failure(self, suggested_message):
315 """The user entered invalid credentials."""
316- print suggested_message
317+ self.output(suggested_message)
318+ self.output("\n")
319
320 def user_refused_to_authorize(self, suggested_message):
321 """The user refused to authorize a request token."""
322- print suggested_message
323+ self.output(suggested_message)
324+ self.output("\n")
325
326 def user_authorized(self, access_level, suggested_message):
327 """The user authorized a request token with some access level."""
328- print suggested_message
329+ self.output(suggested_message)
330+ self.output("\n")
331
332 def server_consumer_differs_from_client_consumer(
333 self, client_name, real_name, suggested_message):
334@@ -461,11 +479,13 @@
335 end-user that its name is "bar". Something is fishy and at the very
336 least the end-user should be warned about this.
337 """
338- print suggested_message
339+ self.output("\n")
340+ self.output(suggested_message)
341+ self.output("\n")
342
343 def success(self, suggested_message):
344 """The token was successfully authorized."""
345- print suggested_message
346+ self.output(suggested_message)
347
348
349 class TokenAuthorizationException(Exception):
350
351=== modified file 'src/launchpadlib/docs/command-line.txt'
352--- src/launchpadlib/docs/command-line.txt 2009-10-27 16:26:06 +0000
353+++ src/launchpadlib/docs/command-line.txt 2009-11-02 12:35:19 +0000
354@@ -20,9 +20,11 @@
355
356 >>> import simplejson
357 >>> from launchpadlib.apps import RequestTokenApp
358- >>> app = RequestTokenApp(
359- ... "http://launchpad.dev:8085/", "consumer", "context")
360- >>> json = simplejson.loads(app.run())
361+
362+ >>> web_root = "http://launchpad.dev:8085/"
363+ >>> consumer_name = "consumer"
364+ >>> token_app = RequestTokenApp(web_root, consumer_name, "context")
365+ >>> json = simplejson.loads(token_app.run())
366
367 >>> sorted(json.keys())
368 ['access_levels', 'lp.context', 'oauth_token',
369@@ -33,3 +35,171 @@
370
371 >>> print json['oauth_token_consumer']
372 consumer
373+
374+TrustedTokenAuthorizationConsoleApp
375+===================================
376+
377+This class is called by the command-line script
378+launchpad-credentials-console. It asks for the user's Launchpad
379+username and password, and authorizes a request token on their
380+behalf.
381+
382+ >>> from launchpadlib.apps import TrustedTokenAuthorizationConsoleApp
383+ >>> from launchpadlib.testing.helpers import UserInput
384+
385+This class does not create the request token, or exchange it for the
386+access token--that's the job of the program that calls
387+launchpad-credentials-console. So we'll use the request token created
388+earlier by RequestTokenApp.
389+
390+ >>> request_token = json['oauth_token']
391+
392+Since this is a test, we don't want the application to call sys.exit()
393+or try to open up pages in a web browser. This subclass of
394+TrustedTokenAuthorizationConsoleApp will print messages instead of
395+performing such un-doctest-like actions.
396+
397+ >>> class ConsoleApp(TrustedTokenAuthorizationConsoleApp):
398+ ... def open_login_page_in_user_browser(self, url):
399+ ... """Print a status message."""
400+ ... self.output("[If this were a real application, the "
401+ ... "end-user's web browser would be opened "
402+ ... "to %s]" % url)
403+ ...
404+ ... def exit_with(self, code):
405+ ... print "Application exited with code %d" % code
406+
407+We'll use a UserInput object to simulate a user typing things in at
408+the prompt and hitting enter. This UserInput runs the program
409+correctly, entering the Launchpad username and password, choosing an
410+access level, and hitting Enter to exit.
411+
412+ >>> username = "salgado@ubuntu.com"
413+ >>> password = "zeca"
414+ >>> fake_input = UserInput([username, password, "1", ""])
415+
416+Here's a successful run of the application. When the request token is
417+authorized, the script's response code is 0.
418+
419+ >>> app = ConsoleApp(
420+ ... web_root, consumer_name, request_token,
421+ ... 'READ_PRIVATE, READ_PUBLIC', input_method=fake_input)
422+ >>> app.run()
423+ Launchpad credential client (console)
424+ -------------------------------------
425+ An application identified as "consumer" wants to access Launchpad...
426+ What email address do you use on Launchpad?
427+ (No Launchpad account? Just hit enter.) [User input: salgado@ubuntu.com]
428+ What's your Launchpad password? [User input: zeca]
429+ Now it's time for you to decide how much power to give "consumer"...
430+ 1: Read Non-Private Data
431+ 2: Read Anything
432+ What should "consumer" be allowed to do...? [1-2 or Q] [User input: 1]
433+ Okay, I'm telling Launchpad to grant "consumer" access to your account.
434+ You're all done!...
435+ Press enter to go back to "consumer". [User input: ]
436+ Application exited with code 0
437+
438+Now that the request token has been authorized, we'll need to create
439+another one to continue the test.
440+
441+ >>> json = simplejson.loads(token_app.run())
442+ >>> request_token = json['oauth_token']
443+ >>> app.request_token = request_token
444+
445+Invalid input is ignored. The user may enter 'Q' instead of a number
446+to refuse to authorize the request token. When the user denies access,
447+the exit code is -2.
448+
449+ >>> fake_input = UserInput([username, password, "A", "99", "Q", ""])
450+ >>> app.input_method = fake_input
451+ >>> app.run()
452+ Launchpad credential client (console)
453+ -------------------------------------
454+ An application identified as "consumer"...
455+ What should "consumer" be allowed to do...? [1-2 or Q] [User input: A]
456+ What should "consumer" be allowed to do...? [1-2 or Q] [User input: 99]
457+ What should "consumer" be allowed to do...? [1-2 or Q] [User input: Q]
458+ Okay, I'm going to cancel the request...
459+ You're all done! "consumer" still doesn't have access...
460+ <BLANKLINE>
461+ Press enter to go back to "consumer". [User input: ]
462+ Application exited with code -2
463+
464+When the third-party application will allow only one level of access,
465+the end-user is presented with a yes-or-no choice instead of a list to
466+choose from. Again, invalid input is ignored.
467+
468+ >>> json = simplejson.loads(token_app.run())
469+ >>> request_token = json['oauth_token']
470+ >>> fake_input = UserInput([username, password, "1", "Q", "Y", ""])
471+
472+ >>> app = ConsoleApp(
473+ ... web_root, consumer_name, request_token,
474+ ... 'READ_PRIVATE', input_method=fake_input)
475+
476+ >>> app.run()
477+ Launchpad credential client (console)
478+ -------------------------------------
479+ An application identified as "consumer"...
480+ Do you want to give "consumer" this level of access? [YN] [User input: 1]
481+ Do you want to give "consumer" this level of access? [YN] [User input: Q]
482+ Do you want to give "consumer" this level of access? [YN] [User input: Y]
483+ ...
484+ Application exited with code 0
485+
486+
487+Error handling
488+--------------
489+
490+When the end-user refuses to authorize the request token, the app
491+exits with a return code of -2, as seen above. When any other error
492+gets in the way of the authorization of the request token, the app's
493+return code is -1.
494+
495+If the user hits enter when asked for their email address, indicating
496+that they don't have a Launchpad account, the app opens their browser
497+to the Launchpad login page.
498+
499+ >>> json = simplejson.loads(token_app.run())
500+ >>> app.request_token = json['oauth_token']
501+
502+ >>> input_nothing = UserInput(["", ""])
503+ >>> app.input_method = input_nothing
504+
505+ >>> app.run()
506+ Launchpad credential client (console)
507+ -------------------------------------
508+ An application identified as "consumer"...
509+ [If this were a real application, the end-user's web browser...]
510+ OK, you'll need to get yourself a Launchpad account before...
511+ <BLANKLINE>
512+ I'm opening the Launchpad registration page in your web browser...
513+ Press enter to go back to "consumer". [User input: ]
514+ Application exited with code -1
515+
516+If the user keeps entering bad passwords, the app eventually gives up.
517+
518+ >>> input_bad_password = UserInput(
519+ ... [username, "badpw", "", "badpw", "", "badpw", ""])
520+ >>> json = simplejson.loads(token_app.run())
521+ >>> request_token = json['oauth_token']
522+ >>> app.request_token = request_token
523+ >>> app.input_method = input_bad_password
524+ >>> app.run()
525+ Launchpad credential client (console)
526+ -------------------------------------
527+ An application identified as "consumer"...
528+ What email address do you use on Launchpad?
529+ (No Launchpad account? Just hit enter.) [User input: salgado@ubuntu.com]
530+ What's your Launchpad password? [User input: badpw]
531+ I can't log in with the credentials you gave me. Let's try again.
532+ What email address do you use on Launchpad? [salgado@ubuntu.com] [User input: ]
533+ What's your Launchpad password? [User input: badpw]
534+ I can't log in with the credentials you gave me. Let's try again.
535+ What email address do you use on Launchpad? [salgado@ubuntu.com] [User input: ]
536+ What's your Launchpad password? [User input: badpw]
537+ You've failed the password entry too many times...
538+ Press enter to go back to "consumer". [User input: ]
539+ Application exited with code -1
540+
541
542=== modified file 'src/launchpadlib/testing/helpers.py'
543--- src/launchpadlib/testing/helpers.py 2009-10-30 14:48:21 +0000
544+++ src/launchpadlib/testing/helpers.py 2009-11-02 12:35:19 +0000
545@@ -119,11 +119,36 @@
546 print suggested_message
547 return self.password
548
549- def input_access_level(self, available_levels, suggested_message):
550+ def input_access_level(self, available_levels, suggested_message,
551+ only_one_option):
552 """Collect the desired level of access from the end-user."""
553 print suggested_message
554 print [level['value'] for level in available_levels]
555 return self.choose_access_level
556
557- def startup(self, suggested_message):
558- print suggested_message
559+ def startup(self, suggested_messages):
560+ for message in suggested_messages:
561+ print message
562+
563+
564+class UserInput(object):
565+ """A class to store fake user input in a readable way.
566+
567+ An instance of this class can be used as a substitute for the
568+ raw_input() function.
569+ """
570+
571+ def __init__(self, inputs):
572+ """Initialize with a line of user inputs."""
573+ self.stream = iter(inputs)
574+
575+ def __call__(self, prompt):
576+ """Print and return the next line of input."""
577+ line = self.readline()
578+ print prompt + "[User input: %s]" % line
579+ return line
580+
581+ def readline(self):
582+ """Return the next line of input."""
583+ next_input = self.stream.next()
584+ return str(next_input)

Subscribers

People subscribed via source and target branches