← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:py3-app-exception-modules into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:py3-app-exception-modules into launchpad:master.

Commit message:
lp.app: Use IGNORE_EXCEPTION_MODULE_IN_PYTHON2

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/397154

This allows doctests that test tracebacks to work on both Python 2 and 3.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:py3-app-exception-modules into launchpad:master.
diff --git a/lib/lp/app/doc/batch-navigation.txt b/lib/lp/app/doc/batch-navigation.txt
index 86436d7..eebef5d 100644
--- a/lib/lp/app/doc/batch-navigation.txt
+++ b/lib/lp/app/doc/batch-navigation.txt
@@ -81,9 +81,10 @@ InvalidBatchSizeError is raised.
     ...     """))
     >>> request = build_request({"start": "0", "batch": "20"})
     >>> BatchNavigator(reindeer, request=request )
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    InvalidBatchSizeError: Maximum for "batch" parameter is 5.
+    lazr.batchnavigator.interfaces.InvalidBatchSizeError: Maximum for "batch" parameter is 5.
 
     >>> ignored = config.pop('max-batch-size')
 
diff --git a/lib/lp/app/doc/displaying-numbers.txt b/lib/lp/app/doc/displaying-numbers.txt
index fcae7bc..59ed96b 100644
--- a/lib/lp/app/doc/displaying-numbers.txt
+++ b/lib/lp/app/doc/displaying-numbers.txt
@@ -56,9 +56,10 @@ Is the same as:
 Here's a set of exhaustive examples:
 
     >>> test_tales('foo/fmt:float', foo=12345.67890)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LocationError: 'fmt:float requires a single decimal argument'
+    zope.location.interfaces.LocationError: 'fmt:float requires a single decimal argument'
 
     >>> test_tales('foo/fmt:float/0.3', foo=-12345.67890)
     '-12345.679'
diff --git a/lib/lp/app/doc/launchpadform.txt b/lib/lp/app/doc/launchpadform.txt
index 571002f..86a5fc3 100644
--- a/lib/lp/app/doc/launchpadform.txt
+++ b/lib/lp/app/doc/launchpadform.txt
@@ -554,9 +554,10 @@ request:
   ...           'field.actions.change': 'Change'})
   >>> view = UnsafeActionTestView(context, request)
   >>> view.initialize()
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  UnsafeFormGetSubmissionError: field.actions.change
+  lp.services.webapp.interfaces.UnsafeFormGetSubmissionError: field.actions.change
 
   >>> request = LaunchpadTestRequest(
   ...     method='POST',
diff --git a/lib/lp/app/doc/object-privacy.txt b/lib/lp/app/doc/object-privacy.txt
index cf4d5ba..18c14e5 100644
--- a/lib/lp/app/doc/object-privacy.txt
+++ b/lib/lp/app/doc/object-privacy.txt
@@ -30,9 +30,10 @@ private.
 
     >>> question = getUtility(IQuestionSet).get(1)
     >>> question.private
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    ForbiddenAttribute:...
+    zope.security.interfaces.ForbiddenAttribute: ...
     >>> IObjectPrivacy(question).is_private
     False
 
diff --git a/lib/lp/app/doc/tales.txt b/lib/lp/app/doc/tales.txt
index 75e2629..a6b31fe 100644
--- a/lib/lp/app/doc/tales.txt
+++ b/lib/lp/app/doc/tales.txt
@@ -1314,9 +1314,10 @@ Everything you can do with 'something/fmt:foo', you should be able to do
 with 'None/fmt:foo'.
 
     >>> test_tales('foo/fmt:shorten', foo=None)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LocationError: 'you need to traverse a number after fmt:shorten'
+    zope.location.interfaces.LocationError: 'you need to traverse a number after fmt:shorten'
 
     >>> test_tales('foo/fmt:shorten/8', foo=None)
     ''
@@ -1416,10 +1417,10 @@ We don't get a ValueError when we use a value that doesn't appear in the
 DBSchema the item comes from.
 
     >>> test_tales('deb/enumvalue:CHEESEFISH', deb=udeb)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LocationError: 'The enumerated type BinaryPackageFormat does not have a
-                     value CHEESEFISH.'
+    zope.location.interfaces.LocationError: 'The enumerated type BinaryPackageFormat does not have a value CHEESEFISH.'
 
 It is possible for dbschemas to have a 'None' value.  This is a bit
 awkward, because when the value is None, we can't do any checking
@@ -1438,10 +1439,10 @@ dbschema items too:
     False
 
     >>> test_tales('deb/enumvalue:CHEESEFISH', deb=wrapped_deb)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LocationError: 'The enumerated type BinaryPackageFormat does not have a
-                     value CHEESEFISH.'
+    zope.location.interfaces.LocationError: 'The enumerated type BinaryPackageFormat does not have a value CHEESEFISH.'
 
 
 Formatting timedelta objects
diff --git a/lib/lp/app/stories/basics/marketing.txt b/lib/lp/app/stories/basics/marketing.txt
index fd4c1ce..714be3a 100644
--- a/lib/lp/app/stories/basics/marketing.txt
+++ b/lib/lp/app/stories/basics/marketing.txt
@@ -44,9 +44,10 @@ But the source directory isn't available:
 
     >>> browser.open(
     ...     'http://launchpad.test/+tour/source/code-hosting_SVG.svg')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 
 == +about compatibility ==
diff --git a/lib/lp/app/stories/basics/max-batch-size.txt b/lib/lp/app/stories/basics/max-batch-size.txt
index 12b15e0..9bd585b 100644
--- a/lib/lp/app/stories/basics/max-batch-size.txt
+++ b/lib/lp/app/stories/basics/max-batch-size.txt
@@ -7,9 +7,10 @@ large and what is the current maximum.
 
     >>> anon_browser.handleErrors = True
     >>> anon_browser.open('http://launchpad.test/projects/+all?start=0&batch=1000')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    HTTPError: HTTP Error 400: Bad Request
+    urllib.error.HTTPError: HTTP Error 400: Bad Request
 
     >>> print(extract_text(find_main_content(anon_browser.contents)))
     Invalid Batch Size
diff --git a/lib/lp/app/stories/basics/xx-developerexceptions.txt b/lib/lp/app/stories/basics/xx-developerexceptions.txt
index 57eed1a..a8d08b6 100644
--- a/lib/lp/app/stories/basics/xx-developerexceptions.txt
+++ b/lib/lp/app/stories/basics/xx-developerexceptions.txt
@@ -104,7 +104,8 @@ want to see tracebacks instead of error pages.
   >>> print(http(r"""
   ... GET /whatever HTTP/1.1
   ... """, handle_errors=False))
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  NotFound: ...
+  zope.publisher.interfaces.NotFound: ...
 
diff --git a/lib/lp/app/stories/basics/xx-launchpad-statistics.txt b/lib/lp/app/stories/basics/xx-launchpad-statistics.txt
index 542384a..891b1d0 100644
--- a/lib/lp/app/stories/basics/xx-launchpad-statistics.txt
+++ b/lib/lp/app/stories/basics/xx-launchpad-statistics.txt
@@ -3,9 +3,10 @@ We also have the special Launchpad Statistics summary page. This is only
 acessible to launchpad Admins:
 
   >>> user_browser.open('http://launchpad.test/+statistics')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  Unauthorized:...
+  zope.security.interfaces.Unauthorized: ...
 
 
 When we login as an admin, we can see all the stats listed:
diff --git a/lib/lp/app/stories/basics/xx-offsite-form-post.txt b/lib/lp/app/stories/basics/xx-offsite-form-post.txt
index 4b1bcc8..045c34a 100644
--- a/lib/lp/app/stories/basics/xx-offsite-form-post.txt
+++ b/lib/lp/app/stories/basics/xx-offsite-form-post.txt
@@ -44,9 +44,10 @@ If we try to create a new team with with the referrer set to
   >>> browser.getControl('Name', index=0).value = 'team1'
   >>> browser.getControl('Display Name').value = 'Team 1'
   >>> browser.getControl('Create').click()
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  OffsiteFormPostError: http://evil.people.com/
+  lp.services.webapp.interfaces.OffsiteFormPostError: http://evil.people.com/
 
 
 Similarly, posting with a garbage referer fails:
@@ -56,9 +57,10 @@ Similarly, posting with a garbage referer fails:
   >>> browser.getControl('Name', index=0).value = 'team2'
   >>> browser.getControl('Display Name').value = 'Team 2'
   >>> browser.getControl('Create').click()
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  OffsiteFormPostError: not a url
+  lp.services.webapp.interfaces.OffsiteFormPostError: not a url
 
 
 It also fails if there is no referrer.
@@ -68,9 +70,10 @@ It also fails if there is no referrer.
   >>> browser.getControl('Name', index=0).value = 'team3'
   >>> browser.getControl('Display Name').value = 'Team 3'
   >>> browser.getControl('Create').click()
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  NoReferrerError: No value for REFERER header
+  lp.services.webapp.interfaces.NoReferrerError: No value for REFERER header
 
 When a POST request is rejected because the REFERER header is missing, it
 may be because the user is trying to enforce anonymity.  Therefore, we
@@ -81,9 +84,10 @@ present a hopefully helpful error message.
   >>> browser.getControl('Name', index=0).value = 'team3'
   >>> browser.getControl('Display Name').value = 'Team 3'
   >>> browser.getControl('Create').click()
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  HTTPError...
+  urllib.error.HTTPError: ...
   >>> print(browser.headers['status'])
   403 Forbidden
   >>> print(extract_text(find_main_content(browser.contents)))
@@ -113,9 +117,10 @@ exception for +access-token, it would result in an
 OffsiteFormPostError.
 
   >>> browser.post('http://launchpad.test/+access-token', 'x=1')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  HTTPError: HTTP Error 401: Unauthorized
+  urllib.error.HTTPError: HTTP Error 401: Unauthorized
 
 We also let the request go through if the referrer is from a site
 managed by launchpad:
@@ -148,30 +153,34 @@ a referrerless POST request to the browser-accessible API.
   >>> no_referrer_browser = setupBrowserWithReferrer(None)
 
   >>> browser.post('http://launchpad.test/api/devel/people', 'ws.op=foo&x=1')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  OffsiteFormPostError: http://evil.people.com/
+  lp.services.webapp.interfaces.OffsiteFormPostError: http://evil.people.com/
 
   >>> no_referrer_browser.post(
   ...     'http://launchpad.test/api/devel/people', 'ws.op=foo&x=1')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  NoReferrerError: No value for REFERER header
+  lp.services.webapp.interfaces.NoReferrerError: No value for REFERER header
 
 You can't cheat by making your referrerless POST request seem as
 though it were signed with OAuth.
 
   >>> browser.post(
   ...     'http://launchpad.test/', 'oauth_consumer_key=foo&oauth_token=bar')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  OffsiteFormPostError: http://evil.people.com/
+  lp.services.webapp.interfaces.OffsiteFormPostError: http://evil.people.com/
 
   >>> no_referrer_browser.post(
   ...     'http://launchpad.test/', 'oauth_consumer_key=foo&oauth_token=bar')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  NoReferrerError: No value for REFERER header
+  lp.services.webapp.interfaces.NoReferrerError: No value for REFERER header
 
 You might think you can actually sign a request with an anonymous
 OAuth credential. You don't need any knowledge of the user account to
@@ -188,17 +197,19 @@ But the browser-accessible API ignores OAuth credentials altogether.
 
   >>> browser.post(
   ...     'http://launchpad.test/api/devel/projects', sig)
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  OffsiteFormPostError: http://evil.people.com/
+  lp.services.webapp.interfaces.OffsiteFormPostError: http://evil.people.com/
 
 If you go through the 'api' vhost, the signed request will be
 processed despite the bogus referrer, but...
 
   >>> browser.post('http://api.launchpad.test/devel/projects', sig)
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  NoneError: None isn't acceptable as a value for Product.owner
+  storm.exceptions.NoneError: None isn't acceptable as a value for Product.owner
 
 You're making an _anonymous_ request. That's a request that 1) is not
 associated with any Launchpad user account (thus the NoneError when
diff --git a/lib/lp/app/stories/basics/xx-opstats.txt b/lib/lp/app/stories/basics/xx-opstats.txt
index f502ede..400cb75 100644
--- a/lib/lp/app/stories/basics/xx-opstats.txt
+++ b/lib/lp/app/stories/basics/xx-opstats.txt
@@ -292,9 +292,10 @@ But our database connections are broken.
     >>> from lp.services.database.interfaces import IStore
     >>> from lp.registry.model.person import Person
     >>> IStore(Person).find(Person, name='janitor')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    DisconnectionError: FATAL:  database "nonexistant" does not exist
+    storm.exceptions.DisconnectionError: FATAL:  database "nonexistant" does not exist
 
     >>> dummy = config.pop('no_db')
     >>> getUtility(IZStorm)._reset()
diff --git a/lib/lp/app/stories/folder.txt b/lib/lp/app/stories/folder.txt
index 02f5547..ee9d9db 100644
--- a/lib/lp/app/stories/folder.txt
+++ b/lib/lp/app/stories/folder.txt
@@ -65,10 +65,10 @@ Requesting a directory raises a NotFound.
 
     >>> view = MyFolder(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'a_dir')
-    >>> view()
+    >>> view()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 By default, subdirectories are not exported. (See below on how to enable
 this)
@@ -76,27 +76,27 @@ this)
     >>> view = MyFolder(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'a_dir')
     >>> view = view.publishTraverse(view.request, 'other.txt')
-    >>> view()
+    >>> view()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 Not requesting any file, also raises NotFound.
 
     >>> view = MyFolder(object(), FakeRequest(version="devel"))
-    >>> view()
+    >>> view()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 As requesting a non-existent file.
 
     >>> view = MyFolder(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'image2')
-    >>> view()
+    >>> view()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 
 == ExportedImageFolder ==
@@ -172,20 +172,20 @@ But traversing to the subdirectory itself will raise a NotFound.
 
     >>> view = MyTree(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'public')
-    >>> print(view())
+    >>> print(view())  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 Trying to request a non-existent file, will also raise a NotFound.
 
     >>> view = MyTree(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'public')
     >>> view = view.publishTraverse(view.request, 'nosuchfile.txt')
-    >>> view()
+    >>> view()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 Traversing beyond an existing file to a non-existant file raises a
 NotFound.
@@ -195,10 +195,10 @@ NotFound.
     >>> view = view.publishTraverse(view.request, 'subdir1')
     >>> view = view.publishTraverse(view.request, 'test1.txt')
     >>> view = view.publishTraverse(view.request, 'nosuchpath')
-    >>> view()
+    >>> view()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    NotFound:...
+    zope.publisher.interfaces.NotFound: ...
 
 
 == Clean-up ==
diff --git a/lib/lp/app/stories/form/xx-form-layout.txt b/lib/lp/app/stories/form/xx-form-layout.txt
index 01b3ac1..c9b09ea 100644
--- a/lib/lp/app/stories/form/xx-form-layout.txt
+++ b/lib/lp/app/stories/form/xx-form-layout.txt
@@ -98,14 +98,16 @@ display forms.
 
     >>> cprov_browser.open(
     ...     'http://launchpad.test/~cprov/+editsshkeys/++form++')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound...
+    zope.publisher.interfaces.NotFound: ...
 
 Nor will it allow unauthorized access to data that it should not present.
 
     >>> browser.open(
     ...     'http://launchpad.test/~cprov/+archive/ppa/+edit/++form++')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized...
+    zope.security.interfaces.Unauthorized: ...
diff --git a/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt b/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt
index 3fa6f24..2ca6482 100644
--- a/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt
+++ b/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt
@@ -49,17 +49,19 @@ projects' pages in Launchpad. The "project of the day" is listed separately.
 Anonymous users cannot see the link to administer featured projects:
 
     >>> anon_browser.getLink(MANAGE_LINK)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LinkNotFoundError
+    zope.testbrowser.browser.LinkNotFoundError
 
 A user without privileges cannot see the administration link, either:
 
     >>> user_browser.open('http://launchpad.test/')
     >>> user_browser.getLink(MANAGE_LINK)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LinkNotFoundError
+    zope.testbrowser.browser.LinkNotFoundError
 
 But Foo Bar, who is an administrator, can see the management link:
 
@@ -71,9 +73,10 @@ But Foo Bar, who is an administrator, can see the management link:
 No Privilege persons is denied access to this page:
 
     >>> user_browser.open('http://launchpad.test/+featuredprojects')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: ...
+    zope.security.interfaces.Unauthorized: ...
 
 Administrators can add a project. Here Foo Bar adds apache as a featured
 project:
diff --git a/lib/lp/app/validators/email.py b/lib/lp/app/validators/email.py
index d3789bc..ce959fc 100644
--- a/lib/lp/app/validators/email.py
+++ b/lib/lp/app/validators/email.py
@@ -79,9 +79,10 @@ def email_validator(emailaddr):
     >>> email_validator('bugs@xxxxxxxxxxx')
     True
     >>> email_validator('not-valid')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LaunchpadValidationError: Invalid email 'not-valid'.
+    lp.app.validators.LaunchpadValidationError: Invalid email 'not-valid'.
     """
     if not valid_email(emailaddr):
         raise LaunchpadValidationError(_(
diff --git a/lib/lp/app/validators/tests/test_validators.py b/lib/lp/app/validators/tests/test_validators.py
index e746416..cb11eea 100644
--- a/lib/lp/app/validators/tests/test_validators.py
+++ b/lib/lp/app/validators/tests/test_validators.py
@@ -12,6 +12,8 @@ from doctest import (
     )
 from unittest import TestSuite
 
+from zope.testing.renormalizing import OutputChecker
+
 from lp.testing.layers import LaunchpadFunctionalLayer
 from lp.testing.systemdocs import (
     setUp,
@@ -42,7 +44,7 @@ def suitefor(module):
     """Make a doctest suite with common setUp and tearDown functions."""
     suite = DocTestSuite(
         module, setUp=lambda test: setUp(test, future=True), tearDown=tearDown,
-        optionflags=ELLIPSIS | NORMALIZE_WHITESPACE)
+        optionflags=ELLIPSIS | NORMALIZE_WHITESPACE, checker=OutputChecker())
     # We have to invoke the LaunchpadFunctionalLayer in order to
     # initialize the ZCA machinery, which is a pre-requisite for using
     # login().
diff --git a/lib/lp/app/validators/tests/validation.txt b/lib/lp/app/validators/tests/validation.txt
index b7ea290..a8d4a8e 100644
--- a/lib/lp/app/validators/tests/validation.txt
+++ b/lib/lp/app/validators/tests/validation.txt
@@ -38,14 +38,16 @@ fail for that specific series.
 
     >>> nomination = bug.addNomination(no_priv, firefox.series[0])
     >>> can_be_nominated_for_series(firefox.series)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LaunchpadValidationError...
+    lp.app.validators.LaunchpadValidationError: ...
 
     >>> can_be_nominated_for_series([firefox.series[0]])
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LaunchpadValidationError...
+    lp.app.validators.LaunchpadValidationError: ...
 
 It will pass for the rest of the series, though.
 
@@ -60,9 +62,10 @@ fail:
     ...     'foo.bar@xxxxxxxxxxxxx')
     >>> nomination.approve(foo_bar)
     >>> can_be_nominated_for_series([firefox.series[0]])
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LaunchpadValidationError...
+    lp.app.validators.LaunchpadValidationError: ...
 
 The validation message will contain all the series that can't be
 nominated.
@@ -70,10 +73,10 @@ nominated.
     >>> trunk_nomination = bug.addNomination(
     ...     no_priv, firefox.series[1])
     >>> can_be_nominated_for_series(firefox.series)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LaunchpadValidationError:
-    This bug has already been nominated for these series: 1.0, Trunk
+    lp.app.validators.LaunchpadValidationError: This bug has already been nominated for these series: 1.0, Trunk
 
 Declined nominations can be re-nominated.
 
@@ -112,7 +115,7 @@ Or a name that is not already in use.
 But you can't use Mark's name, of course. ;)
 
     >>> field.validate(u'mark')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    LaunchpadValidationError: ...mark is already in use by another
