Merge lp:~salgado/launchpad/create-account-for-unseen-openid-id into lp:launchpad
- create-account-for-unseen-openid-id
- Merge into devel
Status: | Merged |
---|---|
Merged at revision: | not available |
Proposed branch: | lp:~salgado/launchpad/create-account-for-unseen-openid-id |
Merge into: | lp:launchpad |
Diff against target: |
388 lines (+164/-29) 6 files modified
lib/canonical/launchpad/database/account.py (+7/-4) lib/canonical/launchpad/webapp/login.py (+31/-3) lib/canonical/launchpad/webapp/tests/cookie-authentication.txt (+1/-1) lib/canonical/launchpad/webapp/tests/login.txt (+1/-1) lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt (+1/-1) lib/canonical/launchpad/webapp/tests/test_login.py (+123/-19) |
To merge this branch: | bzr merge lp:~salgado/launchpad/create-account-for-unseen-openid-id |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Michael Nelson (community) | code | Approve | |
Review via email:
|
Commit message
Description of the change
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Guilherme Salgado (salgado) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Michael Nelson (michael.nelson) wrote : | # |
Hi Guilherme,
I've just got a few small questions below, and one suggestion for the name of your context manager.
Cheers,
-Michael
> === modified file 'lib/canonical/
> --- lib/canonical/
> +++ lib/canonical/
> @@ -12,6 +12,7 @@
> from BeautifulSoup import UnicodeDammit
>
> from openid.
> +from openid.extensions import sreg
> from openid.fetchers import setDefaultFetcher, Urllib2Fetcher
>
> import transaction
> @@ -27,7 +28,6 @@
>
> from z3c.ptcompat import ViewPageTemplat
>
> -from canonical.
> from canonical.config import config
> from canonical.launchpad import _
> from canonical.
> @@ -181,6 +181,8 @@
> openid_vhost = config.
> self.openid_request = consumer.begin(
> allvhosts.
> + self.openid_
> + sreg.SRegReques
>
> trust_root = self.request.
> assert not self.openid_
> @@ -258,8 +260,34 @@
>
> def render(self):
> if self.openid_
> - account = getUtility(
> - self.openid_
> + identifier = self.openid_
> + account_set = getUtility(
> + try:
> + account = account_
> + except LookupError:
> + # Here we assume the OP sent us the user's email address and
> + # full name in the response. Note we can only do that because
> + # we used a fixed OP (login.
> + # user's email address in the response when asked to. Once we
s/email address/email address and full name ? Or is full name returned from
all OP's when requested?
> + # start using other OPs we won't be able to make this
> + # assumption here as they might not include what we want in
> + # the response.
> + sreg_response = sreg.SRegRespon
> + self.openid_
> + assert sreg_response is not None, (
> + "OP didn't include an sreg extension in the response.")
> + email_address = sreg_response.
> + full_name = sreg_response.
> + assert email_address is not None and full_name is not None, (
> + "No email address or full name found in sreg response; "
> + "can't create a new account for this identity URL.")
> + account, email = account_
> + email_address,
> + PersonCreationR
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Guilherme Salgado (salgado) wrote : | # |
On Thu, 2010-02-25 at 12:30 +0000, Michael Nelson wrote:
> Review: Approve code
> Hi Guilherme,
>
> I've just got a few small questions below, and one suggestion for the name of your context manager.
>
> Cheers,
> -Michael
>
> > === modified file 'lib/canonical/
> > --- lib/canonical/
> > +++ lib/canonical/
> > @@ -12,6 +12,7 @@
> > from BeautifulSoup import UnicodeDammit
> >
> > from openid.
> > +from openid.extensions import sreg
> > from openid.fetchers import setDefaultFetcher, Urllib2Fetcher
> >
> > import transaction
> > @@ -27,7 +28,6 @@
> >
> > from z3c.ptcompat import ViewPageTemplat
> >
> > -from canonical.
> > from canonical.config import config
> > from canonical.launchpad import _
> > from canonical.
> > @@ -181,6 +181,8 @@
> > openid_vhost = config.
> > self.openid_request = consumer.begin(
> > allvhosts.
> > + self.openid_
> > + sreg.SRegReques
> >
> > trust_root = self.request.
> > assert not self.openid_
> > @@ -258,8 +260,34 @@
> >
> > def render(self):
> > if self.openid_
> > - account = getUtility(
> > - self.openid_
> > + identifier = self.openid_
> > + account_set = getUtility(
> > + try:
> > + account = account_
> > + except LookupError:
> > + # Here we assume the OP sent us the user's email address and
> > + # full name in the response. Note we can only do that because
> > + # we used a fixed OP (login.
> > + # user's email address in the response when asked to. Once we
>
> s/email address/email address and full name ? Or is full name returned from
> all OP's when requested?
No, by default they only include the identity URL in the response. I've
updated the comment.
>
> > === renamed file 'lib/canonical/
> > --- lib/canonical/
> > +++ lib/canonical/
[...]
> > @@ -42,25 +52,47 @@
> > self.login_called = True
> >
> >
> > +@contextmanager
> > +def stub_SRegRespon
> > + def sregFromFakeSuc
> > + return {'email': success_
> > + 'fullname': success_
> > +...
Preview Diff
1 | === modified file 'lib/canonical/launchpad/database/account.py' |
2 | --- lib/canonical/launchpad/database/account.py 2009-08-14 12:50:43 +0000 |
3 | +++ lib/canonical/launchpad/database/account.py 2010-02-25 13:13:19 +0000 |
4 | @@ -221,11 +221,12 @@ |
5 | implements(IAccountSet) |
6 | |
7 | def new(self, rationale, displayname, password=None, |
8 | - password_is_encrypted=False): |
9 | + password_is_encrypted=False, openid_identifier=DEFAULT): |
10 | """See `IAccountSet`.""" |
11 | |
12 | account = Account( |
13 | - displayname=displayname, creation_rationale=rationale) |
14 | + displayname=displayname, creation_rationale=rationale, |
15 | + openid_identifier=openid_identifier) |
16 | |
17 | # Create the password record. |
18 | if password is not None: |
19 | @@ -243,13 +244,15 @@ |
20 | return account |
21 | |
22 | def createAccountAndEmail(self, email, rationale, displayname, password, |
23 | - password_is_encrypted=False): |
24 | + password_is_encrypted=False, |
25 | + openid_identifier=DEFAULT): |
26 | """See `IAccountSet`.""" |
27 | # Convert the PersonCreationRationale to an AccountCreationRationale. |
28 | account_rationale = getattr(AccountCreationRationale, rationale.name) |
29 | account = self.new( |
30 | account_rationale, displayname, password=password, |
31 | - password_is_encrypted=password_is_encrypted) |
32 | + password_is_encrypted=password_is_encrypted, |
33 | + openid_identifier=openid_identifier) |
34 | account.status = AccountStatus.ACTIVE |
35 | email = getUtility(IEmailAddressSet).new( |
36 | email, status=EmailAddressStatus.PREFERRED, account=account) |
37 | |
38 | === modified file 'lib/canonical/launchpad/webapp/login.py' |
39 | --- lib/canonical/launchpad/webapp/login.py 2010-02-18 10:52:51 +0000 |
40 | +++ lib/canonical/launchpad/webapp/login.py 2010-02-25 13:13:19 +0000 |
41 | @@ -12,6 +12,7 @@ |
42 | from BeautifulSoup import UnicodeDammit |
43 | |
44 | from openid.consumer.consumer import CANCEL, Consumer, FAILURE, SUCCESS |
45 | +from openid.extensions import sreg |
46 | from openid.fetchers import setDefaultFetcher, Urllib2Fetcher |
47 | |
48 | import transaction |
49 | @@ -27,7 +28,6 @@ |
50 | |
51 | from z3c.ptcompat import ViewPageTemplateFile |
52 | |
53 | -from canonical.cachedproperty import cachedproperty |
54 | from canonical.config import config |
55 | from canonical.launchpad import _ |
56 | from canonical.launchpad.interfaces.account import AccountStatus, IAccountSet |
57 | @@ -181,6 +181,8 @@ |
58 | openid_vhost = config.launchpad.openid_provider_vhost |
59 | self.openid_request = consumer.begin( |
60 | allvhosts.configs[openid_vhost].rooturl) |
61 | + self.openid_request.addExtension( |
62 | + sreg.SRegRequest(optional=['email', 'fullname'])) |
63 | |
64 | trust_root = self.request.getApplicationURL() |
65 | assert not self.openid_request.shouldSendRedirect(), ( |
66 | @@ -258,8 +260,34 @@ |
67 | |
68 | def render(self): |
69 | if self.openid_response.status == SUCCESS: |
70 | - account = getUtility(IAccountSet).getByOpenIDIdentifier( |
71 | - self.openid_response.identity_url.split('/')[-1]) |
72 | + identifier = self.openid_response.identity_url.split('/')[-1] |
73 | + account_set = getUtility(IAccountSet) |
74 | + try: |
75 | + account = account_set.getByOpenIDIdentifier(identifier) |
76 | + except LookupError: |
77 | + # Here we assume the OP sent us the user's email address and |
78 | + # full name in the response. Note we can only do that because |
79 | + # we used a fixed OP (login.launchpad.net) that includes the |
80 | + # user's email address and full name in the response when |
81 | + # asked to. Once we start using other OPs we won't be able to |
82 | + # make this assumption here as they might not include what we |
83 | + # want in the response. |
84 | + sreg_response = sreg.SRegResponse.fromSuccessResponse( |
85 | + self.openid_response) |
86 | + assert sreg_response is not None, ( |
87 | + "OP didn't include an sreg extension in the response.") |
88 | + email_address = sreg_response.get('email') |
89 | + full_name = sreg_response.get('fullname') |
90 | + assert email_address is not None and full_name is not None, ( |
91 | + "No email address or full name found in sreg response; " |
92 | + "can't create a new account for this identity URL.") |
93 | + account, email = account_set.createAccountAndEmail( |
94 | + email_address, |
95 | + PersonCreationRationale.OWNER_CREATED_LAUNCHPAD, |
96 | + full_name, |
97 | + password=None, |
98 | + openid_identifier=identifier) |
99 | + |
100 | if account.status == AccountStatus.SUSPENDED: |
101 | return self.suspended_account_template() |
102 | if IPerson(account, None) is None: |
103 | |
104 | === modified file 'lib/canonical/launchpad/webapp/tests/cookie-authentication.txt' |
105 | --- lib/canonical/launchpad/webapp/tests/cookie-authentication.txt 2010-02-17 19:40:41 +0000 |
106 | +++ lib/canonical/launchpad/webapp/tests/cookie-authentication.txt 2010-02-25 13:13:19 +0000 |
107 | @@ -20,7 +20,7 @@ |
108 | <html>...<body onload="document.forms[0].submit();"... |
109 | >>> browser.getControl('Continue').click() |
110 | |
111 | - >>> from canonical.launchpad.webapp.tests.test_new_login import ( |
112 | + >>> from canonical.launchpad.webapp.tests.test_login import ( |
113 | ... fill_login_form_and_submit) |
114 | >>> fill_login_form_and_submit(browser, 'foo.bar@canonical.com', 'test') |
115 | >>> print extract_text(find_tag_by_id(browser.contents, 'logincontrol')) |
116 | |
117 | === modified file 'lib/canonical/launchpad/webapp/tests/login.txt' |
118 | --- lib/canonical/launchpad/webapp/tests/login.txt 2010-02-17 19:40:41 +0000 |
119 | +++ lib/canonical/launchpad/webapp/tests/login.txt 2010-02-25 13:13:19 +0000 |
120 | @@ -25,7 +25,7 @@ |
121 | |
122 | >>> print browser.title |
123 | Login |
124 | - >>> from canonical.launchpad.webapp.tests.test_new_login import ( |
125 | + >>> from canonical.launchpad.webapp.tests.test_login import ( |
126 | ... fill_login_form_and_submit) |
127 | >>> fill_login_form_and_submit(browser, 'test@canonical.com', 'test') |
128 | |
129 | |
130 | === modified file 'lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt' |
131 | --- lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt 2010-02-17 19:40:41 +0000 |
132 | +++ lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt 2010-02-25 13:13:19 +0000 |
133 | @@ -25,7 +25,7 @@ |
134 | <html>...<body onload="document.forms[0].submit();"... |
135 | >>> browser.getControl('Continue').click() |
136 | |
137 | - >>> from canonical.launchpad.webapp.tests.test_new_login import ( |
138 | + >>> from canonical.launchpad.webapp.tests.test_login import ( |
139 | ... fill_login_form_and_submit) |
140 | >>> fill_login_form_and_submit(browser, 'foo.bar@canonical.com', 'test') |
141 | >>> print extract_text(find_tag_by_id(browser.contents, 'logincontrol')) |
142 | |
143 | === renamed file 'lib/canonical/launchpad/webapp/tests/test_new_login.py' => 'lib/canonical/launchpad/webapp/tests/test_login.py' |
144 | --- lib/canonical/launchpad/webapp/tests/test_new_login.py 2010-02-17 19:40:41 +0000 |
145 | +++ lib/canonical/launchpad/webapp/tests/test_login.py 2010-02-25 13:13:19 +0000 |
146 | @@ -1,24 +1,31 @@ |
147 | # Copyright 2009 Canonical Ltd. All rights reserved. |
148 | +from __future__ import with_statement |
149 | |
150 | +# pylint: disable-msg=W0105 |
151 | """Test harness for running the new-login.txt tests.""" |
152 | |
153 | __metaclass__ = type |
154 | |
155 | __all__ = [] |
156 | |
157 | +from contextlib import contextmanager |
158 | import httplib |
159 | import unittest |
160 | |
161 | import mechanize |
162 | |
163 | from openid.consumer.consumer import FAILURE, SUCCESS |
164 | - |
165 | -from canonical.launchpad.interfaces.account import AccountStatus |
166 | +from openid.extensions import sreg |
167 | + |
168 | +from zope.component import getUtility |
169 | +from zope.security.proxy import removeSecurityProxy |
170 | + |
171 | +from canonical.launchpad.interfaces.account import AccountStatus, IAccountSet |
172 | from canonical.launchpad.testing.pages import ( |
173 | extract_text, find_main_content, find_tag_by_id, find_tags_by_class) |
174 | from canonical.launchpad.testing.systemdocs import LayeredDocFileSuite |
175 | from canonical.launchpad.testing.browser import Browser, setUp, tearDown |
176 | -from canonical.launchpad.webapp.login import OpenIDCallbackView |
177 | +from canonical.launchpad.webapp.login import OpenIDCallbackView, OpenIDLogin |
178 | from canonical.launchpad.webapp.servers import LaunchpadTestRequest |
179 | from canonical.testing.layers import AppServerLayer, DatabaseFunctionalLayer |
180 | |
181 | @@ -29,10 +36,13 @@ |
182 | |
183 | class FakeOpenIDResponse: |
184 | |
185 | - def __init__(self, identity_url, status=SUCCESS, message=''): |
186 | + def __init__(self, identity_url, status=SUCCESS, message='', email=None, |
187 | + full_name=None): |
188 | self.message = message |
189 | self.status = status |
190 | self.identity_url = identity_url |
191 | + self.sreg_email = email |
192 | + self.sreg_fullname = full_name |
193 | |
194 | |
195 | class StubbedOpenIDCallbackView(OpenIDCallbackView): |
196 | @@ -42,25 +52,47 @@ |
197 | self.login_called = True |
198 | |
199 | |
200 | +@contextmanager |
201 | +def SRegResponse_fromSuccessResponse_stubbed(): |
202 | + def sregFromFakeSuccessResponse(cls, success_response, signed_only=True): |
203 | + return {'email': success_response.sreg_email, |
204 | + 'fullname': success_response.sreg_fullname} |
205 | + |
206 | + orig_method = sreg.SRegResponse.fromSuccessResponse |
207 | + # Use a stub SRegResponse.fromSuccessResponse that works with |
208 | + # FakeOpenIDResponses instead of real ones. |
209 | + sreg.SRegResponse.fromSuccessResponse = classmethod( |
210 | + sregFromFakeSuccessResponse) |
211 | + |
212 | + yield |
213 | + |
214 | + sreg.SRegResponse.fromSuccessResponse = orig_method |
215 | + |
216 | + |
217 | class TestOpenIDCallbackView(TestCaseWithFactory): |
218 | layer = DatabaseFunctionalLayer |
219 | |
220 | - def _createView(self, account, response_status=SUCCESS, response_msg=''): |
221 | + def _createViewWithResponse( |
222 | + self, account, response_status=SUCCESS, response_msg=''): |
223 | + openid_response = FakeOpenIDResponse( |
224 | + ITestOpenIDPersistentIdentity(account).openid_identity_url, |
225 | + status=response_status, message=response_msg) |
226 | + return self._createView(openid_response) |
227 | + |
228 | + def _createView(self, response): |
229 | request = LaunchpadTestRequest( |
230 | form={'starting_url': 'http://launchpad.dev/after-login'}, |
231 | environ={'PATH_INFO': '/'}) |
232 | view = StubbedOpenIDCallbackView(object(), request) |
233 | view.initialize() |
234 | - view.openid_response = FakeOpenIDResponse( |
235 | - ITestOpenIDPersistentIdentity(account).openid_identity_url, |
236 | - status=response_status, message=response_msg) |
237 | + view.openid_response = response |
238 | return view |
239 | |
240 | def test_full_fledged_account(self): |
241 | # In the common case we just login and redirect to the URL specified |
242 | # in the 'starting_url' query arg. |
243 | person = self.factory.makePerson() |
244 | - view = self._createView(person.account) |
245 | + view = self._createViewWithResponse(person.account) |
246 | view.render() |
247 | self.assertTrue(view.login_called) |
248 | response = view.request.response |
249 | @@ -73,7 +105,7 @@ |
250 | # create one. |
251 | account = self.factory.makeAccount('Test account') |
252 | self.assertIs(None, IPerson(account, None)) |
253 | - view = self._createView(account) |
254 | + view = self._createViewWithResponse(account) |
255 | view.render() |
256 | self.assertIsNot(None, IPerson(account, None)) |
257 | self.assertTrue(view.login_called) |
258 | @@ -82,13 +114,38 @@ |
259 | self.assertEquals(view.request.form['starting_url'], |
260 | response.getHeader('Location')) |
261 | |
262 | + def test_unseen_identity(self): |
263 | + # When we get a positive assertion about an identity URL we've never |
264 | + # seen, we automatically register an account with that identity |
265 | + # because someone who registered on login.lp.net or login.u.c should |
266 | + # be able to login here without any further steps. |
267 | + identifier = '4w7kmzU' |
268 | + account_set = getUtility(IAccountSet) |
269 | + self.assertRaises( |
270 | + LookupError, account_set.getByOpenIDIdentifier, identifier) |
271 | + openid_response = FakeOpenIDResponse( |
272 | + 'http://testopenid.dev/+id/%s' % identifier, status=SUCCESS, |
273 | + email='non-existent@example.com', full_name='Foo User') |
274 | + view = self._createView(openid_response) |
275 | + with SRegResponse_fromSuccessResponse_stubbed(): |
276 | + view.render() |
277 | + self.assertTrue(view.login_called) |
278 | + account = account_set.getByOpenIDIdentifier(identifier) |
279 | + self.assertIsNot(None, account) |
280 | + self.assertEquals(AccountStatus.ACTIVE, account.status) |
281 | + self.assertEquals('non-existent@example.com', |
282 | + removeSecurityProxy(account.preferredemail).email) |
283 | + person = IPerson(account, None) |
284 | + self.assertIsNot(None, person) |
285 | + self.assertEquals('Foo User', person.displayname) |
286 | + |
287 | def test_deactivated_account(self): |
288 | # The user has the account's password and is trying to login, so we'll |
289 | # just re-activate their account. |
290 | account = self.factory.makeAccount( |
291 | 'Test account', status=AccountStatus.DEACTIVATED) |
292 | self.assertIs(None, IPerson(account, None)) |
293 | - view = self._createView(account) |
294 | + view = self._createViewWithResponse(account) |
295 | view.render() |
296 | self.assertIsNot(None, IPerson(account, None)) |
297 | self.assertTrue(view.login_called) |
298 | @@ -102,7 +159,7 @@ |
299 | # login, but we must not allow that. |
300 | account = self.factory.makeAccount( |
301 | 'Test account', status=AccountStatus.SUSPENDED) |
302 | - view = self._createView(account) |
303 | + view = self._createViewWithResponse(account) |
304 | html = view.render() |
305 | self.assertFalse(view.login_called) |
306 | main_content = extract_text(find_main_content(html)) |
307 | @@ -112,7 +169,7 @@ |
308 | # The OpenID provider responded with a negative assertion, so the |
309 | # login error page is shown. |
310 | account = self.factory.makeAccount('Test account') |
311 | - view = self._createView( |
312 | + view = self._createViewWithResponse( |
313 | account, response_status=FAILURE, |
314 | response_msg='Server denied check_authentication') |
315 | html = view.render() |
316 | @@ -139,6 +196,14 @@ |
317 | handler_classes['_redirect'] = MyHTTPRedirectHandler |
318 | |
319 | |
320 | +def fill_login_form_and_submit(browser, email_address, password): |
321 | + assert browser.getControl(name='field.email') is not None, ( |
322 | + "We don't seem to be looking at a login form.") |
323 | + browser.getControl(name='field.email').value = email_address |
324 | + browser.getControl(name='field.password').value = password |
325 | + browser.getControl('Continue').click() |
326 | + |
327 | + |
328 | class TestOpenIDReplayAttack(TestCaseWithFactory): |
329 | layer = AppServerLayer |
330 | |
331 | @@ -172,12 +237,51 @@ |
332 | extract_text(error_msg)) |
333 | |
334 | |
335 | -def fill_login_form_and_submit(browser, email_address, password): |
336 | - assert browser.getControl(name='field.email') is not None, ( |
337 | - "We don't seem to be looking at a login form.") |
338 | - browser.getControl(name='field.email').value = email_address |
339 | - browser.getControl(name='field.password').value = password |
340 | - browser.getControl('Continue').click() |
341 | +class FakeOpenIDRequest: |
342 | + extensions = None |
343 | + |
344 | + def addExtension(self, extension): |
345 | + if self.extensions is None: |
346 | + self.extensions = [extension] |
347 | + else: |
348 | + self.extensions.append(extension) |
349 | + |
350 | + def shouldSendRedirect(self): |
351 | + return False |
352 | + |
353 | + def htmlMarkup(self, trust_root, return_to): |
354 | + return None |
355 | + |
356 | + |
357 | +class FakeOpenIDConsumer: |
358 | + def begin(self, url): |
359 | + return FakeOpenIDRequest() |
360 | + |
361 | + |
362 | +class StubbedOpenIDLogin(OpenIDLogin): |
363 | + def _getConsumer(self): |
364 | + return FakeOpenIDConsumer() |
365 | + |
366 | + |
367 | +class TestOpenIDLogin(TestCaseWithFactory): |
368 | + layer = DatabaseFunctionalLayer |
369 | + |
370 | + def test_sreg_fields(self): |
371 | + # We request the user's email address and Full Name (through the SREG |
372 | + # extension) to the OpenID provider so that we can automatically |
373 | + # register unseen OpenID identities. |
374 | + request = LaunchpadTestRequest() |
375 | + # This is a hack to make the request.getURL(1) call issued by the view |
376 | + # not raise an IndexError. |
377 | + request._app_names = ['foo'] |
378 | + view = StubbedOpenIDLogin(object(), request) |
379 | + view() |
380 | + extensions = view.openid_request.extensions |
381 | + self.assertIsNot(None, extensions) |
382 | + sreg_extension = extensions[0] |
383 | + self.assertIsInstance(sreg_extension, sreg.SRegRequest) |
384 | + self.assertEquals(['email', 'fullname'], |
385 | + sorted(sreg_extension.allRequestedFields())) |
386 | |
387 | |
388 | def test_suite(): |
= Summary =
We'll soon break the auth tables out of the replication cluster, which
means we'll have one copy of them used by login.lp.net (and
login.ubuntu.com) and a separate one used by launchpad.net.
That means accounts created after the split won't be seen by Launchpad,
so we need to automatically create them when we see a new identity URL.
This is a temporary thing as soon afterwards we'll refactor the Account
table used by launchpad.net -- at that time we'll only need to register
the identity URL and maybe associate it with an existing profile.
= Launchpad lint =
Checking for conflicts. and issues in doctests and templates.
Running jslint, xmllint, pyflakes, and pylint.
Using normal rules.
Linting changed files: /launchpad/ database/ account. py /launchpad/ webapp/ login.py /launchpad/ webapp/ tests/test_ login.py /launchpad/ webapp/ tests/no- anonymous- session- cookies. txt /launchpad/ webapp/ tests/cookie- authentication. txt /launchpad/ webapp/ tests/login. txt
lib/canonical
lib/canonical
lib/canonical
lib/canonical
lib/canonical
lib/canonical
This is a lie; Browser is defined in the test globals.
== Pyflakes Doctest notices ==
lib/canonical/ launchpad/ webapp/ tests/login. txt
11: undefined name 'Browser'