← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
lp.services: 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/397315

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-services-exception-modules into launchpad:master.
diff --git a/lib/lp/services/database/doc/db-policy.txt b/lib/lp/services/database/doc/db-policy.txt
index 5a7b372..aa90ed7 100644
--- a/lib/lp/services/database/doc/db-policy.txt
+++ b/lib/lp/services/database/doc/db-policy.txt
@@ -38,10 +38,10 @@ a single master.
     False
 
     >>> ro_janitor.display_name = 'Janice the Janitor'
-    >>> transaction.commit()
+    >>> transaction.commit()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    InternalError: ...
+    storm.database.InternalError: ...
 
     >>> transaction.abort()
 
@@ -81,9 +81,10 @@ resources.
     >>> with SlaveOnlyDatabasePolicy():
     ...     whoops = IMasterStore(Person).find(
     ...         Person, Person.name == 'janitor').one()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    DisallowedStore: master
+    lp.services.database.interfaces.DisallowedStore: master
 
 We can even ensure no database activity occurs at all, for instance
 if we need to guarantee a potentially long running call doesn't access
@@ -94,9 +95,10 @@ database transaction.
     >>> with DatabaseBlockedPolicy():
     ...     whoops = IStore(Person).find(
     ...         Person, Person.name == 'janitor').one()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    DisallowedStore: ('main', 'default')
+    lp.services.database.interfaces.DisallowedStore: ('main', 'default')
 
 Database policies can also be installed and uninstalled using the
 IStoreSelector utility for cases where the 'with' syntax cannot
diff --git a/lib/lp/services/database/doc/multitablecopy.txt b/lib/lp/services/database/doc/multitablecopy.txt
index 83684de..fa68318 100644
--- a/lib/lp/services/database/doc/multitablecopy.txt
+++ b/lib/lp/services/database/doc/multitablecopy.txt
@@ -318,10 +318,10 @@ which means that the attempt to insert it will violate a unique constraint.
     ...             % numeric_holding_table)
 
     >>> copier.pour(transaction)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    IntegrityError: duplicate ... violates unique constraint ...
-    <BLANKLINE>
+    storm.database.IntegrityError: duplicate ... violates unique constraint ...
 
 Now we have a fun situation!  Some data has been copied back into our source
 tables, and we don't know how much.  And some data remains in our holding
diff --git a/lib/lp/services/database/doc/storm.txt b/lib/lp/services/database/doc/storm.txt
index 93d3a7e..5d83c4e 100644
--- a/lib/lp/services/database/doc/storm.txt
+++ b/lib/lp/services/database/doc/storm.txt
@@ -72,10 +72,10 @@ from a store other than the correct Master.
     >>> t = transaction.begin()
     >>> person = main_slave.find(Person, name='mark').one()
     >>> person.display_name = 'Cannot change'
-    >>> transaction.commit()
+    >>> transaction.commit()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    InternalError: ...
+    storm.database.InternalError: ...
 
     >>> transaction.abort()
     >>> t = transaction.begin()
@@ -94,9 +94,10 @@ similarly wrapped.
     >>> person.displayname
     u'No Privileges Person'
     >>> person.name = 'foo'
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: ...
+    zope.security.interfaces.Unauthorized: ...
 
     >>> person = IMasterObject(person)
     >>> removeSecurityProxy(person) is person
@@ -104,9 +105,10 @@ similarly wrapped.
     >>> person.displayname
     u'No Privileges Person'
     >>> person.name = 'foo'
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: ...
+    zope.security.interfaces.Unauthorized: ...
 
     >>> person = IMasterObject(removeSecurityProxy(person))
     >>> removeSecurityProxy(person) is person
diff --git a/lib/lp/services/feeds/doc/feeds.txt b/lib/lp/services/feeds/doc/feeds.txt
index 8b0b32d..b855388 100644
--- a/lib/lp/services/feeds/doc/feeds.txt
+++ b/lib/lp/services/feeds/doc/feeds.txt
@@ -55,9 +55,10 @@ not be found.
     >>> verifyObject(IThing, thing)
     True
     >>> feed_view = getMultiAdapter((thing, request), name='thing-feed.xml')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    ComponentLookupError: ...
+    zope.interface.interfaces.ComponentLookupError: ...
 
 Set the layer on the request for all subsequent uses.
 
@@ -69,15 +70,15 @@ found.
 
     >>> thing = object()
     >>> verifyObject(IThing, thing)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    MultipleInvalid: ...
-    Does not declaratively implement the interface
-    The lp.services.feeds.tests.helper.IThing.value attribute was not provided
+    zope.interface.exceptions.MultipleInvalid: ... Does not declaratively implement the interface The lp.services.feeds.tests.helper.IThing.value attribute was not provided
     >>> feed_view = getMultiAdapter((thing, request), name='thing-feed.atom')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    ComponentLookupError: ...
+    zope.interface.interfaces.ComponentLookupError: ...
 
 If the name is not one of the supported names the view will not be
 found.
@@ -86,9 +87,10 @@ found.
     >>> verifyObject(IThing, thing)
     True
     >>> feed_view = getMultiAdapter((thing, request), name='thing-feed.xml')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    ComponentLookupError: ...
+    zope.interface.interfaces.ComponentLookupError: ...
 
 If the thing is an IThing and the name is supported the view will be
 found, indicated by the absence of a ComponentLookupError.
diff --git a/lib/lp/services/feeds/stories/xx-navigation.txt b/lib/lp/services/feeds/stories/xx-navigation.txt
index a7cc583..719e10a 100644
--- a/lib/lp/services/feeds/stories/xx-navigation.txt
+++ b/lib/lp/services/feeds/stories/xx-navigation.txt
@@ -11,21 +11,25 @@ Trying to view a page which is not registered on the Feeds layer
 returns a 404.
 
     >>> browser.open('http://feeds.launchpad.test/jokosher')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...
+    zope.publisher.interfaces.NotFound: ...
     >>> browser.open('http://feeds.launchpad.test/bugs')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...
+    zope.publisher.interfaces.NotFound: ...
     >>> browser.open('http://feeds.launchpad.test/+dims')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...
+    zope.publisher.interfaces.NotFound: ...
     >>> browser.open('http://feeds.launchpad.test/announcements.atom/foo')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...
+    zope.publisher.interfaces.NotFound: ...
 
 
 == Redirection ==
diff --git a/lib/lp/services/fields/doc/uri-field.txt b/lib/lp/services/fields/doc/uri-field.txt
index 84409bf..ef9bb3e 100644
--- a/lib/lp/services/fields/doc/uri-field.txt
+++ b/lib/lp/services/fields/doc/uri-field.txt
@@ -34,9 +34,10 @@ valid URI:
   >>> field = IURIFieldTest['field']
   >>> field.validate(u'http://launchpad.net/')
   >>> field.validate(u'not-a-uri')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  LaunchpadValidationError: &quot;not-a-uri&quot; is not a valid URI
