← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:app-future-imports into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:app-future-imports into launchpad:master with ~cjwatson/launchpad:app-future-imports-prepare as a prerequisite.

Commit message:
Convert lp.app doctests to preferred __future__ imports

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

These are essentially mechanical changes to convert print statements to print function calls, plus the associated doctest setup changes.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:app-future-imports into launchpad:master.
diff --git a/lib/lp/app/browser/doc/base-layout.txt b/lib/lp/app/browser/doc/base-layout.txt
index eb530d5..ddf10c1 100644
--- a/lib/lp/app/browser/doc/base-layout.txt
+++ b/lib/lp/app/browser/doc/base-layout.txt
@@ -38,7 +38,7 @@ main and side content are positioned using the "yui-t4", "yui-main",
 
     >>> view = MainSideView(user, request)
     >>> html = view.render()
-    >>> print html
+    >>> print(html)
     <!DOCTYPE html>
     ...
       <!--
@@ -62,7 +62,7 @@ content to take up all the horizontal space.
 
     >>> view = MainOnlyView(user, request)
     >>> html = view.render()
-    >>> print html
+    >>> print(html)
     <!DOCTYPE html>
     ...
       <!--
@@ -85,7 +85,7 @@ slots are rendered, as are the application tabs.
 
     >>> view = SearchlessView(user, request)
     >>> html = view.render()
-    >>> print html
+    >>> print(html)
     <!DOCTYPE html>
     ...
     ...
@@ -103,7 +103,7 @@ Page Diagnostics
 
 The page includes a comment after the body with diagnostic information.
 
-    >>> print html[html.index('</body>') + 7:]
+    >>> print(html[html.index('</body>') + 7:])
     ...
     <!--
       Facet name: overview
@@ -124,7 +124,7 @@ The example layouts all used the heading slot to define a heading for their
 test. The template controlled the heading.
 
     >>> content = find_tag_by_id(view.render(), 'maincontent')
-    >>> print content.h1
+    >>> print(content.h1)
     <h1>Heading</h1>
 
 
@@ -141,7 +141,7 @@ Page Footers
     >>> view = BugsMainSideView(user, bugs_request)
     >>> footer = find_tag_by_id(html, 'footer')
     >>> for tag in footer.findAll('a'):
-    ...     print tag.string, tag['href']
+    ...     print(tag.string, tag['href'])
     None http://launchpad.test/
     Take the tour http://launchpad.test/+tour
     Read the guide https://help.launchpad.net/
@@ -172,7 +172,7 @@ attribute.
     ...     visibility=PersonVisibility.PRIVATE)
     >>> view = MainOnlyView(team, request)
     >>> body = find_tag_by_id(view.render(), 'document')
-    >>> print ' '.join(body['class'])
+    >>> print(' '.join(body['class']))
     tab-overview
         main_only
         private
@@ -184,7 +184,7 @@ When the context is public, the 'public' class is in the class attribute.
     >>> team = factory.makeTeam(owner=user, name='a-public-team')
     >>> view = MainOnlyView(team, request)
     >>> body = find_tag_by_id(view.render(), 'document')
-    >>> print ' '.join(body['class'])
+    >>> print(' '.join(body['class']))
     tab-overview main_only public yui3-skin-sam
 
 
@@ -196,7 +196,7 @@ Notifications are displayed between the breadcrumbs and the page content.
     >>> request.response.addInfoNotification('I cannot do that Dave.')
     >>> view = MainOnlyView(user, request)
     >>> body_tag = find_tag_by_id(view.render(), 'maincontent')
-    >>> print str(body_tag)
+    >>> print(str(body_tag))
     <div ... id="maincontent">
       ...
       <div id="request-notifications">
@@ -224,5 +224,5 @@ headers.
     >>> request.response.addInfoNotification('I cannot do that Dave.')
     >>> view = FormView(user, request)
     >>> view.initialize()
-    >>> print request.response.getHeader('X-Lazr-Notifications')
+    >>> print(request.response.getHeader('X-Lazr-Notifications'))
     [[20, "I cannot do that Dave."]]
diff --git a/lib/lp/app/browser/doc/launchpad-search-pages.txt b/lib/lp/app/browser/doc/launchpad-search-pages.txt
index 49442e7..d3ff7f3 100644
--- a/lib/lp/app/browser/doc/launchpad-search-pages.txt
+++ b/lib/lp/app/browser/doc/launchpad-search-pages.txt
@@ -23,7 +23,7 @@ Page title and heading
 The page title and heading suggest to the user to search launchpad
 when there is no search text.
 
-    >>> print search_view.text
+    >>> print(search_view.text)
     None
     >>> search_view.page_title
     'Search Launchpad'
@@ -76,7 +76,7 @@ search action.
 
     >>> search_view = getSearchView(form={})
 
-    >>> print search_view.text
+    >>> print(search_view.text)
     None
     >>> search_view.has_matches
     False
@@ -112,7 +112,7 @@ more bugs than questions.
     True
     >>> search_view.bug.title
     u'Nonsensical bugs are useless'
-    >>> print search_view.question
+    >>> print(search_view.question)
     None
 
 Private bugs are not matched if the user does not have permission to
@@ -137,7 +137,7 @@ But anonymous and unprivileged users cannot see the private bug.
     >>> login(ANONYMOUS)
     >>> search_view = getSearchView(
     ...     form={'field.text': private_bug.id})
-    >>> print search_view.bug
+    >>> print(search_view.bug)
     None
 
 The text and punctuation in the search text is ignored, and only the
@@ -154,7 +154,7 @@ numbers do match questions, but they are not used.
     True
     >>> search_view.bug.title
     u'Nonsensical bugs are useless'
-    >>> print search_view.question
+    >>> print(search_view.question)
     None
 
 It is not an error to search for a non-existent bug or question.
@@ -165,23 +165,22 @@ It is not an error to search for a non-existent bug or question.
     u'55555'
     >>> search_view.has_matches
     False
-    >>> print search_view.bug
+    >>> print(search_view.bug)
     None
-    >>> print search_view.question
+    >>> print(search_view.question)
     None
 
 There is no error if a number cannot be extracted from the search text.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': 'fifteen'})
-    >>> print search_view._getNumericToken(
-    ...     search_view.text)
+    >>> print(search_view._getNumericToken(search_view.text))
     None
     >>> search_view.has_matches
     False
-    >>> print search_view.bug
+    >>> print(search_view.bug)
     None
-    >>> print search_view.question
+    >>> print(search_view.question)
     None
 
 Bugs and questions are only returned for the first page of search,
@@ -192,9 +191,9 @@ when the start param is 0.
     ...           'start': '20'})
     >>> search_view.has_matches
     False
-    >>> print search_view.bug
+    >>> print(search_view.bug)
     None
-    >>> print search_view.question
+    >>> print(search_view.question)
     None
 
 
@@ -229,7 +228,7 @@ with a hyphen.
     True
     >>> search_view.pillar.displayname
     u'GNOME Terminal'
-    >>> print search_view.person_or_team
+    >>> print(search_view.person_or_team)
     None
 
 Since our pillars can have aliases, it's also possible to look up a pillar
@@ -259,9 +258,9 @@ section for how this kind of search can return matches.
     u'yahoo-webservices-python-api.'
     >>> search_view.has_matches
     False
-    >>> print search_view.pillar
+    >>> print(search_view.pillar)
     None
-    >>> print search_view.person_or_team
+    >>> print(search_view.person_or_team)
     None
 
 Leading and trailing punctuation and whitespace are stripped.
@@ -272,7 +271,7 @@ Leading and trailing punctuation and whitespace are stripped.
     u'name12'
     >>> search_view.has_matches
     True
-    >>> print search_view.pillar
+    >>> print(search_view.pillar)
     None
     >>> search_view.person_or_team.displayname
     u'Sample Person'
@@ -285,11 +284,11 @@ search, when the start param is 0.
     ...           'start': '20'})
     >>> search_view.has_matches
     True
-    >>> print search_view.bug
+    >>> print(search_view.bug)
     None
-    >>> print search_view.question
+    >>> print(search_view.question)
     None
-    >>> print search_view.pillar
+    >>> print(search_view.pillar)
     None
 
 Deactivated pillars and non-valid persons and teams cannot be exact
@@ -307,7 +306,7 @@ pillar, nor will nsv match Nicolas Velin's unclaimed account.
     ...           'start': '0'})
     >>> search_view._getNameToken(search_view.text)
     u'python-gnome2-dev'
-    >>> print search_view.pillar
+    >>> print(search_view.pillar)
     None
 
     >>> nsv = getUtility(IPersonSet).getByName('nsv')
@@ -321,7 +320,7 @@ pillar, nor will nsv match Nicolas Velin's unclaimed account.
     ...           'start': '0'})
     >>> search_view._getNameToken(search_view.text)
     u'nsv'
-    >>> print search_view.person_or_team
+    >>> print(search_view.person_or_team)
     None
 
 Private pillars are not matched if the user does not have permission to see
@@ -344,7 +343,7 @@ But anonymous and unprivileged users cannot see the private project.
 
     >>> login(ANONYMOUS)
     >>> search_view = getSearchView(form={'field.text': private_product_name})
-    >>> print search_view.pillar
+    >>> print(search_view.pillar)
     None
 
 
@@ -570,7 +569,7 @@ matching pages.
 The view provides the requested URL so that the template can make a
 link to try the search again
 
-    >>> print search_view.url
+    >>> print(search_view.url)
     https://launchpad.test/+search?field.text=gnomebaker
 
 
@@ -593,7 +592,7 @@ present in the rendered form.
     >>> search_form_view.initialize()
     >>> search_form_view.id_suffix
     '-secondary'
-    >>> print search_form_view.render()
+    >>> print(search_form_view.render())
     <form action="http://launchpad.test/+search"; method="get"
       accept-charset="UTF-8" id="sitesearch-secondary"
       name="sitesearch-secondary">
@@ -615,7 +614,7 @@ field's value is 'bug', as was submitted above.
     >>> search_form_view.initialize()
     >>> search_form_view.id_suffix
     ''
-    >>> print search_form_view.render()
+    >>> print(search_form_view.render())
     <form action="http://launchpad.test/+search"; method="get"
       accept-charset="UTF-8" id="sitesearch"
       name="sitesearch">
@@ -654,7 +653,7 @@ the first 20 items are None. Only the last 5 items are PageMatches.
     ...     page_matches, page_matches.start, page_matches.total)
     >>> len(results)
     25
-    >>> print results[0]
+    >>> print(results[0])
     None
     >>> results[24].title
     u'...Bug... tracker \u201cDebian Bug tracker\u201d'
@@ -719,7 +718,7 @@ batch is 20, which is the start of the next batch from Google.
     >>> batch.endNumber()
     3
     >>> for item in batch:
-    ...     print item
+    ...     print(item)
     0
     1
     2
diff --git a/lib/lp/app/browser/doc/launchpadform-view.txt b/lib/lp/app/browser/doc/launchpadform-view.txt
index fc7994a..d71fad1 100644
--- a/lib/lp/app/browser/doc/launchpadform-view.txt
+++ b/lib/lp/app/browser/doc/launchpadform-view.txt
@@ -35,7 +35,7 @@ can be used for subordinate field indentation, for example.
     >>> view = TestView(person, request)
     >>> view.initialize()
     >>> for tag in find_tags_by_class(view.render(), 'subordinate'):
-    ...     print tag
+    ...     print(tag)
     <div class="field subordinate">
     <label for="field.nickname">Nickname:</label>
     <div>
diff --git a/lib/lp/app/browser/doc/menu.txt b/lib/lp/app/browser/doc/menu.txt
index c883bae..c5f13bd 100644
--- a/lib/lp/app/browser/doc/menu.txt
+++ b/lib/lp/app/browser/doc/menu.txt
@@ -54,16 +54,16 @@ to the named adapter: <tal:menu replace="structure view/@@+related-pages" />
     >>> view = EditView(user, LaunchpadTestRequest())
     >>> menu_view = create_initialized_view(
     ...     view, '+related-pages', principal=user)
-    >>> print menu_view.template.filename
+    >>> print(menu_view.template.filename)
     /.../navigationmenu-related-pages.pt
 
 The view provides access the menu's title and links. Both the enabled
 and disabled links are included.
 
-    >>> print menu_view.title
+    >>> print(menu_view.title)
     Related pages
     >>> for link in menu_view.links:
-    ...     print link.enabled, link.url
+    ...     print(link.enabled, link.url)
     True   http://launchpad.test/~beaker/+edit
     True   http://launchpad.test/~beaker/+edit-people
     False  http://launchpad.test/~beaker/+admin
@@ -73,7 +73,7 @@ link is rendered only if its 'enabled' property is true. The template uses the
 inline-link rules, if the link has an icon, the classes are set, otherwise a
 style attribute is used.
 
-    >>> print menu_view.render()
+    >>> print(menu_view.render())
     <div id="related-pages" class="portlet">
       <h2>Related pages</h2>
     <BLANKLINE>
@@ -100,19 +100,19 @@ be clickable.
 
     >>> request = LaunchpadTestRequest(
     ...     SERVER_URL='http://launchpad.test/~beaker/+edit')
-    >>> print request.getURL()
+    >>> print(request.getURL())
     http://launchpad.test/~beaker/+edit
 
     >>> view = EditView(user, request)
     >>> menu_view = create_initialized_view(
     ...     view, '+related-pages', principal=user)
     >>> for link in menu_view.links:
-    ...     print link.enabled, link.linked, link.url
+    ...     print(link.enabled, link.linked, link.url)
     True  False  http://launchpad.test/~beaker/+edit
     True  True   http://launchpad.test/~beaker/+edit-people
     False True   http://launchpad.test/~beaker/+admin
 
-    >>> print menu_view.render()
+    >>> print(menu_view.render())
     <div id="related-pages" class="portlet">
       <h2>Related pages</h2>
     ...
@@ -133,13 +133,13 @@ links.
     >>> menu_view = create_initialized_view(
     ...     view, '+global-actions', principal=user)
     >>> for link in menu_view.enabled_links:
-    ...     print link.enabled, link.linked, link.url
+    ...     print(link.enabled, link.linked, link.url)
     True  False  http://launchpad.test/~beaker/+edit
     True  True   http://launchpad.test/~beaker/+edit-people
 
 The generated markup is for a portlet with the global-actions id.
 
-    >>> print menu_view.render()
+    >>> print(menu_view.render())
     <div id="global-actions" class="portlet vertical">
       <ul>
         <li>
@@ -165,5 +165,5 @@ may contain links that require special privileges to access.
     >>> menu_view.enabled_links
     []
 
-    >>> print menu_view.render()
+    >>> print(menu_view.render())
     <BLANKLINE>
diff --git a/lib/lp/app/browser/doc/root-views.txt b/lib/lp/app/browser/doc/root-views.txt
index 4c46cbb..ad01689 100644
--- a/lib/lp/app/browser/doc/root-views.txt
+++ b/lib/lp/app/browser/doc/root-views.txt
@@ -18,10 +18,10 @@ The view has a provides a list of featured projects and a top project.
     >>> root = getUtility(ILaunchpadRoot)
     >>> view = create_initialized_view(root, name='index.html')
     >>> for project in view.featured_projects:
-    ...     print project.name
+    ...     print(project.name)
     applets bazaar firefox gentoo gnome-terminal mozilla thunderbird ubuntu
 
-    >>> print view.featured_projects_top.name
+    >>> print(view.featured_projects_top.name)
     gnome
 
 The featured_projects_top property is set by a helper method that pops the
@@ -30,11 +30,11 @@ project from the list of featured_projects.
     >>> featured_projects = list(view.featured_projects)
     >>> featured_projects_top = view.featured_projects_top
     >>> view._setFeaturedProjectsTop()
-    >>> print view.featured_projects_top.name
+    >>> print(view.featured_projects_top.name)
     gnome-terminal
 
     >>> for project in view.featured_projects:
-    ...     print project.name
+    ...     print(project.name)
     applets bazaar firefox gentoo mozilla thunderbird ubuntu
 
 If there are no featured projects, the top featured project is None.
@@ -42,7 +42,7 @@ If there are no featured projects, the top featured project is None.
     >>> view.featured_projects = []
     >>> view.featured_projects_top = None
     >>> view._setFeaturedProjectsTop()
-    >>> print view.featured_projects_top
+    >>> print(view.featured_projects_top)
     None
 
     # Put the projects back as they were.
diff --git a/lib/lp/app/browser/doc/watermark.txt b/lib/lp/app/browser/doc/watermark.txt
index 83af2db..ef1a66d 100644
--- a/lib/lp/app/browser/doc/watermark.txt
+++ b/lib/lp/app/browser/doc/watermark.txt
@@ -29,37 +29,37 @@ object that implements IRootContext.
 Products directly implement IRootContext.
 
     >>> widget = factory.makeProduct(displayname='Widget')
-    >>> print get_hierarchy(widget).heading()
+    >>> print(get_hierarchy(widget).heading())
     <h...><a...>Widget</a></h...>
 
 A series of the product still show the product watermark.
 
     >>> dev_focus = widget.development_focus
-    >>> print get_hierarchy(dev_focus).heading()
+    >>> print(get_hierarchy(dev_focus).heading())
     <h...><a...>Widget</a></h...>
 
 ProjectGroups also directly implement IRootContext ...
 
     >>> kde = factory.makeProject(displayname='KDE')
-    >>> print get_hierarchy(kde).heading()
+    >>> print(get_hierarchy(kde).heading())
     <h...><a...>KDE</a></h...>
 
 ... as do distributions ...
 
     >>> mint = factory.makeDistribution(displayname='Mint Linux')
-    >>> print get_hierarchy(mint).heading()
+    >>> print(get_hierarchy(mint).heading())
     <h...><a...>Mint Linux</a></h...>
 
 ... and people ...
 
     >>> eric = factory.makePerson(displayname="Eric the Viking")
-    >>> print get_hierarchy(eric).heading()
+    >>> print(get_hierarchy(eric).heading())
     <h...><a...>Eric the Viking</a></h...>
 
 ... and sprints.
 
     >>> sprint = factory.makeSprint(title="Launchpad Epic")
-    >>> print get_hierarchy(sprint).heading()
+    >>> print(get_hierarchy(sprint).heading())
     <h...><a...>Launchpad Epic</a></h...>
 
 If there is no root context defined for the object, then the heading is
@@ -67,14 +67,14 @@ If there is no root context defined for the object, then the heading is
 Launchpad.net).
 
     >>> machine = factory.makeCodeImportMachine()
-    >>> print get_hierarchy(machine).heading()
+    >>> print(get_hierarchy(machine).heading())
     <h...><span...>Launchpad.net</span></h...>
 
 Any HTML in the context title will be escaped to avoid XSS vulnerabilities.
 
     >>> person = factory.makePerson(
     ...     displayname="Fubar<br/><script>alert('XSS')</script>")
-    >>> print get_hierarchy(person).heading()
+    >>> print(get_hierarchy(person).heading())
     <h...><a...>Fubar&lt;br/&gt;&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</a></h...>
 
 
@@ -83,15 +83,15 @@ Watermark images
 
 The image for the watermark is determined effectively by context/image:logo.
 
-    >>> print get_hierarchy(dev_focus).logo()
+    >>> print(get_hierarchy(dev_focus).logo())
     <a href="..."><img ... src="/@@/product-logo" /></a>
 
-    >>> print get_hierarchy(eric).logo()
+    >>> print(get_hierarchy(eric).logo())
     <a...><img ... src="/@@/person-logo" /></a>
 
 If there is no root context, the Launchpad logo is shown.
 
-    >>> print get_hierarchy(machine).logo()
+    >>> print(get_hierarchy(machine).logo())
     <img ... src="/@@/launchpad-logo" />
 
 
@@ -108,7 +108,7 @@ Normally, the view class does not implement the marker interface, meaning it
 is not the index page of the context.  In this case the heading is rendered in
 H2.
 
-    >>> print get_hierarchy(widget).heading()
+    >>> print(get_hierarchy(widget).heading())
     <h2...><a...>Widget</a></h2>
 
 If the view class implements IMajorHeadingView, then this is the index page
@@ -119,5 +119,5 @@ for the context and the heading is rendered in H1.
     >>> @implementer(IMajorHeadingView)
     ... class HeadingView(TrivialView):
     ...     pass
-    >>> print get_hierarchy(widget, viewcls=HeadingView).heading()
+    >>> print(get_hierarchy(widget, viewcls=HeadingView).heading())
     <h1...><a...>Widget</a></h1>
diff --git a/lib/lp/app/browser/tests/test_stringformatter.py b/lib/lp/app/browser/tests/test_stringformatter.py
index 6b32101..49cd0af 100644
--- a/lib/lp/app/browser/tests/test_stringformatter.py
+++ b/lib/lp/app/browser/tests/test_stringformatter.py
@@ -43,7 +43,7 @@ def test_split_paragraphs():
 
       >>> from lp.app.browser.stringformatter import split_paragraphs
       >>> for paragraph in split_paragraphs('\na\nb\n\nc\nd\n\n\n'):
-      ...     print paragraph
+      ...     print(paragraph)
       ['a', 'b']
       ['c', 'd']
     """
@@ -65,8 +65,8 @@ def test_re_substitute():
       ...     return '{%s}' % text
 
       >>> pat = re.compile('a{2,6}')
-      >>> print re_substitute(pat, match_func, nomatch_func,
-      ...                     'bbaaaabbbbaaaaaaa aaaaaaaab')
+      >>> print(re_substitute(pat, match_func, nomatch_func,
+      ...                     'bbaaaabbbbaaaaaaa aaaaaaaab'))
       {bb}[aaaa]{bbbb}[aaaaaa]{a }[aaaaaa][aa]{b}
     """
 
@@ -80,18 +80,18 @@ def test_add_word_breaks():
 
       >>> from lp.app.browser.stringformatter import add_word_breaks
 