-    person or team...
+    lp.app.validators.LaunchpadValidationError: ...mark is already in use by another person or team...
diff --git a/lib/lp/app/validators/url.py b/lib/lp/app/validators/url.py
index 08511cf..bf4d998 100644
--- a/lib/lp/app/validators/url.py
+++ b/lib/lp/app/validators/url.py
@@ -154,9 +154,10 @@ def valid_webref(web_ref):
     >>> valid_webref('sftp://example.com//absolute/path/maybe')
     True
     >>> valid_webref('other://example.com/moo')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LaunchpadValidationError: ...
+    lp.app.validators.LaunchpadValidationError: ...
     """
     if validate_url(web_ref, ['http', 'https', 'ftp', 'sftp']):
         # Allow ftp so valid_webref can be used for download_url, and so
diff --git a/lib/lp/app/widgets/date.py b/lib/lp/app/widgets/date.py
index 0c317cb..dd8ef78 100644
--- a/lib/lp/app/widgets/date.py
+++ b/lib/lp/app/widgets/date.py
@@ -84,10 +84,12 @@ class DateTimeWidget(TextWidget):
       >>> widget.request.form[widget.name] = '2005-07-03'
       >>> widget.from_date = datetime(2006, 5, 23,
       ...                             tzinfo=pytz.timezone('UTC'))
-      >>> print(widget.getInputValue())  #doctest: +ELLIPSIS
+      >>> print(widget.getInputValue())
+      ... # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
+      ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
       Traceback (most recent call last):
       ...
-      WidgetInputError: (...Please pick a date after 2006-05-22 17:00:00...)
+      zope.formlib.interfaces.WidgetInputError: (...Please pick a date after 2006-05-22 17:00:00...)
 
     If the date provided is greater than from_date then the widget works as
     expected.
@@ -100,10 +102,12 @@ class DateTimeWidget(TextWidget):
 
       >>> widget.to_date = datetime(2008, 1, 26,
       ...                           tzinfo=pytz.timezone('UTC'))
-      >>> print(widget.getInputValue())  #doctest: +ELLIPSIS
+      >>> print(widget.getInputValue())
+      ... # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
+      ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
       Traceback (most recent call last):
       ...
-      WidgetInputError: (...Please pick a date before 2008-01-25 16:00:00...)
+      zope.formlib.interfaces.WidgetInputError: (...Please pick a date before 2008-01-25 16:00:00...)
 
     A datetime picker can be disabled initially:
 
@@ -380,10 +384,12 @@ class DateTimeWidget(TextWidget):
 
         Invalid dates result in a ConversionError:
 
-          >>> print(widget._parseInput('not a date'))  #doctest: +ELLIPSIS
+          >>> print(widget._parseInput('not a date'))
+          ... # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
+          ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
           Traceback (most recent call last):
             ...
-          ConversionError: ('Invalid date value', ...)
+          zope.formlib.interfaces.ConversionError: ('Invalid date value', ...)
         """
         if input == self._missing:
             return self.context.missing_value
