← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:py3-app-doctest-unicode-strings into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:py3-app-doctest-unicode-strings into launchpad:master.

Commit message:
lp.app: Fix u'...' doctest examples for Python 3

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

There are loads of u'...' doctest examples in our test suite, and unfortunately I don't think any solution is better than just going through and making all of them bilingual.  In most cases the best option is to use print(), but occasionally something like the repr of six.ensure_str() works better in cases where we need a finer-grained test.

This is the first tranche of fixes for this pattern, beginning arbitrarily with lp.app.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:py3-app-doctest-unicode-strings into launchpad:master.
diff --git a/lib/lp/app/browser/doc/launchpad-search-pages.txt b/lib/lp/app/browser/doc/launchpad-search-pages.txt
index 83347e4..ded7f0d 100644
--- a/lib/lp/app/browser/doc/launchpad-search-pages.txt
+++ b/lib/lp/app/browser/doc/launchpad-search-pages.txt
@@ -54,12 +54,12 @@ When text is not None, the title indicates what was searched.
     >>> search_view = getSearchView(
     ...     form={'field.text': 'albatross'})
 
-    >>> search_view.text
-    u'albatross'
-    >>> search_view.page_title
-    u'Pages matching "albatross" in Launchpad'
-    >>> search_view.page_heading
-    u'Pages matching "albatross" in Launchpad'
+    >>> print(search_view.text)
+    albatross
+    >>> print(search_view.page_title)
+    Pages matching "albatross" in Launchpad
+    >>> print(search_view.page_heading)
+    Pages matching "albatross" in Launchpad
 
 
 No matches
@@ -92,14 +92,14 @@ matched by their id.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': '5'})
-    >>> search_view._getNumericToken(search_view.text)
-    u'5'
+    >>> print(search_view._getNumericToken(search_view.text))
+    5
     >>> search_view.has_matches
     True
-    >>> search_view.bug.title
-    u'Firefox install instructions should be complete'
-    >>> search_view.question.title
-    u'Installation failed'
+    >>> print(search_view.bug.title)
+    Firefox install instructions should be complete
+    >>> print(search_view.question.title)
+    Installation failed
 
 Bugs and questions are matched independent of each other. The number
 extracted may only match one kind of object. For example, there are
@@ -107,12 +107,12 @@ more bugs than questions.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': '15'})
-    >>> search_view._getNumericToken(search_view.text)
-    u'15'
+    >>> print(search_view._getNumericToken(search_view.text))
+    15
     >>> search_view.has_matches
     True
-    >>> search_view.bug.title
-    u'Nonsensical bugs are useless'
+    >>> print(search_view.bug.title)
+    Nonsensical bugs are useless
     >>> print(search_view.question)
     None
 
@@ -149,12 +149,12 @@ numbers do match questions, but they are not used.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': 'Question #15, #7, and 5.'})
-    >>> search_view._getNumericToken(search_view.text)
-    u'15'
+    >>> print(search_view._getNumericToken(search_view.text))
+    15
     >>> search_view.has_matches
     True
-    >>> search_view.bug.title
-    u'Nonsensical bugs are useless'
+    >>> print(search_view.bug.title)
+    Nonsensical bugs are useless
     >>> print(search_view.question)
     None
 
@@ -162,8 +162,8 @@ It is not an error to search for a non-existent bug or question.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': '55555'})
-    >>> search_view._getNumericToken(search_view.text)
-    u'55555'
+    >>> print(search_view._getNumericToken(search_view.text))
+    55555
     >>> search_view.has_matches
     False
     >>> print(search_view.bug)
@@ -208,14 +208,14 @@ product, or project group. A person is a person or a team.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': 'launchpad'})
-    >>> search_view._getNameToken(search_view.text)
-    u'launchpad'
+    >>> print(search_view._getNameToken(search_view.text))
+    launchpad
     >>> search_view.has_matches
     True
-    >>> search_view.pillar.displayname
-    u'Launchpad'
-    >>> search_view.person_or_team.displayname
-    u'Launchpad Developers'
+    >>> print(search_view.pillar.displayname)
+    Launchpad
+    >>> print(search_view.person_or_team.displayname)
+    Launchpad Developers
 
 A launchpad name is constructed from the search text. The letters are
 converted to lowercase. groups of spaces and punctuation are replaced
@@ -223,12 +223,12 @@ with a hyphen.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': 'Gnome Terminal'})
-    >>> search_view._getNameToken(search_view.text)
-    u'gnome-terminal'
+    >>> print(search_view._getNameToken(search_view.text))
+    gnome-terminal
     >>> search_view.has_matches
     True
-    >>> search_view.pillar.displayname
-    u'GNOME Terminal'
+    >>> print(search_view.pillar.displayname)
+    GNOME Terminal
     >>> print(search_view.person_or_team)
     None
 
@@ -242,12 +242,12 @@ by any of its aliases.
     >>> login(ANONYMOUS)
     >>> search_view = getSearchView(
     ...     form={'field.text': 'iceweasel'})
-    >>> search_view._getNameToken(search_view.text)
-    u'iceweasel'
+    >>> print(search_view._getNameToken(search_view.text))
+    iceweasel
     >>> search_view.has_matches
     True
-    >>> search_view.pillar.displayname
-    u'Mozilla Firefox'
+    >>> print(search_view.pillar.displayname)
+    Mozilla Firefox
 
 This is a harder example that illustrates that text that is clearly not
 the name of a pillar will none-the-less be tried. See the `Page searches`
@@ -255,8 +255,8 @@ section for how this kind of search can return matches.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': "YAHOO! webservice's Python API."})
-    >>> search_view._getNameToken(search_view.text)
-    u'yahoo-webservices-python-api.'
+    >>> print(search_view._getNameToken(search_view.text))
+    yahoo-webservices-python-api.
     >>> search_view.has_matches
     False
     >>> print(search_view.pillar)
@@ -268,14 +268,14 @@ Leading and trailing punctuation and whitespace are stripped.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': "~name12"})
-    >>> search_view._getNameToken(search_view.text)
-    u'name12'
+    >>> print(search_view._getNameToken(search_view.text))
+    name12
     >>> search_view.has_matches
     True
     >>> print(search_view.pillar)
     None
-    >>> search_view.person_or_team.displayname
-    u'Sample Person'
+    >>> print(search_view.person_or_team.displayname)
+    Sample Person
 
 Pillars, persons and teams are only returned for the first page of
 search, when the start param is 0.
@@ -305,22 +305,22 @@ pillar, nor will nsv match Nicolas Velin's unclaimed account.
     >>> search_view = getSearchView(
     ...     form={'field.text': 'python-gnome2-dev',
     ...           'start': '0'})
-    >>> search_view._getNameToken(search_view.text)
-    u'python-gnome2-dev'
+    >>> print(search_view._getNameToken(search_view.text))
+    python-gnome2-dev
     >>> print(search_view.pillar)
     None
 
     >>> nsv = getUtility(IPersonSet).getByName('nsv')
-    >>> nsv.displayname
-    u'Nicolas Velin'
+    >>> print(nsv.displayname)
+    Nicolas Velin
     >>> nsv.is_valid_person_or_team
     False
 
     >>> search_view = getSearchView(
     ...     form={'field.text': 'nsv',
     ...           'start': '0'})
-    >>> search_view._getNameToken(search_view.text)
-    u'nsv'
+    >>> print(search_view._getNameToken(search_view.text))
+    nsv
     >>> print(search_view.person_or_team)
     None
 
@@ -416,8 +416,8 @@ search terms.
 
     >>> search_view = getSearchView(
     ...     form={'field.text': " bug"})
-    >>> search_view.text
-    u'bug'
+    >>> print(search_view.text)
+    bug
     >>> search_view.has_matches
     True
     >>> search_view.pages
@@ -436,8 +436,10 @@ is a heading when there are only Search Service page matches...
 
     >>> search_view.has_exact_matches
     False