-      >>> print add_word_breaks('abcdefghijklmnop')
+      >>> print(add_word_breaks('abcdefghijklmnop'))
       abcdefghijklmno<wbr />p
 
-      >>> print add_word_breaks('abcdef/ghijklmnop')
+      >>> print(add_word_breaks('abcdef/ghijklmnop'))
       abcdef/<wbr />ghijklmnop
 
-      >>> print add_word_breaks('ab/cdefghijklmnop')
+      >>> print(add_word_breaks('ab/cdefghijklmnop'))
       ab/cdefghijklmn<wbr />op
 
     The string can contain HTML entities, which do not get split:
 
-      >>> print add_word_breaks('abcdef&anentity;hijklmnop')
+      >>> print(add_word_breaks('abcdef&anentity;hijklmnop'))
       abcdef&anentity;<wbr />hijklmnop
     """
 
@@ -104,22 +104,22 @@ def test_break_long_words():
 
       >>> from lp.app.browser.stringformatter import break_long_words
 
-      >>> print break_long_words('1234567890123456')
+      >>> print(break_long_words('1234567890123456'))
       1234567890123456
 
-      >>> print break_long_words('12345678901234567890')
+      >>> print(break_long_words('12345678901234567890'))
       123456789012345<wbr />67890
 
-      >>> print break_long_words('<tag a12345678901234567890="foo"></tag>')
+      >>> print(break_long_words('<tag a12345678901234567890="foo"></tag>'))
       <tag a12345678901234567890="foo"></tag>
 
-      >>> print break_long_words('12345678901234567890 1234567890.1234567890')
+      >>> print(break_long_words('12345678901234567890 1234567890.1234567890'))
       123456789012345<wbr />67890 1234567890.<wbr />1234567890
 
-      >>> print break_long_words('1234567890&abcdefghi;123')
+      >>> print(break_long_words('1234567890&abcdefghi;123'))
       1234567890&abcdefghi;123
 
-      >>> print break_long_words('<tag>1234567890123456</tag>')
+      >>> print(break_long_words('<tag>1234567890123456</tag>'))
       <tag>1234567890123456</tag>
     """
 
diff --git a/lib/lp/app/browser/tests/test_views.py b/lib/lp/app/browser/tests/test_views.py
index 5245ff8..d0d8967 100644
--- a/lib/lp/app/browser/tests/test_views.py
+++ b/lib/lp/app/browser/tests/test_views.py
@@ -28,7 +28,7 @@ bing_flag = FeatureFixture({'sitesearch.engine.name': 'bing'})
 
 
 def setUp_bing(test):
-    setUp(test)
+    setUp(test, future=True)
     bing_flag.setUp()
 
 
@@ -50,11 +50,12 @@ special = {
     # Run these doctests again with the default search engine.
     'launchpad-search-pages.txt': LayeredDocFileSuite(
         '../doc/launchpad-search-pages.txt',
-        setUp=setUp, tearDown=tearDown,
+        setUp=lambda test: setUp(test, future=True), tearDown=tearDown,
         layer=PageTestLayer,
         stdout_logging_level=logging.WARNING),
     }
 
 
 def test_suite():
-    return build_test_suite(here, special)
+    return build_test_suite(
+        here, special, setUp=lambda test: setUp(test, future=True))
diff --git a/lib/lp/app/doc/badges.txt b/lib/lp/app/doc/badges.txt
index 822a7af..1336fbc 100644
--- a/lib/lp/app/doc/badges.txt
+++ b/lib/lp/app/doc/badges.txt
@@ -26,7 +26,7 @@ alternate text and titles.
 Iterating over this collection gives:
 
     >>> for name in sorted(STANDARD_BADGES):
-    ...     print name
+    ...     print(name)
     blueprint
     branch
     bug
@@ -56,10 +56,10 @@ Both `alt` and `title` default to the empty string.
 
 Calling the render methods will produce the default image HTML.
 
-    >>> print bug.renderIconImage()
+    >>> print(bug.renderIconImage())
     <img alt="bug" width="14" height="14" src="/@@/bug"
          title="Linked to a bug"/>
-    >>> print bug.renderHeadingImage()
+    >>> print(bug.renderHeadingImage())
     <img alt="bug" width="32" height="32" src="/@@/bug-large"
          title="Linked to a bug" id="bugbadge"/>
 
@@ -128,7 +128,7 @@ badger class needs to implement the method `getBadge`.
     ...             return HasBadgeBase.getBadge(self, badge_name)
 
     >>> for badge in SimpleBadger(private_object).getVisibleBadges():
-    ...     print badge.alt, "/", badge.title
+    ...     print(badge.alt, "/", badge.title)
     bug / Bug-Title
     fish / Fish-Title
 
@@ -137,7 +137,7 @@ NotImplementedError.
 
     >>> SimpleBadger.badges.append("blueprint")
     >>> for badge in SimpleBadger(private_object).getVisibleBadges():
-    ...     print badge.alt
+    ...     print(badge.alt)
     Traceback (most recent call last):
     ...
     AttributeError:
@@ -173,11 +173,11 @@ determination methods to use the results of an alternative query.
     ... class Foo(object):
     ...     @property
     ...     def bugs(self):
-    ...         print "Foo.bugs"
+    ...         print("Foo.bugs")
     ...         return ['a']
     ...     @property
     ...     def blueprints(self):
-    ...         print "Foo.blueprints"
+    ...         print("Foo.blueprints")
     ...         return []
 
 Now define the adapter for the Foo content class.
@@ -219,7 +219,7 @@ Getting the visible badges for foo calls the underlying methods on foo,
 as illustrated by the printed method calls.
 
     >>> for badge in badger.getVisibleBadges():
-    ...     print badge.renderIconImage()
+    ...     print(badge.renderIconImage())
     Foo.bugs
     Foo.blueprints
     <img alt="bug" width="14" height="14" src="/@@/bug"
@@ -264,7 +264,7 @@ method calls, and thus avoiding unnecessary database hits (for normal
 content classes).
 
     >>> for badge in badger.getVisibleBadges():
-    ...     print badge.renderIconImage()
+    ...     print(badge.renderIconImage())
     <img alt="bug" width="14" height="14" src="/@@/bug"
     title="Linked to a bug"/>
 
@@ -281,13 +281,13 @@ through the printed attribute accessors, uses the attributes of the
 content class.
 
     >>> from lp.testing import test_tales
-    >>> print test_tales('context/badges:small', context=foo)
+    >>> print(test_tales('context/badges:small', context=foo))
     Foo.bugs
     Foo.blueprints
     <img alt="bug" width="14" height="14" src="/@@/bug"
          title="Linked to a bug"/>
 
-    >>> print test_tales('context/badges:large', context=foo)
+    >>> print(test_tales('context/badges:large', context=foo))
     Foo.bugs
     Foo.blueprints
     <img alt="bug" width="32" height="32" src="/@@/bug-large"
@@ -296,9 +296,9 @@ content class.
 Using the delegating foo, we get the delegated methods called and avoid
 the content class method calls.
 
-    >>> print test_tales('context/badges:small', context=delegating_foo)
+    >>> print(test_tales('context/badges:small', context=delegating_foo))
     <img alt="bug" width="14" height="14" src="/@@/bug"
          title="Linked to a bug"/>
-    >>> print test_tales('context/badges:large', context=delegating_foo)
+    >>> print(test_tales('context/badges:large', context=delegating_foo))
     <img alt="bug" width="32" height="32" src="/@@/bug-large"
          title="Linked to a bug" id="bugbadge"/>
diff --git a/lib/lp/app/doc/batch-navigation.txt b/lib/lp/app/doc/batch-navigation.txt
index 0242e1d..1456ef0 100644
--- a/lib/lp/app/doc/batch-navigation.txt
+++ b/lib/lp/app/doc/batch-navigation.txt
@@ -117,7 +117,7 @@ batches, both the upper and lower navigation links view will render.
     >>> navigator = BatchNavigator(reindeer, request=request)
     >>> upper_view = getMultiAdapter(
     ...     (navigator, request), name='+navigation-links-upper')
-    >>> print upper_view.render()
+    >>> print(upper_view.render())
     <table...
     ...<strong>1</strong>...&rarr;...<strong>9</strong>...of 9 results...
     ...<span class="first inactive">...First...
@@ -127,7 +127,7 @@ batches, both the upper and lower navigation links view will render.
 
     >>> lower_view = getMultiAdapter(
     ...     (navigator, request), name='+navigation-links-lower')
-    >>> print lower_view.render()
+    >>> print(lower_view.render())
     <table...
     ...<strong>1</strong>...&rarr;...<strong>9</strong>...of 9 results...
     ...<span class="first inactive">...First...
diff --git a/lib/lp/app/doc/celebrities.txt b/lib/lp/app/doc/celebrities.txt
index 37290e7..720037f 100644
--- a/lib/lp/app/doc/celebrities.txt
+++ b/lib/lp/app/doc/celebrities.txt
@@ -157,7 +157,7 @@ The Savannah bug tracker also has a BugTrackerAlias with the URL
 http://savannah.nognu.org/
 
     >>> for alias in celebs.savannah_tracker.aliases:
-    ...     print alias
+    ...     print(alias)
     http://savannah.nognu.org/
 
 
@@ -190,7 +190,7 @@ team. It's used for determining who is allowed to create new package
 sets.
 
     >>> ubuntu_techboard = personset.getByName('techboard')
-    >>> print ubuntu_techboard.name
+    >>> print(ubuntu_techboard.name)
     techboard
 
     >>> celebs.ubuntu_techboard == ubuntu_techboard
@@ -220,12 +220,12 @@ clear picture of what is missing where.
 
     >>> person_celebrity_names = set(get_person_celebrity_names())
     >>> person_roles_names = set(get_person_roles_names())
-    >>> print "Please add to IPersonRoles: " + (
-    ...       ", ".join(list(person_celebrity_names - person_roles_names)))
+    >>> print("Please add to IPersonRoles: " + (
+    ...       ", ".join(list(person_celebrity_names - person_roles_names))))
     Please add to IPersonRoles:
 
-    >>> print "Please remove from IPersonRoles: " + (
-    ...       ", ".join(list(person_roles_names - person_celebrity_names)))
+    >>> print("Please remove from IPersonRoles: " + (
+    ...       ", ".join(list(person_roles_names - person_celebrity_names))))
     Please remove from IPersonRoles:
 
 
diff --git a/lib/lp/app/doc/displaying-dates.txt b/lib/lp/app/doc/displaying-dates.txt
index 0300427..22a6c0c 100644
--- a/lib/lp/app/doc/displaying-dates.txt
+++ b/lib/lp/app/doc/displaying-dates.txt
@@ -59,14 +59,14 @@ A time that is ten seconds or less will be displayed as an approximate:
     >>> t = fixed_time + timedelta(0, 9, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     'in a moment'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
     >>> t = fixed_time_utc - timedelta(0, 10, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     'a moment ago'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
 
 A time that is very close to the present will be displayed in seconds:
@@ -74,14 +74,14 @@ A time that is very close to the present will be displayed in seconds:
     >>> t = fixed_time + timedelta(0, 11, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     'in 11 seconds'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
     >>> t = fixed_time_utc - timedelta(0, 25, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     '25 seconds ago'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
 
 Further out we expect minutes.  Note that for singular units (e.g. "1
@@ -90,14 +90,14 @@ minute"), we present the singular unit:
     >>> t = fixed_time_utc + timedelta(0, 185, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     'in 3 minutes'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
     >>> t = fixed_time_utc - timedelta(0, 75, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     '1 minute ago'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
 
 Further out we expect hours:
@@ -105,14 +105,14 @@ Further out we expect hours:
     >>> t = fixed_time_utc + timedelta(0, 3635, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     'in 1 hour'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
     >>> t = fixed_time_utc - timedelta(0, 3635, 0)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     '1 hour ago'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     True
 
 And if the approximate date is more than a day away, we expect the date. We
@@ -121,16 +121,16 @@ also expect the fmt:displaydate to change form, and become "on yyyy-mm-dd".
     >>> t = datetime(2004, 1, 13, 15, 35)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     '2004-01-13'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     False
     >>> test_tales('t/testfmt:displaydate', t=t)
     'on 2004-01-13'
     >>> t = datetime(2015, 1, 13, 15, 35)
     >>> test_tales('t/testfmt:approximatedate', t=t)
     '2015-01-13'
-    >>> print (test_tales('t/testfmt:approximatedate', t=t) ==
-    ...        test_tales('t/testfmt:displaydate', t=t))
+    >>> print(test_tales('t/testfmt:approximatedate', t=t) ==
+    ...       test_tales('t/testfmt:displaydate', t=t))
     False
     >>> test_tales('t/testfmt:displaydate', t=t)
     'on 2015-01-13'
diff --git a/lib/lp/app/doc/displaying-paragraphs-of-text.txt b/lib/lp/app/doc/displaying-paragraphs-of-text.txt
index 1c48b85..1b3a638 100644
--- a/lib/lp/app/doc/displaying-paragraphs-of-text.txt
+++ b/lib/lp/app/doc/displaying-paragraphs-of-text.txt
@@ -30,7 +30,7 @@ Basics
     ...     '* joy\n'
     ...     '* elation'
     ...     )
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>This is a paragraph that has been hard-wrapped by an email
     application.<br />
     We used to handle this specially, but we no longer do because it was
@@ -47,7 +47,7 @@ Basics
     ...     "through as-is, which means browsers will ignore them, but "
     ...     "that's fine, they're not important anyway.\n"
     ...     )
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>&nbsp;1. Here&#x27;s an example<br />
     &nbsp;2. where a list is followed by a paragraph.<br />
     &nbsp;&nbsp;&nbsp;Leading spaces in a line or paragraph are presented, which means converting them to &amp;nbsp;. Trailing spaces are passed through as-is, which means browsers will ignore them, but that&#x27;s fine, they&#x27;re not important anyway.</p>
@@ -65,7 +65,7 @@ Basics
     ...     '\n'
     ...     '\n'
     ...     'But they\'re still just two paragraphs.')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>Here are two paragraphs with lots of whitespace between them.</p>
     <p>But they&#x27;re still just two paragraphs.</p>
 
@@ -81,7 +81,7 @@ previous line.  This aids in the display of code samples:
     ...     '    def currentCount(self, language=None):\n'
     ...     '        """See IRosettaStats."""\n'
     ...     '        return self.currentCount\n')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>This is a code sample written in Python.<br />
     &nbsp;&nbsp;&nbsp;&nbsp;def messageCount(self):<br />
     &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&quot;&quot;&quot;See IRosettaStats.&quot;&quot;&quot;<br />
@@ -102,7 +102,7 @@ Testing a bunch of URL links.
     ...     '\n'
     ...     'I have a Jabber account (jabber:foo@xxxxxxxxxxxxxxxxxx)\n'
     ...     'Foo Bar <mailto:foo.bar@xxxxxxxxxxx>')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a rel="nofollow" href="https://launchpad.net/";>https:/<wbr />/launchpad.<wbr />net/</a> is the new Launchpad site<br />
     <a rel="nofollow" href="http://example.com/something?foo=bar&amp;hum=baz";>http://<wbr />example.<wbr />com/something?<wbr />foo=bar&amp;<wbr />hum=baz</a><br />
     You can check the PPC md5sums at <a rel="nofollow" href="ftp://ftp.ubuntu.com/ubuntu/dists/breezy/main/installer-powerpc/current/images/MD5SUMS";>ftp://ftp.<wbr />ubuntu.<wbr />com/ubuntu/<wbr />dists/breezy/<wbr />main/installer-<wbr />powerpc/<wbr />current/<wbr />images/<wbr />MD5SUMS</a><br />
@@ -157,7 +157,7 @@ fmt:text-to-html knows how to linkify URLs:
     ...     'http://localhost?testing=[square-brackets-in-query]\n'
     ... )
 
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a rel="nofollow" href="http://localhost:8086/bar/baz/foo.html";>http://<wbr />localhost:<wbr />8086/bar/<wbr />baz/foo.<wbr />html</a><br />
     <a rel="nofollow" href="ftp://localhost:8086/bar/baz/foo.bar.html";>ftp://localhost<wbr />:8086/bar/<wbr />baz/foo.<wbr />bar.html</a><br />
     <a rel="nofollow" href="sftp://localhost:8086/bar/baz/foo.bar.html";>sftp://<wbr />localhost:<wbr />8086/bar/<wbr />baz/foo.<wbr />bar.html</a>.<br />
@@ -202,7 +202,7 @@ The fmt:text-to-html formatter leaves a number of non-URIs unlinked:
     >>> text = (
     ...     'nothttp://launchpad.net/\n'
     ...     'http::No-cache=True\n')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>nothttp:<wbr />//launchpad.<wbr />net/<br />
     http::No-cache=True</p>
 
@@ -231,7 +231,7 @@ links:
     ...     'bug\n'
     ...     '#123\n'
     ...     'debug #52\n')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/bugs/123" class="bug-link">bug 123</a><br />
     <a href="/bugs/123" class="bug-link">bug    123</a><br />
     <a href="/bugs/123" class="bug-link">bug #123</a><br />
@@ -252,19 +252,19 @@ links:
     >>> text = (
     ...     'bug 123\n'
     ...     'bug 123\n')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/bugs/123" class="bug-link">bug 123</a><br />
     <a href="/bugs/123" class="bug-link">bug 123</a></p>
 
     >>> text = (
     ...     'bug 1234\n'
     ...     'bug 123\n')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/bugs/1234" class="bug-link">bug 1234</a><br />
     <a href="/bugs/123" class="bug-link">bug 123</a></p>
 
     >>> text = 'bug 0123\n'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/bugs/123" class="bug-link">bug 0123</a></p>
 
 
@@ -272,25 +272,25 @@ We linkify bugs that are in the Ubuntu convention for referring to bugs in
 Debian changelogs.
 
     >>> text = 'LP: #123.\n'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>LP: <a href="/bugs/123" class="bug-link">#123</a>.</p>
 
 Works with multiple bugs:
 
     >>> text = 'LP: #123, #2.\n'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>LP: <a href="/bugs/123" class="bug-link">#123</a>, <a href="/bugs/2" class="bug-link">#2</a>.</p>
 
 And with lower case 'lp' too:
 
     >>> text = 'lp: #123, #2.\n'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>lp: <a href="/bugs/123" class="bug-link">#123</a>, <a href="/bugs/2" class="bug-link">#2</a>.</p>
 
 Even line breaks cannot stop the power of bug linking:
 
     >>> text = 'LP:  #123,\n#2.\n'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>LP:  <a href="/bugs/123" class="bug-link">#123</a>,<br />
     <a href="/bugs/2" class="bug-link">#2</a>.</p>
 
@@ -314,7 +314,7 @@ subscriber.
 A private bug is still linked as no check is made on the actual bug.
 
     >>> text = 'bug 6\n'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/bugs/6" class="bug-link">bug 6</a></p>
 
 
@@ -331,7 +331,7 @@ FAQ references are global, and also linkified:
     ...     'faq item 1\n'
     ...     'faq  number  2\n'
     ...     )
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="http://answers.launchpad.test/ubuntu/+faq/1";>faq 1</a><br />
     <a href="http://answers.launchpad.test/ubuntu/+faq/2";>faq #2</a><br />
     <a href="http://answers.launchpad.test/ubuntu/+faq/2";>faq-2</a><br />
@@ -344,7 +344,7 @@ Except, that is, when the FAQ doesn't exist:
     >>> text = (
     ...     'faq 999\n'
     ...     )
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>faq 999</p>
 
 
@@ -365,7 +365,7 @@ Branch references are linkified:
     ...     'lp:foo/bar/baz\n'
     ...     'lp:///foo\n'
     ...     'lp:/foo\n')
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/+code/~foo/bar/baz" class="...">lp:~foo/bar/baz</a><br />
     <a href="/+code/~foo/bar/bug-123" class="...">lp:~foo/bar/bug-123</a><br />
     <a href="/+code/~foo/+junk/baz" class="...">lp:~foo/+junk/baz</a><br />
@@ -382,14 +382,14 @@ Text that looks like a branch reference, but is followed only by digits is
 treated as a link to a bug.
 
     >>> text = 'lp:1234'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/bugs/1234" class="bug-link">lp:1234</a></p>
 
 We are even smart enough to notice the trailing punctuation gunk and separate
 that from the link.
 
     >>> text = 'lp:1234,'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="/bugs/1234" class="bug-link">lp:1234</a>,</p>
 
 
@@ -419,7 +419,7 @@ When not logged in as a privileged user, no link:
     False
 
     >>> text = 'OOPS-38C23'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>OOPS-38C23</p>
 
 
@@ -430,21 +430,21 @@ After login, a link:
     >>> getUtility(ILaunchBag).developer
     True
 
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="https://oops.canonical.com/oops/?oopsid=OOPS-38C23";>OOPS-38C23</a></p>
 
 OOPS references can take a number of forms:
 
     >>> text = 'OOPS-38C23'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="https://oops.canonical.com/oops/?oopsid=OOPS-38C23";>OOPS-38C23</a></p>
 
     >>> text = 'OOPS-123abcdef'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="https://oops.canonical.com/oops/?oopsid=OOPS-123abcdef";>OOPS-123abcdef</a></p>
 
     >>> text = 'OOPS-abcdef123'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="https://oops.canonical.com/oops/?oopsid=OOPS-abcdef123";>OOPS-abcdef123</a></p>
 
 If the configuration value doesn't end with a slash, we won't add one. This