+  lp.app.validators.LaunchpadValidationError: &quot;not-a-uri&quot; is not a valid URI
 
 
 === Scheme Restrictions ===
@@ -48,10 +49,10 @@ will result in a validation error:
   >>> sftp_only = IURIFieldTest['sftp_only']
   >>> sftp_only.validate(u'sFtp://launchpad.net/')
   >>> sftp_only.validate(u'http://launchpad.net/')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  LaunchpadValidationError: The URI scheme &quot;http&quot; is not allowed.
-  Only URIs with the following schemes may be used: sftp
+  lp.app.validators.LaunchpadValidationError: The URI scheme &quot;http&quot; is not allowed. Only URIs with the following schemes may be used: sftp
 
 
 === Disallowing Userinfo ===
@@ -62,14 +63,16 @@ product home page, where authentication is not generally required:
 
   >>> no_userinfo = IURIFieldTest['no_userinfo']
   >>> no_userinfo.validate(u'http://launchpad.net:80@127.0.0.1/ubuntu')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  LaunchpadValidationError: A username may not be specified in the URI.
+  lp.app.validators.LaunchpadValidationError: A username may not be specified in the URI.
 
   >>> no_userinfo.validate(u'http://launchpad.net@127.0.0.1/ubuntu')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  LaunchpadValidationError: A username may not be specified in the URI.
+  lp.app.validators.LaunchpadValidationError: A username may not be specified in the URI.
 
 
 === Disallowing Non-default Ports ===
@@ -79,9 +82,10 @@ URIs.  This can be done with the allow_port option:
 
   >>> no_port = IURIFieldTest['no_port']
   >>> no_port.validate(u'http://launchpad.net:21')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  LaunchpadValidationError: Non-default ports are not allowed.
+  lp.app.validators.LaunchpadValidationError: Non-default ports are not allowed.
 
 Note that an error is not raised if the URI specifies a port but it is
 known to be the default for that scheme:
@@ -97,9 +101,10 @@ reject those URIs:
 
   >>> no_query = IURIFieldTest['no_query']
   >>> no_query.validate(u'http://launchpad.net/?key=value')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  LaunchpadValidationError: URIs with query strings are not allowed.
+  lp.app.validators.LaunchpadValidationError: URIs with query strings are not allowed.
 
 
 === Disallowing the Fragment Component ===
@@ -108,9 +113,10 @@ The fragment component can also be disallowed:
 
   >>> no_fragment = IURIFieldTest['no_fragment']
   >>> no_fragment.validate(u'http://launchpad.net/#fragment')
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  LaunchpadValidationError: URIs with fragment identifiers are not allowed.
+  lp.app.validators.LaunchpadValidationError: URIs with fragment identifiers are not allowed.
 
 
 == Requiring or Forbidding a Trailing Slash ===
@@ -195,9 +201,10 @@ This widget is registered as an input widget:
 Multiple values will cause an UnexpectedFormData exception:
 
   >>> widget._toFieldValue(['http://launchpad.net', 'http://ubuntu.com'])
+  ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
   Traceback (most recent call last):
     ...
-  UnexpectedFormData: Only a single value is expected
+  lp.app.errors.UnexpectedFormData: Only a single value is expected
 
 Values with leading and trailing whitespace are stripped.
 
diff --git a/lib/lp/services/gpg/doc/gpg-signatures.txt b/lib/lp/services/gpg/doc/gpg-signatures.txt
index eb15e2e..6ba60df 100644
--- a/lib/lp/services/gpg/doc/gpg-signatures.txt
+++ b/lib/lp/services/gpg/doc/gpg-signatures.txt
@@ -86,9 +86,10 @@ The text below was "clear signed" by 0xDFD20543 master key but tampered with:
     ... """
 
     >>> master_sig = gpghandler.getVerifiedSignature(content)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGVerificationError: (7, 8, u'Bad signature')
+    lp.services.gpg.interfaces.GPGVerificationError: (7, 8, u'Bad signature')
 
 If no signed content is found, an exception is raised:
 
@@ -101,9 +102,10 @@ If no signed content is found, an exception is raised:
     ... """
 
     >>> master_sig = gpghandler.getVerifiedSignature(content)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGVerificationError: No signatures found
+    lp.services.gpg.interfaces.GPGVerificationError: No signatures found
 
 
 The text below contains two clear signed sections.  As there are two