-    >>> search_view.batch_heading
-    (u'page matching "bug"', u'pages matching "bug"')
+    >>> for heading in search_view.batch_heading:
+    ...     print(heading)
+    page matching "bug"
+    pages matching "bug"
 
 ...and a heading for when there are exact matches and Search Service page
 matches.
@@ -446,8 +448,10 @@ matches.
     ...     form={'field.text': " launchpad"})
     >>> search_view.has_exact_matches
     True
-    >>> search_view.batch_heading
-    (u'other page matching "launchpad"', u'other pages matching "launchpad"')
+    >>> for heading in search_view.batch_heading:
+    ...     print(heading)
+    other page matching "launchpad"
+    other pages matching "launchpad"
 
 The SiteSearchBatchNavigator behaves like most BatchNavigators, except that
 its batch size is always 20. The size restriction conforms to Google's
@@ -461,12 +465,12 @@ maximum number of results that can be returned per request.
     >>> len(pages)
     20
     >>> for page in pages[0:5]:
-    ...     six.ensure_text(page.title)
-    u'Launchpad Bugs'
-    u'Bugs in Ubuntu Linux'
-    u'Bugs related to Sample Person'
-    u'...Bug... #1 in Mozilla Firefox: ...Firefox does not support SVG...'
-    u'Bugs in Source Package ...thunderbird... in Ubuntu Linux'
+    ...     print("'%s'" % page.title)
+    'Launchpad Bugs'
+    'Bugs in Ubuntu Linux'
+    'Bugs related to Sample Person'
+    '...Bug... #1 in Mozilla Firefox: ...Firefox does not support SVG...'
+    'Bugs in Source Package ...thunderbird... in Ubuntu Linux'
 
 The batch navigator provides access to the other batches. There are two
 batches of pages that match the search text 'bugs'. The navigator
@@ -486,8 +490,8 @@ is 20. That is because there were only 25 matching pages.
     ...           'start': '20'})
     >>> search_view.start
     20
-    >>> search_view.text
-    u'bug'
+    >>> print(search_view.text)
+    bug
     >>> search_view.has_matches
     True
 
@@ -497,12 +501,12 @@ is 20. That is because there were only 25 matching pages.
     >>> len(pages)
     5
     >>> for page in pages:
-    ...     six.ensure_text(page.title)
-    u'...Bug... #2 in Ubuntu Hoary: \u201cBlackhole Trash folder\u201d'
-    u'...Bug... #2 in mozilla-firefox (Debian): ...Blackhole Trash folder...'
-    u'...Bug... #3 in mozilla-firefox (Debian): \u201cBug Title Test\u201d'
-    u'...Bug... trackers registered in Launchpad'
-    u'...Bug... tracker \u201cDebian Bug tracker\u201d'
+    ...     print("'%s'" % page.title)
+    '...Bug... #2 in Ubuntu Hoary: “Blackhole Trash folder”'
+    '...Bug... #2 in mozilla-firefox (Debian): ...Blackhole Trash folder...'
+    '...Bug... #3 in mozilla-firefox (Debian): “Bug Title Test”'
+    '...Bug... trackers registered in Launchpad'
+    '...Bug... tracker “Debian Bug tracker”'
 
     >>> search_view.pages.nextBatchURL()
     ''
@@ -516,12 +520,12 @@ showing the matching terms in context of the page text.
     >>> page = pages[0]
     >>> page
     <...PageMatch ...>
-    >>> page.title
-    u'...Bug... #2 in Ubuntu Hoary: \u201cBlackhole Trash folder\u201d'
+    >>> print("'%s'" % page.title)
+    '...Bug... #2 in Ubuntu Hoary: “Blackhole Trash folder”'
     >>> page.url
     'http://bugs.launchpad.test/ubuntu/hoary/+bug/2'
-    >>> page.summary
-    u'...Launchpad\u2019s ...bug... tracker allows collaboration...'
+    >>> print("'%s'" % page.summary)
+    '...Launchpad’s ...bug... tracker allows collaboration...'
 
 
 No page matches
@@ -562,8 +566,8 @@ matching pages.
     >>> search_view = getSearchView(form={'field.text': 'gnomebaker'})
     >>> search_view.has_matches
     True
-    >>> search_view.pillar.displayname
-    u'gnomebaker'
+    >>> print(search_view.pillar.displayname)
+    gnomebaker
     >>> search_view.has_page_service
     False
 
@@ -656,8 +660,8 @@ the first 20 items are None. Only the last 5 items are PageMatches.
     25
     >>> print(results[0])
     None
-    >>> results[24].title
-    u'...Bug... tracker \u201cDebian Bug tracker\u201d'
+    >>> print("'%s'" % results[24].title)
+    '...Bug... tracker “Debian Bug tracker”'
     >>> results[18, 22]
     [None, None, <...PageMatch ...>, <...PageMatch ...>]
 
diff --git a/lib/lp/app/doc/batch-navigation.txt b/lib/lp/app/doc/batch-navigation.txt
index 1456ef0..86436d7 100644
--- a/lib/lp/app/doc/batch-navigation.txt
+++ b/lib/lp/app/doc/batch-navigation.txt
@@ -103,13 +103,13 @@ upper and lower navigation link views.
     >>> navigator = BatchNavigator([], request=request)
     >>> upper_view = getMultiAdapter(
     ...     (navigator, request), name='+navigation-links-upper')
-    >>> upper_view.render()
-    u''
+    >>> print(upper_view.render())
+    <BLANKLINE>
 
     >>> lower_view = getMultiAdapter(
     ...     (navigator, request), name='+navigation-links-lower')
-    >>> lower_view.render()
-    u''
+    >>> print(lower_view.render())
+    <BLANKLINE>
 
 When there is a current batch, but there are no previous or next
 batches, both the upper and lower navigation links view will render.
diff --git a/lib/lp/app/doc/displaying-paragraphs-of-text.txt b/lib/lp/app/doc/displaying-paragraphs-of-text.txt
index 1b3a638..9875fd8 100644
--- a/lib/lp/app/doc/displaying-paragraphs-of-text.txt
+++ b/lib/lp/app/doc/displaying-paragraphs-of-text.txt
@@ -13,13 +13,15 @@ Basics
     >>> text = ('This is a paragraph.\n'
     ...         '\n'
     ...         'This is another paragraph.')
-    >>> test_tales('foo/fmt:text-to-html', foo=text)
-    u'<p>This is a paragraph.</p>\n<p>This is another paragraph.</p>'
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
+    <p>This is a paragraph.</p>
+    <p>This is another paragraph.</p>
 
     >>> text = ('This is a line.\n'
     ...         'This is another line.')