@@ -457,14 +457,14 @@ lets us configure the URL to use query parameters.
     ...     """
     >>> config.push('oops_root_url', oops_root_url)
     >>> text = 'OOPS-38C23'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p><a href="http://foo/barOOPS-38C23";>OOPS-38C23</a></p>
     >>> config_data = config.pop('oops_root_url')
 
 Check against false positives:
 
     >>> text = 'OOPS code'
-    >>> print test_tales('foo/fmt:text-to-html', foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
     <p>OOPS code</p>
 
 Reset login information.
diff --git a/lib/lp/app/doc/hierarchical-menu.txt b/lib/lp/app/doc/hierarchical-menu.txt
index a63e58b..8bfa600 100644
--- a/lib/lp/app/doc/hierarchical-menu.txt
+++ b/lib/lp/app/doc/hierarchical-menu.txt
@@ -200,9 +200,9 @@ text must be overridden in subclasses.
     >>> breadcrumb = Breadcrumb(cookbook)
     >>> verifyObject(IBreadcrumb, breadcrumb)
     True
-    >>> print breadcrumb.text
+    >>> print(breadcrumb.text)
     None
-    >>> print breadcrumb.url
+    >>> print(breadcrumb.url)
     http://launchpad.test/joy-of-cooking
 
 As said above, the breadcrumb's attributes can be overridden with subclassing
@@ -258,7 +258,7 @@ location bar.
     ...         recursive=False)
     ...     segments = [extract_text(step).encode('us-ascii', 'replace')
     ...                 for step in hierarchy]
-    ...     print 'Location:', ' > '.join(segments)
+    ...     print('Location:', ' > '.join(segments))
 
     >>> markup = hierarchy.render()
     >>> print_hierarchy(markup)
@@ -267,7 +267,7 @@ location bar.
 The items in the breadcrumbs are linked, except for the last one which
 represents the current location.
 
-    >>> print markup
+    >>> print(markup)
     <ol itemprop="breadcrumb" class="breadcrumbs">
       <li>
         <a href="http://launchpad.test/joy-of-cooking";>Joy of cooking</a>
diff --git a/lib/lp/app/doc/launchpadform.txt b/lib/lp/app/doc/launchpadform.txt
index 95bca7e..a5a1e83 100644
--- a/lib/lp/app/doc/launchpadform.txt
+++ b/lib/lp/app/doc/launchpadform.txt
@@ -193,7 +193,7 @@ submitted with that button.
   ...           'field.actions.change': 'Change Name'})
   >>> view = FormTestView4(context, request)
   >>> view.initialize()
-  >>> print context.displayname
+  >>> print(context.displayname)
   bob
 
 Note that input validation should not be performed inside the action
@@ -292,9 +292,9 @@ required fields, and show how to validate one field at a time.
   ...     view.setUpWidgets()
   ...     for error in view.validate_widgets(data, names=names):
   ...         if isinstance(error, str):
-  ...            print error
+  ...            print(error)
   ...         else:
-  ...             print "%s: %s" % (error.widget_title, error.doc())
+  ...             print("%s: %s" % (error.widget_title, error.doc()))
 
 Only the fields we specify will be validated:
 
@@ -340,7 +340,7 @@ redirect the user to another URL.  The URL is specified by the
   >>> view.initialize()
   >>> request.response.getStatus()
   302
-  >>> print request.response.getHeader('location')
+  >>> print(request.response.getHeader('location'))
   http://www.ubuntu.com/
 
 
@@ -420,7 +420,7 @@ on page load.  By default, the first form widget will be focused:
   >>> request = LaunchpadTestRequest()
   >>> view = FormTestView5(context, request)
   >>> view.initialize()
-  >>> print view.focusedElementScript()
+  >>> print(view.focusedElementScript())
   <!--
   setFocusByName('field.name');
   // -->
@@ -435,7 +435,7 @@ The focus can also be set explicitly by overriding initial_focus_widget:
   >>> request = LaunchpadTestRequest()
   >>> view = FormTestView7(context, request)
   >>> view.initialize()
-  >>> print view.focusedElementScript()
+  >>> print(view.focusedElementScript())
   <!--
   setFocusByName('field.password');
   // -->
@@ -451,7 +451,7 @@ Note that if the form is being redisplayed because of a validation
 error, the generated script will focus the first widget with an error:
 
   >>> view.setFieldError('password', 'Bad password')
-  >>> print view.focusedElementScript()
+  >>> print(view.focusedElementScript())
   <!--
   setFocusByName('field.password');
   // -->
@@ -489,7 +489,7 @@ By default, all widgets are visible.
   >>> from lp.services.beautifulsoup import BeautifulSoup
   >>> soup = BeautifulSoup(view())
   >>> for input in soup.findAll('input'):
-  ...     print input
+  ...     print(input)
   <input ... name="field.displayname" ... type="text" ...
 
 If we change a widget's 'visible' flag to False, that widget is rendered
@@ -503,7 +503,7 @@ using its hidden() method, which should return a hidden <input> tag.
 
   >>> soup = BeautifulSoup(view())
   >>> for input in soup.findAll('input'):
-  ...     print input
+  ...     print(input)
   <input ... name="field.displayname" type="hidden" ...
 
   >>> import os
@@ -534,12 +534,12 @@ action).  Those actions can be marked as such:
   ...
   ...     @action(u'Change', name='change')
   ...     def redirect_action(self, action, data):
-  ...         print 'Change'
+  ...         print('Change')
   ...
   ...     @safe_action
   ...     @action(u'Search', name='search')
   ...     def search_action(self, action, data):
-  ...         print 'Search'
+  ...         print('Search')
   >>> context = FormTest()
 
 With this form, the "change" action can only be submitted with a POST
@@ -596,7 +596,7 @@ In other respects, it is used the same way as LaunchpadFormView:
   ...     @action(u"Change Name", name="change")
   ...     def change_action(self, action, data):
   ...         if self.updateContextFromData(data):
-  ...             print 'Context was updated'
+  ...             print('Context was updated')
 
   >>> context = FormTest()
   >>> request = LaunchpadTestRequest()
@@ -606,7 +606,7 @@ In other respects, it is used the same way as LaunchpadFormView:
 
 The field values take their defaults from the context object:
 
-  >>> print view.widgets['displayname']()
+  >>> print(view.widgets['displayname']())
   <input...value="Fred"...
 
 The updateContextFromData() method takes care of updating the context
diff --git a/lib/lp/app/doc/launchpadformharness.txt b/lib/lp/app/doc/launchpadformharness.txt
index 5e6db63..bcf0015 100644
--- a/lib/lp/app/doc/launchpadformharness.txt
+++ b/lib/lp/app/doc/launchpadformharness.txt
@@ -57,7 +57,7 @@ though:
 We can then get a list of the whole-form errors:
 
   >>> for message in harness.getFormErrors():
-  ...     print message
+  ...     print(message)
   number must not be equal to string length
 
 
@@ -67,9 +67,9 @@ We can also check for per-widget errors:
   ...                           'field.number': 'not a number' })
   >>> harness.hasErrors()
   True
-  >>> print harness.getFieldError('string')
+  >>> print(harness.getFieldError('string'))
   <BLANKLINE>
-  >>> print harness.getFieldError('number')
+  >>> print(harness.getFieldError('number'))
   Invalid integer data
 
 
@@ -80,7 +80,7 @@ by setFieldError():
   ...                           'field.number': '7' })
   >>> harness.hasErrors()
   True
-  >>> print harness.getFieldError('number')
+  >>> print(harness.getFieldError('number'))
   number can not be 7
 
 
diff --git a/lib/lp/app/doc/launchpadview.txt b/lib/lp/app/doc/launchpadview.txt
index 07942aa..d0ffb61 100644
--- a/lib/lp/app/doc/launchpadview.txt
+++ b/lib/lp/app/doc/launchpadview.txt
@@ -9,7 +9,7 @@ This is the base-class we should use for all View classes in Launchpad.
     >>> class MyView(LaunchpadView):
     ...
     ...     def initialize(self):
-    ...         print "Initalizing..."
+    ...         print("Initalizing...")
     ...
     ...     def render(self):
     ...         return "rendered content"
@@ -23,32 +23,32 @@ Note that constructing a view does not initialize().
 
 Anonymous logged in, so view.account and view.user are None.
 
-    >>> print view.account
+    >>> print(view.account)
     None
-    >>> print view.user
+    >>> print(view.user)
     None
 
     >>> result = view()
     Initalizing...
 
-    >>> print result
+    >>> print(result)
     rendered content
 
 Now, we log in a user and see what happens to the 'user' attribute.  The
 existing view should have the same user, 'None', because it was cached.
 
     >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> print view.user
+    >>> print(view.user)
     None
 
 A new view should have the new user.
 
     >>> view = MyView(context, request)
-    >>> print view.account
+    >>> print(view.account)
     <...lp.services.identity.model.account.Account instance ...>
-    >>> print view.user
+    >>> print(view.user)
     <...lp.registry.model.person.Person instance ...>
-    >>> print view.user.name
+    >>> print(view.user.name)
     name16
 
 A view can have `error_message` or `info_message` set for display in
@@ -57,9 +57,9 @@ using a `structure` directive). The supplied value must be None or
 an IStructuredString implementation.
 
     >>> view = MyView(context, request)
-    >>> print view.error_message
+    >>> print(view.error_message)
     None
-    >>> print view.info_message
+    >>> print(view.info_message)
     None
 
     >>> view.error_message = six.ensure_str('A simple string.')
@@ -67,7 +67,7 @@ an IStructuredString implementation.
     ...
     ValueError: <type 'str'> is not a valid value for error_message,
     only None and IStructuredString are allowed.
-    >>> print view.error_message
+    >>> print(view.error_message)
     None
 
     >>> view.info_message = six.ensure_str('A simple string.')
@@ -75,7 +75,7 @@ an IStructuredString implementation.
     ...
     ValueError: <type 'str'> is not a valid value for info_message,
     only None and IStructuredString are allowed.
-    >>> print view.info_message
+    >>> print(view.info_message)
     None
 
     >>> from lp.services.webapp.escaping import structured
diff --git a/lib/lp/app/doc/lazr-js-widgets.txt b/lib/lp/app/doc/lazr-js-widgets.txt
index 9948024..c66b99a 100644
--- a/lib/lp/app/doc/lazr-js-widgets.txt
+++ b/lib/lp/app/doc/lazr-js-widgets.txt
@@ -35,7 +35,7 @@ over, and the tag that surrounds the text.
 The widget is rendered by executing it, it prints out the attribute
 content.
 
-    >>> print widget()
+    >>> print(widget())
     <h1 id="edit-display_name">
     <span class="yui3-editable_text-text ellipsis"
           style="max-width: 90%;">
@@ -48,7 +48,7 @@ the edit view that appears as well as a <script> tag that will change that
 link into an AJAX control when JS is available:
 
     >>> ignored = login_person(product.owner)
-    >>> print widget()
+    >>> print(widget())
     <h1 id="edit-display_name">
     <span class="yui3-editable_text-text ellipsis"
           style="max-width: 90%;">
@@ -73,7 +73,7 @@ attribute being edited.  This can be overridden if needed using the
     >>> span_widget = TextLineEditorWidget(
     ...     product, title_field, title, 'span', content_box_id="overridden")
     >>> login(ANONYMOUS) # To not get the script tag rendered
-    >>> print span_widget()
+    >>> print(span_widget())
     <span id="overridden">...</span>
 
 
@@ -88,18 +88,18 @@ of the object being edited.  This can be overridden in two ways:
   * provide an 'edit_url' to use instead
   * provide an 'edit_title' to set the title attribute of the anchor
 
-    >>> print widget.edit_url
+    >>> print(widget.edit_url)
     http://launchpad.test/widget/+edit
 
     >>> diff_view = TextLineEditorWidget(
     ...     product, title_field, title, 'h1', edit_view='+edit-people',
     ...     edit_title='Change the product title')
-    >>> print diff_view.edit_url
+    >>> print(diff_view.edit_url)
     http://launchpad.test/widget/+edit-people
-    >>> print diff_view.edit_title
+    >>> print(diff_view.edit_title)
     Change the product title
     >>> ignored = login_person(product.owner)
-    >>> print diff_view()
+    >>> print(diff_view())
     <h1...
     <a class="yui3-editable_text-trigger sprite edit action-icon"
        href="http://launchpad.test/widget/+edit-people";
@@ -107,7 +107,7 @@ of the object being edited.  This can be overridden in two ways:
 
     >>> diff_url = TextLineEditorWidget(
     ...     product, title_field, title, 'h1', edit_url='http://example.com/')
-    >>> print diff_url.edit_url
+    >>> print(diff_url.edit_url)
     http://example.com/
 
 
@@ -149,7 +149,7 @@ over.
 With no-one logged in, there are no edit buttons.
 
     >>> login(ANONYMOUS)
-    >>> print widget()
+    >>> print(widget())
     <div>
       <div class="lazr-multiline-edit" id="edit-description">
         <div class="clearfix">
@@ -168,7 +168,7 @@ javascript is written to the page to hook up the links to show the multiline
 editor.
 
     >>> ignored = login_person(eric)
-    >>> print widget()
+    >>> print(widget())
     <div>
       <div class="lazr-multiline-edit" id="edit-description">
         <div class="clearfix">
@@ -207,7 +207,7 @@ tag.
     >>> archive.description = None
     >>> from lp.services.propertycache import clear_property_cache
     >>> clear_property_cache(widget)
-    >>> print widget()
+    >>> print(widget())
     <div>
     <div class="lazr-multiline-edit hidden" id="edit-description">
     ...
@@ -217,7 +217,7 @@ False.
 
     >>> widget = TextAreaEditorWidget(
     ...     archive, description, 'A title', hide_empty=False)
-    >>> print widget()
+    >>> print(widget())
     <div>
     <div class="lazr-multiline-edit" id="edit-description">
     ...
@@ -265,7 +265,7 @@ to select.
     >>> ignore = login_person(product.owner)
     >>> owner = IProduct['owner']
     >>> widget = InlineEditPickerWidget(product, owner, default_text)
-    >>> print widget()
+    >>> print(widget())
     <span id="edit-owner">
       <span class="yui3-activator-data-box">
         <a href="/~eric" class="sprite person">Eric</a>
@@ -326,7 +326,7 @@ on the current value of the field on the object:
     ...     false_text="Don't hide it",
     ...     true_text="Keep it secret",
     ...     prefix="My email: ")
-    >>> print widget()
+    >>> print(widget())
     <span id="edit-hide_email_addresses">
     My email: <span class="value">Don't hide it</span>
     </span>
@@ -335,7 +335,7 @@ If the user has edit rights, an edit icon is rendered and some javascript is
 rendered to hook up the widget.
 
     >>> ignored = login_person(eric)
-    >>> print widget()
+    >>> print(widget())
     <span id="edit-hide_email_addresses">
     My email: <span class="value">Don't hide it</span>
       <span>
@@ -396,7 +396,7 @@ on the current value of the field on the object:
     ...     header='Select distroseries:', vocabulary='BuildableDistroSeries',
     ...     label_tag='dt', items_tag='dl',
     ...     selected_items=recipe.distroseries)
-    >>> print widget()
+    >>> print(widget())
     <span id="edit-distroseries">
       <dt>
         Recipe distro series
@@ -412,7 +412,7 @@ If the user has edit rights, an edit icon is rendered and some javascript is
 rendered to hook up the widget.
 
     >>> ignored = login_person(eric)
-    >>> print widget()
+    >>> print(widget())
     <span id="edit-distroseries">
       <dt>
         Recipe distro series
diff --git a/lib/lp/app/doc/menus.txt b/lib/lp/app/doc/menus.txt
index e761f1b..7625701 100644
--- a/lib/lp/app/doc/menus.txt
+++ b/lib/lp/app/doc/menus.txt
@@ -293,12 +293,12 @@ can access `self.context`.
     ...     if facet is not None:
     ...         extra_arguments['selectedfacetname'] = facet
     ...     for link in menu.iterlinks(url, **extra_arguments):
-    ...         print 'link %s' % link.name
+    ...         print('link %s' % link.name)
     ...         attributes = ('url', 'enabled', 'menu', 'selected', 'linked')
     ...         for attrname in attributes:
     ...             if not safe_hasattr(link, attrname):
     ...                 continue
-    ...             print '    %s: %s' % (attrname, getattr(link, attrname))
+    ...             print('    %s: %s' % (attrname, getattr(link, attrname)))
 
     >>> summarise_links(
     ...     CookeryFacetMenu(cookbook),
@@ -581,7 +581,7 @@ objects cannot be adapted to menus.
     ...     traversed_objects=[cookbook, recipe])
     >>> recipe_view = getMultiAdapter((recipe, request), name='+index')
     >>> request._last_obj_traversed = recipe_view
-    >>> print queryAdapter(recipe_view, INavigationMenu)
+    >>> print(queryAdapter(recipe_view, INavigationMenu))
     None
 
 Once registered, the objects can be adapted. The RecipeFacetMenu can be
@@ -876,14 +876,14 @@ see if we have a link to a page within Launchpad.
     ...         text = 'Spoo'
     ...         return Link(target, text)
 
-    >>> print canonical_url(cookbook)
+    >>> print(canonical_url(cookbook))
     http://launchpad.test/joy-of-cooking
 
     >>> request_url = URI('http://launchpad.test/joy-of-cooking')
 
     >>> facets = AbsoluteUrlTargetTestFacets(cookbook)
     >>> for link in facets.iterlinks(request_url):
-    ...     print link.url, link.linked
+    ...     print(link.url, link.linked)
     http://launchpad.test/joy-of-cooking False
     ftp://barlink.example.com/barbarbar True
     http://launchpad.test/joy-of-cooking/+baz True
@@ -999,8 +999,8 @@ over the links. The TALES takes the form of 'view/menu:navigation'.
 
 The attributes of the menu can be accessed with the normal path method.
 
-    >>> print test_tales('context/menu:navigation/journal_entries',
-    ...     context=recipe_journal_view, request=request)
+    >>> print(test_tales('context/menu:navigation/journal_entries',
+    ...     context=recipe_journal_view, request=request))
     42
 
 
@@ -1035,7 +1035,7 @@ that the comment refers to.
 We'll simulate the user viewing a comment.
 
     >>> comment = Comment('a-comment', recipe)
-    >>> print canonical_url(comment)
+    >>> print(canonical_url(comment))
     http://launchpad.test/joy-of-cooking/fried-spam/a-comment
 
 When we try to look up the menu for the comment, the navigation menu for
@@ -1148,7 +1148,7 @@ The Summary in the facet menu is selected because the current facet is
 
     >>> recipe_facet_menu_view = FacetMenuView(recipe, request)
     >>> recipe_facet_menu_view.initialize()
-    >>> print recipe_facet_menu_view()
+    >>> print(recipe_facet_menu_view())
     <div>
       <ul>
         <li>
@@ -1171,7 +1171,7 @@ available (as can be seen in the next example).
 
     >>> recipe_menu_view = NavigationMenuView(recipe, request)
     >>> recipe_menu_view.initialize()
-    >>> print recipe_menu_view()
+    >>> print(recipe_menu_view())
     <div>
       <ul>
         <li>
@@ -1193,7 +1193,7 @@ URL.
     >>> recipe_view_menu_view = NavigationMenuView(
     ...     recipe_journal_view, request)
     >>> recipe_view_menu_view.initialize()
-    >>> print recipe_view_menu_view()
+    >>> print(recipe_view_menu_view())
     <div>
       <label>Journal</label>
       <ul>
diff --git a/lib/lp/app/doc/multistep.txt b/lib/lp/app/doc/multistep.txt
index ccd09c1..5fed170 100644
--- a/lib/lp/app/doc/multistep.txt
+++ b/lib/lp/app/doc/multistep.txt
@@ -30,14 +30,14 @@ main_action() method to process the form.
     ...     step_name = 'three'
     ...     def main_action(self, data):
     ...         assert data['__visited_steps__'] == visited_steps
-    ...         print self.step_name
+    ...         print(self.step_name)
 
     >>> class StepTwo(StepView):
     ...     schema = IStep
     ...     step_name = 'two'
     ...     def main_action(self, data):
     ...         assert data['__visited_steps__'] == visited_steps
-    ...         print self.step_name
+    ...         print(self.step_name)
     ...         self.next_step = StepThree
 
     >>> class StepOne(StepView):
@@ -45,7 +45,7 @@ main_action() method to process the form.
     ...     step_name = 'one'
     ...     def main_action(self, data):
     ...         assert data['__visited_steps__'] == visited_steps
-    ...         print self.step_name
+    ...         print(self.step_name)
     ...         self.next_step = StepTwo
 
     >>> class CounterView(MultiStepView):
@@ -114,7 +114,7 @@ method.
     ...     def main_action(self, data):
     ...         pass
     ...     def validateStep(self, data):
-    ...         print self.step_name
+    ...         print(self.step_name)
 
     >>> class StepFive(StepView):
     ...     schema = IStep
@@ -122,7 +122,7 @@ method.
     ...     def main_action(self, data):
     ...         self.next_step = StepSix
     ...     def validateStep(self, data):
-    ...         print self.step_name
+    ...         print(self.step_name)
 
     >>> class StepFour(StepView):
     ...     schema = IStep
@@ -130,7 +130,7 @@ method.
     ...     def main_action(self, data):
     ...         self.next_step = StepFive
     ...     def validateStep(self, data):
-    ...         print self.step_name
+    ...         print(self.step_name)
 
     >>> class CounterView(MultiStepView):
     ...     first_step = StepFour
diff --git a/lib/lp/app/doc/tales-email-formatting.txt b/lib/lp/app/doc/tales-email-formatting.txt
index 4941709..8c5fc79 100644
--- a/lib/lp/app/doc/tales-email-formatting.txt
+++ b/lib/lp/app/doc/tales-email-formatting.txt
@@ -21,7 +21,7 @@ Paragraphs that mix quoted and reply text fold only the quoted lines.
     ...                      'This is a reply to the line above.\n'
     ...                      'This is a continuation line.'
     ...                      '\n')
-    >>> print test_tales('foo/fmt:email-to-html', foo=mixed_quoted_text)
+    >>> print(test_tales('foo/fmt:email-to-html', foo=mixed_quoted_text))
     <p>Mister X wrote:<br />
     <span class="foldable-quoted">
     &gt; This is a quoted line<br />
@@ -37,7 +37,7 @@ the quoted section from the paragraph.
     ...                       '> quoted_line\n'
     ...                       'Remark line.\n'
     ...                       '\n')
-    >>> print test_tales('foo/fmt:email-to-html', foo=quoted_remark_text)
+    >>> print(test_tales('foo/fmt:email-to-html', foo=quoted_remark_text))
     <p>Attribution line<br />
     <span class="foldable-quoted">
     &gt; quoted_line<br />
@@ -57,7 +57,7 @@ span.
     ...                       '> First line in the third paragraph.\n'
     ...                       '> Second line in the third paragraph.\n'
     ...                       '\n')
-    >>> print test_tales('foo/fmt:email-to-html', foo=quoted_paragraphs)
+    >>> print(test_tales('foo/fmt:email-to-html', foo=quoted_paragraphs))
     <p>Attribution line<br />
     <span class="foldable-quoted">
     &gt; First line in the first paragraph.<br />
@@ -77,7 +77,7 @@ is no distinction between the nested levels of quoting.
     ...                   '>>> three\n'
     ...                   '>> two\n'
     ...                   '> one\n')
-    >>> print test_tales('foo/fmt:email-to-html', foo=nested_quoting)
+    >>> print(test_tales('foo/fmt:email-to-html', foo=nested_quoting))
     <p><span class="foldable-quoted">&gt;&gt;&gt;&gt; four<br />
     &gt;&gt;&gt; three<br />
     &gt;&gt; two<br />
@@ -92,7 +92,7 @@ wrapped in a foldable-quoted span.
     ...                      '>> This is a double quoted line\n'
     ...                      '>> > This is a triple quoted line.\n'
     ...                      '\n')
-    >>> print test_tales('foo/fmt:email-to-html', foo=weird_quoted_text)
+    >>> print(test_tales('foo/fmt:email-to-html', foo=weird_quoted_text))
     <p>Ms. Y wrote:<br />
     <span class="foldable-quoted">
     &gt;&gt; This is a double quoted line<br />
@@ -113,14 +113,14 @@ the preceding nested quoting test).
 
     >>> python = ('>>> tz = pytz.timezone("Asia/Calcutta")\n'
     ...           '>>> mydate = datetime.datetime(2007, 2, 18, 15, 35)\n'
-    ...           '>>> print tz.localize(mydate)\n'
+    ...           '>>> print(tz.localize(mydate))\n'
     ...           '2007-02-18 15:35:00+05:30\n'
     ...           '\n')
     >>> not_python = ('> This line really is a quoted passage.\n'
     ...               '>>> This does not invoke an exception rule.\n'
     ...               '\n')
-    >>> print test_tales('foo/fmt:email-to-html',
-    ...                  foo='\n'.join([python, not_python]))
+    >>> print(test_tales('foo/fmt:email-to-html',
+    ...                  foo='\n'.join([python, not_python])))
     <p>&gt;&gt;&gt; tz = pytz.timezone(<wbr />&quot;Asia/Calcutta&quot;...
     &gt;&gt;&gt; mydate = datetime.<wbr />datetime(<wbr />2007, 2, ...
     2007-02-18 15:35:00+05:30</p>
@@ -137,7 +137,7 @@ output and not fold it.
     >>> bar_quoted_text = ('Someone said sometime ago:\n'
     ...                    '| Quote passages are folded.\n'
     ...                    '\n')
-    >>> print test_tales('foo/fmt:email-to-html', foo=bar_quoted_text)
+    >>> print(test_tales('foo/fmt:email-to-html', foo=bar_quoted_text))
     <p>Someone said sometime ago:<br />
     <span class="foldable-quoted">
     | Quote passages are folded.
@@ -151,7 +151,7 @@ output and not fold it.
     ...         '+++-==============-==============-====================\n'
     ...         'ii libdvdread3 0.9.7-2ubuntu1 library for reading DVDs\n'
     ...         '\n')
-    >>> print test_tales('foo/fmt:email-to-html', foo=dpkg)
+    >>> print(test_tales('foo/fmt:email-to-html', foo=dpkg))
     <p>dpkg -l libdvdread3<br />
     Desired=<wbr />Unknown/<wbr />Install/<wbr />...
     | Status=<wbr />Not/Installed/<wbr />Config-<wbr />...
@@ -170,8 +170,8 @@ output and not fold it.
     ...             '+++-==============-==============-==================\n'
     ...             'ii libdvdread3 0.9.7-2ubuntu1 library for reading DVDs\n'
     ...             '\n')
-    >>> print test_tales('foo/fmt:email-to-html',
-    ...                  foo='\n'.join([bad_dpkg]))
+    >>> print(test_tales('foo/fmt:email-to-html',
+    ...                  foo='\n'.join([bad_dpkg])))
     <p>When dpkg output is in text, possibly tampered with,<br />
     we must take care to identify &#x27;|&#x27; quoted passages.<br />
     $ Desired=<wbr />Unknown/<wbr />Install/<wbr />Remove/...
diff --git a/lib/lp/app/doc/tales.txt b/lib/lp/app/doc/tales.txt
index 682360a..9088f70 100644
--- a/lib/lp/app/doc/tales.txt
+++ b/lib/lp/app/doc/tales.txt
@@ -74,7 +74,7 @@ We also have image:icon for KarmaCategory:
 
     >>> from lp.registry.model.karma import KarmaCategory
     >>> for category in KarmaCategory.select(orderBy='title'):
-    ...     print test_tales("category/image:icon", category=category)
+    ...     print(test_tales("category/image:icon", category=category))
     <img ... title="Answer Tracker" src="/@@/question" />
     <img ... title="Bazaar Branches" src="/@@/branch" />
     <img ... title="Bug Management" src="/@@/bug" />
@@ -86,13 +86,13 @@ We also have an icon for bugs.
 
     >>> from lp.bugs.interfaces.bug import IBugSet
     >>> bug = getUtility(IBugSet).get(1)
-    >>> print test_tales("bug/image:sprite_css", bug=bug)
+    >>> print(test_tales("bug/image:sprite_css", bug=bug))
     sprite bug
 
 Icons for each type (purpose) of archive we support. Starting with
 personal package archives (PPAs).
 
-    >>> print test_tales("ppa/image:icon", ppa=mark.archive)
+    >>> print(test_tales("ppa/image:icon", ppa=mark.archive))
     <img ... src="/@@/ppa-icon" />
 
 Then distribution main archives (primary and partner).
@@ -102,10 +102,10 @@ Then distribution main archives (primary and partner).
     >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
     >>> [primary, partner] = ubuntu.all_distro_archives
 
-    >>> print test_tales("archive/image:icon", archive=primary)
+    >>> print(test_tales("archive/image:icon", archive=primary))
     <img ... src="/@@/distribution" />
 
-    >>> print test_tales("archive/image:icon", archive=partner)
+    >>> print(test_tales("archive/image:icon", archive=partner))
     <img ... src="/@@/distribution" />
 
 And finally Copy archives.
@@ -116,7 +116,7 @@ And finally Copy archives.
     ...     owner=mark, purpose=ArchivePurpose.COPY,
     ...     distribution=ubuntu, name='rebuild')
 
-    >>> print test_tales("archive/image:icon", archive=copy)
+    >>> print(test_tales("archive/image:icon", archive=copy))
     <img ... src="/@@/distribution" />
 
 PPAs have a 'link' formatter, which returns the appropriate HTML used
@@ -128,10 +128,10 @@ displays the unique ppa reference.
     >>> public_ppa = factory.makeArchive(
     ...     name='ppa', private=False, owner=owner)
     >>> login(ANONYMOUS)
-    >>> print test_tales("ppa/fmt:link", ppa=public_ppa)
+    >>> print(test_tales("ppa/fmt:link", ppa=public_ppa))
     <a href="/~joe/+archive/ubuntu/ppa"
        class="sprite ppa-icon">PPA for Joe Smith</a>
-    >>> print test_tales("ppa/fmt:reference", ppa=public_ppa)
+    >>> print(test_tales("ppa/fmt:reference", ppa=public_ppa))
     ppa:joe/ppa
 
 Disabled PPAs links use a different icon and are only linkified for
@@ -140,13 +140,13 @@ users with launchpad.View on them.
     >>> login('admin@xxxxxxxxxxxxx')
     >>> public_ppa.disable()
 
-    >>> print test_tales("ppa/fmt:link", ppa=public_ppa)
+    >>> print(test_tales("ppa/fmt:link", ppa=public_ppa))
     <a href="/~joe/+archive/ubuntu/ppa" class="sprite ppa-icon-inactive">PPA
     for Joe Smith</a>
 
     >>> login(ANONYMOUS)
 
-    >>> print test_tales("ppa/fmt:link", ppa=public_ppa)
+    >>> print(test_tales("ppa/fmt:link", ppa=public_ppa))
     <span class="sprite ppa-icon-inactive">PPA for Joe Smith</span>
 
 Private PPAs links are not rendered for users without launchpad.View on
@@ -156,25 +156,25 @@ them.
     >>> private_ppa = factory.makeArchive(
     ...     name='pppa', private=True, owner=owner)
 
-    >>> print test_tales("ppa/fmt:link", ppa=private_ppa)
+    >>> print(test_tales("ppa/fmt:link", ppa=private_ppa))
     <a href="/~joe/+archive/ubuntu/pppa" class="sprite ppa-icon private">PPA named
     pppa for Joe Smith</a>
 
     >>> login(ANONYMOUS)
 
-    >>> print test_tales("ppa/fmt:link", ppa=private_ppa)
+    >>> print(test_tales("ppa/fmt:link", ppa=private_ppa))
 
 Similarly, references to private PPAs are not rendered unless the user has
 a subscription to the PPA.
 
     >>> ppa_user = factory.makePerson(name="jake", displayname="Jake Smith")
     >>> ignored = login_person(ppa_user)
-    >>> print test_tales("ppa/fmt:reference", ppa=private_ppa)
+    >>> print(test_tales("ppa/fmt:reference", ppa=private_ppa))
 
     >>> ignored = login_person(owner)
     >>> ignore = private_ppa.newSubscription(ppa_user, owner)
     >>> ignored = login_person(ppa_user)
-    >>> print test_tales("ppa/fmt:reference", ppa=private_ppa)
+    >>> print(test_tales("ppa/fmt:reference", ppa=private_ppa))
     ppa:joe/pppa
 
 The same 'link' formatter works for distribution archives, with a
@@ -182,15 +182,15 @@ different sprite.  The link target for main archives (primary and
 partner) is the distribution rather than the archive, as the archives
 would just redirect anyway.
 
-    >>> print test_tales("archive/fmt:link", archive=primary)
+    >>> print(test_tales("archive/fmt:link", archive=primary))
     <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu
     Linux</a>
 
-    >>> print test_tales("archive/fmt:link", archive=partner)
+    >>> print(test_tales("archive/fmt:link", archive=partner))
     <a href="/ubuntu" class="sprite distribution">Partner Archive for Ubuntu
     Linux</a>
 
-    >>> print test_tales("archive/fmt:link", archive=copy)
+    >>> print(test_tales("archive/fmt:link", archive=copy))
     <a href="/ubuntu/+archive/rebuild" class="sprite distribution">Copy
     archive rebuild for Mark Shuttleworth</a>
 
@@ -213,34 +213,34 @@ We also have icons for builds which may have different dimensions.
 
 The 'Needs building' build is 14x14:
 
-    >>> print test_tales("build/image:icon", build=build)
+    >>> print(test_tales("build/image:icon", build=build))
     <img width="14" height="14"...src="/@@/build-needed" />
 
 The 'building' build is 14x14:
 
     >>> from lp.buildmaster.enums import BuildStatus
     >>> build.updateStatus(BuildStatus.BUILDING)
-    >>> print test_tales("build/image:icon", build=build)
+    >>> print(test_tales("build/image:icon", build=build))
     <img width="14" height="14"...src="/@@/processing" />
 
 But the 'failed to build' build is 16x14:
 
     >>> build.updateStatus(BuildStatus.FAILEDTOBUILD)
-    >>> print test_tales("build/image:icon", build=build)
+    >>> print(test_tales("build/image:icon", build=build))
     <img width="16" height="14"...src="/@@/build-failed" />
 
 All objects can be represented as a boolean icon.
 
-    >>> print test_tales("context/image:boolean", context=None)
+    >>> print(test_tales("context/image:boolean", context=None))
     <span class="sprite no action-icon">no</span>
 
-    >>> print test_tales("context/image:boolean", context=False)
+    >>> print(test_tales("context/image:boolean", context=False))
     <span class="sprite no action-icon">no</span>
 
-    >>> print test_tales("context/image:boolean", context=object())
+    >>> print(test_tales("context/image:boolean", context=object()))
     <span class="sprite yes action-icon">yes</span>
 
-    >>> print test_tales("context/image:boolean", context=True)
+    >>> print(test_tales("context/image:boolean", context=True))
     <span class="sprite yes action-icon">yes</span>
 
 
@@ -272,14 +272,14 @@ To truncate a long string, use fmt:shorten:
 To ellipsize the middle of a string. use fmt:ellipsize and pass the max
 length.
 
-    >>> print test_tales('foo/fmt:ellipsize/25',
-    ...     foo='foo-bar-baz-bazoo_22.443.tar.gz')
+    >>> print(test_tales('foo/fmt:ellipsize/25',
+    ...     foo='foo-bar-baz-bazoo_22.443.tar.gz'))
     foo-bar-baz....443.tar.gz
 
 The string is not ellipsized if it is less than the max length.
 
-    >>> print test_tales('foo/fmt:ellipsize/25',
-    ...     foo='firefox_0.9.2.orig.tar.gz')
+    >>> print(test_tales('foo/fmt:ellipsize/25',
+    ...     foo='firefox_0.9.2.orig.tar.gz'))
     firefox_0.9.2.orig.tar.gz
 
 To preserve newlines in text when displaying as HTML, use fmt:nl_to_br:
@@ -324,7 +324,7 @@ The fmt: namespace to get URLs
 
 The `fmt:url` is used when you want the canonical URL of a given object.
 
-    >>> print test_tales("bug/fmt:url", bug=bug)
+    >>> print(test_tales("bug/fmt:url", bug=bug))
     http://bugs.launchpad.test/bugs/1
 
 You can also specify an extra argument (a view's name), if you want the
@@ -333,7 +333,7 @@ to simulate a browser request -- that's why we login() here.
 
     >>> from lp.services.webapp.servers import LaunchpadTestRequest
     >>> login(ANONYMOUS, LaunchpadTestRequest())
-    >>> print test_tales("bug/fmt:url/+text", bug=bug)
+    >>> print(test_tales("bug/fmt:url/+text", bug=bug))
     http://bugs.launchpad.test/bugs/1/+text
 
 
@@ -342,16 +342,16 @@ fmt:url accepts an rootsite extension to make URLs to a specific application.
     >>> login(ANONYMOUS,
     ...     LaunchpadTestRequest(SERVER_URL='http://code.launchpad.net'))
 
-    >>> print test_tales("person/fmt:url:bugs", person=mark)
+    >>> print(test_tales("person/fmt:url:bugs", person=mark))
     http://bugs.launchpad.test/~mark
 
-    >>> print test_tales("person/fmt:url:feeds", person=mark)
+    >>> print(test_tales("person/fmt:url:feeds", person=mark))
     http://feeds.launchpad.test/~mark
 
-    >>> print test_tales("pillar/fmt:url:answers", pillar=ubuntu)
+    >>> print(test_tales("pillar/fmt:url:answers", pillar=ubuntu))
     http://answers.launchpad.test/ubuntu
 
-    >>> print test_tales("bug/fmt:url:mainsite", bug=bug)
+    >>> print(test_tales("bug/fmt:url:mainsite", bug=bug))
     http://launchpad.test/bugs/1
 
     >>> login(ANONYMOUS)
@@ -367,20 +367,20 @@ This path is everything after the web service version number.
     ...     LaunchpadTestRequest(SERVER_URL='http://bugs.launchpad.net'))
 
     >>> bob = factory.makePerson(name='bob')
-    >>> print test_tales("person/fmt:api_url", person=bob)
+    >>> print(test_tales("person/fmt:api_url", person=bob))
     /~bob
 
     >>> freewidget = factory.makeProduct(name='freewidget')
-    >>> print test_tales("product/fmt:api_url", product=freewidget)
+    >>> print(test_tales("product/fmt:api_url", product=freewidget))
     /freewidget
 
     >>> debuntu = factory.makeDistribution(name='debuntu')
-    >>> print test_tales("distro/fmt:api_url", distro=debuntu)
+    >>> print(test_tales("distro/fmt:api_url", distro=debuntu))
     /debuntu
 
     >>> branch = factory.makeProductBranch(
     ...     owner=bob, product=freewidget, name='fix-bug')
-    >>> print test_tales("branch/fmt:api_url", branch=branch)
+    >>> print(test_tales("branch/fmt:api_url", branch=branch))
     /~bob/freewidget/fix-bug
 
     >>> login(ANONYMOUS)
@@ -433,28 +433,28 @@ The link can make the URL go to a specific app.
     >>> login(ANONYMOUS,
     ...     LaunchpadTestRequest(SERVER_URL='http://code.launchpad.net'))
 
-    >>> print test_tales("pillar/fmt:link:translations", pillar=ubuntu)
+    >>> print(test_tales("pillar/fmt:link:translations", pillar=ubuntu))
     <a ...http://translations.launchpad.test/ubuntu...
 
-    >>> print test_tales("person/fmt:url:feeds", person=mark)
+    >>> print(test_tales("person/fmt:url:feeds", person=mark))
     http://feeds.launchpad.test/~mark
 
-    >>> print test_tales("bug/fmt:url:mainsite", bug=bug)
+    >>> print(test_tales("bug/fmt:url:mainsite", bug=bug))
     http://launchpad.test/bugs/1
 
 The default behaviour for pillars, persons, and teams is to link to
 the mainsite.
 
-    >>> print test_tales("pillar/fmt:link", pillar=ubuntu)
+    >>> print(test_tales("pillar/fmt:link", pillar=ubuntu))
     <a ...http://launchpad.test/ubuntu...
 
-    >>> print test_tales("person/fmt:link", person=mark)
+    >>> print(test_tales("person/fmt:link", person=mark))
     <a ...http://launchpad.test/~mark...
 
-    >>> print test_tales("person/fmt:link-display-name-id", person=mark)
+    >>> print(test_tales("person/fmt:link-display-name-id", person=mark))
     <a ...http://launchpad.test/~mark...>Mark Shuttleworth (mark)</a>
 
-    >>> print test_tales("team/fmt:link", team=ubuntu_team)
+    >>> print(test_tales("team/fmt:link", team=ubuntu_team))
     <a ...http://launchpad.test/~ubuntu-team...
 
     >>> login(ANONYMOUS)
@@ -488,7 +488,7 @@ If the person has no time_zone specified, we use UTC.
     '... AWST'
 
     >>> from zope.security.proxy import removeSecurityProxy
-    >>> print removeSecurityProxy(mark).location
+    >>> print(removeSecurityProxy(mark).location)
     None
     >>> mark.time_zone
     u'UTC'
@@ -507,13 +507,13 @@ For branches, fmt:link links to the branch page.
     >>> fooix = factory.makeProduct(name='fooix')
     >>> branch = factory.makeProductBranch(
     ...     owner=eric, product=fooix, name='bar', title='The branch title')
-    >>> print test_tales("branch/fmt:link", branch=branch)
+    >>> print(test_tales("branch/fmt:link", branch=branch))
     <a href=".../~eric/fooix/bar"
       class="sprite branch">lp://dev/~eric/fooix/bar</a>
 
 The bzr-link formatter uses the bzr identity.
 
-    >>> print test_tales("branch/fmt:bzr-link", branch=branch)
+    >>> print(test_tales("branch/fmt:bzr-link", branch=branch))
     <a href="http://code.launchpad.test/~eric/fooix/bar";
       class="sprite branch">lp://dev/~eric/fooix/bar</a>
 
@@ -521,7 +521,7 @@ The bzr-link formatter uses the bzr identity.
     >>> fooix.development_focus.branch = branch
     >>> from lp.services.propertycache import clear_property_cache
     >>> clear_property_cache(branch)
-    >>> print test_tales("branch/fmt:bzr-link", branch=branch)
+    >>> print(test_tales("branch/fmt:bzr-link", branch=branch))
     <a href=".../~eric/fooix/bar" class="sprite branch">lp://dev/fooix</a>
 
 
@@ -532,7 +532,7 @@ For Git repositories, fmt:link links to the repository page.
 
     >>> repository = factory.makeGitRepository(
     ...     owner=eric, target=fooix, name=u'bar')
-    >>> print test_tales("repository/fmt:link", repository=repository)
+    >>> print(test_tales("repository/fmt:link", repository=repository))
     <a href=".../~eric/fooix/+git/bar">lp:~eric/fooix/+git/bar</a>
 
 
@@ -542,7 +542,7 @@ Git references
 For Git references, fmt:link links to the reference page.
 
     >>> [ref] = factory.makeGitRefs(repository=repository, paths=[u"master"])
-    >>> print test_tales("ref/fmt:link", ref=ref)
+    >>> print(test_tales("ref/fmt:link", ref=ref))
     <a href=".../~eric/fooix/+git/bar/+ref/master">~eric/fooix/+git/bar:master</a>
 
 
@@ -624,10 +624,10 @@ Code review comments
 ....................
 
     >>> comment = factory.makeCodeReviewComment()
-    >>> print test_tales('comment/fmt:url', comment=comment)
+    >>> print(test_tales('comment/fmt:url', comment=comment))
     http:.../~person-name.../product-name.../branch.../+merge/.../comments/...
 
-    >>> print test_tales('comment/fmt:link', comment=comment)
+    >>> print(test_tales('comment/fmt:link', comment=comment))
     <a href="...">Comment by Person-name...</a>
 
 
@@ -671,7 +671,7 @@ size), plus extra links for the MD5 hash and signature of that PRF.
     ...     soup = BeautifulSoup(html)
     ...     for link in soup.findAll('a'):
     ...         attrs = dict(link.attrs)
-    ...         print "%s: %s" % (attrs.get('href'), attrs.get('title', ''))
+    ...         print("%s: %s" % (attrs.get('href'), attrs.get('title', '')))
 
     >>> release_file = factory.makeProductReleaseFile()
     >>> html = test_tales("release_file/fmt:link", release_file=release_file)
@@ -693,14 +693,14 @@ not included.
 
 The url for the release file can be retrieved using fmt:url.
 
-    >>> print test_tales("release_file/fmt:url", release_file=release_file)
+    >>> print(test_tales("release_file/fmt:url", release_file=release_file))
     http://launchpad.test/.../+download/test.txt
 
 HTML in the file description is escaped in the fmt:link.
 
     >>> release_file = factory.makeProductReleaseFile(
     ...     signed=False, description='><script>XSS failed</script>')
-    >>> print test_tales("release_file/fmt:link", release_file=release_file)
+    >>> print(test_tales("release_file/fmt:link", release_file=release_file))
     <img ...
     <a title="&gt;&lt;script&gt;XSS failed&lt;/script&gt; (4 bytes)"
     href="http://launchpad.test/.../+download/test.txt";>test.txt</a> ...
@@ -799,12 +799,12 @@ which help when hiding email addresses from users who are not logged in.
     >>> def print_formatted_bugtrackers():
     ...     expression = "bugtracker/fmt:%s"
     ...     for format in ['link', 'external-link', 'external-title-link']:
-    ...         print '%s -->\n  %r' % (
+    ...         print('%s -->\n  %r' % (
     ...             format, test_tales(expression % format,
-    ...                                bugtracker=bugtracker))
-    ...     print 'aliases -->\n  %r' % (
+    ...                                bugtracker=bugtracker)))
+    ...     print('aliases -->\n  %r' % (
     ...         list(test_tales(expression % 'aliases',
-    ...                         bugtracker=bugtracker)),)
+    ...                         bugtracker=bugtracker)),))
 
     >>> login('test@xxxxxxxxxxxxx')
     >>> print_formatted_bugtrackers()
@@ -933,15 +933,15 @@ or change the behaviour of the text as needed.
 When given simple paragraphs it behaves just as the text-to-html
 formatter.
 
-    >>> print test_tales('foo/fmt:email-to-html',
-    ...                  foo=text)
+    >>> print(test_tales('foo/fmt:email-to-html',
+    ...                  foo=text))
     <p>Top quoting is simply bad netiquette.<br />
     The words of the leading text should be displayed<br />
     normally--no markup to hide it from view.<br />
     Raise your hand if you can read this.</p>
 
-    >>> print test_tales('foo/fmt:text-to-html',
-    ...                  foo=text)
+    >>> print(test_tales('foo/fmt:text-to-html',
+    ...                  foo=text))
     <p>Top quoting is simply bad netiquette.<br />
     The words of the leading text should be displayed<br />
     normally--no markup to hide it from view.<br />
@@ -954,8 +954,8 @@ Marking PGP blocks
 PGP signed messages have opening and closing blocks that are wrapped in
 a foldable span.
 
-    >>> print test_tales('foo/fmt:email-to-html',
-    ...                  foo='\n'.join([pgp_open, text, pgp_close]))
+    >>> print(test_tales('foo/fmt:email-to-html',
+    ...                  foo='\n'.join([pgp_open, text, pgp_close])))
     <p><span class="foldable">-----BEGIN PGP SIGNED MESSAGE-----<br />
     Hash: SHA1
     </span></p>
@@ -977,8 +977,8 @@ In this example, we see the main paragraph and the signature marked up
 as HTML. All the text inside the signature is wrapped with the foldable
 span.
 
-    >>> print test_tales('foo/fmt:email-to-html',
-    ...                  foo='\n'.join([text, signature]))
+    >>> print(test_tales('foo/fmt:email-to-html',
+    ...                  foo='\n'.join([text, signature])))
     <p>Top quoting is simply bad netiquette.<br />
     The words of the leading text should be displayed<br />
     normally--no markup to hide it from view.<br />
@@ -1008,8 +1008,8 @@ foldable-quoted span.
     ...                    '> 3. Get new handwriting.\n'
     ...                    '> 4. Add Year Zero to the calendar.\n'
     ...                    '\n')
-    >>> print test_tales('foo/fmt:email-to-html',
-    ...                  foo='\n'.join([text, quoted_text, quoted_text_all]))
+    >>> print(test_tales('foo/fmt:email-to-html',
+    ...                  foo='\n'.join([text, quoted_text, quoted_text_all])))
     <p>Top quoting is simply bad netiquette.<br />
     The words of the leading text should be displayed<br />
     normally--no markup to hide it from view.<br />
@@ -1032,9 +1032,9 @@ Different kinds of content can be marked up in a single call
 The formatter is indifferent to the number and kinds of paragraphs it
 must markup. We can format the three examples at the same time.
 
-    >>> print test_tales('foo/fmt:email-to-html',
+    >>> print(test_tales('foo/fmt:email-to-html',
     ...     foo='\n'.join(
-    ...         [text, quoted_text, text, quoted_text_all, signature]))
+    ...         [text, quoted_text, text, quoted_text_all, signature])))
     <p>Top quoting is simply bad netiquette.<br />
     The words of the leading text should be displayed<br />
     normally--no markup to hide it from view.<br />
@@ -1150,8 +1150,8 @@ replaced with the message '<email address hidden>'.
     >>> print(test_tales('foo/fmt:obfuscate-email', foo='<foo@xxxxxxx>'))
     <email address hidden>
 
-    >>> print test_tales('foo/fmt:obfuscate-email/fmt:text-to-html',
-    ...     foo=signature)
+    >>> print(test_tales('foo/fmt:obfuscate-email/fmt:text-to-html',
+    ...     foo=signature))
     <p>--<br />
     __C U R T I S  C.  H O V E Y_______<br />
     &lt;email address hidden&gt;<br />
@@ -1506,7 +1506,7 @@ MenuLinks (ILink) can be formatted anchored text and icons.
 
 The link can be rendered as an anchored icon.
 
-    >>> print test_tales('menu_link/fmt:icon', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:icon', menu_link=menu_link))
     <a class="menu-link-test_link sprite icon action-icon"
        href="http://launchpad.test/+place";
        title="summary">text</a>
@@ -1514,7 +1514,7 @@ The link can be rendered as an anchored icon.
 The default rendering can be explicitly called too, text with an icon to
 the left.
 
-    >>> print test_tales('menu_link/fmt:link', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:link', menu_link=menu_link))
     <a class="menu-link-test_link sprite icon"
        href="http://launchpad.test/+place";
        title="summary">text</a>
@@ -1523,19 +1523,19 @@ The 'edit', 'remove' and 'trash-icon' links are rendered icons followed
 by text. They have both the sprite and modify CSS classes.
 
     >>> menu_link.icon = 'edit'
-    >>> print test_tales('menu_link/fmt:link', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:link', menu_link=menu_link))
     <a class="menu-link-test_link sprite modify edit"
        href="http://launchpad.test/+place";
        title="summary">text</a>
 
     >>> menu_link.icon = 'remove'
-    >>> print test_tales('menu_link/fmt:link', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:link', menu_link=menu_link))
     <a class="menu-link-test_link sprite modify remove"
        href="http://launchpad.test/+place";
        title="summary">text</a>
 
     >>> menu_link.icon = 'trash-icon'
-    >>> print test_tales('menu_link/fmt:link', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:link', menu_link=menu_link))
     <a class="menu-link-test_link sprite modify trash-icon"
        href="http://launchpad.test/+place";
        title="summary">text</a>
@@ -1545,19 +1545,19 @@ fmt:link. They do not control formatting as they once did; fmt:link
 controls the format based on the icon name.
 
     >>> menu_link.icon = 'icon'
-    >>> print test_tales('menu_link/fmt:icon-link', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:icon-link', menu_link=menu_link))
     <a class="menu-link-test_link sprite icon"
        href="http://launchpad.test/+place";
        title="summary">text</a>
 
-    >>> print test_tales('menu_link/fmt:link-icon', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:link-icon', menu_link=menu_link))
     <a class="menu-link-test_link sprite icon"
        href="http://launchpad.test/+place";
        title="summary">text</a>
 
 And the url format is also available.
 
-    >>> print test_tales('menu_link/fmt:url', menu_link=menu_link)
+    >>> print(test_tales('menu_link/fmt:url', menu_link=menu_link))
     http://launchpad.test/+place
 
 If the link is disabled, no markup is rendered.
@@ -1586,7 +1586,7 @@ Any object can be converted to the 'public' CSS class. The object does
 not need to implement IPrivacy.
 
     >>> thing = object()
-    >>> print test_tales('context/fmt:global-css', context=thing)
+    >>> print(test_tales('context/fmt:global-css', context=thing))
     public
 
 The CSS class honors the state of the object's privacy if the object
@@ -1594,10 +1594,10 @@ supports the private attribute. If the object is not private, the class
 is 'public'.
 
     >>> bug = factory.makeBug(title='public-and-private')
-    >>> print bug.private
+    >>> print(bug.private)
     False
 
-    >>> print test_tales('context/fmt:global-css', context=bug)
+    >>> print(test_tales('context/fmt:global-css', context=bug))
     public
 
 If the private attribute is True, the class is 'private'.
@@ -1607,7 +1607,7 @@ If the private attribute is True, the class is 'private'.
     >>> bug.setPrivate(True, owner)
     True
 
-    >>> print test_tales('context/fmt:global-css', context=bug)
+    >>> print(test_tales('context/fmt:global-css', context=bug))
     private
 
     >>> login(ANONYMOUS)
diff --git a/lib/lp/app/doc/textformatting.txt b/lib/lp/app/doc/textformatting.txt
index 3d15a5b..bcbdc00 100644
--- a/lib/lp/app/doc/textformatting.txt
+++ b/lib/lp/app/doc/textformatting.txt
@@ -29,7 +29,7 @@ So, with textwrap:
     ...     "actual email that gets sent.\n\n"
     ...     "It's also smart enough to preserve whitespace, finally!""")
     >>> wrapped_description = textwrap.fill(description, width=56)