@@ -140,14 +142,16 @@ change. -- Guilherme Salgado, 2007-04-05
 (https://lists.ubuntu.com/mailman/private/launchpad/2007-April/015085.html)
 
     >>> master_sig = gpghandler.getVerifiedSignature(content)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGVerificationError...
+    lp.services.gpg.interfaces.GPGVerificationError: ...
 
 #     >>> master_sig = gpghandler.getVerifiedSignature(content)
+#     ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
 #     Traceback (most recent call last):
 #     ...
-#     GPGVerificationError: Single signature expected, found multiple signatures
+#     lp.services.gpg.interfaces.GPGVerificationError: Single signature expected, found multiple signatures
 
 The text below was signed by  key that's is not part of the
 imported keyring. Note that we have extra debug information containing
@@ -166,10 +170,10 @@ the GPGME error codes (they may be helpful).
     ... -----END PGP SIGNATURE-----
     ... """
     >>> gpghandler.getVerifiedSignature(content)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGKeyDoesNotExistOnServer: GPG key E192C0543B1BB2EB does not exist on the
-    keyserver.
+    lp.services.gpg.interfaces.GPGKeyDoesNotExistOnServer: GPG key E192C0543B1BB2EB does not exist on the keyserver.
 
 Due to unpredictable behaviour between the production system and
 the external keyserver, we have a resilient signature verifier,
@@ -180,12 +184,10 @@ exception. The exception raised by this method will contain debug
 information for the 3 failures.
 
     >>> gpghandler.getVerifiedSignatureResilient(content)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGVerificationError: Verification failed 3 times:
-    ['GPG key E192C0543B1BB2EB does not exist on the keyserver.',
-     'GPG key E192C0543B1BB2EB does not exist on the keyserver.',
-     'GPG key E192C0543B1BB2EB does not exist on the keyserver.']
+    lp.services.gpg.interfaces.GPGVerificationError: Verification failed 3 times: ['GPG key E192C0543B1BB2EB does not exist on the keyserver.', 'GPG key E192C0543B1BB2EB does not exist on the keyserver.', 'GPG key E192C0543B1BB2EB does not exist on the keyserver.']
 
 
 Debugging exceptions
diff --git a/lib/lp/services/gpg/doc/gpghandler.txt b/lib/lp/services/gpg/doc/gpghandler.txt
index d57bd75..0fabddf 100644
--- a/lib/lp/services/gpg/doc/gpghandler.txt
+++ b/lib/lp/services/gpg/doc/gpghandler.txt
@@ -39,16 +39,18 @@ unit tests somewhere else at some point. -- Guilherme Salgado, 2006-08-23
 A GPGKeyNotFoundError is raised if we try to import an empty content.
 
     >>> key = gpghandler.importPublicKey('')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGKeyNotFoundError...
+    lp.services.gpg.interfaces.GPGKeyNotFoundError: ...
 
 The same happens for bogus content.
 
     >>> key = gpghandler.importPublicKey('XXXXXXXXX')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGKeyNotFoundError: ...
+    lp.services.gpg.interfaces.GPGKeyNotFoundError: ...
 
 Let's recover some coherent data and verify if it works as expected:
 
@@ -93,9 +95,10 @@ SecretGPGKeyImportDetected exception to be raised.
     >>> filepath = os.path.join(gpgkeysdir, 'test@xxxxxxxxxxxxxxxxx')
     >>> seckey = open(filepath).read()
     >>> key = gpghandler.importPublicKey(seckey)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    SecretGPGKeyImportDetected: ...
+    lp.services.gpg.interfaces.SecretGPGKeyImportDetected: ...
 
 Now, try to import two public keys, causing a MoreThanOneGPGKeyFound
 exception to be raised.
@@ -103,17 +106,19 @@ exception to be raised.
     >>> filepath = os.path.join(gpgkeysdir, 'foo.bar@xxxxxxxxxxxxxxxxx')
     >>> pubkey2 = open(filepath).read()
     >>> key = gpghandler.importPublicKey('\n'.join([pubkey, pubkey2]))
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    MoreThanOneGPGKeyFound: ...
+    lp.services.gpg.interfaces.MoreThanOneGPGKeyFound: ...
 
 Raise a GPGKeyNotFoundError if we try to import a public key with damaged
 preamble.
 
     >>> key = gpghandler.importPublicKey(pubkey[1:])
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGKeyNotFoundError: ...
+    lp.services.gpg.interfaces.GPGKeyNotFoundError: ...
 
 Apparently GPGME is able to import an incomplete public key:
 
@@ -128,9 +133,10 @@ But we get an error if the damage is big:
 (what probably happened in bug #2547)
 
     >>> key = gpghandler.importPublicKey(pubkey[:-500])
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGKeyNotFoundError: ...
+    lp.services.gpg.interfaces.GPGKeyNotFoundError: ...
 
 
 == Importing secret OpenPGP keys ==
@@ -209,10 +215,10 @@ hit the keyserver and import it automatically.
 An attempt to upload an unknown key will fail.
 
     >>> gpghandler.uploadPublicKey('F' * 40)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGKeyDoesNotExistOnServer: GPG key
-    FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF does not exist on the keyserver.
+    lp.services.gpg.interfaces.GPGKeyDoesNotExistOnServer: GPG key FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF does not exist on the keyserver.
 
 Uploading the same key more than once is fine, it is handled on the
 keyserver side.
@@ -224,10 +230,10 @@ in a error.
 
     >>> tac.tearDown()
     >>> gpghandler.uploadPublicKey(new_key.fingerprint)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGUploadFailure: Could not reach keyserver at
-    http://localhost:11371...Connection refused...
+    lp.services.gpg.interfaces.GPGUploadFailure: Could not reach keyserver at http://localhost:11371...Connection refused...
 
 
 == Fingerprint sanitizing ==
diff --git a/lib/lp/services/helpers.py b/lib/lp/services/helpers.py
index ca2f9ac..b102871 100644
--- a/lib/lp/services/helpers.py
+++ b/lib/lp/services/helpers.py
@@ -135,9 +135,10 @@ def shortlist(sequence, longest_expected=15, hardlimit=None):
     sequences with no more than 2 items.  There were 3 items.
 
     >>> shortlist([1, 2, 3, 4], hardlimit=2)
+    ... # doctest: +NORMALIZE_WHITESPACE,+IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    ShortListTooBigError: Hard limit of 2 exceeded.
+    lp.services.helpers.ShortListTooBigError: Hard limit of 2 exceeded.
 
     >>> shortlist(
     ...     [1, 2, 3, 4], 2, hardlimit=4) #doctest: +NORMALIZE_WHITESPACE
@@ -153,10 +154,11 @@ def shortlist(sequence, longest_expected=15, hardlimit=None):
     ...
     TypeError: ...
 
-    >>> shortlist(iter(range(10)), 5, hardlimit=8) #doctest: +ELLIPSIS
+    >>> shortlist(iter(range(10)), 5, hardlimit=8)
+    ... # doctest: +ELLIPSIS,+IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    ShortListTooBigError: ...
+    lp.services.helpers.ShortListTooBigError: ...
 
     """
     if hardlimit is not None:
diff --git a/lib/lp/services/identity/doc/emailaddress.txt b/lib/lp/services/identity/doc/emailaddress.txt
index 3ddd590..5d8d9d6 100644
--- a/lib/lp/services/identity/doc/emailaddress.txt
+++ b/lib/lp/services/identity/doc/emailaddress.txt
@@ -36,16 +36,18 @@ exception.
     >>> personset = getUtility(IPersonSet)
     >>> foobar = personset.getByName('name16')
     >>> emailset.new(email.email, foobar)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    EmailAddressAlreadyTaken: The email address '...' is already registered.
+    lp.services.identity.interfaces.emailaddress.EmailAddressAlreadyTaken: The email address '...' is already registered.
 
 The email address verification is case insensitive as well:
 
     >>> emailset.new(email.email.upper(), foobar)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    EmailAddressAlreadyTaken: The email address '...' is already registered.
+    lp.services.identity.interfaces.emailaddress.EmailAddressAlreadyTaken: The email address '...' is already registered.
 
 Registering a new email address works -- and preserves case -- though:
 
@@ -87,16 +89,17 @@ or the address of a team's mailing list.
 Otherwise, UndeletableEmailAddress is raised.
 
     >>> foobar.preferredemail.destroySelf()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    UndeletableEmailAddress: This is a person's preferred email...
+    lp.services.identity.model.emailaddress.UndeletableEmailAddress: This is a person's preferred email...
 
     >>> from lp.registry.tests.mailinglists_helper import (
     ...     new_list_for_team)
     >>> mailing_list = new_list_for_team(guadamen)
     >>> email = emailset.getByEmail(guadamen.mailing_list.address)
     >>> email.destroySelf()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    UndeletableEmailAddress: This is the email address of a team's mailing
-    list...
+    lp.services.identity.model.emailaddress.UndeletableEmailAddress: This is the email address of a team's mailing list...
diff --git a/lib/lp/services/librarian/doc/librarian.txt b/lib/lp/services/librarian/doc/librarian.txt
index 5f64a74..7c8b66c 100644
--- a/lib/lp/services/librarian/doc/librarian.txt
+++ b/lib/lp/services/librarian/doc/librarian.txt
@@ -364,14 +364,16 @@ If you try to retrieve this file through the standard ILibrarianClient,
 you'll get a DownloadFailed error.
 
     >>> client.getFileByAlias(private_file_id)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    DownloadFailed: Alias ... cannot be downloaded from this client.
+    lp.services.librarian.interfaces.client.DownloadFailed: Alias ... cannot be downloaded from this client.
 
     >>> client.getURLForAlias(private_file_id)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    DownloadFailed: Alias ... cannot be downloaded from this client.
+    lp.services.librarian.interfaces.client.DownloadFailed: Alias ... cannot be downloaded from this client.
 
 But using the restricted librarian will work:
 
@@ -389,9 +391,10 @@ Trying to access that file directly on the normal librarian will fail
     ...     config.librarian.restricted_download_url,
     ...     config.librarian.download_url)
     >>> urlopen(sneaky_url).read()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    HTTPError: HTTP Error 404: Not Found