-    >>> test_tales('foo/fmt:text-to-html', foo=text)
-    u'<p>This is a line.<br />\nThis is another line.</p>'
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
+    <p>This is a line.<br />
+    This is another line.</p>
 
     >>> text = (
     ...     'This is a paragraph that has been hard-wrapped by an email'
@@ -55,8 +57,8 @@ Basics
     >>> text = (
     ...     'This is a little paragraph all by itself. How cute!'
     ...     )
-    >>> test_tales('foo/fmt:text-to-html', foo=text)
-    u'<p>This is a little paragraph all by itself. How cute!</p>'
+    >>> print(test_tales('foo/fmt:text-to-html', foo=text))
+    <p>This is a little paragraph all by itself. How cute!</p>
 
     >>> text = (
     ...     'Here are two paragraphs with lots of whitespace between them.\n'
@@ -523,8 +525,8 @@ url to demonstrate quoting in the HTML attribute.
     ...     six.ensure_str('y"y'))
     >>> sorted(matchobj.groupdict().items())
     [('bug', None), ('url', 'y"y')]
-    >>> FormattersAPI._linkify_substitution(matchobj)
-    u'<a rel="nofollow" href="y&quot;y">y&quot;y</a>'
+    >>> print(FormattersAPI._linkify_substitution(matchobj))
+    <a rel="nofollow" href="y&quot;y">y&quot;y</a>
 
 When we have a bug reference, the 'bug' group is used as the text of the link,
 and the 'bugnum' is used to look up the bug.
diff --git a/lib/lp/app/doc/hierarchical-menu.txt b/lib/lp/app/doc/hierarchical-menu.txt
index 8bfa600..d9d2af0 100644
--- a/lib/lp/app/doc/hierarchical-menu.txt
+++ b/lib/lp/app/doc/hierarchical-menu.txt
@@ -288,8 +288,8 @@ considered to be on the home page if there are no breadcrumbs.
     >>> homepage_hierarchy.items
     []
 
-    >>> homepage_hierarchy.render().strip()
-    u''
+    >>> print(homepage_hierarchy.render().strip())
+    <BLANKLINE>
 
 
 Put the monkey patched method back.
diff --git a/lib/lp/app/doc/launchpadform.txt b/lib/lp/app/doc/launchpadform.txt
index a5a1e83..571002f 100644
--- a/lib/lp/app/doc/launchpadform.txt
+++ b/lib/lp/app/doc/launchpadform.txt
@@ -245,10 +245,12 @@ Check that form wide errors can be reported:
   >>> view.setUpFields()
   >>> view.setUpWidgets()
   >>> data = {}
-  >>> view._validate(None, data)
-  [u'your password may not be the same as your name']
-  >>> view.form_wide_errors
-  [u'your password may not be the same as your name']
+  >>> for error in view._validate(None, data):
+  ...     print(error)
+  your password may not be the same as your name
+  >>> for error in view.form_wide_errors:
+  ...     print(error)
+  your password may not be the same as your name
 
 Check that widget specific errors can be reported:
 
@@ -259,10 +261,12 @@ Check that widget specific errors can be reported:
   >>> view.setUpFields()
   >>> view.setUpWidgets()
   >>> data = {}
-  >>> view._validate(None, data)
-  [u'your password must not be &quot;password&quot;']
-  >>> view.widget_errors
-  {'password': u'your password must not be &quot;password&quot;'}
+  >>> for error in view._validate(None, data):
+  ...     print(error)
+  your password must not be &quot;password&quot;
+  >>> for field, error in view.widget_errors.items():
+  ...     print("%s: %s" % (field, error))
+  password: your password must not be &quot;password&quot;
 
 The base template used for LaunchpadFormView classes takes care of
 displaying these errors in the appropriate locations.
@@ -370,8 +374,8 @@ executing the template attribute (which can be set from ZCML):
 
   >>> context = FormTest()
   >>> view = RenderFormTest(context, LaunchpadTestRequest(form={}))
-  >>> view()
-  u'Content that comes from a ZCML registered template.'
+  >>> print(view())
+  Content that comes from a ZCML registered template.
 
 When a redirection is done (either by calling
 self.request.response.redirect() or setting the next_url attribute), the
@@ -383,8 +387,8 @@ rendered content is always the empty string.
   ...     form={'field.displayname': 'bob',
   ...           'field.actions.redirect': 'Redirect'})
   >>> view = RenderFormTest(context, request)
-  >>> view()
-  u''
+  >>> print(view())
+  <BLANKLINE>
 
 As an alternative to executing the template attribute, an action handler
 can directly return the rendered content:
@@ -395,8 +399,8 @@ can directly return the rendered content:
   ...     form={'field.displayname': 'bob',
   ...           'field.actions.update': 'Update'})
   >>> view = RenderFormTest(context, request)
-  >>> view()
-  u'Display name changed to: bob.'
+  >>> print(view())
+  Display name changed to: bob.
 
 This is also true of failure handlers:
 
@@ -406,8 +410,8 @@ This is also true of failure handlers:
   ...     form={'field.displayname': '',
   ...           'field.actions.update': 'Update'})
   >>> view = RenderFormTest(context, request)
-  >>> view()
-  u'Some errors occured.'
+  >>> print(view())
+  Some errors occured.
 
 
 == Initial Focused Widget ==
@@ -467,7 +471,6 @@ using a custom widget.
 First we'll create a fake pagetemplate which doesn't use Launchpad's main
 template and thus is way simpler.
 
-  >>> from StringIO import StringIO
   >>> from tempfile import mkstemp
   >>> from zope.browserpage import ViewPageTemplateFile
   >>> file, filename = mkstemp()
@@ -624,8 +627,8 @@ object for us too:
   >>> request.response.getStatus()
   302
 
-  >>> context.displayname
-  u'James Henstridge'
+  >>> print(context.displayname)
+  James Henstridge
 
 By default updateContextFromData() uses the view's context, but it's
 possible to pass in a specific context to use instead:
@@ -633,6 +636,5 @@ possible to pass in a specific context to use instead:
   >>> custom_context = FormTest()
   >>> view.updateContextFromData({'displayname': u'New name'}, custom_context)
   True
-  >>> custom_context.displayname
-  u'New name'
-
+  >>> print(custom_context.displayname)
+  New name
diff --git a/lib/lp/app/doc/launchpadformharness.txt b/lib/lp/app/doc/launchpadformharness.txt
index bcf0015..314db9f 100644
--- a/lib/lp/app/doc/launchpadformharness.txt
+++ b/lib/lp/app/doc/launchpadformharness.txt
@@ -103,8 +103,8 @@ see where we were redirected to:
 We can also see that the context object was updated by this form
 submission:
 
-  >>> context.string
-  u'abcdef'
+  >>> print(context.string)
+  abcdef
   >>> context.number
   42
 
diff --git a/lib/lp/app/doc/launchpadview.txt b/lib/lp/app/doc/launchpadview.txt
index d0ffb61..35abcdb 100644
--- a/lib/lp/app/doc/launchpadview.txt
+++ b/lib/lp/app/doc/launchpadview.txt
@@ -81,8 +81,8 @@ an IStructuredString implementation.
     >>> from lp.services.webapp.escaping import structured
     >>> view.error_message = structured(
     ...    'A structure is just "%s".', 'smoke & mirrors')
-    >>> view.error_message.escapedtext
-    u'A structure is just "smoke &amp; mirrors".'
+    >>> print(view.error_message.escapedtext)
+    A structure is just "smoke &amp; mirrors".
     >>> view.error_message = structured('Information overload.')
-    >>> view.error_message.escapedtext
-    u'Information overload.'
+    >>> print(view.error_message.escapedtext)
+    Information overload.
diff --git a/lib/lp/app/doc/menus.txt b/lib/lp/app/doc/menus.txt
index 7625701..e50330a 100644
--- a/lib/lp/app/doc/menus.txt
+++ b/lib/lp/app/doc/menus.txt
@@ -89,11 +89,11 @@ object has a canonical url derived from its place in the hierarchy.
     ...     'http://launchpad.test/joy-of-cooking/fried-spam',
     ...     traversed_objects=[cookbook, recipe])
 
-    >>> canonical_url(cookbook)
-    u'http://launchpad.test/joy-of-cooking'
+    >>> print(canonical_url(cookbook))
+    http://launchpad.test/joy-of-cooking
 
-    >>> canonical_url(recipe)
-    u'http://launchpad.test/joy-of-cooking/fried-spam'
+    >>> print(canonical_url(recipe))
+    http://launchpad.test/joy-of-cooking/fried-spam
 
 Content objects are not suitable for presentation by themselves; they
 require a view class to adapt them to the required format. An object may
diff --git a/lib/lp/app/doc/tales.txt b/lib/lp/app/doc/tales.txt
index 427b4c0..75e2629 100644
--- a/lib/lp/app/doc/tales.txt
+++ b/lib/lp/app/doc/tales.txt
@@ -284,26 +284,28 @@ The string is not ellipsized if it is less than the max length.
 
 To preserve newlines in text when displaying as HTML, use fmt:nl_to_br:
 