-    >>> print wrapped_description  #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_description)  #doctest: -NORMALIZE_WHITESPACE
     A new description that is quite long. But the nice thing
     is that the edit notification email generator knows how
     to indent and wrap descriptions, so this will appear
@@ -45,7 +45,7 @@ MailWrapper to the rescue!
 
     >>> from lp.services.mail.mailwrapper import MailWrapper
     >>> mailwrapper = MailWrapper(width=56)
-    >>> print mailwrapper.format(description) #doctest: -NORMALIZE_WHITESPACE
+    >>> print(mailwrapper.format(description)) #doctest: -NORMALIZE_WHITESPACE
     A new description that is quite long. But the nice thing
     is that the edit notification email generator knows how
     to indent and wrap descriptions, so this will appear
@@ -65,7 +65,7 @@ Let's just make sure that it handles a single paragraph as well.
     ...     "and wrap descriptions, so this will appear quite nice in the "
     ...     "actual email that gets sent.")
     >>> wrapped_text = mailwrapper.format(single_paragraph)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
     A new description that is quite long. But the nice thing
     is that the edit notification email generator knows how
     to indent and wrap descriptions, so this will appear
@@ -80,7 +80,7 @@ already.
     ... characters. It shouldn't be wrapped.
     ... """
     >>> wrapped_text = mailwrapper.format(already_wrapped)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
     This paragraph contains only lines that are less than 56
     characters. It shouldn't be wrapped.
 
@@ -95,7 +95,7 @@ of spaces:
     ... It shouldn't be wrapped.
     ... """
     >>> wrapped_text = mailwrapper.format(already_wrapped)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
     This paragraph contains only lines that are less than 56
     characters.
     <BLANKLINE>