+    urllib.error.HTTPError: HTTP Error 404: Not Found
 
 But downloading it from the restricted host, will work.
 
@@ -412,14 +415,16 @@ also fails:
     >>> transaction.commit()
 
     >>> restricted_client.getURLForAlias(public_file_id)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    DownloadFailed: ...
+    lp.services.librarian.interfaces.client.DownloadFailed: ...
 
     >>> restricted_client.getFileByAlias(public_file_id)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    DownloadFailed: ...
+    lp.services.librarian.interfaces.client.DownloadFailed: ...
 
 The remoteAddFile() on the restricted client, also creates a restricted
 file:
@@ -476,9 +481,10 @@ An UploadFailed will be raised if you try to create a file with no
 content
 
     >>> client.addFile('test.txt', 0, io.BytesIO(b'hello'), 'text/plain')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
         [...]
-    UploadFailed: Invalid length: 0
+    lp.services.librarian.interfaces.client.UploadFailed: Invalid length: 0
 
 If you really want a zero length file you can do it:
 
@@ -631,9 +637,10 @@ The count starts at 0, and cannot be changed directly.
     0
 
     >>> public_file.hits = 10
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    ForbiddenAttribute: ...
+    zope.security.interfaces.ForbiddenAttribute: ...
 
 To change that, we have to use the updateDownloadCount() method, which
 takes care of creating/updating the necessary LibraryFileDownloadCount
diff --git a/lib/lp/services/mail/doc/emailauthentication.txt b/lib/lp/services/mail/doc/emailauthentication.txt
index fc37e8a..923fb15 100644
--- a/lib/lp/services/mail/doc/emailauthentication.txt
+++ b/lib/lp/services/mail/doc/emailauthentication.txt
@@ -99,9 +99,10 @@ InvalidSignature will be raised:
     >>> name, addr = email.utils.parseaddr(msg['From'])
     >>> from_user = getUtility(IPersonSet).getByEmail(addr)
     >>> principal = authenticateEmail(msg, accept_any_timestamp)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    InvalidSignature:...
+    lp.services.mail.incoming.InvalidSignature: ...
 
 Before the signature is verified, the signed text's line endings should
 be canonicalised to \r\n. In order to ensure that the line endings in
@@ -122,9 +123,10 @@ message.
     >>> from lp.services.gpg.interfaces import IGPGHandler
     >>> getUtility(IGPGHandler).getVerifiedSignature(
     ...     msg.signedContent, msg.signature)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    GPGVerificationError: (7, 8, u'Bad signature')
+    lp.services.gpg.interfaces.GPGVerificationError: (7, 8, u'Bad signature')
 
     >>> getUtility(IGPGHandler).getVerifiedSignature(
     ...     msg.signedContent.replace(b'\n', b'\r\n'), msg.signature)
diff --git a/lib/lp/services/mail/doc/mailbox.txt b/lib/lp/services/mail/doc/mailbox.txt
index 4da62d6..c3274cf 100644
--- a/lib/lp/services/mail/doc/mailbox.txt
+++ b/lib/lp/services/mail/doc/mailbox.txt
@@ -62,10 +62,10 @@ Before we can use it, it has to be opened.
 To prevent two threads opening it the same time, if it's already open,
 we can't open it again:
 
-    >>> mailbox.open()
+    >>> mailbox.open()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    MailBoxError: The mail box is already open.
+    lp.services.mail.mailbox.MailBoxError: The mail box is already open.
 
 There's only one mail in the mail box, and it's the same as we sent
 before:
@@ -103,9 +103,9 @@ Let's delete all mails in the mail box:
 
 If we try to delete a mail that doesn't exist we get an error:
 
-    >>> mailbox.delete(-1)
+    >>> mailbox.delete(-1)  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    MailBoxError: No such id: -1
+    lp.services.mail.mailbox.MailBoxError: No such id: -1
 
     >>> mailbox.close()
diff --git a/lib/lp/services/mail/doc/notification-recipient-set.txt b/lib/lp/services/mail/doc/notification-recipient-set.txt
index 7dea670..a1030ee 100644
--- a/lib/lp/services/mail/doc/notification-recipient-set.txt
+++ b/lib/lp/services/mail/doc/notification-recipient-set.txt
@@ -114,14 +114,16 @@ Requesting the reason for somebody that is not a recipient raises a
 UnknownRecipientError:
 
     >>> recipients.getReason(no_priv)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    UnknownRecipientError: ...
+    lp.services.mail.interfaces.UnknownRecipientError: ...
 
     >>> recipients.getReason(six.ensure_str('no-priv@xxxxxxxxxxxxx'))
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    UnknownRecipientError: 'no-priv@xxxxxxxxxxxxx'
+    lp.services.mail.interfaces.UnknownRecipientError: 'no-priv@xxxxxxxxxxxxx'
 
 Passing something else than an IPerson or a string is forbidden:
 