-    >>> test_tales('foo/fmt:nl_to_br',
-    ...             foo='icicle\nbicycle\ntricycle & troika')
-    u'icicle<br />\nbicycle<br />\ntricycle &amp; troika'
+    >>> print(test_tales('foo/fmt:nl_to_br',
+    ...             foo='icicle\nbicycle\ntricycle & troika'))
+    icicle<br />
+    bicycle<br />
+    tricycle &amp; troika
 
 To "<pre>" format a string, use fmt:nice_pre:
 
-    >>> import pprint, textwrap
-    >>> pprint.pprint(textwrap.wrap(
-    ...     test_tales('foo/fmt:nice_pre', foo='hello & goodbye')
-    ...    ))
-    [u'<pre class="wrap">hello &amp; goodbye</pre>']
+    >>> import textwrap
+    >>> for line in textwrap.wrap(
+    ...         test_tales('foo/fmt:nice_pre', foo='hello & goodbye')):
+    ...     print(line)
+    <pre class="wrap">hello &amp; goodbye</pre>
 
 Add manual word breaks to long words in a string:
 
-    >>> test_tales('foo/fmt:break-long-words', foo='short words')
-    u'short words'
+    >>> print(test_tales('foo/fmt:break-long-words', foo='short words'))
+    short words
 
-    >>> test_tales('foo/fmt:break-long-words',
-    ...     foo='<http://launchpad.net/products/launchpad>')
-    u'&lt;http:/<wbr />/launchpad.<wbr />...<wbr />launchpad&gt;'
+    >>> print(test_tales('foo/fmt:break-long-words',
+    ...     foo='<http://launchpad.net/products/launchpad>'))
+    &lt;http:/<wbr />/launchpad.<wbr />...<wbr />launchpad&gt;
 
 To get a int with its thousands separated by a comma, use fmt:intcomma.
 
@@ -418,15 +420,15 @@ Person entries
 For a person or team, fmt:link gives us a link to that person's page,
 containing the person name and an icon.
 
-    >>> test_tales("person/fmt:link", person=mark)
-    u'<a href=".../~mark" class="sprite person">Mark Shuttleworth</a>'
+    >>> print(test_tales("person/fmt:link", person=mark))
+    <a href=".../~mark" class="sprite person">Mark Shuttleworth</a>
 
-    >>> test_tales("person/fmt:link", person=matsubara)
-    u'<a href=".../~matsubara" class="sprite person-inactive">Diogo ...</a>'
+    >>> print(test_tales("person/fmt:link", person=matsubara))
+    <a href=".../~matsubara" class="sprite person-inactive">Diogo ...</a>
 
     >>> ubuntu_team = getUtility(IPersonSet).getByName('ubuntu-team')
-    >>> test_tales("person/fmt:link", person=ubuntu_team)
-    u'<a href=".../~ubuntu-team" class="sprite team">Ubuntu Team</a>'
+    >>> print(test_tales("person/fmt:link", person=ubuntu_team))
+    <a href=".../~ubuntu-team" class="sprite team">Ubuntu Team</a>
 
 The link can make the URL go to a specific app.
 
@@ -467,22 +469,22 @@ Person's displayname will be escaped; averting a XSS vulnerability.
     >>> sample_person = getUtility(IPersonSet).getByName('name12')
     >>> sample_person.display_name = (
     ...     "Sample Person<br/><script>alert('XSS')</script>")
-    >>> test_tales("person/fmt:link", person=sample_person)
-    u'<a href=".../~name12"...>Sample
-      Person&lt;br/&gt;&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</a>'
+    >>> print(test_tales("person/fmt:link", person=sample_person))
+    <a href=".../~name12"...>Sample
+      Person&lt;br/&gt;&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</a>
 
 The fmt:link formatter takes an additional view_name component to extend
 the link:
 
     >>> login(ANONYMOUS, LaunchpadTestRequest())
-    >>> test_tales("person/fmt:link/+edit", person=matsubara)
-    u'<a href=".../~matsubara/+edit"...>...'
+    >>> print(test_tales("person/fmt:link/+edit", person=matsubara))
+    <a href=".../~matsubara/+edit"...>...
 
 The fmt:local-time formatter will return the local time for that person.
 If the person has no time_zone specified, we use UTC.
 
-    >>> sample_person.time_zone
-    u'Australia/Perth'
+    >>> print(sample_person.time_zone)
+    Australia/Perth
 
     >>> test_tales("person/fmt:local-time", person=sample_person)
     '... AWST'
@@ -490,8 +492,8 @@ If the person has no time_zone specified, we use UTC.
     >>> from zope.security.proxy import removeSecurityProxy
     >>> print(removeSecurityProxy(mark).location)
     None
-    >>> mark.time_zone
-    u'UTC'
+    >>> print(mark.time_zone)
+    UTC
 
     >>> test_tales("person/fmt:local-time", person=mark)
     '... UTC'
@@ -552,17 +554,17 @@ Bugs
 For bugs, fmt:link takes to the bug redirect page.
 
     >>> bug = getUtility(IBugSet).get(1)
-    >>> test_tales("bug/fmt:link", bug=bug)
-    u'<a href=".../bugs/1" class="sprite bug">Bug #1:
-      Firefox does not support SVG</a>'
+    >>> print(test_tales("bug/fmt:link", bug=bug))
+    <a href=".../bugs/1" class="sprite bug">Bug #1:
+      Firefox does not support SVG</a>
 
 For bugtasks, fmt:link shows the severity bug icon, and links to the
 appropriate project's bug.
 
     >>> bugtask = bug.bugtasks[0]
-    >>> test_tales("bugtask/fmt:link", bugtask=bugtask)
-    u'<a href=".../firefox/+bug/1" class="sprite bug-low"
-         title="Low - New">Bug #1: Firefox does not support SVG</a>'
+    >>> print(test_tales("bugtask/fmt:link", bugtask=bugtask))
+    <a href=".../firefox/+bug/1" class="sprite bug-low"
+         title="Low - New">Bug #1: Firefox does not support SVG</a>
 
 Bug titles may contain markup (when describing issue regarding markup).
 Their titles are escaped so that they display correctly. This also
@@ -571,13 +573,13 @@ title might be interpreted by the browser.
 
     >>> login('test@xxxxxxxxxxxxx')
     >>> bug.title = "Opps<br/><script>alert('XSS')</script>"
-    >>> test_tales("bug/fmt:link", bug=getUtility(IBugSet).get(1))
-    u'<a href=".../bugs/1" ...>Bug #1:
-      Opps&lt;br/&gt;&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</a>'
+    >>> print(test_tales("bug/fmt:link", bug=getUtility(IBugSet).get(1)))
+    <a href=".../bugs/1" ...>Bug #1:
+      Opps&lt;br/&gt;&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</a>
 
-    >>> test_tales("bugtask/fmt:link", bugtask=bugtask)
-    u'<a href=".../firefox/+bug/1" ...>Bug #1:
-      Opps&lt;br/&gt;&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</a>'
+    >>> print(test_tales("bugtask/fmt:link", bugtask=bugtask))
+    <a href=".../firefox/+bug/1" ...>Bug #1:
+      Opps&lt;br/&gt;&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</a>
 
 
 Branch subscriptions
@@ -592,16 +594,15 @@ adequate permissions, a link is not generated.
     ...     name='michael', displayname='Michael the Viking')
     >>> subscription = factory.makeBranchSubscription(
     ...     branch=branch, person=michael)
-    >>> test_tales("subscription/fmt:link", subscription=subscription)
-    u'Subscription of Michael the Viking to
-      lp://dev/~eric/fooix/my-branch'
+    >>> print(test_tales("subscription/fmt:link", subscription=subscription))
+    Subscription of Michael the Viking to lp://dev/~eric/fooix/my-branch
 
 But if we log in as the subscriber, a link is presented.
 
     >>> ignored = login_person(subscription.person)