@@ -113,7 +113,7 @@ allowed length. These shouldn't be wrapped.
     ... This is a reply to the line above.
     ... """
     >>> wrapped_text = mailwrapper.format(long_quoted_lines)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
     > > > > > Someone wrote this a long time ago. When it was written
     > > > > > all lines were less than 56 characters, but now they are
     > > > > > longer.
@@ -130,7 +130,7 @@ single line, such as URLs.
     ...     "Even though it's longer than 56 characters, it stays on a "
     ...     "single line.")
     >>> wrapped_text = mailwrapper.format(long_word)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
     This paragraph includes a long URL,
     https://launchpad.net/greenishballoon/+bug/1733/+subscriptions.
     Even though it's longer than 56 characters, it stays on
@@ -160,7 +160,7 @@ It preserves whitespace in the beginning of the line.
     ...                `^'     \ :/           `^'  `-^-'   \v/ :  \/
     ... """
     >>> wrapped_text = mailwrapper.format(ascii_cow)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
     <BLANKLINE>
                                                   /;    ;\
                                               __  \\____//
@@ -186,14 +186,14 @@ We can indent text as well:
 
     >>> mailwrapper = MailWrapper(width=56, indent=4*' ')
     >>> wrapped_text = mailwrapper.format(long_quoted_lines)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
         > > > > > Someone wrote this a long time ago. When it was written
         > > > > > all lines were less than 56 characters, but now they are
         > > > > > longer.
     <BLANKLINE>
         This is a reply to the line above.
 
-    >>> print mailwrapper.format(description) #doctest: -NORMALIZE_WHITESPACE
+    >>> print(mailwrapper.format(description)) #doctest: -NORMALIZE_WHITESPACE
         A new description that is quite long. But the nice
         thing is that the edit notification email generator
         knows how to indent and wrap descriptions, so this
@@ -208,7 +208,7 @@ Sometimes we don't want to indent the first line.
 
     >>> mailwrapper = MailWrapper(
     ...     width=56, indent=4*' ', indent_first_line=False)
-    >>> print mailwrapper.format(description) #doctest: -NORMALIZE_WHITESPACE
+    >>> print(mailwrapper.format(description)) #doctest: -NORMALIZE_WHITESPACE
     A new description that is quite long. But the nice thing
         is that the edit notification email generator knows
         how to indent and wrap descriptions, so this will
@@ -219,7 +219,7 @@ Sometimes we don't want to indent the first line.
         finally!
 
     >>> wrapped_text = mailwrapper.format(long_quoted_lines)
-    >>> print wrapped_text #doctest: -NORMALIZE_WHITESPACE
+    >>> print(wrapped_text) #doctest: -NORMALIZE_WHITESPACE
     > > > > > Someone wrote this a long time ago. When it was written
         > > > > > all lines were less than 56 characters, but now they are
         > > > > > longer.
@@ -264,7 +264,7 @@ long hyphenated URL.  Under normal circumstances, this will get wrapped.
 
     >>> wrapper = MailWrapper(72)
     >>> body = wrapper.format(text, force_wrap=True)
-    >>> print body
+    >>> print(body)
     Hello Scarlett O'Hara,
     <BLANKLINE>
     frankly-my-dear-i-dont-give-a-damn has a new message requiring your
@@ -297,7 +297,7 @@ The callable's argument is the pre-wrapped paragraph.
     ...     return paragraph.startswith('http://')
 
     >>> body = wrapper.format(text, force_wrap=True, wrap_func=nowrap)
-    >>> print body
+    >>> print(body)
     Hello Scarlett O'Hara,
     <BLANKLINE>
     frankly-my-dear-i-dont-give-a-damn has a new message requiring your
diff --git a/lib/lp/app/stories/basics/copyright.txt b/lib/lp/app/stories/basics/copyright.txt
index f10c8d7..3a6aee4 100644
--- a/lib/lp/app/stories/basics/copyright.txt
+++ b/lib/lp/app/stories/basics/copyright.txt
@@ -4,12 +4,13 @@ The tour pages.
 
   >>> browser.open('http://launchpad.test/')
   >>> browser.getLink('Take the tour').click()
-  >>> print extract_text(find_tag_by_id(browser.contents, 'footer-navigation'))
+  >>> print(
+  ...     extract_text(find_tag_by_id(browser.contents, 'footer-navigation')))
   Next...© 2004-2020 Canonical Ltd...
 
 The main template.
 
   >>> browser.open('http://launchpad.test')
-  >>> print extract_text(find_tag_by_id(browser.contents, 'footer'))
+  >>> print(extract_text(find_tag_by_id(browser.contents, 'footer')))
   © 2004-2020 Canonical Ltd.
   ...
diff --git a/lib/lp/app/stories/basics/demo-and-lpnet.txt b/lib/lp/app/stories/basics/demo-and-lpnet.txt
index c367ed9..7083ba8 100644
--- a/lib/lp/app/stories/basics/demo-and-lpnet.txt
+++ b/lib/lp/app/stories/basics/demo-and-lpnet.txt
@@ -29,7 +29,7 @@ information and styles.
     >>> from lp.testing.fixture import DemoMode
     >>> demo_mode_fixture = DemoMode()
     >>> demo_mode_fixture.setUp()
-    >>> print config.launchpad.is_demo
+    >>> print(config.launchpad.is_demo)
     True
 
 The demo style is applied and the site_message is also included as part
@@ -39,29 +39,29 @@ be escaped.
 For a 3-0 page:
 
     >>> browser.open('http://launchpad.test/ubuntu')
-    >>> print browser.contents
+    >>> print(browser.contents)
     <...
     <style...url(/@@/demo)...</style>
     ...
-    >>> print extract_text(find_tag_by_id(browser.contents, 'lp-version'))
+    >>> print(extract_text(find_tag_by_id(browser.contents, 'lp-version')))
     • r... devmode demo site (Get the code!)
 
-    >>> print extract_text(find_tags_by_class(
-    ...     browser.contents, 'sitemessage')[0])
+    >>> print(extract_text(find_tags_by_class(
+    ...     browser.contents, 'sitemessage')[0]))
     This is a demo site mmk. File a bug.
-    >>> print browser.getLink(url="http://example.com";).text
+    >>> print(browser.getLink(url="http://example.com";).text)
     File a bug
 
 When you are not on a demo site, the text no longer appears.
 
     >>> demo_mode_fixture.cleanUp()
-    >>> print config.launchpad.is_demo
+    >>> print(config.launchpad.is_demo)
     False
 
 First for a 3-0 page:
 
     >>> browser.open('http://launchpad.test/ubuntu')
-    >>> print extract_text(find_tag_by_id(browser.contents, 'lp-version'))
+    >>> print(extract_text(find_tag_by_id(browser.contents, 'lp-version')))
     • r... devmode (Get the code!)
     >>> len(find_tags_by_class(browser.contents, 'sitemessage'))
     0
diff --git a/lib/lp/app/stories/basics/marketing.txt b/lib/lp/app/stories/basics/marketing.txt
index 1c56332..fd4c1ce 100644
--- a/lib/lp/app/stories/basics/marketing.txt
+++ b/lib/lp/app/stories/basics/marketing.txt
@@ -4,12 +4,12 @@ From Launchpad's front page, you can access the tour.
 
     >>> browser.open('http://launchpad.test/')
     >>> tour_link = browser.getLink('Take the tour')
-    >>> print tour_link.url
+    >>> print(tour_link.url)
     http://launchpad.test/+tour
     >>> tour_link.click()
-    >>> print browser.title
+    >>> print(browser.title)
     Launchpad tour
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/index
 
 The tour is circular. Clicking the Next button repeatedly will bring you
@@ -17,7 +17,7 @@ back to the tour start.
 
     >>> def take_the_tour(steps_taken=0):
     ...     browser.getLink(id='btnNext').click()
-    ...     print browser.url
+    ...     print(browser.url)
     ...     if browser.url != 'http://launchpad.test/+tour/index':
     ...         if steps_taken >= 20:
     ...             raise RuntimeError('Never ending tour!')
@@ -55,27 +55,27 @@ Each application used to have an introduction living at +about, this is
 now redirected to the relevant tour page.
 
     >>> browser.open('http://launchpad.test/+about')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/index
 
     >>> browser.open('http://code.launchpad.test/+about')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/branch-hosting-tracking
 
     >>> browser.open('http://bugs.launchpad.test/+about')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/bugs
 
     >>> browser.open('http://blueprints.launchpad.test/+about')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/feature-tracking
 
     >>> browser.open('http://translations.launchpad.test/+about')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/translation
 
     >>> browser.open('http://answers.launchpad.test/+about')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/community-support
 
 
@@ -85,27 +85,27 @@ Similarly, each application has their +tour redirecting to their proper
 tour page.
 
     >>> browser.open('http://launchpad.test/+tour')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/index
 
     >>> browser.open('http://code.launchpad.test/+tour')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/branch-hosting-tracking
 
     >>> browser.open('http://bugs.launchpad.test/+tour')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/bugs
 
     >>> browser.open('http://blueprints.launchpad.test/+tour')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/feature-tracking
 
     >>> browser.open('http://translations.launchpad.test/+tour')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/translation
 
     >>> browser.open('http://answers.launchpad.test/+tour')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/community-support
 
 
@@ -115,23 +115,23 @@ Each application also had a +faq link, that link is also redirected to
 the appropriate tour page.
 
     >>> browser.open('http://code.launchpad.test/+faq')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/branch-hosting-tracking
 
     >>> browser.open('http://bugs.launchpad.test/+faq')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/bugs
 
     >>> browser.open('http://blueprints.launchpad.test/+faq')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/feature-tracking
 
     >>> browser.open('http://translations.launchpad.test/+faq')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/translation
 
     >>> browser.open('http://answers.launchpad.test/+faq')
-    >>> print browser.url
+    >>> print(browser.url)
     http://launchpad.test/+tour/community-support
 
 
@@ -145,7 +145,7 @@ the user to the appropriate tour page.
 
     >>> browser.open('http://code.launchpad.test')
     >>> tour_link = browser.getLink('Take a tour')