diff --git a/lib/lp/services/mail/tests/incomingmail.txt b/lib/lp/services/mail/tests/incomingmail.txt
index f9fb29f..c42ef12 100644
--- a/lib/lp/services/mail/tests/incomingmail.txt
+++ b/lib/lp/services/mail/tests/incomingmail.txt
@@ -298,10 +298,10 @@ reporting in the web interface, are not ignored in the email interface.
     ... doesn't matter
     ... """)
     >>> msgid = sendmail(msg, ['edit@malone-domain'])
-    >>> handleMailForTest()
+    >>> handleMailForTest()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     ERROR:process-mail:An exception was raised inside the handler:
     ...
-    Unauthorized
+    twisted.cred.error.Unauthorized
 
     >>> print(str(pop_notifications()[-1]))
     From ...
@@ -401,10 +401,10 @@ logged.
     >>> len(stub.test_emails)
     2
 
-    >>> handleMailForTest()
+    >>> handleMailForTest()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     ERROR:...:Upload to Librarian failed...
     ...
-    UploadFailed: ...Connection refused...
+    lp.services.librarian.interfaces.client.UploadFailed: ...Connection refused...
 
     >>> len(stub.test_emails)
     2
diff --git a/lib/lp/services/messages/doc/message.txt b/lib/lp/services/messages/doc/message.txt
index 1bd8b0d..3113703 100644
--- a/lib/lp/services/messages/doc/message.txt
+++ b/lib/lp/services/messages/doc/message.txt
@@ -385,9 +385,10 @@ explicitly told to do so:
     ...
     ... Foo Bar
     ... ''')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
         [...]
-    UnknownSender: u'invalid@xxxxxxxxxxx'
+    lp.services.messages.interfaces.message.UnknownSender: u'invalid@xxxxxxxxxxx'
 
     >>> msg = msgset.fromEmail(b'''\
     ... From: invalid@xxxxxxxxxxx
@@ -418,9 +419,10 @@ passed through a broken MTA and we have no choice but to bounce them.
     ...
     ... Moo
     ... ''')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
         [...]
-    InvalidEmailMessage: Missing Message-Id
+    lp.services.messages.interfaces.message.InvalidEmailMessage: Missing Message-Id
 
     >>> msg = msgset.fromEmail(b'''\
     ... Date: Fri, 17 Jun 2005 10:45:13 +0100
@@ -429,9 +431,10 @@ passed through a broken MTA and we have no choice but to bounce them.
     ...
     ... Moo
     ... ''')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
         [...]
-    InvalidEmailMessage: No From: or Reply-To: header
+    lp.services.messages.interfaces.message.InvalidEmailMessage: No From: or Reply-To: header
 
 Also, we generally insist that a message has a date associated with it.
 
@@ -442,9 +445,10 @@ Also, we generally insist that a message has a date associated with it.
     ...
     ... Moo
     ... ''')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
         [...]
-    InvalidEmailMessage: Invalid date...
+    lp.services.messages.interfaces.message.InvalidEmailMessage: Invalid date...
 
 However, we can override this behaviour by passing a date_created
 parameter to fromEmail(). This is optional, and defaults to None, but it
diff --git a/lib/lp/services/oauth/doc/oauth-pages.txt b/lib/lp/services/oauth/doc/oauth-pages.txt
index 858a4ec..8c29dc9 100644
--- a/lib/lp/services/oauth/doc/oauth-pages.txt
+++ b/lib/lp/services/oauth/doc/oauth-pages.txt
@@ -154,14 +154,16 @@ package, it must specify the distribution and the package's name.
 An error is raised if the context is not found.
 
     >>> view, token = get_view_with_fresh_token({'lp.context': 'fooooo'})
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    UnexpectedFormData: ...
+    lp.app.errors.UnexpectedFormData: ...
 
 Or if the user gives us a package in a non-existing distribution.
 
     >>> view, token = get_view_with_fresh_token(
     ...     {'lp.context': 'firefox/evolution'})
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    UnexpectedFormData: ...
+    lp.app.errors.UnexpectedFormData: ...
diff --git a/lib/lp/services/oauth/stories/access-token.txt b/lib/lp/services/oauth/stories/access-token.txt
index e1e8ed7..1d3c448 100644
--- a/lib/lp/services/oauth/stories/access-token.txt
+++ b/lib/lp/services/oauth/stories/access-token.txt
@@ -36,9 +36,10 @@ will fail because request tokens can be used only once.
 
     >>> anon_browser.open(
     ...     'http://launchpad.test/+access-token', data=urlencode(data))
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    HTTPError: HTTP Error 401: Unauthorized
+    urllib.error.HTTPError: HTTP Error 401: Unauthorized
 
 The token's context, when not None, is sent to the consumer together
 with the token's key and secret.
diff --git a/lib/lp/services/oauth/stories/authorize-token.txt b/lib/lp/services/oauth/stories/authorize-token.txt
index 1ad5c47..e217b8a 100644
--- a/lib/lp/services/oauth/stories/authorize-token.txt
+++ b/lib/lp/services/oauth/stories/authorize-token.txt
@@ -36,9 +36,10 @@ it involves OpenID, which would complicate this test quite a bit.)
     ...     oauth_token=token.key, oauth_callback='http://launchpad.test/bzr')
     >>> url = "http://launchpad.test/+authorize-token?%s"; % urlencode(params)
     >>> browser.open(url)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized:...
+    zope.security.interfaces.Unauthorized: ...
 
     >>> browser = setupBrowser(auth='Basic no-priv@xxxxxxxxxxxxx:test')
     >>> browser.open(url)
@@ -125,11 +126,10 @@ integration has its own section, below.)
     >>> browser.open(
     ...     "http://launchpad.test/+authorize-token?%s&%s";
     ...         % (urlencode(params), allow_permission))
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: Consumer "foobar123451432" asked for desktop
-    integration, but didn't say what kind of desktop it is, or name
-    the computer being integrated.
+    zope.security.interfaces.Unauthorized: Consumer "foobar123451432" asked for desktop integration, but didn't say what kind of desktop it is, or name the computer being integrated.
 
 An application may also specify a context, so that the access granted
 by the user is restricted to things related to that context.
@@ -365,17 +365,17 @@ DESKTOP_INTEGRATION, since the whole point is to have a permission
 level that specifically applies across the entire desktop.
 
     >>> print_access_levels_for('allow_permission=WRITE_PRIVATE')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: Desktop integration token requested a permission
-    ("Change Anything") not supported for desktop-wide use.
+    zope.security.interfaces.Unauthorized: Desktop integration token requested a permission ("Change Anything") not supported for desktop-wide use.
 
     >>> print_access_levels_for(
     ...     'allow_permission=WRITE_PUBLIC&' + allow_desktop)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: Desktop integration token requested a permission
-    ("Change Non-Private Data") not supported for desktop-wide use.
+    zope.security.interfaces.Unauthorized: Desktop integration token requested a permission ("Change Non-Private Data") not supported for desktop-wide use.
 
 You can't specify a callback URL when authorizing a desktop-wide
 token, since callback URLs should only be used when integrating
@@ -383,19 +383,19 @@ websites into Launchpad.
 
     >>> params['oauth_callback'] = 'http://launchpad.test/bzr'
     >>> print_access_levels_for(allow_desktop)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: A desktop integration may not specify an OAuth
-    callback URL.
+    zope.security.interfaces.Unauthorized: A desktop integration may not specify an OAuth callback URL.
 
 This is true even if the desktop token isn't asking for the
 DESKTOP_INTEGRATION permission.
 
     >>> print_access_levels_for('allow_permission=WRITE_PRIVATE')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: A desktop integration may not specify an OAuth
-    callback URL.
+    zope.security.interfaces.Unauthorized: A desktop integration may not specify an OAuth callback URL.
 
     >>> del params['oauth_callback']
 
diff --git a/lib/lp/services/oauth/stories/managing-tokens.txt b/lib/lp/services/oauth/stories/managing-tokens.txt
index d0fe794..a75ad04 100644
--- a/lib/lp/services/oauth/stories/managing-tokens.txt
+++ b/lib/lp/services/oauth/stories/managing-tokens.txt
@@ -130,6 +130,7 @@ That page is protected with the launchpad.Edit permission, for obvious
 reasons, so users can only access their own.
 
     >>> user_browser.open('http://launchpad.test/~salgado/+oauth-tokens')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: ...launchpad.Edit...
+    zope.security.interfaces.Unauthorized: ...launchpad.Edit...
diff --git a/lib/lp/services/temporaryblobstorage/doc/temporaryblobstorage.txt b/lib/lp/services/temporaryblobstorage/doc/temporaryblobstorage.txt
index 463966f..d8b67fd 100644
--- a/lib/lp/services/temporaryblobstorage/doc/temporaryblobstorage.txt
+++ b/lib/lp/services/temporaryblobstorage/doc/temporaryblobstorage.txt
@@ -52,9 +52,10 @@ Size limits can be enforced, although this is turned off by default:
     ...     """
     >>> config.push('max_blob_size', max_blob_size)
     >>> uuid = tsm.new(data)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    BlobTooLarge: 7