-    >>> test_tales("subscription/fmt:link", subscription=subscription)
-    u'<a href="http://.../+subscription/michael";>Subscription
-      of Michael the Viking to lp://dev/~eric/fooix/my-branch</a>'
+    >>> print(test_tales("subscription/fmt:link", subscription=subscription))
+    <a href="http://.../+subscription/michael";>Subscription
+      of Michael the Viking to lp://dev/~eric/fooix/my-branch</a>
 
 Merge proposals also have a link formatter, which displays branch
 titles:
@@ -616,8 +617,8 @@ Merge proposals
     >>> target = factory.makeProductBranch(product=fooix)
     >>> fooix.development_focus.branch = target
     >>> proposal = source.addLandingTarget(eric, target)
-    >>> test_tales("proposal/fmt:link", proposal=proposal)
-    u'<a href="...">[Merge] lp://dev/~eric/fooix/fix into lp://dev/fooix</a>'
+    >>> print(test_tales("proposal/fmt:link", proposal=proposal))
+    <a href="...">[Merge] lp://dev/~eric/fooix/fix into lp://dev/fooix</a>
 
 
 Code review comments
@@ -639,8 +640,8 @@ Bug branches
     >>> bug = factory.makeBug()
     >>> bug.linkBranch(branch, branch.owner)
     >>> [bugbranch] = bug.linked_bugbranches
-    >>> test_tales("bugbranch/fmt:link", bugbranch=bugbranch)
-    u'<a href="...+bug...">Bug #...</a>'
+    >>> print(test_tales("bugbranch/fmt:link", bugbranch=bugbranch))
+    <a href="...+bug...">Bug #...</a>
 
 
 Code imports
@@ -652,8 +653,8 @@ support the branch deletion code.
 
     >>> login('foo.bar@xxxxxxxxxxxxx')
     >>> code_import = factory.makeCodeImport(branch_name="trunk")
-    >>> test_tales("code_import/fmt:link", code_import=code_import)
-    u'<a href=".../trunk">Import of...</a>'
+    >>> print(test_tales("code_import/fmt:link", code_import=code_import))
+    <a href=".../trunk">Import of...</a>
 
 
 Product release files
@@ -711,8 +712,9 @@ Product series
 ..............
 
     >>> product_series = factory.makeProductSeries()
-    >>> test_tales("product_series/fmt:link", product_series=product_series)
-    u'... series...'
+    >>> print("'%s'" % test_tales(
+    ...     "product_series/fmt:link", product_series=product_series))
+    '... series...'
 
 
 Blueprints
@@ -723,8 +725,9 @@ Blueprints
     >>> login('test@xxxxxxxxxxxxx')
     >>> specification = factory.makeSpecification(
     ...     priority=SpecificationPriority.UNDEFINED)
-    >>> test_tales("specification/fmt:link", specification=specification)
-    u'<a...class="sprite blueprint-undefined">...</a>'
+    >>> print(test_tales(
+    ...     "specification/fmt:link", specification=specification))
+    <a...class="sprite blueprint-undefined">...</a>
 
 
 Blueprint branches
@@ -734,17 +737,17 @@ Blueprint branches
     ...     priority=SpecificationPriority.UNDEFINED)
     >>> branch = factory.makeAnyBranch()
     >>> specification_branch = specification.linkBranch(branch, branch.owner)
-    >>> test_tales("specification_branch/fmt:link",
-    ...     specification_branch=specification_branch)
-    u'<a...class="sprite blueprint-undefined">...</a>'
+    >>> print(test_tales("specification_branch/fmt:link",
+    ...     specification_branch=specification_branch))
+    <a...class="sprite blueprint-undefined">...</a>
 
 
 Projects
 ........
 
     >>> product = factory.makeProduct()
-    >>> test_tales('product/fmt:link', product=product)
-    u'<a href=... class="sprite product">...</a>'
+    >>> print(test_tales('product/fmt:link', product=product))
+    <a href=... class="sprite product">...</a>
 
 
 Questions
@@ -752,24 +755,24 @@ Questions
 
     >>> from lp.answers.interfaces.questioncollection import IQuestionSet
     >>> question = getUtility(IQuestionSet).get(1)
-    >>> test_tales("question/fmt:link", question=question)
-    u'<a... class="sprite question">1:...</a>'
+    >>> print(test_tales("question/fmt:link", question=question))
+    <a... class="sprite question">1:...</a>
 
 
 Distributions
 .............
 
     >>> distribution = factory.makeDistribution()
-    >>> test_tales("distribution/fmt:link", distribution=distribution)
-    u'<a... class="sprite distribution">...</a>'
+    >>> print(test_tales("distribution/fmt:link", distribution=distribution))
+    <a... class="sprite distribution">...</a>
 
 
 Distribution Series
 ...................
 
     >>> distroseries = factory.makeDistroArchSeries().distroseries
-    >>> test_tales("distroseries/fmt:link", distroseries=distroseries)
-    u'<a href="...">...</a>'
+    >>> print(test_tales("distroseries/fmt:link", distroseries=distroseries))
+    <a href="...">...</a>
 
 
 The fmt: namespace for specially formatted object info
@@ -787,8 +790,8 @@ Bug Trackers
 
 The "standard" 'url' name is supported:
 
-    >>> test_tales("bugtracker/fmt:url", bugtracker=bugtracker)
-    u'http://bugs.launchpad.test/bugs/bugtrackers/email'
+    >>> print(test_tales("bugtracker/fmt:url", bugtracker=bugtracker))
+    http://bugs.launchpad.test/bugs/bugtrackers/email
 
 (The url is relative if possible, and our test request claims to be from
 launchpad.test, so the url is relative.)
@@ -799,36 +802,36 @@ 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  '%s'" % (
     ...             format, test_tales(expression % format,
     ...                                bugtracker=bugtracker)))
-    ...     print('aliases -->\n  %r' % (
-    ...         list(test_tales(expression % 'aliases',
-    ...                         bugtracker=bugtracker)),))
+    ...     print("aliases -->\n  [%s]" % (', '.join(
+    ...         "'%s'" % alias for alias in test_tales(
+    ...             expression % 'aliases', bugtracker=bugtracker))))
 
     >>> login('test@xxxxxxxxxxxxx')
     >>> print_formatted_bugtrackers()
     link -->
-      u'<a href=".../bugs/bugtrackers/email">an@email.address bug tracker</a>'
+      '<a href=".../bugs/bugtrackers/email">an@email.address bug tracker</a>'
     external-link -->
-      u'<a class="link-external"
-        href="mailto:bugs@xxxxxxxxxxx";>mailto:bugs@xxxxxxxxxxx</a>'
+      '<a class="link-external"
+       href="mailto:bugs@xxxxxxxxxxx";>mailto:bugs@xxxxxxxxxxx</a>'
     external-title-link -->
-      u'<a class="link-external"
-        href="mailto:bugs@xxxxxxxxxxx";>an@email.address bug tracker</a>'
+      '<a class="link-external"
+       href="mailto:bugs@xxxxxxxxxxx";>an@email.address bug tracker</a>'
     aliases -->
-      [u'http://bugs.vikingsrool.no/', u'mailto:eatme@xxxxxxxxxxxx']
+      ['http://bugs.vikingsrool.no/', 'mailto:eatme@xxxxxxxxxxxx']
 
     >>> login(ANONYMOUS)
     >>> print_formatted_bugtrackers()
     link -->
-      u'<a href="...ckers/email">&lt;email address hidden&gt; bug tracker</a>'
+      '<a href="...ckers/email">&lt;email address hidden&gt; bug tracker</a>'
     external-link -->
-      u'mailto:&lt;email address hidden&gt;'
+      'mailto:&lt;email address hidden&gt;'
     external-title-link -->