-    >>> print tour_link.url
+    >>> print(tour_link.url)
     http://launchpad.test/+tour/branch-hosting-tracking
     >>> tour_link.click()
 
@@ -154,7 +154,7 @@ the user to the appropriate tour page.
 
     >>> browser.open('http://bugs.launchpad.test')
     >>> tour_link = browser.getLink('take a tour')
-    >>> print tour_link.url
+    >>> print(tour_link.url)
     http://bugs.launchpad.test/+tour
     >>> tour_link.click()
 
@@ -163,7 +163,7 @@ the user to the appropriate tour page.
 
     >>> browser.open('http://blueprints.launchpad.test')
     >>> tour_link = browser.getLink('Take a tour')
-    >>> print tour_link.url
+    >>> print(tour_link.url)
     http://launchpad.test/+tour/feature-tracking
     >>> tour_link.click()
 
@@ -172,7 +172,7 @@ the user to the appropriate tour page.
 
     >>> browser.open('http://translations.launchpad.test')
     >>> tour_link = browser.getLink('Take a tour')
-    >>> print tour_link.url
+    >>> print(tour_link.url)
     http://launchpad.test/+tour/translation
     >>> tour_link.click()
 
@@ -181,6 +181,6 @@ the user to the appropriate tour page.
 
     >>> browser.open('http://answers.launchpad.test')
     >>> tour_link = browser.getLink('Take a tour')
-    >>> print tour_link.url
+    >>> print(tour_link.url)
     http://launchpad.test/+tour/community-support
     >>> tour_link.click()
diff --git a/lib/lp/app/stories/basics/max-batch-size.txt b/lib/lp/app/stories/basics/max-batch-size.txt
index 0954057..12b15e0 100644
--- a/lib/lp/app/stories/basics/max-batch-size.txt
+++ b/lib/lp/app/stories/basics/max-batch-size.txt
@@ -11,7 +11,7 @@ large and what is the current maximum.
     ...
     HTTPError: HTTP Error 400: Bad Request
 
-    >>> print extract_text(find_main_content(anon_browser.contents))
+    >>> print(extract_text(find_main_content(anon_browser.contents)))
     Invalid Batch Size
     Your requested batch size exceeded the maximum batch size allowed.
     ...
diff --git a/lib/lp/app/stories/basics/notfound-error.txt b/lib/lp/app/stories/basics/notfound-error.txt
index f6b0d75..cebd9fa 100644
--- a/lib/lp/app/stories/basics/notfound-error.txt
+++ b/lib/lp/app/stories/basics/notfound-error.txt
@@ -11,7 +11,7 @@ the contents of any error page. So we use http() instead.)
     ... GET /+fhqwhgads HTTP/1.1
     ... Referer: http://launchpad.test/+about
     ... """))
-    >>> print page_with_referer
+    >>> print(page_with_referer)
     HTTP/1.1 404 Not Found
     ...href="http://launchpad.test/+about";...
 
@@ -19,7 +19,7 @@ It also contains instructions specific to broken links.
 
     >>> main_content = find_main_content(page_with_referer)
     >>> for paragraph in main_content('p'):
-    ...     print extract_text(paragraph)
+    ...     print(extract_text(paragraph))
     This page does not exist, or you may not have permission to see it.
     If you have been to this page before, it is possible it has been removed.
     If you got here from a link elsewhere on Launchpad...
@@ -34,12 +34,12 @@ things like mistyped URLs.
     >>> page_with_no_referer = str(http(r"""
     ... GET /+fhqwhgads HTTP/1.1
     ... """))
-    >>> print page_with_no_referer
+    >>> print(page_with_no_referer)
     HTTP/1.1 404 Not Found
     ...
     >>> main_content = find_main_content(page_with_no_referer)
     >>> for paragraph in main_content('p'):
-    ...     print extract_text(paragraph)
+    ...     print(extract_text(paragraph))
     This page does not exist, or you may not have permission to see it.
     If you have been to this page before, it is possible it has been removed.
     Check that you are logged in with the correct account, or that you
diff --git a/lib/lp/app/stories/basics/notfound-head.txt b/lib/lp/app/stories/basics/notfound-head.txt
index 1fe93d3..8639eea 100644
--- a/lib/lp/app/stories/basics/notfound-head.txt
+++ b/lib/lp/app/stories/basics/notfound-head.txt
@@ -5,21 +5,21 @@ errors (such as 404s).
   >>> response = http(r"""
   ... HEAD / HTTP/1.1
   ... """)
-  >>> print str(response).split('\n')[0]
+  >>> print(str(response).split('\n')[0])
   HTTP/1.1 200 Ok
-  >>> print response.getHeader('Content-Length')
+  >>> print(response.getHeader('Content-Length'))
   0
-  >>> print response.getBody()
+  >>> print(response.getBody())
   <BLANKLINE>
 
   >>> response = http(r"""
   ... HEAD /badurl HTTP/1.1
   ... """)
-  >>> print str(response).split('\n')[0]
+  >>> print(str(response).split('\n')[0])
   HTTP/1.1 404 Not Found
-  >>> print response.getHeader('Content-Length')
+  >>> print(response.getHeader('Content-Length'))
   0
-  >>> print response.getBody()
+  >>> print(response.getBody())
   <BLANKLINE>
 
 Register a test page that generates HTTP 500 errors.
@@ -41,11 +41,11 @@ no body.
   >>> response = http(r"""
   ... HEAD /error-test HTTP/1.1
   ... """)
-  >>> print str(response).split('\n')[0]
+  >>> print(str(response).split('\n')[0])
   HTTP/1.1 500 Internal Server Error
-  >>> print response.getHeader('Content-Length')
+  >>> print(response.getHeader('Content-Length'))
   0
-  >>> print response.getBody()
+  >>> print(response.getBody())
   <BLANKLINE>
 
 
diff --git a/lib/lp/app/stories/basics/page-request-summaries.txt b/lib/lp/app/stories/basics/page-request-summaries.txt
index ee51675..546f8a1 100644
--- a/lib/lp/app/stories/basics/page-request-summaries.txt
+++ b/lib/lp/app/stories/basics/page-request-summaries.txt
@@ -5,7 +5,7 @@ finish rendering -- the authoritative source is in OOPS reports or in the web
 app's stderr.
 
   >>> browser.open('http://launchpad.test/')
-  >>> print browser.contents
+  >>> print(browser.contents)
   <!DOCTYPE...
   ...<!--... At least ... actions issued in ... seconds ...-->...
 
@@ -13,6 +13,6 @@ It's available for any page:
 
 
   >>> browser.open('http://launchpad.test/~mark/')
-  >>> print browser.contents
+  >>> print(browser.contents)
   <!DOCTYPE...
   ...<!--... At least ... actions issued in ... seconds ...-->...
diff --git a/lib/lp/app/stories/basics/user-requested-oops.txt b/lib/lp/app/stories/basics/user-requested-oops.txt
index 6f7db14..d2475a0 100644
--- a/lib/lp/app/stories/basics/user-requested-oops.txt
+++ b/lib/lp/app/stories/basics/user-requested-oops.txt
@@ -13,7 +13,7 @@ page traversal.
 The OOPS id is put into the comment at the end of the document.
 
     >>> (page, summary) = browser.contents.split('</body>')
-    >>> print summary
+    >>> print(summary)
     ...
     <!-- ...
     At least ... actions issued in ... seconds OOPS-...
@@ -26,7 +26,7 @@ The ++oops++ can be anywhere in the traversal.
 
     >>> browser.open("http://launchpad.test/gnome-terminal/++oops++/trunk";)
     >>> (page, summary) = browser.contents.split('</body>')
-    >>> print summary
+    >>> print(summary)
     ...
     <!-- ...
     At least ... actions issued in ... seconds OOPS-...
diff --git a/lib/lp/app/stories/basics/xx-dbpolicy.txt b/lib/lp/app/stories/basics/xx-dbpolicy.txt
index f7cc46e..b315236 100644
--- a/lib/lp/app/stories/basics/xx-dbpolicy.txt
+++ b/lib/lp/app/stories/basics/xx-dbpolicy.txt
@@ -53,14 +53,14 @@ Read only requests such as GET and HEAD will use the MAIN SLAVE
 Store by default.
 
     >>> browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(browser)
+    >>> print(whichdb(browser))
     SLAVE
 
 POST requests might make updates, so they use the MAIN MASTER
 Store by default.
 
     >>> browser.getControl('Do Post').click()
-    >>> print whichdb(browser)
+    >>> print(whichdb(browser))
     MASTER
 
 This is an unauthenticated browser.  These typically have no session, unless
@@ -68,7 +68,7 @@ special dispensation has been made. Without a session, subsequent requests
 will then immediately return to using the SLAVE.
 
     >>> browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(browser)
+    >>> print(whichdb(browser))
     SLAVE
 
 However, if the request has a session (that is, is authenticated; or is
@@ -80,20 +80,20 @@ slave databases may lag some time behind the master database.
 
     >>> browser.addHeader('Authorization', 'Basic mark@xxxxxxxxxxx:test')
     >>> browser.getControl('Do Post').click() # POST request
-    >>> print whichdb(browser)
+    >>> print(whichdb(browser))
     MASTER
     >>> browser.open('http://launchpad.test/+whichdb') # GET request
-    >>> print whichdb(browser)
+    >>> print(whichdb(browser))
     MASTER
 
 GET and HEAD requests from other clients are unaffected though
 and use the MAIN SLAVE Store by default.
 
     >>> anon_browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(anon_browser)
+    >>> print(whichdb(anon_browser))
     SLAVE
     >>> admin_browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(admin_browser)
+    >>> print(whichdb(admin_browser))
     SLAVE
 
 If no more POST requests are made for 5 minutes, GET and HEAD
@@ -111,13 +111,13 @@ To test this, first we need to wind forward the database policy's clock.
 
 
     >>> browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(browser)
+    >>> print(whichdb(browser))
     MASTER
 
     >>> dbpolicy._now = _future_now # Install the time machine.
 
     >>> browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(browser)
+    >>> print(whichdb(browser))
     SLAVE
 
     >>> dbpolicy._now = _original_now # Reset the time machine.
@@ -128,12 +128,12 @@ replication oddities from becoming too bad, as well as lightening the load
 on the slaves allowing them to catch up.
 
     >>> anon_browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(anon_browser)
+    >>> print(whichdb(anon_browser))
     SLAVE
 
     >>> dbpolicy._test_lag = timedelta(minutes=10)
     >>> anon_browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(anon_browser)
+    >>> print(whichdb(anon_browser))
     MASTER
     >>> dbpolicy._test_lag = None
 
@@ -149,7 +149,7 @@ returning a 404 error to the user.
 
     # Confirm requests are going to the SLAVE
     >>> anon_browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(anon_browser)
+    >>> print(whichdb(anon_browser))
     SLAVE
 
     # The slave database contains no data, but we don't get
@@ -166,7 +166,7 @@ returning a 404 error to the user.
 
     # This session is still using the SLAVE though by default.
     >>> anon_browser.open('http://launchpad.test/+whichdb')
-    >>> print whichdb(anon_browser)
+    >>> print(whichdb(anon_browser))
     SLAVE
 
 Reset our config to avoid affecting other tests.
diff --git a/lib/lp/app/stories/basics/xx-developerexceptions.txt b/lib/lp/app/stories/basics/xx-developerexceptions.txt
index 86e7d1e..57eed1a 100644
--- a/lib/lp/app/stories/basics/xx-developerexceptions.txt
+++ b/lib/lp/app/stories/basics/xx-developerexceptions.txt
@@ -42,7 +42,7 @@ Anonymous users don't get tracebacks.
 
 And the OOPS ID is displayed but not linkified.
 
-    >>> print find_main_content(str(result))
+    >>> print(find_main_content(str(result)))
     <...
     <h1 class="exception">Oops!</h1>
     ...
@@ -61,7 +61,7 @@ In this case, we are logged in as the foo.bar@xxxxxxxxxxxxx user.
 
 And the OOPS ID is displayed and linkified.
 
-    >>> print find_main_content(str(result))
+    >>> print(find_main_content(str(result)))
     <...
     <h1 class="exception">Oops!</h1>
     ...
@@ -80,7 +80,7 @@ case, Carlos.
 
 And the OOPS ID is displayed but not linkified.
 
-    >>> print find_main_content(str(result))
+    >>> print(find_main_content(str(result)))
     <...
     <h1 class="exception">Oops!</h1>
     ...
@@ -101,9 +101,9 @@ unregister the adapter.
 lp.testing.pages.http accepts the handle_errors parameter in case you
 want to see tracebacks instead of error pages.
 
-  >>> print http(r"""
+  >>> print(http(r"""
   ... GET /whatever HTTP/1.1
-  ... """, handle_errors=False)
+  ... """, handle_errors=False))
   Traceback (most recent call last):
   ...
   NotFound: ...
diff --git a/lib/lp/app/stories/basics/xx-launchpad-statistics.txt b/lib/lp/app/stories/basics/xx-launchpad-statistics.txt
index 269ed87..542384a 100644
--- a/lib/lp/app/stories/basics/xx-launchpad-statistics.txt
+++ b/lib/lp/app/stories/basics/xx-launchpad-statistics.txt
@@ -11,7 +11,7 @@ acessible to launchpad Admins:
 When we login as an admin, we can see all the stats listed:
 
   >>> admin_browser.open('http://launchpad.test/+statistics/')
-  >>> print admin_browser.title
+  >>> print(admin_browser.title)
   Launchpad statistics
   >>> 'answered_question_count' in admin_browser.contents
   True
diff --git a/lib/lp/app/stories/basics/xx-lowercase-redirection.txt b/lib/lp/app/stories/basics/xx-lowercase-redirection.txt
index 41c3e40..6dbee0c 100644
--- a/lib/lp/app/stories/basics/xx-lowercase-redirection.txt
+++ b/lib/lp/app/stories/basics/xx-lowercase-redirection.txt
@@ -4,34 +4,34 @@ When someone visits a page such as http://launchpad.net/jOkOshEr
 launchpad does a permanent redirect to the lowercase path:
 
     >>> anon_browser.open('http://launchpad.test/jOkOshEr')
-    >>> print anon_browser.url
+    >>> print(anon_browser.url)
     http://launchpad.test/jokosher
 
 The redirection also works for URLs below the root, and with query
 parameters:
 
     >>> anon_browser.open('http://launchpad.test/UbUntU/+search?text=foo')
-    >>> print anon_browser.url
+    >>> print(anon_browser.url)
     http://launchpad.test/ubuntu/+search?text=foo
 
 The redirection also works for other Launchpad subdomains:
 
     >>> anon_browser.open(
     ...     'http://bugs.launchpad.test/jOkOshEr/+bugs?orderby=-datecreated')
-    >>> print anon_browser.url
+    >>> print(anon_browser.url)
     http://bugs.launchpad.test/jokosher/+bugs?orderby=-datecreated
 
     >>> anon_browser.open('http://answers.launchpad.test/~nAmE12')
-    >>> print anon_browser.url
+    >>> print(anon_browser.url)
     http://answers.launchpad.test/~name12
     
 When doing a POST to an invalid URL, we get an error:
 
-    >>> print http(r"""
+    >>> print(http(r"""
     ... POST /UbUntU/hoary/+source/evolution/+pots/evolution-2.2/es/+translate HTTP/1.1
     ... Host: translations.launchpad.test
     ... Referer: https://launchpad.test/
-    ... """)
+    ... """))
     HTTP/1.1 500 Internal Server Error
     ...
     </ul><p>POSTToNonCanonicalURL...
diff --git a/lib/lp/app/stories/basics/xx-maintenance-message.txt b/lib/lp/app/stories/basics/xx-maintenance-message.txt
index db47ea2..75a9c04 100644
--- a/lib/lp/app/stories/basics/xx-maintenance-message.txt
+++ b/lib/lp/app/stories/basics/xx-maintenance-message.txt
@@ -31,7 +31,7 @@ this timebombing.
     >>> os.system(
     ... 'date --iso-8601=minutes -u -d +10mins30secs > +maintenancetime.txt')
     0
-    >>> print front_page_content()
+    >>> print(front_page_content())
     HTTP/1.1 200 Ok
     ...
       Launchpad will be going offline for maintenance
diff --git a/lib/lp/app/stories/basics/xx-notifications.txt b/lib/lp/app/stories/basics/xx-notifications.txt
index 37d3709..82da694 100644
--- a/lib/lp/app/stories/basics/xx-notifications.txt
+++ b/lib/lp/app/stories/basics/xx-notifications.txt
@@ -3,10 +3,10 @@ Ensure that notifications are being displayed and propogated correctly.
 
 This first page adds notifications itself before being rendered.
 
->>> print http(r"""
+>>> print(http(r"""
 ... GET /+notificationtest1 HTTP/1.1
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
-... """)
+... """))
 HTTP/1.1 200 Ok
 Content-Length: ...
 Content-Type: text/html;charset=utf-8
@@ -28,7 +28,7 @@ The notification messages should be propogated.
 ... GET /+notificationtest2 HTTP/1.1
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
 ... """)
->>> print result
+>>> print(result)
 HTTP/1.1 303 See Other
 ...
 Location: /
@@ -38,11 +38,11 @@ Location: /
 >>> launchpad_session_cookie = re.search(
 ...     '(?m)^Set-Cookie:\slaunchpad_tests=(.*?);', str(result)).group(1)
 ...     )
->>> print http(r"""
+>>> print(http(r"""
 ... GET %(destination_url)s HTTP/1.1
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
 ... Cookie: launchpad_tests=%(launchpad_session_cookie)s
-... """ % vars())
+... """ % vars()))
 HTTP/1.1 200 Ok
 ...
 ...<div class="error message">Error notification <b>1</b></div>
@@ -64,7 +64,7 @@ combined.
 ... GET /+notificationtest3 HTTP/1.1
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
 ... """)
->>> print result
+>>> print(result)
 HTTP/1.1 303 See Other
 ...
 Content-Length: 0
@@ -76,11 +76,11 @@ Location: /+notificationtest1
 >>> launchpad_session_cookie = re.search(
 ...     '(?m)^Set-Cookie:\slaunchpad_tests=(.*?);', str(result)).group(1)
 ...     )
->>> print http(r"""
+>>> print(http(r"""
 ... GET %(destination_url)s HTTP/1.1
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
 ... Cookie: launchpad_tests=%(launchpad_session_cookie)s
-... """ % vars())
+... """ % vars()))
 HTTP/1.1 200 Ok
 ...
 ...<div class="error message">+notificationtest3 error</div>
@@ -105,7 +105,7 @@ be needed.
 ... GET /+notificationtest4 HTTP/1.1
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
 ... """)
->>> print result
+>>> print(result)
 HTTP/1.1 303 See Other
 ...
 Content-Length: 0