+    lp.services.temporaryblobstorage.interfaces.BlobTooLarge: 7
     >>> config_data = config.pop('max_blob_size')
 
 
diff --git a/lib/lp/services/tests/test_helpers.py b/lib/lp/services/tests/test_helpers.py
index ff3d68e..4a1aa84 100644
--- a/lib/lp/services/tests/test_helpers.py
+++ b/lib/lp/services/tests/test_helpers.py
@@ -5,6 +5,8 @@ from doctest import DocTestSuite
 from textwrap import dedent
 import unittest
 
+from zope.testing.renormalizing import OutputChecker
+
 from lp.services import helpers
 from lp.services.tarfile_helpers import LaunchpadWriteTarFile
 
@@ -190,7 +192,7 @@ class TruncateTextTest(unittest.TestCase):
 def test_suite():
     suite = unittest.TestSuite()
     suite.addTest(DocTestSuite())
-    suite.addTest(DocTestSuite(helpers))
+    suite.addTest(DocTestSuite(helpers, checker=OutputChecker()))
     suite.addTest(
         unittest.TestLoader().loadTestsFromTestCase(TruncateTextTest))
     return suite
diff --git a/lib/lp/services/webapp/doc/canonical_url.txt b/lib/lp/services/webapp/doc/canonical_url.txt
index 1082d4b..ba277c9 100644
--- a/lib/lp/services/webapp/doc/canonical_url.txt
+++ b/lib/lp/services/webapp/doc/canonical_url.txt
@@ -110,10 +110,10 @@ Configure a browser:url for ITown.  Our first attempt fails because we mistyped
     ...       />
     ... </configure>
     ... """.format(module_name=module.__name__))
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    ZopeXMLConfigurationError: File "<string>", line ...
-        AttributeError: The name "countryOopsTypo" is not in ....ITown
+    zope.configuration.xmlconfig.ZopeXMLConfigurationError: File "<string>", line ... AttributeError: The name "countryOopsTypo" is not in ....ITown
 
     >>> zcmlcontext = xmlconfig.string("""
     ... <configure xmlns:browser="http://namespaces.zope.org/browser";>
@@ -271,9 +271,10 @@ itself be adapted to ICanonicalUrlData.
 
     >>> object_that_has_no_url = object()
     >>> canonical_url(object_that_has_no_url)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NoCanonicalUrl: No url for <...object at ...> because <...object at ...> broke the chain.
+    lp.services.webapp.interfaces.NoCanonicalUrl: No url for <...object at ...> because <...object at ...> broke the chain.
 
 Now, we must test the case where the object can be adapted to
 ICanonicalUrlData, but its parent or its parent's parent (and so on) cannot.
@@ -285,9 +286,10 @@ ICanonicalUrlData, but its parent or its parent's parent (and so on) cannot.
     ...         self.inside = parent
     >>> unrooted_object = ObjectThatHasUrl('unrooted', object_that_has_no_url)
     >>> canonical_url(unrooted_object)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NoCanonicalUrl: No url for <...ObjectThatHasUrl...> because <...object...> broke the chain.
+    lp.services.webapp.interfaces.NoCanonicalUrl: No url for <...ObjectThatHasUrl...> because <...object...> broke the chain.
 
 The first argument to NoCanonicalUrl is the object that a canonical url was
 requested for.  The second argument is the object that broke the chain.
@@ -329,20 +331,20 @@ to work properly.
     >>> iterator = canonical_url_iterator(object_that_has_no_url)
     >>> next(iterator).__class__.__name__
     'object'
-    >>> next(iterator)
+    >>> next(iterator)  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NoCanonicalUrl: No url for <...object...> because <...object...> broke the chain.
+    lp.services.webapp.interfaces.NoCanonicalUrl: No url for <...object...> because <...object...> broke the chain.
 
     >>> iterator = canonical_url_iterator(unrooted_object)
     >>> next(iterator).__class__.__name__
     'ObjectThatHasUrl'
     >>> next(iterator).__class__.__name__
     'object'
-    >>> next(iterator)
+    >>> next(iterator)  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NoCanonicalUrl: No url for <...ObjectThatHasUrl...> because <...object...> broke the chain.
+    lp.services.webapp.interfaces.NoCanonicalUrl: No url for <...ObjectThatHasUrl...> because <...object...> broke the chain.
 
 
 == canonical_url and requests ==
diff --git a/lib/lp/services/webapp/doc/menus.txt b/lib/lp/services/webapp/doc/menus.txt
index d2c1910..0803475 100644
--- a/lib/lp/services/webapp/doc/menus.txt
+++ b/lib/lp/services/webapp/doc/menus.txt
@@ -809,9 +809,10 @@ We also report when the selected facet does not exist with a
 LocationError exception:
 
     >>> test_tales('view/menu:broken/bar', view=view)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LocationError: ..., 'broken')
+    zope.location.interfaces.LocationError: ..., 'broken')
 
 We can also get a context menu as menu:context.  It makes no difference
 whether the TALES code is view/menu:context or context/menu:context,
@@ -844,9 +845,10 @@ When there is no menu for a thing, we get an empty iterator.
 And thus, we don't have a facet to navigate to:
 
     >>> test_tales('view/menu:foo/+first', view=view)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    LocationError: ..., 'foo')
+    zope.location.interfaces.LocationError: ..., 'foo')
 
     >>> view = LaunchpadView(house, request)
     >>> view.__launchpad_facetname__ = 'bar'
diff --git a/lib/lp/services/webapp/doc/navigation.txt b/lib/lp/services/webapp/doc/navigation.txt
index abb4133..9abe14f 100644
--- a/lib/lp/services/webapp/doc/navigation.txt
+++ b/lib/lp/services/webapp/doc/navigation.txt
@@ -220,9 +220,10 @@ The name doesn't begin with a 't', so it isn't found.
     >>> INewLayer.providedBy(request)
     False
     >>> navigation.publishTraverse(request, 'xxx')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...ThingSet...name: 'xxx'
+    zope.publisher.interfaces.NotFound: ...ThingSet...name: 'xxx'
 
 Note that the request has been put onto the INewLayer layer.
 