-      u'&lt;email address hidden&gt; bug tracker'
+      '&lt;email address hidden&gt; bug tracker'
     aliases -->
-      [u'http://bugs.vikingsrool.no/', u'mailto:<email address hidden>']
+      ['http://bugs.vikingsrool.no/', 'mailto:<email address hidden>']
 
     >>> login('test@xxxxxxxxxxxxx')
 
@@ -850,48 +853,52 @@ Bug Watches
 
 The "standard" 'url' name is supported:
 
-    >>> test_tales("bugwatch/fmt:url", bugwatch=sf_bugwatch)
-    u'http://bugs.launchpad.test/bugs/12/+watch/...'
+    >>> print(test_tales("bugwatch/fmt:url", bugwatch=sf_bugwatch))
+    http://bugs.launchpad.test/bugs/12/+watch/...
 
-    >>> test_tales("bugwatch/fmt:url", bugwatch=email_bugwatch)
-    u'http://bugs.launchpad.test/bugs/12/+watch/...'
+    >>> print(test_tales("bugwatch/fmt:url", bugwatch=email_bugwatch))
+    http://bugs.launchpad.test/bugs/12/+watch/...
 
 As are 'external-link' and 'external-link-short', which help when hiding
 email addresses from users who are not logged in:
 
     >>> login('test@xxxxxxxxxxxxx')
 
-    >>> test_tales("bugwatch/fmt:external-link", bugwatch=sf_bugwatch)
-    u'<a class="link-external"
-      href="http://sourceforge.net/support/tracker.php?aid=1234";>sf #1234</a>'
+    >>> print(test_tales("bugwatch/fmt:external-link", bugwatch=sf_bugwatch))
+    <a class="link-external"
+       href="http://sourceforge.net/support/tracker.php?aid=1234";>sf #1234</a>
 
-    >>> test_tales("bugwatch/fmt:external-link-short", bugwatch=sf_bugwatch)
-    u'<a class="link-external"
-      href="http://sourceforge.net/support/tracker.php?aid=1234";>1234</a>'
+    >>> print(test_tales(
+    ...     "bugwatch/fmt:external-link-short", bugwatch=sf_bugwatch))
+    <a class="link-external"
+       href="http://sourceforge.net/support/tracker.php?aid=1234";>1234</a>
 
-    >>> test_tales("bugwatch/fmt:external-link", bugwatch=email_bugwatch)
-    u'<a class="link-external" href="mailto:bugs@xxxxxxxxxxx";>email</a>'
+    >>> print(test_tales(
+    ...     "bugwatch/fmt:external-link", bugwatch=email_bugwatch))
+    <a class="link-external" href="mailto:bugs@xxxxxxxxxxx";>email</a>
 
-    >>> test_tales(
-    ...     "bugwatch/fmt:external-link-short", bugwatch=email_bugwatch)
-    u'<a class="link-external" href="mailto:bugs@xxxxxxxxxxx";>&mdash;</a>'
+    >>> print(test_tales(
+    ...     "bugwatch/fmt:external-link-short", bugwatch=email_bugwatch))
+    <a class="link-external" href="mailto:bugs@xxxxxxxxxxx";>&mdash;</a>
 
     >>> login(ANONYMOUS)
 
-    >>> test_tales("bugwatch/fmt:external-link", bugwatch=sf_bugwatch)
-    u'<a class="link-external"
-      href="http://sourceforge.net/support/tracker.php?aid=1234";>sf #1234</a>'
+    >>> print(test_tales("bugwatch/fmt:external-link", bugwatch=sf_bugwatch))
+    <a class="link-external"
+       href="http://sourceforge.net/support/tracker.php?aid=1234";>sf #1234</a>
 
-    >>> test_tales("bugwatch/fmt:external-link-short", bugwatch=sf_bugwatch)
-    u'<a class="link-external"
-      href="http://sourceforge.net/support/tracker.php?aid=1234";>1234</a>'
+    >>> print(test_tales(
+    ...     "bugwatch/fmt:external-link-short", bugwatch=sf_bugwatch))
+    <a class="link-external"
+       href="http://sourceforge.net/support/tracker.php?aid=1234";>1234</a>
 
-    >>> test_tales("bugwatch/fmt:external-link", bugwatch=email_bugwatch)
-    u'email'
+    >>> print(test_tales(
+    ...     "bugwatch/fmt:external-link", bugwatch=email_bugwatch))
+    email
 
-    >>> test_tales(
-    ...     "bugwatch/fmt:external-link-short", bugwatch=email_bugwatch)
-    u'&mdash;'
+    >>> print(test_tales(
+    ...     "bugwatch/fmt:external-link-short", bugwatch=email_bugwatch))
+    &mdash;
 
     >>> login('test@xxxxxxxxxxxxx')
 
@@ -1065,11 +1072,11 @@ Escaping strings
 
 To escape a string you should use fmt:escape.
 
-    >>> test_tales('foo/fmt:escape', foo='some value')
-    u'some value'
+    >>> print(test_tales('foo/fmt:escape', foo='some value'))
+    some value
 
-    >>> test_tales('foo/fmt:escape', foo='some <br /> value')
-    u'some &lt;br /&gt; value'
+    >>> print(test_tales('foo/fmt:escape', foo='some <br /> value'))
+    some &lt;br /&gt; value
 
 
 CSS ids
@@ -1204,25 +1211,25 @@ Launchpad and linkify them to point at the profile page for that person.
 The resulting HTML includes a person icon next to the linked text to
 emphasise the linkage.
 
-    >>> test_tales('foo/fmt:linkify-email',
-    ...    foo='I am the mighty foo.bar@xxxxxxxxxxxxx hear me roar.')
-    u'...<a href="http://launchpad.test/~name16";
-      class="sprite person">foo.bar@xxxxxxxxxxxxx</a>...'
+    >>> print("'%s'" % test_tales('foo/fmt:linkify-email',
+    ...     foo='I am the mighty foo.bar@xxxxxxxxxxxxx hear me roar.'))
+    '...<a href="http://launchpad.test/~name16";
+          class="sprite person">foo.bar@xxxxxxxxxxxxx</a>...'
 
 Multiple addresses may be linkified at once:
 
-    >>> test_tales('foo/fmt:linkify-email',
-    ...     foo='foo.bar@xxxxxxxxxxxxx and cprov@xxxxxxxxxx')
-    u'<a href="http://launchpad.test/~name16";
-      class="sprite person">foo.bar@xxxxxxxxxxxxx</a>
-      and <a href="http://launchpad.test/~cprov";
-        class="sprite person">cprov@xxxxxxxxxx</a>'
+    >>> print(test_tales('foo/fmt:linkify-email',
+    ...     foo='foo.bar@xxxxxxxxxxxxx and cprov@xxxxxxxxxx'))
+    <a href="http://launchpad.test/~name16";
+       class="sprite person">foo.bar@xxxxxxxxxxxxx</a>
+    and <a href="http://launchpad.test/~cprov";
+           class="sprite person">cprov@xxxxxxxxxx</a>
 
 Team addresses are linkified with a team icon:
 
-    >>> test_tales('foo/fmt:linkify-email', foo='support@xxxxxxxxxx')
-    u'<a href="http://launchpad.test/~ubuntu-team";
-      class="sprite team">support@xxxxxxxxxx</a>'
+    >>> print(test_tales('foo/fmt:linkify-email', foo='support@xxxxxxxxxx'))
+    <a href="http://launchpad.test/~ubuntu-team";
+       class="sprite team">support@xxxxxxxxxx</a>
 
 Unknown email addresses are not altered in any way:
 
@@ -1255,8 +1262,8 @@ Test the 'fmt:url' namespace for canonical urls.
     ...     rootsite = None
 
     >>> object_having_url = ObjectThatHasUrl()
-    >>> test_tales('foo/fmt:url', foo=object_having_url)
-    u'/bonobo/saki'
+    >>> print(test_tales('foo/fmt:url', foo=object_having_url))
+    /bonobo/saki
 
 Now, we need to test that it gets the correct application URL from the
 request.