@@ -122,7 +122,7 @@ Location: /+notificationtest3
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
 ... Cookie: launchpad_tests=%(launchpad_session_cookie)s
 ... """ % vars())
->>> print result
+>>> print(result)
 HTTP/1.1 303 See Other
 ...
 Content-Length: 0
@@ -134,11 +134,11 @@ Location: /+notificationtest1
 >>> launchpad_session_cookie = re.search(
 ...     '(?m)^Set-Cookie:\slaunchpad_tests=(.*?);', str(result)).group(1)
 ...     )
->>> print http(r"""
+>>> print(http(r"""
 ... GET %(destination_url)s HTTP/1.1
 ... Authorization: Basic Y2FybG9zQGNhbm9uaWNhbC5jb206dGVzdA==
 ... Cookie: launchpad_tests=%(launchpad_session_cookie)s
-... """ % vars())
+... """ % vars()))
 HTTP/1.1 200 Ok
 ...
 ...<div class="error message">+notificationtest4 error</div>
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 74b9812..a58154b 100644
--- a/lib/lp/app/stories/basics/xx-offsite-form-post.txt
+++ b/lib/lp/app/stories/basics/xx-offsite-form-post.txt
@@ -84,9 +84,9 @@ present a hopefully helpful error message.
   Traceback (most recent call last):
     ...
   HTTPError...
-  >>> print browser.headers['status']
+  >>> print(browser.headers['status'])
   403 Forbidden
-  >>> print extract_text(find_main_content(browser.contents))
+  >>> print(extract_text(find_main_content(browser.contents)))
   No REFERER Header
   ...
   >>> browser.getLink('the FAQ').url
@@ -135,7 +135,7 @@ managed by launchpad:
   >>> browser.getControl('Name', index=0).value = 'team4'
   >>> browser.getControl('Display Name').value = 'Team 4'
   >>> browser.getControl('Create').click()
-  >>> print browser.url
+  >>> print(browser.url)
   http://launchpad.test/~team4
 
   # Now restore our site's hostname.
diff --git a/lib/lp/app/stories/basics/xx-opstats.txt b/lib/lp/app/stories/basics/xx-opstats.txt
index 655b7d0..88a567b 100644
--- a/lib/lp/app/stories/basics/xx-opstats.txt
+++ b/lib/lp/app/stories/basics/xx-opstats.txt
@@ -22,7 +22,7 @@ Create a function to report our stats for these tests.
     ...     for stat_key in sorted(stats.keys()):
     ...         value = stats[stat_key]
     ...         if value > 0:
-    ...             print "%s: %d" % (stat_key, value)
+    ...             print("%s: %d" % (stat_key, value))
     ...     reset()
     ...
 
@@ -39,7 +39,7 @@ are adjusted after the request has been served:
     >>> for key in sorted(stats.keys()):
     ...     # Print all so new keys added to OpStats.stats will trigger
     ...     # failures in this test prompting developers to extend it.
-    ...     print '%s: %d' % (key, stats[key])
+    ...     print('%s: %d' % (key, stats[key]))
     1XXs: 0
     2XXs: 0
     3XXs: 0
@@ -146,7 +146,7 @@ Number of XML-RPC Faults
 
     >>> try:
     ...     opstats = lp_xmlrpc.invalid() # XXX: Need a HTTP test too
-    ...     print 'Should have raised a Fault exception!'
+    ...     print('Should have raised a Fault exception!')
     ... except xmlrpc_client.Fault:
     ...     pass
     >>> report()
@@ -212,7 +212,7 @@ Stats can also be retrieved via HTTP in cricket-graph format:
 
     >>> output = http("GET / HTTP/1.1\nHost: launchpad.test\n")
     >>> output = http("GET / HTTP/1.1\nHost: launchpad.test\n")
-    >>> print http("GET /+opstats HTTP/1.1\nHost: launchpad.test\n")
+    >>> print(http("GET /+opstats HTTP/1.1\nHost: launchpad.test\n"))
     HTTP/1.1 200 Ok
     ...
     Content-Type: text/plain; charset=US-ASCII
@@ -265,7 +265,7 @@ the new connection information is used.
 
 We can still access the opstats page.
 
-    >>> print http("GET /+opstats HTTP/1.1\nHost: launchpad.test\n")
+    >>> print(http("GET /+opstats HTTP/1.1\nHost: launchpad.test\n"))
     HTTP/1.1 200 Ok
     ...
     Content-Type: text/plain; charset=US-ASCII
@@ -275,11 +275,11 @@ We can still access the opstats page.
 
 This is also true if we are provide authentication.
 
-    >>> print http(r"""
+    >>> print(http(r"""
     ... GET /+opstats HTTP/1.1
     ... Host: launchpad.test
     ... Authorization: Basic Zm9vLmJhckBjYW5vbmljYWwuY29tOnRlc3Q=
-    ... """)
+    ... """))
     HTTP/1.1 200 Ok
     ...
     Content-Type: text/plain; charset=US-ASCII
diff --git a/lib/lp/app/stories/basics/xx-pagetest-logging.txt b/lib/lp/app/stories/basics/xx-pagetest-logging.txt
index f7ab7b1..c140d45 100644
--- a/lib/lp/app/stories/basics/xx-pagetest-logging.txt
+++ b/lib/lp/app/stories/basics/xx-pagetest-logging.txt
@@ -6,6 +6,6 @@ pagetests-access.log file.
     >>> browser.open('http://launchpad.test/')
 
     >>> log = open('logs/pagetests-access.log').read()
-    >>> print log.strip().split('\n')[-1]
+    >>> print(log.strip().split('\n')[-1])
     127.0.0.88 - ... "launchpad.test" [...] "GET / HTTP/1.1" 200 ...
     "Anonymous" "RootObject:index.html" "" "Python-urllib/..."
diff --git a/lib/lp/app/stories/basics/xx-request-expired.txt b/lib/lp/app/stories/basics/xx-request-expired.txt
index fe21067..6f586bf 100644
--- a/lib/lp/app/stories/basics/xx-request-expired.txt
+++ b/lib/lp/app/stories/basics/xx-request-expired.txt
@@ -14,10 +14,10 @@ causing a soft timeout to be logged.
     ...     soft_request_timeout: 2
     ...     """)
     >>> config.push('base_test_data', test_data)
-    >>> print http(r"""
+    >>> print(http(r"""
     ... GET /+soft-timeout HTTP/1.1
     ... Authorization: Basic Zm9vLmJhckBjYW5vbmljYWwuY29tOnRlc3Q=
-    ... """)
+    ... """))
     HTTP/1.1 503 Service Unavailable
     ...
     Retry-After: 900
diff --git a/lib/lp/app/stories/basics/xx-soft-timeout.txt b/lib/lp/app/stories/basics/xx-soft-timeout.txt
index 9a2e175..f846f7f 100644
--- a/lib/lp/app/stories/basics/xx-soft-timeout.txt
+++ b/lib/lp/app/stories/basics/xx-soft-timeout.txt
@@ -2,9 +2,9 @@
 order to prevent it from being abused, only Launchpad developers may
 access that page.
 
-    >>> print http(r"""
+    >>> print(http(r"""
     ... GET /+soft-timeout HTTP/1.1
-    ... """)
+    ... """))
     HTTP/1.1 303 See Other
     ...
     Location: http://.../+soft-timeout/+login
@@ -13,10 +13,10 @@ access that page.
 Sample Person doesn't have access to the page since they aren't a
 Launchpad developer:
 
-    >>> print http(r"""
+    >>> print(http(r"""
     ... GET /+soft-timeout HTTP/1.1
     ... Authorization: Basic dGVzdEBjYW5vbmljYWwuY29tOnRlc3Q=
-    ... """)
+    ... """))
     HTTP/1.1 403 Forbidden
     ...
 
@@ -27,10 +27,10 @@ no timeout value is set, no soft timeout will be generated, though.
     >>> config.database.soft_request_timeout is None
     True
 
-    >>> print http(r"""
+    >>> print(http(r"""
     ... GET /+soft-timeout HTTP/1.1
     ... Authorization: Basic Zm9vLmJhckBjYW5vbmljYWwuY29tOnRlc3Q=
-    ... """)
+    ... """))
     HTTP/1.1 200 Ok
     ...
     No soft timeout threshold is set.
@@ -46,10 +46,10 @@ causing a soft timeout to be logged.
     ...     """))
     >>> config.push('base_test_data', test_data)
 
-    >>> print http(r"""
+    >>> print(http(r"""
     ... GET /+soft-timeout HTTP/1.1
     ... Authorization: Basic Zm9vLmJhckBjYW5vbmljYWwuY29tOnRlc3Q=
-    ... """)
+    ... """))
     HTTP/1.1 200 Ok
     ...
     Soft timeout threshold is set to 1 ms. This page took ... ms to render.
diff --git a/lib/lp/app/stories/folder.txt b/lib/lp/app/stories/folder.txt
index 7764753..02f5547 100644
--- a/lib/lp/app/stories/folder.txt
+++ b/lib/lp/app/stories/folder.txt
@@ -40,13 +40,13 @@ all the traversal logic.
 The view will serve the file that it traverses to.
 
     >>> view = view.publishTraverse(view.request, 'test.txt')
-    >>> print view()
+    >>> print(view())
     Text file
 
 It also sets the appropriate headers for cache control on the response.
 
     >>> for name in sorted(view.request.response.headers):
-    ...     print "%s: %s" % (name, view.request.response.getHeader(name))
+    ...     print("%s: %s" % (name, view.request.response.getHeader(name)))
     Cache-Control: public...
     Content-Type: text/plain
     Expires: ...
@@ -58,7 +58,7 @@ identifier.
     >>> view = MyFolder(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'rev6510')
     >>> view = view.publishTraverse(view.request, 'image1.gif')
-    >>> print view()
+    >>> print(view())
     GIF file
 
 Requesting a directory raises a NotFound.
@@ -117,9 +117,9 @@ image_extensions property.
     ('.png', '.gif')
 
     >>> view = view.publishTraverse(view.request, 'image2')
-    >>> print view()
+    >>> print(view())
     PNG file
-    >>> print view.request.response.getHeader('Content-Type')
+    >>> print(view.request.response.getHeader('Content-Type'))
     image/png
 
 If a file without extension exists, that one will be served.
@@ -131,12 +131,12 @@ If a file without extension exists, that one will be served.
 
     >>> view = MyImageFolder(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'image3')
-    >>> print view()
+    >>> print(view())
     Image without extension
 
     >>> view = MyImageFolder(object(), FakeRequest(version="devel"))
     >>> view = view.publishTraverse(view.request, 'image3.gif')
-    >>> print view()
+    >>> print(view())
     Image with extension
 
 
@@ -165,14 +165,14 @@ Traversing to a file in a subdirectory will now work.
     >>> view = view.publishTraverse(view.request, 'public')
     >>> view = view.publishTraverse(view.request, 'subdir1')
     >>> view = view.publishTraverse(view.request, 'test1.txt')
-    >>> print view()
+    >>> print(view())
     Sub file 1
 
 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())
     Traceback (most recent call last):
       ...
     NotFound:...
diff --git a/lib/lp/app/stories/form/xx-form-layout.txt b/lib/lp/app/stories/form/xx-form-layout.txt
index fa3b8ec..01b3ac1 100644
--- a/lib/lp/app/stories/form/xx-form-layout.txt
+++ b/lib/lp/app/stories/form/xx-form-layout.txt
@@ -13,7 +13,7 @@ The new team form contains an example of a normal text widget.
 
     >>> user_browser.open('http://launchpad.test/people/+newteam')
     >>> content = find_main_content(user_browser.contents)
-    >>> print content
+    >>> print(content)
     <...
     <tr>
     <td colspan="2">
@@ -30,7 +30,7 @@ The new team form contains an example of a normal text widget.
 
 If the text field is optional, then that is noted after the widget.
 
-    >>> print content
+    >>> print(content)
     <...
     <tr>
     <td colspan="2">
@@ -54,7 +54,7 @@ Checkboxes have their label to the right. Let's look at one example.
 
     >>> admin_browser.open(
     ...     'http://launchpad.test/firefox/+review-license')
-    >>> print find_tag_by_id(admin_browser.contents, 'launchpad-form-widgets')
+    >>> print(find_tag_by_id(admin_browser.contents, 'launchpad-form-widgets'))
     <...
     <tr>
       <td colspan="2">
@@ -75,7 +75,7 @@ just the form content to be rendered for any URL corresponding to an
 LPFormView:
 
     >>> admin_browser.open('http://launchpad.test/evolution/+edit/++form++')
-    >>> print admin_browser.contents
+    >>> print(admin_browser.contents)
     <div...
     <table class="form" id="launchpad-form-widgets">
     ...
@@ -87,7 +87,7 @@ Or for another example.
     ...     auth='Basic celso.providelo@xxxxxxxxxxxxx:test')
     >>> cprov_browser.open(
     ...   'http://launchpad.test/~cprov/+archive/ppa/+edit/++form++')
-    >>> print cprov_browser.contents
+    >>> print(cprov_browser.contents)
     <div...
     <table class="form" id="launchpad-form-widgets">
     ...
diff --git a/lib/lp/app/stories/launchpad-root/front-pages.txt b/lib/lp/app/stories/launchpad-root/front-pages.txt
index 68a4251..50eebe2 100644
--- a/lib/lp/app/stories/launchpad-root/front-pages.txt
+++ b/lib/lp/app/stories/launchpad-root/front-pages.txt
@@ -12,7 +12,7 @@ Visit our home page with the typical configuration:
 It contains a string which our IS uses to determine whether or not
 Launchpad is alive:
 
-    >>> print browser.contents
+    >>> print(browser.contents)
     <!DOCTYPE...
     ...
     <!-- Is your project registered yet? -->
@@ -26,13 +26,13 @@ And links to the important applications and facets:
 
 It also includes a search form...
 
-    >>> print browser.getControl('Search Launchpad').value
+    >>> print(browser.getControl('Search Launchpad').value)
     Search Launchpad
 
 ...and lists of featured projects and marketing material.
 
     >>> featured = find_tag_by_id(browser.contents, 'homepage-featured')
-    >>> print extract_text(featured.renderContents())
+    >>> print(extract_text(featured.renderContents()))
     Featured projects
     ...
     Ubuntu
@@ -40,15 +40,15 @@ It also includes a search form...
 
 The footer doesn't contain the links that are already present on the page.
 
-    >>> print find_tags_by_class(browser.contents, 'lp-arcana')
+    >>> print(find_tags_by_class(browser.contents, 'lp-arcana'))
     []
 
 The front page also lists the recent blog posts published on the Launchpad
 blog:
 
-    >>> print extract_text(
+    >>> print(extract_text(
     ...     find_tag_by_id(browser.contents, 'homepage-blogposts'),
-    ...     formatter='html')
+    ...     formatter='html'))
     Recent Launchpad blog posts
     Launchpad EPIC 2010 photo
     &ndash; 16 Jul 2010
@@ -99,6 +99,6 @@ is labelled "Launchpad Home" to make clear where it goes.
 The footer of those pages contains the link to the front page, tour
 and guide:
 
-    >>> print extract_text(
-    ...     find_tags_by_class(user_browser.contents, 'lp-arcana')[0])
+    >>> print(extract_text(
+    ...     find_tags_by_class(user_browser.contents, 'lp-arcana')[0]))
     • Take the tour • Read the guide
diff --git a/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt b/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt
index 7c0a9f9..3fa6f24 100644
--- a/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt
+++ b/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt
@@ -25,16 +25,16 @@ projects' pages in Launchpad. The "project of the day" is listed separately.
 
     >>> anon_browser.open('http://launchpad.test/')
     >>> featured = find_tag_by_id(anon_browser.contents, 'homepage-featured')
-    >>> print extract_text(featured.h2)
+    >>> print(extract_text(featured.h2))
     Featured projects
 
     >>> top_project = featured.find('', 'featured-project-top')
-    >>> print extract_text(top_project.h3)
+    >>> print(extract_text(top_project.h3))
     GNOME
 
     >>> featured_list = featured.find('', 'featured-projects-list')
     >>> for link in featured_list.findAll('a'):
-    ...     print extract_text(link)
+    ...     print(extract_text(link))
     Gnome Applets
     Bazaar
     Mozilla Firefox
@@ -93,12 +93,12 @@ is now at index '4' and is therefore displayed as the top project:
     >>> anon_browser.open('http://launchpad.test/')
     >>> featured = find_tag_by_id(anon_browser.contents, 'homepage-featured')
     >>> top_project = featured.find('', 'featured-project-top')
-    >>> print extract_text(top_project.h3)
+    >>> print(extract_text(top_project.h3))
     Gentoo
 
     >>> featured_list = featured.find('', 'featured-projects-list')
     >>> for link in featured_list.findAll('a'):
-    ...     print extract_text(link)
+    ...     print(extract_text(link))
     Apache
     Gnome Applets
     Bazaar
@@ -126,7 +126,7 @@ that Apache has been removed:
     >>> anon_browser.open('http://launchpad.test/')
     >>> featured = find_tag_by_id(anon_browser.contents, 'homepage-featured')
     >>> for link in featured.findAll('a'):
-    ...     print extract_text(link)
+    ...     print(extract_text(link))
     GNOME
     Gnome Applets
     Bazaar
diff --git a/lib/lp/app/stories/launchpad-search/site-search.txt b/lib/lp/app/stories/launchpad-search/site-search.txt
index b43a2e6..e794534 100644
--- a/lib/lp/app/stories/launchpad-search/site-search.txt
+++ b/lib/lp/app/stories/launchpad-search/site-search.txt
@@ -12,7 +12,7 @@ bugs, teams, etc.).
     ...         contents = anon_browser.contents
     ...     tag = find_tag_by_id(contents, 'search-results')
     ...     if tag:
-    ...         print extract_text(tag)
+    ...         print(extract_text(tag))
 
     # Another helper to make searching convenient.
 
@@ -29,7 +29,7 @@ The search form is available on almost every Launchpad page.
 
     >>> anon_browser.open('http://launchpad.test/ubuntu')
     >>> search_for('test1')
-    >>> print anon_browser.url
+    >>> print(anon_browser.url)
     http://launchpad.test/+search?field.text=test1
 
 But the search results page has its own search form, so the global one
@@ -44,19 +44,19 @@ If by chance someone ends up at /+search with no search parameters, they
 get an explanation of the search function.
 
     >>> anon_browser.open('http://launchpad.test/+search')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Search Launchpad
 
     >>> print_search_results()
 
-    >>> print extract_text(find_tag_by_id(anon_browser.contents, 'no-search'))
+    >>> print(extract_text(find_tag_by_id(anon_browser.contents, 'no-search')))
     Enter a term or many terms to find matching pages...
 
 When the user searches for specific item, such as a project name, they
 see a result for that exact match in Launchpad.
 
     >>> search_for('firefox')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Pages matching "firefox" in Launchpad
 
     >>> print_search_results()
@@ -71,7 +71,7 @@ matching question number, if one exists. Searching for "3", the user
 sees that a bug matched.
 
     >>> search_for('3')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Pages matching "3" in Launchpad
 
     >>> print_search_results()
@@ -88,7 +88,7 @@ navigation states that the page is showing 1 through 20 of 25 total results.
     # Use our pre-defined search results for the 'bug' search.
 
     >>> search_for('bug')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Pages matching "bug" in Launchpad
 
     >>> print_search_results()
@@ -127,7 +127,7 @@ matching ...".
     # Use our pre-defined search results for the 'launchpad' search.
 
     >>> search_for('launchpad')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Pages matching "launchpad" in Launchpad
 
     >>> print_search_results()
@@ -238,8 +238,8 @@ the user. The text field is focused so that the user can try another
 search.
 
     >>> search_for('fnord')
-    >>> print extract_text(
-    ...     find_main_content(anon_browser.contents), skip_tags=[])
+    >>> print(extract_text(
+    ...     find_main_content(anon_browser.contents), skip_tags=[]))
     Pages matching "fnord" in Launchpad
     <!-- setFocusByName('field.text'); // -->
     Your search for “fnord” did not return any results.
@@ -254,7 +254,7 @@ displayed that explains that the search can be performed again to find
 matching pages.
 
     >>> search_for('gnomebaker')
-    >>> print find_tag_by_id(anon_browser.contents, 'no-page-service')
+    >>> print(find_tag_by_id(anon_browser.contents, 'no-page-service'))
     <p id="no-page-service">
     The page search service was not available when this search was
     performed.
@@ -271,7 +271,7 @@ field, the page does not contain any results. The user can see that the
 page is identical to the page visited without performing a search.
 
     >>> search_for('')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Search Launchpad
 
     >>> print_search_results()
@@ -303,17 +303,17 @@ Most pages have the global search form in them. Any user can enter terms
 in the page they are viewing and submit the form to see the results.
 
     >>> anon_browser.open('http://bugs.launchpad.test/firefox')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Bugs : Mozilla Firefox
 
-    >>> print anon_browser.url
+    >>> print(anon_browser.url)
     http://bugs.launchpad.test/firefox
 
     >>> search_for('mozilla')
-    >>> print anon_browser.title
+    >>> print(anon_browser.title)
     Pages matching "mozilla" in Launchpad
 
-    >>> print anon_browser.url
+    >>> print(anon_browser.url)
     http://launchpad.test/+search?...
 
     >>> print_search_results()
diff --git a/lib/lp/app/tests/test_doc.py b/lib/lp/app/tests/test_doc.py
index 3e93aee..17b22e8 100644
--- a/lib/lp/app/tests/test_doc.py
+++ b/lib/lp/app/tests/test_doc.py
@@ -27,7 +27,7 @@ bing_flag = FeatureFixture({'sitesearch.engine.name': 'bing'})
 
 
 def setUp_bing(test):
-    setUpGlobs(test)
+    setUpGlobs(test, future=True)
     bing_flag.setUp()
 
 
@@ -39,11 +39,12 @@ def tearDown_bing(test):
 special = {
     'tales.txt': LayeredDocFileSuite(
         '../doc/tales.txt',
-        setUp=setUp, tearDown=tearDown,
+        setUp=lambda test: setUp(test, future=True), tearDown=tearDown,
         layer=LaunchpadFunctionalLayer,
         ),
     'menus.txt': LayeredDocFileSuite(
-        '../doc/menus.txt', setUp=setGlobs, layer=None,
+        '../doc/menus.txt',
+        setUp=lambda test: setGlobs(test, future=True), layer=None,
         ),
     'stories/launchpad-search(Bing)': PageTestSuite(
         '../stories/launchpad-search/',
@@ -53,10 +54,11 @@ special = {
     # Run these doctests again with the default search engine.
     '../stories/launchpad-search': PageTestSuite(
         '../stories/launchpad-search/',
-        setUp=setUpGlobs, tearDown=tearDown,
+        setUp=lambda test: setUpGlobs(test, future=True), tearDown=tearDown,
         ),
     }
 
 
 def test_suite():
-    return build_test_suite(here, special)
+    return build_test_suite(
+        here, special, setUp=lambda test: setUp(test, future=True))
diff --git a/lib/lp/app/tests/test_tales.py b/lib/lp/app/tests/test_tales.py
index e8c0e0a..a3947dd 100644
--- a/lib/lp/app/tests/test_tales.py
+++ b/lib/lp/app/tests/test_tales.py
@@ -97,15 +97,15 @@ def test_cookie_scope():
     The cookie scope will use the secure attribute if the request was
     secure:
 
-        >>> print cookie_scope('http://launchpad.net/')
+        >>> print(cookie_scope('http://launchpad.net/'))
         ; Path=/; Domain=.launchpad.net
-        >>> print cookie_scope('https://launchpad.net/')
+        >>> print(cookie_scope('https://launchpad.net/'))
         ; Path=/; Secure; Domain=.launchpad.net
 
     The domain parameter is omitted for domains that appear to be
     separate from a Launchpad instance:
 
-        >>> print cookie_scope('https://example.com/')
+        >>> print(cookie_scope('https://example.com/'))
         ; Path=/; Secure
     """
 
diff --git a/lib/lp/app/tests/test_validation.py b/lib/lp/app/tests/test_validation.py
index f77e037..e560964 100644
--- a/lib/lp/app/tests/test_validation.py
+++ b/lib/lp/app/tests/test_validation.py
@@ -24,7 +24,7 @@ def test_suite():
     import lp.app.validators.validation
     test = DocTestSuite(
         lp.app.validators.validation,
-        setUp=setUp,
+        setUp=lambda test: setUp(test, future=True),
         tearDown=tearDown,
         optionflags=ELLIPSIS | NORMALIZE_WHITESPACE
         )
diff --git a/lib/lp/app/tests/test_versioninfo.py b/lib/lp/app/tests/test_versioninfo.py
index a9e57a2..7db9b35 100644
--- a/lib/lp/app/tests/test_versioninfo.py
+++ b/lib/lp/app/tests/test_versioninfo.py
@@ -18,7 +18,7 @@ class TestVersionInfo(unittest.TestCase):
         # Getting version info should still work in them.
         args = [os.path.join(TREE_ROOT, "bin/py"), "-c",
                 "from lp.app.versioninfo import revision;"
-                "print revision"]
+                "print(revision)"]
         process = subprocess.Popen(args, cwd='/tmp', stdout=subprocess.PIPE)
         (output, errors) = process.communicate(None)
         self.assertEqual(revision, output.rstrip("\n"))