@@ -332,13 +333,15 @@ Let's create a subclass of ThingSetNavigation, and add a 'stepto'.
     >>> navigation2.publishTraverse(request, 'thistle')
     'A little thistle'
     >>> navigation2.publishTraverse(request, 'neverthere')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...ThingSet..., name: 'neverthere'
+    zope.publisher.interfaces.NotFound: ...ThingSet..., name: 'neverthere'
     >>> navigation2.publishTraverse(request, 'neverthere2')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...ThingSet..., name: 'neverthere2'
+    zope.publisher.interfaces.NotFound: ...ThingSet..., name: 'neverthere2'
 
 
 == stepthrough traversals ==
@@ -381,15 +384,17 @@ Let's create another subclass and add a stepthrough.
     >>> request.traversal_stack = ['prince', 'charming']
     >>> navigation3 = ThingSetNavigation3(thingset, request)
     >>> navigation3.publishTraverse(request, 'neverland')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...ThingSet..., name: 'charming'
+    zope.publisher.interfaces.NotFound: ...ThingSet..., name: 'charming'
     >>> request.traversal_stack = ['prince', 'charming']
     >>> navigation3 = ThingSetNavigation3(thingset, request)
     >>> navigation3.publishTraverse(request, 'neverland2')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    NotFound: ...ThingSet..., name: 'charming'
+    zope.publisher.interfaces.NotFound: ...ThingSet..., name: 'charming'
 
 
 Check that the request's state is as it should be.
diff --git a/lib/lp/services/webapp/doc/renamed-view.txt b/lib/lp/services/webapp/doc/renamed-view.txt
index 08e9287..fafb350 100644
--- a/lib/lp/services/webapp/doc/renamed-view.txt
+++ b/lib/lp/services/webapp/doc/renamed-view.txt
@@ -87,9 +87,10 @@ raise an error. e.g. http://launchpad.test/ubuntu/+tickets/foo
     >>> request = LaunchpadTestRequest()
     >>> view = RenamedView(ubuntu, request, '+tickets')
     >>> view.publishTraverse(request, u'foo')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
      ...
-    NotFound: Object: <Distribution 'Ubuntu' (ubuntu)>, name: u'foo'
+    zope.publisher.interfaces.NotFound: Object: <Distribution 'Ubuntu' (ubuntu)>, name: u'foo'
 
 
 == Registering from ZCML ==