@@ -541,10 +547,12 @@ class DateWidget(DateTimeWidget):
 
         Invalid dates result in a ConversionError:
 
-          >>> print(widget._toFieldValue('not a date'))  #doctest: +ELLIPSIS
+          >>> print(widget._toFieldValue('not a date'))
+          ... # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
+          ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
           Traceback (most recent call last):
             ...
-          ConversionError: ('Invalid date value', ...)
+          zope.formlib.interfaces.ConversionError: ('Invalid date value', ...)
 
         """
         parsed = self._parseInput(input)
diff --git a/lib/lp/app/widgets/doc/announcement-date-widget.txt b/lib/lp/app/widgets/doc/announcement-date-widget.txt
index 3fc2dfd..39e69dc 100644
--- a/lib/lp/app/widgets/doc/announcement-date-widget.txt
+++ b/lib/lp/app/widgets/doc/announcement-date-widget.txt
@@ -56,12 +56,10 @@ If you choose to publish immediately, the date field must be empty.
     >>> action_widget.request.form[action_widget.name] = 'immediately'
     >>> date_widget.request.form[date_widget.name] = '2005-07-23'
     >>> print(widget.getInputValue())
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.foo', u'Foo',
-                       LaunchpadValidationError(u'Please do not provide a date
-                                                  if you want to publish
-                                                  immediately.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.foo', u'Foo', LaunchpadValidationError(u'Please do not provide a date if you want to publish immediately.'))
 
 If you choose to publish at a specific date in the future, the date field
 must be filled.
@@ -69,7 +67,7 @@ must be filled.
     >>> action_widget.request.form[action_widget.name] = 'specific'
     >>> date_widget.request.form[date_widget.name] = ''
     >>> print(widget.getInputValue())
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.foo', u'Foo',
-    LaunchpadValidationError(u'Please provide a publication date.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.foo', u'Foo', LaunchpadValidationError(u'Please provide a publication date.'))
diff --git a/lib/lp/app/widgets/doc/image-widget.txt b/lib/lp/app/widgets/doc/image-widget.txt
index 23d4c86..fb740cf 100644
--- a/lib/lp/app/widgets/doc/image-widget.txt
+++ b/lib/lp/app/widgets/doc/image-widget.txt
@@ -266,12 +266,10 @@ dimensions smaller than person_mugshot.dimensions, it must be rejected.
     >>> form = {'field.mugshot.action': 'change', 'field.mugshot.image': logo}
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
-    >>> widget.getInputValue()
+    >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.mugshot', u'Mugshot',
-                       LaunchpadValidationError(u'\nThis image is not exactly
-                                                  192x192\npixels in size.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.mugshot', u'Mugshot', LaunchpadValidationError(u'\nThis image is not exactly 192x192\npixels in size.'))
 
 This is what we see when the image is the correct dimensions, and within
 the max_size:
@@ -312,13 +310,10 @@ image, we'll get a validation error.
     >>> _ = mugshot.seek(0)
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
-    >>> widget.getInputValue()
+    >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.mugshot', u'Mugshot',
-                       LaunchpadValidationError(u'\nThis image exceeds the
-                                                  maximum allowed size in
-                                                  bytes.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.mugshot', u'Mugshot', LaunchpadValidationError(u'\nThis image exceeds the maximum allowed size in bytes.'))
 
 A similar error will be raised if the image's dimensions are bigger than
 the maximum we allow.
@@ -328,23 +323,19 @@ the maximum we allow.
     >>> _ = mugshot.seek(0)
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
-    >>> widget.getInputValue()
+    >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.mugshot', u'Mugshot',
-                       LaunchpadValidationError(u'\nThis image is not exactly
-                                                  191x193\npixels in size.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.mugshot', u'Mugshot', LaunchpadValidationError(u'\nThis image is not exactly 191x193\npixels in size.'))
 
     >>> person_mugshot.dimensions = (image.size[0] + 1, image.size[1] - 1)
     >>> _ = mugshot.seek(0)
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
-    >>> widget.getInputValue()
+    >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.mugshot', u'Mugshot',
-                       LaunchpadValidationError(u'\nThis image is not exactly
-                                                  193x191\npixels in size.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.mugshot', u'Mugshot', LaunchpadValidationError(u'\nThis image is not exactly 193x191\npixels in size.'))
 
 Finally, if the user specifies the 'change' action they must also provide
 a file to be uploaded.
@@ -352,12 +343,10 @@ a file to be uploaded.
     >>> form = {'field.mugshot.action': 'change', 'field.mugshot.image': ''}
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
-    >>> widget.getInputValue()
+    >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.mugshot', u'Mugshot',
-                       LaunchpadValidationError(u'Please specify the image you
-                                                  want to use.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.mugshot', u'Mugshot', LaunchpadValidationError(u'Please specify the image you want to use.'))
 
 
 Non-exact Image Dimensions
@@ -376,12 +365,10 @@ by setting the exact_dimensions attribute of the field to False:
     ...         'field.mugshot.image': mugshot}
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
-    >>> widget.getInputValue()
+    >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.mugshot', u'Mugshot',
-                       LaunchpadValidationError(u'\nThis image is larger than
-                                                  64x64\npixels in size.'))
+    zope.formlib.interfaces.WidgetInputError: ('field.mugshot', u'Mugshot', LaunchpadValidationError(u'\nThis image is larger than 64x64\npixels in size.'))
 
 If the image is smaller than the dimensions, the input validates:
 
diff --git a/lib/lp/app/widgets/doc/project-scope-widget.txt b/lib/lp/app/widgets/doc/project-scope-widget.txt
index 35c3ed0..4c3980f 100644
--- a/lib/lp/app/widgets/doc/project-scope-widget.txt
+++ b/lib/lp/app/widgets/doc/project-scope-widget.txt
@@ -51,10 +51,10 @@ If the widget is required, getInputValue() raises UnexpectedFormData if
 there is no input.
 
     >>> widget.required = True
-    >>> widget.getInputValue()
+    >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    UnexpectedFormData: No valid option was selected.
+    lp.app.errors.UnexpectedFormData: No valid option was selected.
 
 By default, the 'All projects' scope is selected:
 
@@ -117,11 +117,10 @@ raised:
     >>> widget.hasInput()
     True
     >>> selected_scope = widget.getInputValue()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.scope', u'',
-    LaunchpadValidationError(u'There is no project named
-    'invalid' registered in Launchpad'))
+    zope.formlib.interfaces.WidgetInputError: ('field.scope', u'', LaunchpadValidationError(u'There is no project named 'invalid' registered in Launchpad'))
 
 The same error text is returned by error():
 
@@ -137,10 +136,10 @@ If no project name is given at all, a widget error is also raised:
     >>> widget.hasInput()
     True
     >>> selected_scope = widget.getInputValue()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.scope', u'',
-    LaunchpadValidationError(u'Please enter a project name'))
+    zope.formlib.interfaces.WidgetInputError: ('field.scope', u'', LaunchpadValidationError(u'Please enter a project name'))
 
     >>> print(widget.error())
     Please enter a project name
@@ -152,10 +151,10 @@ If no project name is given at all, a widget error is also raised:
     >>> widget.hasInput()
     True
     >>> selected_scope = widget.getInputValue()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    WidgetInputError: ('field.scope', u'',
-    LaunchpadValidationError(u'Please enter a project name'))
+    zope.formlib.interfaces.WidgetInputError: ('field.scope', u'', LaunchpadValidationError(u'Please enter a project name'))
 
     >>> print(widget.error())
     Please enter a project name
diff --git a/lib/lp/app/widgets/doc/stripped-text-widget.txt b/lib/lp/app/widgets/doc/stripped-text-widget.txt
index 9d363c2..c98c6e2 100644
--- a/lib/lp/app/widgets/doc/stripped-text-widget.txt
+++ b/lib/lp/app/widgets/doc/stripped-text-widget.txt
@@ -60,7 +60,7 @@ provided.
   ...     __name__=six.ensure_str('field'), title=u'Title', required=True)
   >>> request = LaunchpadTestRequest(form={'field.field':'    \n    '})
   >>> widget = StrippedTextWidget(required_field, request)
-  >>> widget.getInputValue()
+  >>> widget.getInputValue()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
   ...
-  WidgetInputError: ('field', u'Title', RequiredMissing('field'))
+  zope.formlib.interfaces.WidgetInputError: ('field', u'Title', RequiredMissing('field'))
diff --git a/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.txt b/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.txt
index 393f06b..241526d 100644
--- a/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.txt
+++ b/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.txt
@@ -40,18 +40,20 @@ contains more than one argument.
     >>> Zope3WidgetsUseIBrowserFormNGMonkeyPatch.install()
 
     >>> int_widget.getInputValue()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    UnexpectedFormData: ...
+    lp.app.errors.UnexpectedFormData: ...
 
     >>> text_field = schema.TextLine(__name__='text')
     >>> request = LaunchpadTestRequest(
     ...     form={'field.text': ['two', 'strings']})
     >>> text_widget = TextWidget(text_field, request)
     >>> text_widget.getInputValue()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    UnexpectedFormData: ...
+    lp.app.errors.UnexpectedFormData: ...
 
 Since the SimpleInputWidget._getFormValue is overridden, it also works
 with the Launchpad widgets extending it:
@@ -60,9 +62,10 @@ with the Launchpad widgets extending it:
 
     >>> stripped_text_widget = StrippedTextWidget(text_field, request)
     >>> stripped_text_widget.getInputValue()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    UnexpectedFormData: ...
+    lp.app.errors.UnexpectedFormData: ...
 
 Widgets expecting a variable number of values continue to work
 with this monkey patch:
diff --git a/lib/lp/app/widgets/tests/test_widget_doctests.py b/lib/lp/app/widgets/tests/test_widget_doctests.py
index cbad1dc..5bcf0f0 100644
--- a/lib/lp/app/widgets/tests/test_widget_doctests.py
+++ b/lib/lp/app/widgets/tests/test_widget_doctests.py
@@ -6,12 +6,17 @@ __metaclass__ = type
 import doctest
 import unittest
 
+from zope.testing.renormalizing import OutputChecker
+
 from lp.testing.layers import DatabaseFunctionalLayer
 
 
 def test_suite():
     suite = unittest.TestSuite()
     suite.layer = DatabaseFunctionalLayer
-    suite.addTest(doctest.DocTestSuite('lp.app.widgets.textwidgets'))
-    suite.addTest(doctest.DocTestSuite('lp.app.widgets.date'))
+    checker = OutputChecker()
+    suite.addTest(doctest.DocTestSuite(
+        'lp.app.widgets.textwidgets', checker=checker))
+    suite.addTest(doctest.DocTestSuite(
+        'lp.app.widgets.date', checker=checker))
     return suite
diff --git a/lib/lp/app/widgets/textwidgets.py b/lib/lp/app/widgets/textwidgets.py
index f4dd12c..fa666b4 100644
--- a/lib/lp/app/widgets/textwidgets.py
+++ b/lib/lp/app/widgets/textwidgets.py
@@ -56,6 +56,93 @@ class NoneableTextWidget(StrippedTextWidget):
             return value
 
 
+<<<<<<< lib/lp/app/widgets/textwidgets.py
+=======
+class LocalDateTimeWidget(TextWidget):
+    """A datetime widget that uses a particular time zone."""
+
+    timeZoneName = 'UTC'
+
+    def _toFieldValue(self, input):
+        """Convert a string to a datetime value.
+
+          >>> from zope.publisher.browser import TestRequest
+          >>> from zope.schema import Field
+          >>> field = Field(__name__='foo', title=u'Foo')
+          >>> widget = LocalDateTimeWidget(field, TestRequest())
+
+        The widget converts an empty string to the missing value:
+
+          >>> widget._toFieldValue('') == field.missing_value
+          True
+
+        By default, the date is interpreted as UTC:
+
+          >>> print(widget._toFieldValue('2006-01-01 12:00:00'))
+          2006-01-01 12:00:00+00:00
+
+        But it will handle other time zones:
+
+          >>> widget.timeZoneName = 'Australia/Perth'
+          >>> print(widget._toFieldValue('2006-01-01 12:00:00'))
+          2006-01-01 12:00:00+08:00
+
+        Invalid dates result in a ConversionError:
+
+          >>> print(widget._toFieldValue('not a date'))
+          ... # doctest: +NORMALIZE_WHITESPACE,+ELLIPSIS
+          ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
+          Traceback (most recent call last):
+            ...
+          zope.formlib.interfaces.ConversionError: ('Invalid date value', ...)
+        """
+        if input == self._missing:
+            return self.context.missing_value
+        try:
+            year, month, day, hour, minute, second, dummy_tz = parse(input)
+            second, micro = divmod(second, 1.0)
+            micro = round_half_up(micro * 1000000)
+            dt = datetime.datetime(year, month, day,
+                                   hour, minute, int(second), micro)
+        except (DateTimeError, ValueError, IndexError) as v:
+            raise ConversionError('Invalid date value', v)
+        tz = pytz.timezone(self.timeZoneName)
+        return tz.localize(dt)
+
+    def _toFormValue(self, value):
+        """Convert a date to its string representation.
+
+          >>> from zope.publisher.browser import TestRequest
+          >>> from zope.schema import Field
+          >>> field = Field(__name__='foo', title=u'Foo')
+          >>> widget = LocalDateTimeWidget(field, TestRequest())
+
+        The 'missing' value is converted to an empty string:
+
+          >>> print(widget._toFormValue(field.missing_value))
+          <BLANKLINE>
+
+        Dates are displayed without an associated time zone:
+
+          >>> dt = datetime.datetime(2006, 1, 1, 12, 0, 0,
+          ...                        tzinfo=pytz.timezone('UTC'))
+          >>> widget._toFormValue(dt)
+          '2006-01-01 12:00:00'
+
+        The date value will be converted to the widget's time zone
+        before being displayed:
+
+          >>> widget.timeZoneName = 'Australia/Perth'
+          >>> widget._toFormValue(dt)
+          '2006-01-01 20:00:00'
+        """
+        if value == self.context.missing_value:
+            return self._missing
+        tz = pytz.timezone(self.timeZoneName)
+        return value.astimezone(tz).strftime('%Y-%m-%d %H:%M:%S')
+
+
+>>>>>>> lib/lp/app/widgets/textwidgets.py
 class URIWidget(StrippedTextWidget):
     """A widget that represents a URI."""