@@ -1291,8 +1298,8 @@ Make a mock-up IBrowserRequest, and use this as the interaction.
 Note how the URL has only a path part, because it is for the same site
 as the current request.
 
-    >>> test_tales('foo/fmt:url', foo=object_having_url)
-    u'/bonobo/saki'
+    >>> print(test_tales('foo/fmt:url', foo=object_having_url))
+    /bonobo/saki
 
 
 The some_string/fmt:something helper
@@ -1563,17 +1570,17 @@ And the url format is also available.
 If the link is disabled, no markup is rendered.
 
     >>> menu_link.enabled = False
-    >>> test_tales('menu_link/fmt:icon', menu_link=menu_link)
-    u''
+    >>> print(test_tales('menu_link/fmt:icon', menu_link=menu_link))
+    <BLANKLINE>
 
-    >>> test_tales('menu_link/fmt:link-icon', menu_link=menu_link)
-    u''
+    >>> print(test_tales('menu_link/fmt:link-icon', menu_link=menu_link))
+    <BLANKLINE>
 
-    >>> test_tales('menu_link/fmt:link', menu_link=menu_link)
-    u''
+    >>> print(test_tales('menu_link/fmt:link', menu_link=menu_link))
+    <BLANKLINE>
 
-    >>> test_tales('menu_link/fmt:url', menu_link=menu_link)
-    u''
+    >>> print(test_tales('menu_link/fmt:url', menu_link=menu_link))
+    <BLANKLINE>
 
 
 CSS classes for public and private objects
@@ -1627,48 +1634,48 @@ Foo Bar is an administrator so they can see all.
 
     >>> login('foo.bar@xxxxxxxxxxxxx')
     >>> myteam = getUtility(IPersonSet).getByName('myteam')
-    >>> test_tales("team/fmt:link", team=myteam)
-    u'<a ...class="sprite team private"...>My Team</a>'
+    >>> print(test_tales("team/fmt:link", team=myteam))
+    <a ...class="sprite team private"...>My Team</a>
 
-    >>> test_tales("team/fmt:displayname", team=myteam)
-    u'My Team'
+    >>> print(test_tales("team/fmt:displayname", team=myteam))
+    My Team
 
-    >>> test_tales("team/fmt:unique_displayname", team=myteam)
-    u'My Team (myteam)'
+    >>> print(test_tales("team/fmt:unique_displayname", team=myteam))
+    My Team (myteam)
 
 Owner is a member of myteam so they can see all.
 
     >>> login('owner@xxxxxxxxxxxxx')
-    >>> test_tales("team/fmt:link", team=myteam)
-    u'<a ...class="sprite team private"...>My Team</a>'
+    >>> print(test_tales("team/fmt:link", team=myteam))
+    <a ...class="sprite team private"...>My Team</a>
 
-    >>> test_tales("team/fmt:displayname", team=myteam)
-    u'My Team'
+    >>> print(test_tales("team/fmt:displayname", team=myteam))
+    My Team
 
-    >>> test_tales("team/fmt:unique_displayname", team=myteam)
-    u'My Team (myteam)'
+    >>> print(test_tales("team/fmt:unique_displayname", team=myteam))
+    My Team (myteam)
 
 No Priv is neither a member of myteam nor an administrator, so the
 information about myteam is hidden.
 
     >>> login('no-priv@xxxxxxxxxxxxx')
-    >>> test_tales("team/fmt:link", team=myteam)
-    u'<span ...class="sprite team"...>&lt;hidden&gt;</span>'
+    >>> print(test_tales("team/fmt:link", team=myteam))
+    <span ...class="sprite team"...>&lt;hidden&gt;</span>
 
-    >>> test_tales("team/fmt:displayname", team=myteam)
-    u'<hidden>'
+    >>> print(test_tales("team/fmt:displayname", team=myteam))
+    <hidden>
 
-    >>> test_tales("team/fmt:unique_displayname", team=myteam)
-    u'<hidden>'
+    >>> print(test_tales("team/fmt:unique_displayname", team=myteam))
+    <hidden>
 
 The anonymous user is not allowed to see private team details.
 
     >>> login(ANONYMOUS)
-    >>> test_tales("team/fmt:link", team=myteam)
-    u'<span ...class="sprite team"...>&lt;hidden&gt;</span>'
+    >>> print(test_tales("team/fmt:link", team=myteam))
+    <span ...class="sprite team"...>&lt;hidden&gt;</span>
 
-    >>> test_tales("team/fmt:displayname", team=myteam)
-    u'<hidden>'
+    >>> print(test_tales("team/fmt:displayname", team=myteam))
+    <hidden>
 
-    >>> test_tales("team/fmt:unique_displayname", team=myteam)
-    u'<hidden>'
+    >>> print(test_tales("team/fmt:unique_displayname", team=myteam))
+    <hidden>
diff --git a/lib/lp/app/doc/validation.txt b/lib/lp/app/doc/validation.txt
index 38cffa3..7244fe5 100644
--- a/lib/lp/app/doc/validation.txt
+++ b/lib/lp/app/doc/validation.txt
@@ -18,5 +18,5 @@ an IWidgetInputErrorView:
 
     >>> IWidgetInputErrorView.providedBy(view)
     True
-    >>> view.snippet()
-    u'lp validation error'
+    >>> print(view.snippet())
+    lp validation error
diff --git a/lib/lp/app/stories/basics/xx-dbpolicy.txt b/lib/lp/app/stories/basics/xx-dbpolicy.txt
index b315236..1d42045 100644
--- a/lib/lp/app/stories/basics/xx-dbpolicy.txt
+++ b/lib/lp/app/stories/basics/xx-dbpolicy.txt
@@ -25,8 +25,8 @@ request is querying the master or slave database.
     >>> dbname == master.execute("SELECT current_database()").get_one()[0]
     True
     >>> slave = ISlaveStore(Person)
-    >>> slave.execute("SELECT current_database()").get_one()[0]
-    u'launchpad_empty'
+    >>> print(slave.execute("SELECT current_database()").get_one()[0])
+    launchpad_empty
 
 We should confirm that the empty database is as empty as we hope it is.
 
diff --git a/lib/lp/app/stories/basics/xx-opstats.txt b/lib/lp/app/stories/basics/xx-opstats.txt
index 88a567b..f502ede 100644
--- a/lib/lp/app/stories/basics/xx-opstats.txt
+++ b/lib/lp/app/stories/basics/xx-opstats.txt
@@ -299,5 +299,5 @@ But our database connections are broken.
     >>> dummy = config.pop('no_db')
     >>> getUtility(IZStorm)._reset()
 
-    >>> IStore(Person).find(Person, name='janitor').one().name
-    u'janitor'
+    >>> print(IStore(Person).find(Person, name='janitor').one().name)
+    janitor
diff --git a/lib/lp/app/widgets/date.py b/lib/lp/app/widgets/date.py
index bbeca15..0c317cb 100644
--- a/lib/lp/app/widgets/date.py
+++ b/lib/lp/app/widgets/date.py
@@ -408,8 +408,8 @@ class DateTimeWidget(TextWidget):
 
         The 'missing' value is converted to an empty string:
 
-          >>> widget._toFormValue(field.missing_value)
-          u''
+          >>> print(widget._toFormValue(field.missing_value))
+          <BLANKLINE>
 
         DateTimes are displayed without the corresponding time zone
         information:
@@ -562,8 +562,8 @@ class DateWidget(DateTimeWidget):
 
         The 'missing' value is converted to an empty string:
 
-          >>> widget._toFormValue(field.missing_value)
-          u''
+          >>> print(widget._toFormValue(field.missing_value))
+          <BLANKLINE>
 
         The widget ignores time and time zone information, returning only
         the date:
diff --git a/lib/lp/app/widgets/doc/image-widget.txt b/lib/lp/app/widgets/doc/image-widget.txt
index 164576c..23d4c86 100644
--- a/lib/lp/app/widgets/doc/image-widget.txt
+++ b/lib/lp/app/widgets/doc/image-widget.txt
@@ -137,8 +137,8 @@ And now we change it to a random image.
     >>> widget = ImageChangeWidget(
     ...     person_logo, LaunchpadTestRequest(form=form), edit_style)
     >>> fileupload = widget.getInputValue()
-    >>> fileupload.filename
-    u'logo.png'
+    >>> print(fileupload.filename)
+    logo.png
 
     >>> fileupload.content.filesize == len(logo.getvalue())
     True
@@ -299,8 +299,8 @@ Image is the correct dimensions:
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
     >>> fileupload = widget.getInputValue()
-    >>> fileupload.filename
-    u'mugshot.png'
+    >>> print(fileupload.filename)
+    mugshot.png
 
     >>> fileupload.content.filesize == len(mugshot.getvalue())
     True
@@ -390,8 +390,8 @@ If the image is smaller than the dimensions, the input validates:
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
     >>> fileupload = widget.getInputValue()
-    >>> fileupload.filename
-    u'mugshot.png'
+    >>> print(fileupload.filename)
+    mugshot.png
 
 The same occurs if the image matches the specified dimensions:
 
@@ -400,7 +400,5 @@ The same occurs if the image matches the specified dimensions:
     >>> widget = ImageChangeWidget(
     ...     person_mugshot, LaunchpadTestRequest(form=form), edit_style)
     >>> fileupload = widget.getInputValue()
-    >>> fileupload.filename
-    u'mugshot.png'
-
-
+    >>> print(fileupload.filename)
+    mugshot.png
diff --git a/lib/lp/app/widgets/doc/lower-case-text-widget.txt b/lib/lp/app/widgets/doc/lower-case-text-widget.txt
index 36c4e29..eb84fe1 100644
--- a/lib/lp/app/widgets/doc/lower-case-text-widget.txt
+++ b/lib/lp/app/widgets/doc/lower-case-text-widget.txt
@@ -13,16 +13,16 @@ lower case:
   >>> field = IBug['description']
   >>> request = LaunchpadTestRequest(form={'field.description':'Foo'})
   >>> widget = LowerCaseTextWidget(field, request)
-  >>> widget.getInputValue()
-  u'foo'
+  >>> print(widget.getInputValue())
+  foo
 
 However, strings without lower case characters are left unchanged:
 
   >>> field = IBug['description']
   >>> request = LaunchpadTestRequest(form={'field.description':'foo1'})
   >>> widget = LowerCaseTextWidget(field, request)
-  >>> widget.getInputValue()
-  u'foo1'
+  >>> print(widget.getInputValue())
+  foo1
 
 In addition, the widget also renders itself with a CSS style that causes
 characters to be rendered in lower case as they are typed in by the
@@ -33,4 +33,4 @@ user:
 
 This style is defined by "lib/canonical/launchpad/icing/style.css". Note
 that the style only causes text to be rendered in lower case, and does
-not convert the underlying string to lower case.
\ No newline at end of file
+not convert the underlying string to lower case.
diff --git a/lib/lp/app/widgets/doc/noneable-text-widgets.txt b/lib/lp/app/widgets/doc/noneable-text-widgets.txt
index 362edb3..911a9e3 100644
--- a/lib/lp/app/widgets/doc/noneable-text-widgets.txt
+++ b/lib/lp/app/widgets/doc/noneable-text-widgets.txt
@@ -62,6 +62,5 @@ Excess whitespace is stripped, but newlines are preserved.
     >>> request = LaunchpadTestRequest(
     ...     form={'field.summary' : ' flower \n grass '})
     >>> widget = NoneableDescriptionWidget(field, request)
-    >>> widget.getInputValue()
-    u'flower \n grass'
-
+    >>> six.ensure_str(widget.getInputValue())
+    'flower \n grass'
diff --git a/lib/lp/app/widgets/doc/project-scope-widget.txt b/lib/lp/app/widgets/doc/project-scope-widget.txt
index a09b2a0..35c3ed0 100644
--- a/lib/lp/app/widgets/doc/project-scope-widget.txt
+++ b/lib/lp/app/widgets/doc/project-scope-widget.txt
@@ -104,8 +104,8 @@ by getInputValue().
     >>> selected_scope = widget.getInputValue()
     >>> IProjectGroup.providedBy(selected_scope)
     True
-    >>> selected_scope.name
-    u'mozilla'
+    >>> print(selected_scope.name)
+    mozilla
 
 If an non-existant distribution name is provided, a widget error is
 raised:
diff --git a/lib/lp/app/widgets/doc/stripped-text-widget.txt b/lib/lp/app/widgets/doc/stripped-text-widget.txt
index 06e0a1d..9d363c2 100644
--- a/lib/lp/app/widgets/doc/stripped-text-widget.txt
+++ b/lib/lp/app/widgets/doc/stripped-text-widget.txt
@@ -43,8 +43,8 @@ We pass a string with leading and trailing whitespaces to the widget
 
 And check that the leading and trailing whitespaces were correctly stripped.
 
-  >>> widget.getInputValue()
-  u'123456'
+  >>> print(widget.getInputValue())
+  123456
 
 If only whitespace is provided, the widget acts like no input was
 provided.
diff --git a/lib/lp/app/widgets/doc/tokens-text-widget.txt b/lib/lp/app/widgets/doc/tokens-text-widget.txt
index bc38372..e51ff15 100644
--- a/lib/lp/app/widgets/doc/tokens-text-widget.txt
+++ b/lib/lp/app/widgets/doc/tokens-text-widget.txt
@@ -19,7 +19,7 @@ satisfied.
 
 The widget removed the extra whitespace and punctuation.
 
-    >>> widget.getInputValue()
-    u'news feeds HTTP RSS UTF-8'
+    >>> print(widget.getInputValue())
+    news feeds HTTP RSS UTF-8
 
 
diff --git a/lib/lp/app/widgets/textwidgets.py b/lib/lp/app/widgets/textwidgets.py
index 23b3f5f..4532762 100644
--- a/lib/lp/app/widgets/textwidgets.py
+++ b/lib/lp/app/widgets/textwidgets.py
@@ -123,8 +123,8 @@ class LocalDateTimeWidget(TextWidget):
 
         The 'missing' value is converted to an empty string:
 
-          >>> widget._toFormValue(field.missing_value)
-          u''
+          >>> print(widget._toFormValue(field.missing_value))
+          <BLANKLINE>
 
         Dates are displayed without an associated time zone:
 
@@ -218,14 +218,14 @@ class DelimitedListWidget(TextAreaWidget):
 
         The 'missing' value is converted to an empty string:
 
-          >>> widget._toFormValue(field.missing_value)
-          u''
+          >>> print(widget._toFormValue(field.missing_value))
+          <BLANKLINE>
 
         By default, lists are displayed one item on a line:
 
           >>> names = ['fred', 'bob', 'harry']
-          >>> widget._toFormValue(names)
-          u'fred\\r\\nbob\\r\\nharry'
+          >>> six.ensure_str(widget._toFormValue(names))
+          'fred\\r\\nbob\\r\\nharry'
         """
         if value == self.context.missing_value:
             value = self._missing
@@ -250,8 +250,11 @@ class DelimitedListWidget(TextAreaWidget):
 
         By default, lists are split by whitespace:
 
-          >>> print(widget._toFieldValue(u'fred\\nbob harry'))
-          [u'fred', u'bob', u'harry']
+          >>> for item in widget._toFieldValue(u'fred\\nbob harry'):
+          ...     print("'%s'" % item)
+          'fred'
+          'bob'
+          'harry'
         """
         value = super(
             DelimitedListWidget, self)._toFieldValue(value)

References