diff --git a/lib/lp/app/tour/api b/lib/lp/app/tour/api
index d9fff29..28184ea 100644
--- a/lib/lp/app/tour/api
+++ b/lib/lp/app/tour/api
@@ -92,7 +92,7 @@ We've even done the hard work for you: use our LGPL Python library &mdash; <a hr
                                 <code>
                                 people = launchpad.people<br />
                                 salgado = people['salgado']<br />
-                                print salgado.display_name<br />
+                                print(salgado.display_name)<br />
                                 # Guilherme Salgado<br />
                                 </code>
                                 <br />
diff --git a/lib/lp/app/validators/tests/test_doc.py b/lib/lp/app/validators/tests/test_doc.py
index 44f473a..b4c1dfe 100644
--- a/lib/lp/app/validators/tests/test_doc.py
+++ b/lib/lp/app/validators/tests/test_doc.py
@@ -18,7 +18,8 @@ from lp.testing.systemdocs import (
 def test_suite():
     suite = unittest.TestSuite()
     test = LayeredDocFileSuite(
-        'validation.txt', setUp=setUp, tearDown=tearDown,
+        'validation.txt',
+        setUp=lambda test: setUp(test, future=True), tearDown=tearDown,
         layer=LaunchpadFunctionalLayer)
     suite.addTest(test)
     return suite
diff --git a/lib/lp/app/validators/tests/test_validators.py b/lib/lp/app/validators/tests/test_validators.py
index 5d8e296..e746416 100644
--- a/lib/lp/app/validators/tests/test_validators.py
+++ b/lib/lp/app/validators/tests/test_validators.py
@@ -41,7 +41,7 @@ def test_suite():
 def suitefor(module):
     """Make a doctest suite with common setUp and tearDown functions."""
     suite = DocTestSuite(
-        module, setUp=setUp, tearDown=tearDown,
+        module, setUp=lambda test: setUp(test, future=True), tearDown=tearDown,
         optionflags=ELLIPSIS | NORMALIZE_WHITESPACE)
     # We have to invoke the LaunchpadFunctionalLayer in order to
     # initialize the ZCA machinery, which is a pre-requisite for using
diff --git a/lib/lp/app/widgets/date.py b/lib/lp/app/widgets/date.py
index b6e8a38..2d7d119 100644
--- a/lib/lp/app/widgets/date.py
+++ b/lib/lp/app/widgets/date.py
@@ -52,14 +52,14 @@ class DateTimeWidget(TextWidget):
     The datetime popup widget shows the time zone in which it will return
     the time:
 
-      >>> print widget()  #doctest: +ELLIPSIS
+      >>> print(widget())  #doctest: +ELLIPSIS
       <BLANKLINE>
       <...in time zone: UTC...
 
     The datetime popup widget links to the page which allows the user to
     change their system time zone.
 
-      >>> print widget()  #doctest: +ELLIPSIS
+      >>> print(widget())  #doctest: +ELLIPSIS
       <BLANKLINE>
       <...<a href="/people/+me/+editlocation">...
 
@@ -67,7 +67,7 @@ class DateTimeWidget(TextWidget):
     default, and the user is not invited to change the time zone:
 
       >>> widget.required_time_zone = pytz.timezone('America/Los_Angeles')
-      >>> print widget()  #doctest: +ELLIPSIS
+      >>> print(widget())  #doctest: +ELLIPSIS
       <BLANKLINE>
       <...in time zone: America/Los_Angeles...
       >>> 'change time zone' not in widget()
@@ -83,7 +83,7 @@ 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: +ELLIPSIS
       Traceback (most recent call last):
       ...
       WidgetInputError: (...Please pick a date after 2006-05-22 17:00:00...)
@@ -92,14 +92,14 @@ class DateTimeWidget(TextWidget):
     expected.
 
       >>> widget.request.form[widget.name] = '2009-09-14'
-      >>> print widget.getInputValue()  #doctest: +ELLIPSIS
+      >>> print(widget.getInputValue())  #doctest: +ELLIPSIS
       2009-09-14 00:00:00-07:00
 
     If to_date is provided then getInputValue() will enforce this too.
 
       >>> widget.to_date = datetime(2008, 1, 26,
       ...                           tzinfo=pytz.timezone('UTC'))
-      >>> print widget.getInputValue()  #doctest: +ELLIPSIS
+      >>> print(widget.getInputValue())  #doctest: +ELLIPSIS
       Traceback (most recent call last):
       ...
       WidgetInputError: (...Please pick a date before 2008-01-25 16:00:00...)
@@ -168,12 +168,12 @@ class DateTimeWidget(TextWidget):
         The time zone is a time zone object, not the string representation
         of that.
 
-          >>> print type(widget.time_zone)
+          >>> print(type(widget.time_zone))
           <class 'pytz.UTC'>
 
         The widget required_time_zone is None by default.
 
-          >>> print widget.required_time_zone
+          >>> print(widget.required_time_zone)
           None
 
         The widget "system time zone" is generally UTC. It is the logged in
@@ -181,21 +181,21 @@ class DateTimeWidget(TextWidget):
         user. Although this isn't used directly, it influences the outcome
         of widget.time_zone.
 
-          >>> print widget.system_time_zone
+          >>> print(widget.system_time_zone)
           UTC
 
         When there is no required_time_zone, then we get the system time
         zone.
 
-          >>> print widget.required_time_zone
+          >>> print(widget.required_time_zone)
           None
-          >>> print widget.time_zone
+          >>> print(widget.time_zone)
           UTC
 
         When there is a required_time_zone, we get it:
 
           >>> widget.required_time_zone = pytz.timezone('Africa/Maseru')
-          >>> print widget.time_zone
+          >>> print(widget.time_zone)
           Africa/Maseru
 
         """
@@ -255,9 +255,9 @@ class DateTimeWidget(TextWidget):
 
         The default date range is unlimited:
 
-          >>> print widget.from_date
+          >>> print(widget.from_date)
           None
-          >>> print widget.to_date
+          >>> print(widget.to_date)
           None
 
         If there is no date range, we return None so it won't be included
@@ -265,7 +265,7 @@ class DateTimeWidget(TextWidget):
 
           >>> widget.from_date = None
           >>> widget.to_date = None
-          >>> print widget.daterange
+          >>> print(widget.daterange)
           None
 
         The daterange is correctly expressed as JavaScript in all the
@@ -368,18 +368,18 @@ class DateTimeWidget(TextWidget):
 
         The widget prints out times in UTC:
 
-          >>> print widget._parseInput('2006-01-01 12:00:00')
+          >>> print(widget._parseInput('2006-01-01 12:00:00'))
           2006-01-01 12:00:00+00:00
 
         But it will handle other time zones:
 
           >>> widget.required_time_zone = pytz.timezone('Australia/Perth')
-          >>> print widget._parseInput('2006-01-01 12:00:00')
+          >>> print(widget._parseInput('2006-01-01 12:00:00'))
           2006-01-01 12:00:00+08:00
 
         Invalid dates result in a ConversionError:
 
-          >>> print widget._parseInput('not a date')  #doctest: +ELLIPSIS
+          >>> print(widget._parseInput('not a date'))  #doctest: +ELLIPSIS
           Traceback (most recent call last):
             ...
           ConversionError: ('Invalid date value', ...)
@@ -527,20 +527,20 @@ class DateWidget(DateTimeWidget):
         The widget ignores time and time zone information, returning only
         the date:
 
-          >>> print widget._toFieldValue('2006-01-01 12:00:00')
+          >>> print(widget._toFieldValue('2006-01-01 12:00:00'))
           2006-01-01
 
         Even if you feed it information that gives a time zone, it will
         ignore that:
 
-          >>> print widget._toFieldValue('2006-01-01 2:00:00+06:00')
+          >>> print(widget._toFieldValue('2006-01-01 2:00:00+06:00'))
           2006-01-01
-          >>> print widget._toFieldValue('2006-01-01 23:00:00-06:00')
+          >>> print(widget._toFieldValue('2006-01-01 23:00:00-06:00'))
           2006-01-01
 
         Invalid dates result in a ConversionError:
 
-          >>> print widget._toFieldValue('not a date')  #doctest: +ELLIPSIS
+          >>> print(widget._toFieldValue('not a date'))  #doctest: +ELLIPSIS
           Traceback (most recent call last):
             ...
           ConversionError: ('Invalid date value', ...)
diff --git a/lib/lp/app/widgets/doc/announcement-date-widget.txt b/lib/lp/app/widgets/doc/announcement-date-widget.txt
index d65b4f2..3fc2dfd 100644
--- a/lib/lp/app/widgets/doc/announcement-date-widget.txt
+++ b/lib/lp/app/widgets/doc/announcement-date-widget.txt
@@ -10,7 +10,7 @@ future, or to manually publish it later.
     >>> from lp.app.widgets.announcementdate import AnnouncementDateWidget
     >>> field = Field(__name__=six.ensure_str('foo'), title=u'Foo')
     >>> widget = AnnouncementDateWidget(field, LaunchpadTestRequest())
-    >>> print extract_text(widget())
+    >>> print(extract_text(widget()))
     Publish this announcement:
     Immediately
     At some time in the future when I come back to authorize it
@@ -24,7 +24,7 @@ return the date you specified.
     >>> action_widget.request.form[action_widget.name] = 'specific'
     >>> date_widget = widget.announcement_date_widget
     >>> date_widget.request.form[date_widget.name] = '2005-07-23'
-    >>> print widget.getInputValue()
+    >>> print(widget.getInputValue())
     2005-07-23 00:00:00+00:00
 
 When you choose to publish immediately, the widget will return the current
@@ -38,7 +38,7 @@ date and time.
     >>> before = now - timedelta(1) # 1 day
     >>> after = now + timedelta(1) # 1 day
     >>> immediate_date = widget.getInputValue()
-    >>> print repr(immediate_date)
+    >>> print(repr(immediate_date))
     datetime.datetime(...)
     >>> before < immediate_date < after
     True
@@ -48,14 +48,14 @@ won't return a date.
 
     >>> action_widget.request.form[action_widget.name] = 'sometime'
     >>> date_widget.request.form[date_widget.name] = '2005-07-23'
-    >>> print widget.getInputValue()
+    >>> print(widget.getInputValue())
     None
 
 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()
+    >>> print(widget.getInputValue())
     Traceback (most recent call last):
     ...
     WidgetInputError: ('field.foo', u'Foo',
@@ -68,7 +68,7 @@ must be filled.
 
     >>> action_widget.request.form[action_widget.name] = 'specific'
     >>> date_widget.request.form[date_widget.name] = ''
-    >>> print widget.getInputValue()
+    >>> print(widget.getInputValue())
     Traceback (most recent call last):
     ...
     WidgetInputError: ('field.foo', u'Foo',
diff --git a/lib/lp/app/widgets/doc/image-widget.txt b/lib/lp/app/widgets/doc/image-widget.txt
index f82ec44..204cad7 100644
--- a/lib/lp/app/widgets/doc/image-widget.txt
+++ b/lib/lp/app/widgets/doc/image-widget.txt
@@ -61,7 +61,7 @@ button allows the user to upload a new image.
 
     >>> from lp.services.beautifulsoup import BeautifulSoup
     >>> html = widget()
-    >>> print BeautifulSoup(html).find('img').get('src')
+    >>> print(BeautifulSoup(html).find('img').get('src'))
     /@@/person-logo
 
     >>> def print_radio_items(html):
@@ -72,7 +72,7 @@ button allows the user to upload a new image.
     ...             item += ': SELECTED'
     ...         else:
     ...             item += ': NOT SELECTED'
-    ...         print item
+    ...         print(item)
     >>> print_radio_items(html)
     keep: SELECTED
     change: NOT SELECTED
diff --git a/lib/lp/app/widgets/doc/launchpad-radio-widget.txt b/lib/lp/app/widgets/doc/launchpad-radio-widget.txt
index 03b2817..ef2b8e8 100644
--- a/lib/lp/app/widgets/doc/launchpad-radio-widget.txt
+++ b/lib/lp/app/widgets/doc/launchpad-radio-widget.txt
@@ -25,7 +25,7 @@ buttons inside.
 
     >>> html = BeautifulSoup(radio_widget())
     >>> for label in html.findAll('label'):
-    ...     print label.encode_contents(formatter='html')
+    ...     print(label.encode_contents(formatter='html'))
     <input checked="checked" class="radioType" id="field.branch_type.0"
            name="field.branch_type" type="radio" value="HOSTED"/>&nbsp;Hosted
     <input class="radioType" id="field.branch_type.1" name="field.branch_type"
@@ -51,7 +51,7 @@ under the labels.  The labels are rendered next to the radio buttons,
 in a different table cell, and use the 'for' attribute of the label
 to associate the label with the radio button input.
 
-    >>> print radio_widget()
+    >>> print(radio_widget())
     <table class="radio-button-widget"><tr>
       <td rowspan="2"><input class="radioType" checked="checked"
           id="field.branch_type.0" name="field.branch_type" type="radio"
@@ -105,7 +105,7 @@ rows are not rendered.
 
     >>> radio_widget = LaunchpadRadioWidgetWithDescription(
     ...     branch_type_field, SomeFruit, request)
-    >>> print radio_widget()
+    >>> print(radio_widget())
     <table class="radio-button-widget"><tr>
       <td><input class="radioType" id="field.branch_type.0"
                  name="field.branch_type" type="radio" value="APPLE" /></td>
@@ -130,7 +130,7 @@ by setting the optional extra_hint and extra_hint_class attributes on the
 widget.
     >>> radio_widget.extra_hint = 'Some additional information'
     >>> radio_widget.extra_hint_class = 'inline-informational'
-    >>> print radio_widget()
+    >>> print(radio_widget())
     <div class="inline-informational">Some additional information</div>
     <table class="radio-button-widget"><tr>
     ...
@@ -161,7 +161,7 @@ are rendered as 'yes' and 'no'; a missing value radio item is not rendered.
     >>> agent = Agent(True)
     >>> bound_field = field.bind(agent)
     >>> radio_widget = LaunchpadBooleanRadioWidget(bound_field, request)
-    >>> print radio_widget()
+    >>> print(radio_widget())
     <label style="font-weight: normal"><input
         class="radioType" checked="checked" id="field.sentient.0"
         name="field.sentient" type="radio" value="yes"
@@ -176,7 +176,7 @@ false_label attributes.
 
     >>> radio_widget.true_label = 'I think therefore I am'
     >>> radio_widget.false_label = 'I am a turing test'
-    >>> print radio_widget()
+    >>> print(radio_widget())
     <label style="font-weight: normal"><input
         class="radioType" checked="checked" id="field.sentient.0"
         name="field.sentient" type="radio" value="yes"
diff --git a/lib/lp/app/widgets/doc/noneable-text-widgets.txt b/lib/lp/app/widgets/doc/noneable-text-widgets.txt
index 71cfeff..362edb3 100644
--- a/lib/lp/app/widgets/doc/noneable-text-widgets.txt
+++ b/lib/lp/app/widgets/doc/noneable-text-widgets.txt
@@ -24,7 +24,7 @@ composed of whitespace; the value is considered to be None.
 
 The widget converted the meaningless value to None.
 
-    >>> print widget.getInputValue()
+    >>> print(widget.getInputValue())
     None
 
 Excess whitespace and newlines are stripped.
@@ -32,7 +32,7 @@ Excess whitespace and newlines are stripped.
     >>> request = LaunchpadTestRequest(
     ...     form={'field.code_name' : ' flower \n'})
     >>> widget = NoneableTextWidget(field, request)
-    >>> print widget.getInputValue()
+    >>> print(widget.getInputValue())
     flower
 
 
@@ -54,7 +54,7 @@ composed of whitespace; the value is considered to be None.
 
 The widget converted the meaningless value to None.
 
-    >>> print widget.getInputValue()
+    >>> print(widget.getInputValue())
     None
 
 Excess whitespace is stripped, but newlines are preserved.
diff --git a/lib/lp/app/widgets/doc/project-scope-widget.txt b/lib/lp/app/widgets/doc/project-scope-widget.txt
index 0a87803..a09b2a0 100644
--- a/lib/lp/app/widgets/doc/project-scope-widget.txt
+++ b/lib/lp/app/widgets/doc/project-scope-widget.txt
@@ -58,7 +58,7 @@ there is no input.
 
 By default, the 'All projects' scope is selected:
 
-    >>> print widget()
+    >>> print(widget())
     <label>
       <input class="radioType" checked="checked"
              id="field.scope.option.all" name="field.scope"
@@ -125,7 +125,7 @@ raised:
 
 The same error text is returned by error():
 
-    >>> print widget.error()
+    >>> print(widget.error())
     There is no project named &#x27;invalid&#x27; registered in Launchpad
 
 If no project name is given at all, a widget error is also raised:
@@ -142,7 +142,7 @@ If no project name is given at all, a widget error is also raised:
     WidgetInputError: ('field.scope', u'',
     LaunchpadValidationError(u'Please enter a project name'))
 
-    >>> print widget.error()
+    >>> print(widget.error())
     Please enter a project name
 
     >>> del form['field.scope.target']
@@ -157,7 +157,7 @@ If no project name is given at all, a widget error is also raised:
     WidgetInputError: ('field.scope', u'',
     LaunchpadValidationError(u'Please enter a project name'))
 
-    >>> print widget.error()
+    >>> print(widget.error())
     Please enter a project name
 
 == setRenderedValue() ==
@@ -171,7 +171,7 @@ button, as well as displaying the product name in the project widget.
     ...     scope_field, scope_field.vocabulary, empty_request)
     >>> projectgroups = getUtility(IProjectGroupSet)
     >>> widget.setRenderedValue(projectgroups.getByName('mozilla'))
-    >>> print widget()
+    >>> print(widget())
     <label>
       <input class="radioType" id="field.scope.option.all"
              name="field.scope" type="radio" value="all" />
@@ -191,7 +191,7 @@ button, as well as displaying the product name in the project widget.
 Setting the scope to None, will default to the 'All projects' option.
 
     >>> widget.setRenderedValue(None)
-    >>> print widget()
+    >>> print(widget())
     <label>
       <input class="radioType" checked="checked"
              id="field.scope.option.all" name="field.scope"
@@ -210,7 +210,7 @@ of the scope option, or `None` if no scope was selected.
     >>> widget = ProjectScopeWidget(
     ...     scope_field, scope_field.vocabulary,
     ...     LaunchpadTestRequest(form=form))
-    >>> print widget.getScope()
+    >>> print(widget.getScope())
     project
 
     >>> form = {'field.scope': 'all',
@@ -218,12 +218,12 @@ of the scope option, or `None` if no scope was selected.
     >>> widget = ProjectScopeWidget(
     ...     scope_field, scope_field.vocabulary,
     ...     LaunchpadTestRequest(form=form))
-    >>> print widget.getScope()
+    >>> print(widget.getScope())
     all
 
     >>> form = {'field.scope.target': ''}
     >>> widget = ProjectScopeWidget(
     ...     scope_field, scope_field.vocabulary,
     ...     LaunchpadTestRequest(form=form))
-    >>> print widget.getScope()
+    >>> print(widget.getScope())
     None
diff --git a/lib/lp/app/widgets/doc/stripped-text-widget.txt b/lib/lp/app/widgets/doc/stripped-text-widget.txt
index fe9ed47..06e0a1d 100644
--- a/lib/lp/app/widgets/doc/stripped-text-widget.txt
+++ b/lib/lp/app/widgets/doc/stripped-text-widget.txt
@@ -21,7 +21,7 @@ set value.
 None is an accepted field value.
 
     >>> non_required_field.set(thing, None)
-    >>> print non_required_field.get(thing)
+    >>> print(non_required_field.get(thing))
     None
 
 
diff --git a/lib/lp/app/widgets/tests/test_doc.py b/lib/lp/app/widgets/tests/test_doc.py
index 6970373..fa66c2b 100644
--- a/lib/lp/app/widgets/tests/test_doc.py
+++ b/lib/lp/app/widgets/tests/test_doc.py
@@ -21,11 +21,12 @@ here = os.path.dirname(os.path.realpath(__file__))
 special = {
     'image-widget.txt': LayeredDocFileSuite(
         '../doc/image-widget.txt',
-        setUp=setUp, tearDown=tearDown,
+        setUp=lambda test: setUp(test, future=True), tearDown=tearDown,
         layer=LaunchpadFunctionalLayer,
         ),
     }
 
 
 def test_suite():
-    return build_test_suite(here, special)
+    return build_test_suite(
+        here, special, setUp=lambda test: setUp(test, future=True))
diff --git a/lib/lp/app/widgets/textwidgets.py b/lib/lp/app/widgets/textwidgets.py
index 6b50946..ff28a1e 100644
--- a/lib/lp/app/widgets/textwidgets.py
+++ b/lib/lp/app/widgets/textwidgets.py
@@ -82,18 +82,18 @@ class LocalDateTimeWidget(TextWidget):
 
         By default, the date is interpreted as UTC:
 
-          >>> print widget._toFieldValue('2006-01-01 12:00:00')
+          >>> 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')
+          >>> 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: +ELLIPSIS
+          >>> print(widget._toFieldValue('not a date'))  #doctest: +ELLIPSIS
           Traceback (most recent call last):
             ...
           ConversionError: ('Invalid date value', ...)
@@ -248,7 +248,7 @@ class DelimitedListWidget(TextAreaWidget):
 
         By default, lists are split by whitespace:
 
-          >>> print widget._toFieldValue(u'fred\\nbob harry')
+          >>> print(widget._toFieldValue(u'fred\\nbob harry'))
           [u'fred', u'bob', u'harry']
         """
         value = super(