diff --git a/lib/lp/services/webapp/doc/test_adapter.txt b/lib/lp/services/webapp/doc/test_adapter.txt
index 656e4b1..2cb559d 100644
--- a/lib/lp/services/webapp/doc/test_adapter.txt
+++ b/lib/lp/services/webapp/doc/test_adapter.txt
@@ -212,12 +212,10 @@ timeout by sleeping for 200ms with a 100ms statement timeout:
     >>> print current_statement_timeout(store)
     100ms
     >>> store.execute('SELECT pg_sleep(0.200)', noresult=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    LaunchpadTimeoutError: Statement: 'SELECT pg_sleep(0.200)'
-    Parameters:()
-    Original error: QueryCanceledError('canceling statement due to
-    statement timeout\n',)
+    lp.services.webapp.adapter.LaunchpadTimeoutError: Statement: 'SELECT pg_sleep(0.200)' Parameters:() Original error: QueryCanceledError('canceling statement due to statement timeout\n',)
 
 Even though the statement timed out, it is recorded in the statement log:
 
@@ -285,12 +283,10 @@ This final invokation, we will actually sleep to ensure that the
 timeout being reported by PostgreSQL is actually working:
 
     >>> store.execute('SELECT pg_sleep(0.2)', noresult=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    LaunchpadTimeoutError: Statement: 'SELECT pg_sleep(0.2)'
-    Parameters:()
-    Original error: QueryCanceledError('canceling statement due to
-    statement timeout\n',)
+    lp.services.webapp.adapter.LaunchpadTimeoutError: Statement: 'SELECT pg_sleep(0.2)' Parameters:() Original error: QueryCanceledError('canceling statement due to statement timeout\n',)
     >>> clear_request_started()
 
 
@@ -346,9 +342,10 @@ another query:
 
     >>> set_request_started(time.time() - 60)
     >>> store.execute('SELECT 2', noresult=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    RequestExpired: request expired.
+    lp.services.webapp.adapter.RequestExpired: request expired.
 
 The statement about to be executed is not recorded in the statement log.
 The request time limit was exceeded before the statement was issued to
@@ -365,10 +362,10 @@ transaction will be doomed:
     >>> transaction.get().isDoomed()
     True
     >>> transaction.commit()
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    DoomedTransaction: transaction doomed, cannot commit
-    <BLANKLINE>
+    transaction.interfaces.DoomedTransaction: transaction doomed, cannot commit
 
 Cleanup:
 
@@ -409,9 +406,10 @@ remove the timout:
     >>> thread.start()
     >>> _ = started_request.wait()
     >>> store.execute('SELECT 1', noresult=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    RequestExpired: request expired.
+    lp.services.webapp.adapter.RequestExpired: request expired.
     >>> statement_issued.set()
     >>> thread.join()
     >>> clear_request_started()
@@ -449,10 +447,10 @@ config section.  By default we connect as "launchpad"
     >>> store.execute("""
     ...     INSERT INTO SourcePackageName(name) VALUES ('fnord4')
     ...     """, noresult=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    ProgrammingError: permission denied for relation sourcepackagename
-    <BLANKLINE>
+    storm.database.ProgrammingError: permission denied for relation sourcepackagename
 
 This is not reset at the end of the transaction:
 
@@ -462,10 +460,10 @@ This is not reset at the end of the transaction:
     >>> store.execute("""
     ...     INSERT INTO SourcePackageName(name) VALUES ('fnord4')
     ...     """, noresult=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    ProgrammingError: permission denied for relation sourcepackagename
-    <BLANKLINE>
+    storm.database.ProgrammingError: permission denied for relation sourcepackagename
     >>> transaction.abort()
 
 So you need to explicitly set the user back to the default:
diff --git a/lib/lp/services/webapp/doc/test_adapter_permissions.txt b/lib/lp/services/webapp/doc/test_adapter_permissions.txt
index 1751c38..aaf56ec 100644
--- a/lib/lp/services/webapp/doc/test_adapter_permissions.txt
+++ b/lib/lp/services/webapp/doc/test_adapter_permissions.txt
@@ -20,20 +20,20 @@ If a SLAVE_FLAVOR store is requested, it should trap all writes.
     >>> main_slave = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
     >>> janitor = main_slave.find(Person, name='janitor').one()
     >>> janitor.display_name = 'Ben Dover'
-    >>> transaction.commit()
+    >>> transaction.commit()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    InternalError: ...
+    storm.database.InternalError: ...
 
 Test this once more to ensure the settings stick across transactions.
 
     >>> transaction.abort()
     >>> t = transaction.begin()
     >>> main_slave.find(Person, name='janitor').one().display_name = 'BenD'
-    >>> transaction.commit()
+    >>> transaction.commit()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    InternalError: ...
+    storm.database.InternalError: ...
 
 If a MASTER_FLAVOR is requested, it should allow writes to table in that
 Store's replication set.
diff --git a/lib/lp/services/webapp/doc/test_adapter_timeout.txt.disabled b/lib/lp/services/webapp/doc/test_adapter_timeout.txt.disabled
index 5940282..b2fe604 100644
--- a/lib/lp/services/webapp/doc/test_adapter_timeout.txt.disabled
+++ b/lib/lp/services/webapp/doc/test_adapter_timeout.txt.disabled
@@ -84,9 +84,10 @@ Now we actually demonstrate the behaviour.  The view did raise a TimeoutError.
 
     >>> browser = setupBrowser()
     >>> browser.open('http://launchpad.test/doom_test')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    TimeoutError...
+    lp.services.timeout.TimeoutError: ...
 
 The exception view did not.
 
diff --git a/lib/lp/services/webapp/doc/timeout.txt b/lib/lp/services/webapp/doc/timeout.txt
index 3387d72..9a80f1d 100644
--- a/lib/lp/services/webapp/doc/timeout.txt
+++ b/lib/lp/services/webapp/doc/timeout.txt
@@ -49,10 +49,10 @@ If the timeout is already expired, a RequestExpired error is raised:
     >>> from lp.services.webapp.adapter import clear_request_started
     >>> clear_request_started()
     >>> adapter.set_request_started(time.time()-12)
-    >>> timeout_func()
+    >>> timeout_func()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    RequestExpired: request expired.
+    lp.services.webapp.adapter.RequestExpired: request expired.
 
 Same thing if a function decorated using @with_timeout is called.
 
@@ -60,10 +60,10 @@ Same thing if a function decorated using @with_timeout is called.
     >>> @with_timeout()
     ... def wait_a_little():
     ...     time.sleep(1)
-    >>> wait_a_little()
+    >>> wait_a_little()  # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    RequestExpired: request expired.
+    lp.services.webapp.adapter.RequestExpired: request expired.
 
 @with_timeout allows the actual timeout value to be specified, either as a
 numeric argument or a function argument returning the required value. Here we
diff --git a/lib/lp/services/webapp/doc/webapp-publication.txt b/lib/lp/services/webapp/doc/webapp-publication.txt
index 3df0f24..8be85b6 100644
--- a/lib/lp/services/webapp/doc/webapp-publication.txt
+++ b/lib/lp/services/webapp/doc/webapp-publication.txt
@@ -533,9 +533,10 @@ UnexpectedFormData is raised if more than one value was submitted for
 the field:
 
     >>> request.form_ng.getOne('items_field')
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    UnexpectedFormData:...
+    lp.app.errors.UnexpectedFormData: ...
 
 None is returned if the field wasn't submitted:
 
@@ -773,9 +774,10 @@ If that happens, though, we'll remove the
     ... except:
     ...     publication.handleException(
     ...         None, request, sys.exc_info(), retry_allowed=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Retry: foo
+    zope.publisher.interfaces.Retry: foo
 
     >>> 'launchpad.publicationduration' in request._orig_env
     False
@@ -801,9 +803,10 @@ If that happens, though, we'll remove the
     ...     exc_info = sys.exc_info()
     >>> publication.handleException(
     ...     None, request, exc_info, retry_allowed=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Retry: foo DisconnectionError
+    zope.publisher.interfaces.Retry: foo DisconnectionError
 
     >>> 'launchpad.publicationduration' in request._orig_env
     False
@@ -833,9 +836,10 @@ WSGI env.
     ... except:
     ...     publication.handleException(
     ...         None, request, sys.exc_info(), retry_allowed=True)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Retry: foo
+    zope.publisher.interfaces.Retry: foo
 
     >>> request._orig_env['launchpad.publicationduration']
     0.5
@@ -1161,9 +1165,10 @@ raised, though.
     >>> form2['oauth_nonce'] = '1764572616e48616d6d65724c61686'
     >>> test_request = LaunchpadTestRequest(form=form2)
     >>> publication.getPrincipal(test_request)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    TokenException: Expired token...
+    lp.services.oauth.interfaces.TokenException: Expired token...
 
     >>> access_token.date_expires = now + timedelta(days=1)
 
@@ -1172,9 +1177,10 @@ raised, though.
     >>> form2['oauth_nonce'] = '4572616e48616d6d65724c61686176'
     >>> test_request = LaunchpadTestRequest(form=form2)
     >>> publication.getPrincipal(test_request)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    TokenException: Unknown access token...
+    lp.services.oauth.interfaces.TokenException: Unknown access token...
 
 The consumer must be registered as well, and the signature must be
 correct.
@@ -1183,18 +1189,20 @@ correct.
     >>> form2['oauth_consumer_key'] += 'z'
     >>> test_request = LaunchpadTestRequest(form=form2)
     >>> publication.getPrincipal(test_request)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    Unauthorized: Unknown consumer (foobar123451432z).
+    zope.security.interfaces.Unauthorized: Unknown consumer (foobar123451432z).
 
     >>> form2 = form.copy()
     >>> form2['oauth_signature'] += 'z'
     >>> form2['oauth_nonce'] = '2616e48616d6d65724c61686176457'
     >>> test_request = LaunchpadTestRequest(form=form2)
     >>> publication.getPrincipal(test_request)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    TokenException: Invalid signature.
+    lp.services.oauth.interfaces.TokenException: Invalid signature.
 
 Close the bogus request that was started by the call to
 beforeTraversal, in order to ensure we leave our state sane.
diff --git a/lib/lp/services/webservice/doc/launchpadlib.txt b/lib/lp/services/webservice/doc/launchpadlib.txt
index c3c0cde..30e7142 100644
--- a/lib/lp/services/webservice/doc/launchpadlib.txt
+++ b/lib/lp/services/webservice/doc/launchpadlib.txt
@@ -14,9 +14,10 @@ if a specific user exists...
     '/'
 
     >>> browser.open('%s/~stimpy' % root_url)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
     ...
-    HTTPError: HTTP Error 404: Not Found
+    urllib.error.HTTPError: HTTP Error 404: Not Found
 
 ...and when they don't, create them.
 
diff --git a/lib/lp/services/webservice/stories/launchpadlib.txt b/lib/lp/services/webservice/stories/launchpadlib.txt
index 5a79f52..70c552c 100644
--- a/lib/lp/services/webservice/stories/launchpadlib.txt
+++ b/lib/lp/services/webservice/stories/launchpadlib.txt
@@ -79,9 +79,10 @@ But trying to access information that requires a logged in user
 results in an error.
 
     >>> print(lp_anon.me.name)
+    ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
     Traceback (most recent call last):
       ...
-    Unauthorized: HTTP Error 401: Unauthorized...
+    lazr.restfulclient.errors.Unauthorized: HTTP Error 401: Unauthorized...
 
 
 Caching