← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:drop-py35 into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:drop-py35 into launchpad:master.

Commit message:
Drop various bits of code to handle Python <= 3.5

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Hi!  I wanted to do a real-world performance test of my new laptop, so I thought of doing a full Launchpad test run on it (about 2h, if you're curious - it was usually more like 9h on my old laptop by the time I left Canonical), and I used a random half-finished refactoring branch I had lying around.  Since it passes, you might as well have the results.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:drop-py35 into launchpad:master.
diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py
index d6b2760..fc1c2fc 100644
--- a/lib/lp/app/browser/tales.py
+++ b/lib/lp/app/browser/tales.py
@@ -56,7 +56,6 @@ from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.projectgroup import IProjectGroup
 from lp.registry.interfaces.socialaccount import SOCIAL_PLATFORM_TYPES_MAP
-from lp.services.compat import tzname
 from lp.services.utils import round_half_up
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.canonicalurl import nearest_adapter
@@ -1301,8 +1300,7 @@ class PersonFormatterAPI(ObjectFormatterAPI):
     def local_time(self):
         """Return the local time for this person."""
         time_zone = self._context.time_zone
-        dt = datetime.now(tz.gettz(time_zone))
-        return "%s %s" % (dt.strftime("%T"), tzname(dt))
+        return datetime.now(tz.gettz(time_zone)).strftime("%T %Z")
 
     def url(self, view_name=None, rootsite="mainsite"):
         """See `ObjectFormatterAPI`.
@@ -2390,7 +2388,7 @@ class DateTimeFormatterAPI:
     def time(self):
         if self._datetime.tzinfo:
             value = self._datetime.astimezone(getUtility(ILaunchBag).time_zone)
-            return "%s %s" % (value.strftime("%T"), tzname(value))
+            return value.strftime("%T %Z")
         else:
             return self._datetime.strftime("%T")
 
diff --git a/lib/lp/app/widgets/date.py b/lib/lp/app/widgets/date.py
index 99d8f3f..885518d 100644
--- a/lib/lp/app/widgets/date.py
+++ b/lib/lp/app/widgets/date.py
@@ -33,7 +33,6 @@ from zope.formlib.textwidgets import TextWidget
 from zope.formlib.widget import DisplayWidget
 
 from lp.app.validators import LaunchpadValidationError
-from lp.services.compat import tzname
 from lp.services.utils import round_half_up
 from lp.services.webapp.escaping import html_escape
 from lp.services.webapp.interfaces import ILaunchBag
@@ -638,6 +637,4 @@ class DatetimeDisplayWidget(DisplayWidget):
         if value == self.context.missing_value:
             return ""
         value = value.astimezone(time_zone)
-        return html_escape(
-            "%s %s" % (value.strftime("%Y-%m-%d %H:%M:%S", tzname(value)))
-        )
+        return html_escape(value.strftime("%Y-%m-%d %H:%M:%S %Z"))
diff --git a/lib/lp/blueprints/browser/sprint.py b/lib/lp/blueprints/browser/sprint.py
index a22ebaa..91ee23b 100644
--- a/lib/lp/blueprints/browser/sprint.py
+++ b/lib/lp/blueprints/browser/sprint.py
@@ -61,7 +61,6 @@ from lp.registry.browser.menu import (
     RegistryCollectionActionMenuBase,
 )
 from lp.registry.interfaces.person import IPersonSet
-from lp.services.compat import tzname
 from lp.services.database.bulk import load_referencing
 from lp.services.helpers import shortlist
 from lp.services.propertycache import cachedproperty
@@ -225,35 +224,28 @@ class SprintView(HasSpecificationsView):
     def formatDateTime(self, dt):
         """Format a datetime value according to the sprint's time zone"""
         dt = dt.astimezone(self.tzinfo)
-        return "%s %s" % (dt.strftime("%Y-%m-%d %H:%M"), tzname(dt))
+        return dt.strftime("%Y-%m-%d %H:%M %Z")
 
     def formatDate(self, dt):
         """Format a date value according to the sprint's time zone"""
         dt = dt.astimezone(self.tzinfo)
         return dt.strftime("%Y-%m-%d")
 
-    def _formatLocal(self, dt):
-        return "%s %s on %s" % (
-            dt.strftime("%H:%M"),
-            tzname(dt),
-            dt.strftime("%A, %Y-%m-%d"),
-        )
+    _local_timeformat = "%H:%M %Z on %A, %Y-%m-%d"
 
     @property
     def local_start(self):
         """The sprint start time, in the local time zone, as text."""
-        return self._formatLocal(
-            self.context.time_starts.astimezone(
-                tz.gettz(self.context.time_zone)
-            )
-        )
+        return self.context.time_starts.astimezone(
+            tz.gettz(self.context.time_zone)
+        ).strftime(self._local_timeformat)
 
     @property
     def local_end(self):
         """The sprint end time, in the local time zone, as text."""
-        return self._formatLocal(
-            self.context.time_ends.astimezone(tz.gettz(self.context.time_zone))
-        )
+        return self.context.time_ends.astimezone(
+            tz.gettz(self.context.time_zone)
+        ).strftime(self._local_timeformat)
 
 
 class SprintAddView(LaunchpadFormView):
diff --git a/lib/lp/bugs/scripts/uct/models.py b/lib/lp/bugs/scripts/uct/models.py
index e11ce8c..418f074 100644
--- a/lib/lp/bugs/scripts/uct/models.py
+++ b/lib/lp/bugs/scripts/uct/models.py
@@ -42,7 +42,6 @@ from lp.registry.model.person import Person
 from lp.registry.model.product import Product
 from lp.registry.model.sourcepackage import SourcePackage
 from lp.registry.model.sourcepackagename import SourcePackageName
-from lp.services.compat import tzname
 from lp.services.propertycache import cachedproperty
 
 __all__ = [
@@ -389,7 +388,7 @@ class UCTRecord:
 
     @classmethod
     def _format_datetime(cls, dt: datetime) -> str:
-        return "%s %s" % (dt.strftime("%Y-%m-%d %H:%M:%S"), tzname(dt))
+        return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
 
     @classmethod
     def _format_notes(cls, notes: List[Tuple[str, str]]) -> str:
diff --git a/lib/lp/bugs/tests/bug.py b/lib/lp/bugs/tests/bug.py
index 9798b9b..97dc8b5 100644
--- a/lib/lp/bugs/tests/bug.py
+++ b/lib/lp/bugs/tests/bug.py
@@ -40,7 +40,7 @@ def print_also_notified(bug_page):
 
 def print_subscribers(bug_page, subscription_level=None, reverse=False):
     """Print the subscribers listed in the subscribers JSON portlet."""
-    details = json.loads(bug_page.decode())
+    details = json.loads(bug_page)
 
     if details is None:
         # No subscribers at all.
diff --git a/lib/lp/buildmaster/tests/builderproxy.py b/lib/lp/buildmaster/tests/builderproxy.py
index a891b6c..fece785 100644
--- a/lib/lp/buildmaster/tests/builderproxy.py
+++ b/lib/lp/buildmaster/tests/builderproxy.py
@@ -28,7 +28,7 @@ class ProxyAuthAPITokensResource(resource.Resource):
         self.requests = []
 
     def render_POST(self, request):
-        content = json.loads(request.content.read().decode("UTF-8"))
+        content = json.loads(request.content.read())
         self.requests.append(
             {
                 "method": request.method,
diff --git a/lib/lp/buildmaster/tests/fetchservice.py b/lib/lp/buildmaster/tests/fetchservice.py
index dd21e27..3fd879c 100644
--- a/lib/lp/buildmaster/tests/fetchservice.py
+++ b/lib/lp/buildmaster/tests/fetchservice.py
@@ -27,7 +27,7 @@ class FetchServiceAuthAPITokensResource(resource.Resource):
         self.requests = []
 
     def render_POST(self, request):
-        content = json.loads(request.content.read().decode("UTF-8"))
+        content = json.loads(request.content.read())
         self.requests.append(
             {
                 "method": request.method,
diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
index d3a27b7..08313ab 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
@@ -423,7 +423,7 @@ class TestCharmRecipeAddView(BaseTestCharmRecipeView):
             url=Equals("http://charmhub.example/v1/tokens";),
             method=Equals("POST"),
             body=AfterPreprocessing(
-                lambda b: json.loads(b.decode()),
+                json.loads,
                 Equals(
                     {
                         "description": ("charmhub-name for launchpad.test"),
@@ -1094,7 +1094,7 @@ class TestCharmRecipeAuthorizeView(BaseTestCharmRecipeView):
             url=Equals("http://charmhub.example/v1/tokens";),
             method=Equals("POST"),
             body=AfterPreprocessing(
-                lambda b: json.loads(b.decode()),
+                json.loads,
                 Equals(
                     {
                         "description": (f"{store_name} for launchpad.test"),
@@ -1303,9 +1303,7 @@ class TestCharmRecipeAuthorizeView(BaseTestCharmRecipeView):
                     ),
                 }
             ),
-            body=AfterPreprocessing(
-                lambda b: json.loads(b.decode()), Equals({})
-            ),
+            body=AfterPreprocessing(json.loads, Equals({})),
         )
         self.assertThat(
             responses.calls,
diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py
index 7ca8734..34cd783 100644
--- a/lib/lp/charms/tests/test_charmhubclient.py
+++ b/lib/lp/charms/tests/test_charmhubclient.py
@@ -119,9 +119,7 @@ class RequestMatches(MatchesAll):
         if json_data is not None:
             matchers.append(
                 MatchesStructure(
-                    body=AfterPreprocessing(
-                        lambda b: json.loads(b.decode()), Equals(json_data)
-                    )
+                    body=AfterPreprocessing(json.loads, Equals(json_data))
                 )
             )
         elif file_data is not None:
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 0b41a97..1493f6f 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -1043,7 +1043,7 @@ class TestCharmRecipeAuthorization(TestCaseWithFactory):
             url=Equals("http://charmhub.example/v1/tokens";),
             method=Equals("POST"),
             body=AfterPreprocessing(
-                lambda b: json.loads(b.decode()),
+                json.loads,
                 Equals(
                     {
                         "description": (
@@ -1158,9 +1158,7 @@ class TestCharmRecipeAuthorization(TestCaseWithFactory):
                     ),
                 }
             ),
-            body=AfterPreprocessing(
-                lambda b: json.loads(b.decode()), Equals({})
-            ),
+            body=AfterPreprocessing(json.loads, Equals({})),
         )
         self.assertThat(
             responses.calls,
@@ -2164,9 +2162,7 @@ class TestCharmRecipeWebservice(TestCaseWithFactory):
                     "package-view-revisions",
                 ],
             }
-            self.assertEqual(
-                expected_body, json.loads(call.request.body.decode("UTF-8"))
-            )
+            self.assertEqual(expected_body, json.loads(call.request.body))
             self.assertEqual({"root": root_macaroon_raw}, recipe.store_secrets)
         return response, root_macaroon_raw
 
@@ -2276,9 +2272,7 @@ class TestCharmRecipeWebservice(TestCaseWithFactory):
                     ),
                 }
             ),
-            body=AfterPreprocessing(
-                lambda b: json.loads(b.decode()), Equals({})
-            ),
+            body=AfterPreprocessing(json.loads, Equals({})),
         )
         self.assertThat(
             responses.calls,
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index d6da0d3..089aee6 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -1101,9 +1101,7 @@ class GitRepositoryPermissionsView(LaunchpadFormView):
         field_type = field_bits[0]
         try:
             ref_pattern = decode_form_field_id(field_bits[1])
-        # base64.b32decode raises TypeError for decoding errors on Python 2,
-        # but binascii.Error on Python 3.
-        except (TypeError, binascii.Error):
+        except binascii.Error:
             raise UnexpectedFormData(
                 "Cannot parse field name: %s" % field_name
             )
diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
index 0f5cf62..c876123 100644
--- a/lib/lp/code/model/tests/test_githosting.py
+++ b/lib/lp/code/model/tests/test_githosting.py
@@ -106,9 +106,7 @@ class TestGitHostingClient(TestCase):
             ),
         )
         if json_data is not None:
-            self.assertEqual(
-                json_data, json.loads(request.body.decode("UTF-8"))
-            )
+            self.assertEqual(json_data, json.loads(request.body))
         timeline = get_request_timeline(get_current_browser_request())
         action = timeline.actions[-1]
         self.assertEqual("git-hosting-%s" % method.lower(), action.category)
diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
index 04061a7..0a1211d 100644
--- a/lib/lp/oci/model/ociregistryclient.py
+++ b/lib/lp/oci/model/ociregistryclient.py
@@ -69,7 +69,7 @@ class OCIRegistryClient:
         """Read JSON out of a `LibraryFileAlias`."""
         try:
             reference.open()
-            return json.loads(reference.read().decode("UTF-8"))
+            return json.loads(reference.read())
         finally:
             reference.close()
 
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index c3dec1c..6d01ea9 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -220,7 +220,7 @@ class TestOCIRegistryClient(
         # We should have uploaded to the digest, not the tag
         self.assertIn("sha256:", responses.calls[1].request.url)
         self.assertNotIn("edge", responses.calls[1].request.url)
-        request = json.loads(responses.calls[1].request.body.decode("UTF-8"))
+        request = json.loads(responses.calls[1].request.body)
 
         layer_matchers = [
             MatchesDict(
@@ -327,7 +327,7 @@ class TestOCIRegistryClient(
 
         self.client.upload(self.build)
 
-        request = json.loads(responses.calls[1].request.body.decode("UTF-8"))
+        request = json.loads(responses.calls[1].request.body)
 
         layer_matchers = [
             MatchesDict(
@@ -1078,7 +1078,7 @@ class TestOCIRegistryClient(
                     },
                 ],
             },
-            json.loads(send_manifest_call.request.body.decode("UTF-8")),
+            json.loads(send_manifest_call.request.body),
         )
 
     @responses.activate
@@ -1195,7 +1195,7 @@ class TestOCIRegistryClient(
                     },
                 ],
             },
-            json.loads(send_manifest_call.request.body.decode("UTF-8")),
+            json.loads(send_manifest_call.request.body),
         )
 
     @responses.activate
@@ -1267,7 +1267,7 @@ class TestOCIRegistryClient(
                     }
                 ],
             },
-            json.loads(send_manifest_call.request.body.decode("UTF-8")),
+            json.loads(send_manifest_call.request.body),
         )
 
     @responses.activate
@@ -1441,7 +1441,7 @@ class TestOCIRegistryClient(
                     },
                 ],
             },
-            json.loads(responses.calls[2].request.body.decode("UTF-8")),
+            json.loads(responses.calls[2].request.body),
         )
 
 
diff --git a/lib/lp/registry/interfaces/ssh.py b/lib/lp/registry/interfaces/ssh.py
index 5559526..e99a000 100644
--- a/lib/lp/registry/interfaces/ssh.py
+++ b/lib/lp/registry/interfaces/ssh.py
@@ -176,15 +176,5 @@ class SSHKeyAdditionError(Exception):
             )
         if "exception" in kwargs:
             exception = kwargs.pop("exception")
-            try:
-                exception_text = str(exception)
-            except UnicodeDecodeError:
-                # On Python 2, Key.fromString can raise exceptions with
-                # non-UTF-8 messages.
-                exception_text = (
-                    bytes(exception)
-                    .decode("unicode_escape")
-                    .encode("unicode_escape")
-                )
-            msg = "%s (%s)" % (msg, exception_text)
+            msg = "%s (%s)" % (msg, exception)
         super().__init__(msg, *args, **kwargs)
diff --git a/lib/lp/services/auth/utils.py b/lib/lp/services/auth/utils.py
index f4c997b..c1a780c 100644
--- a/lib/lp/services/auth/utils.py
+++ b/lib/lp/services/auth/utils.py
@@ -7,12 +7,9 @@ __all__ = [
     "create_access_token_secret",
 ]
 
-import binascii
-import os
+import secrets
 
 
-# XXX cjwatson 2021-09-30: Replace this with secrets.token_hex(32) once we
-# can rely on Python 3.6 everywhere.
 def create_access_token_secret():
     """Create a secret suitable for use in a personal access token."""
-    return binascii.hexlify(os.urandom(32)).decode("ASCII")
+    return secrets.token_hex(32)
diff --git a/lib/lp/services/compat.py b/lib/lp/services/compat.py
index 86d833a..ee2c2fe 100644
--- a/lib/lp/services/compat.py
+++ b/lib/lp/services/compat.py
@@ -8,12 +8,9 @@ Use this for things that six doesn't provide.
 
 __all__ = [
     "message_as_bytes",
-    "tzname",
 ]
 
 import io
-from datetime import datetime, time, timezone
-from typing import Union
 
 
 def message_as_bytes(message):
@@ -24,18 +21,3 @@ def message_as_bytes(message):
     g = BytesGenerator(fp, mangle_from_=False, maxheaderlen=0, policy=compat32)
     g.flatten(message)
     return fp.getvalue()
-
-
-def tzname(obj: Union[datetime, time]) -> str:
-    """Return this (date)time object's time zone name as a string.
-
-    Python 3.5's `timezone.utc.tzname` returns "UTC+00:00", rather than
-    "UTC" which is what we prefer.  Paper over this until we can rely on
-    Python >= 3.6 everywhere.
-    """
-    if obj.tzinfo is None:
-        return ""
-    elif obj.tzinfo is timezone.utc:
-        return "UTC"
-    else:
-        return obj.tzname()
diff --git a/lib/lp/services/librarian/tests/test_client.py b/lib/lp/services/librarian/tests/test_client.py
index 012d1e1..f258bab 100644
--- a/lib/lp/services/librarian/tests/test_client.py
+++ b/lib/lp/services/librarian/tests/test_client.py
@@ -154,10 +154,7 @@ class LibrarianFileWrapperTestCase(TestCase):
     def test_unbounded_read_incorrect_length(self):
         file = self.makeFile(extra_content_length=1)
         with ExpectedException(http.client.IncompleteRead):
-            # Python 3 notices the short response on the first read.
             self.assertEqual(b"abcdef", file.read())
-            # Python 2 only notices the short response on the next read.
-            file.read()
 
     def test_bounded_read_correct_length(self):
         file = self.makeFile()
diff --git a/lib/lp/services/oauth/stories/authorize-token.rst b/lib/lp/services/oauth/stories/authorize-token.rst
index 61f3cc5..a3c30d1 100644
--- a/lib/lp/services/oauth/stories/authorize-token.rst
+++ b/lib/lp/services/oauth/stories/authorize-token.rst
@@ -169,7 +169,7 @@ the list of authentication levels.
     >>> json_browser.open(
     ...     "http://launchpad.test/+authorize-token?%s"; % urlencode(params)
     ... )
-    >>> json_token = json.loads(json_browser.contents.decode())
+    >>> json_token = json.loads(json_browser.contents)
     >>> sorted(json_token.keys())
     ['access_levels', 'oauth_token', 'oauth_token_consumer']
 
@@ -190,7 +190,7 @@ the list of authentication levels.
     ...     )
     ...     % urlencode(params)
     ... )
-    >>> json_token = json.loads(json_browser.contents.decode())
+    >>> json_token = json.loads(json_browser.contents)
     >>> sorted(
     ...     (level["value"], level["title"])
     ...     for level in json_token["access_levels"]
diff --git a/lib/lp/services/oauth/stories/request-token.rst b/lib/lp/services/oauth/stories/request-token.rst
index 2bc110a..799fcc0 100644
--- a/lib/lp/services/oauth/stories/request-token.rst
+++ b/lib/lp/services/oauth/stories/request-token.rst
@@ -30,7 +30,7 @@ levels.
     >>> json_browser.open(
     ...     "http://launchpad.test/+request-token";, data=urlencode(data)
     ... )
-    >>> token = json.loads(json_browser.contents.decode())
+    >>> token = json.loads(json_browser.contents)
     >>> sorted(token.keys())
     ['access_levels', 'oauth_token', 'oauth_token_consumer',
      'oauth_token_secret']
diff --git a/lib/lp/services/signing/testing/fakesigning.py b/lib/lp/services/signing/testing/fakesigning.py
index eb7b27f..243408b 100644
--- a/lib/lp/services/signing/testing/fakesigning.py
+++ b/lib/lp/services/signing/testing/fakesigning.py
@@ -89,7 +89,7 @@ class GenerateResource(BoxedAuthenticationResource):
         self.requests = []
 
     def render_POST(self, request):
-        payload = json.loads(self._decrypt(request).decode("UTF-8"))
+        payload = json.loads(self._decrypt(request))
         self.requests.append(payload)
         # We don't need to bother with generating a real key here.  Just
         # make up some random data.
@@ -117,7 +117,7 @@ class SignResource(BoxedAuthenticationResource):
         self.requests = []
 
     def render_POST(self, request):
-        payload = json.loads(self._decrypt(request).decode("UTF-8"))
+        payload = json.loads(self._decrypt(request))
         self.requests.append(payload)
         _, public_key = self.keys[payload["fingerprint"]]
         # We don't need to bother with generating a real signature here.
@@ -143,7 +143,7 @@ class InjectResource(BoxedAuthenticationResource):
         self.requests = []
 
     def render_POST(self, request):
-        payload = json.loads(self._decrypt(request).decode("UTF-8"))
+        payload = json.loads(self._decrypt(request))
         self.requests.append(payload)
         private_key = base64.b64decode(payload["private-key"].encode("UTF-8"))
         public_key = base64.b64decode(payload["public-key"].encode("UTF-8"))
diff --git a/lib/lp/services/signing/tests/test_proxy.py b/lib/lp/services/signing/tests/test_proxy.py
index 1db9530..6bb14ff 100644
--- a/lib/lp/services/signing/tests/test_proxy.py
+++ b/lib/lp/services/signing/tests/test_proxy.py
@@ -93,7 +93,7 @@ class SigningServiceResponseFactory:
         """
         box = Box(self.service_private_key, self.client_public_key)
         decrypted = box.decrypt(value, self.nonce, encoder=Base64Encoder)
-        return json.loads(decrypted.decode("UTF-8"))
+        return json.loads(decrypted)
 
     def getAPISignedContent(self, call_index=0):
         """Returns the signed message returned by the API.
diff --git a/lib/lp/services/twistedsupport/xmlrpc.py b/lib/lp/services/twistedsupport/xmlrpc.py
index 7cdf389..8e75dfc 100644
--- a/lib/lp/services/twistedsupport/xmlrpc.py
+++ b/lib/lp/services/twistedsupport/xmlrpc.py
@@ -56,9 +56,7 @@ def trap_fault(failure, *fault_classes):
     :param failure: A Twisted L{Failure}.
     :param *fault_codes: `LaunchpadFault` subclasses.
     :raise Exception: if 'failure' is not a Fault failure, or if the fault
-        code does not match the given codes.  In line with L{Failure.trap},
-        the exception is the L{Failure} itself on Python 2 and the
-        underlying exception on Python 3.
+        code does not match the given codes.
     :return: The Fault if it matches one of the codes.
     """
     failure.trap(xmlrpc.Fault)
diff --git a/lib/lp/services/webapp/tests/test_candid.py b/lib/lp/services/webapp/tests/test_candid.py
index ea1898b..dfb4a8a 100644
--- a/lib/lp/services/webapp/tests/test_candid.py
+++ b/lib/lp/services/webapp/tests/test_candid.py
@@ -499,8 +499,7 @@ class TestCandidCallbackView(TestCaseWithFactory):
                 }
             ),
             body=AfterPreprocessing(
-                lambda b: json.loads(b.decode()),
-                MatchesDict({"code": Equals("test code")}),
+                json.loads, MatchesDict({"code": Equals("test code")})
             ),
         )
         discharge_matcher = MatchesStructure(
diff --git a/lib/lp/services/webapp/tests/test_view_model.py b/lib/lp/services/webapp/tests/test_view_model.py
index 329fa64..f4b6ed6 100644
--- a/lib/lp/services/webapp/tests/test_view_model.py
+++ b/lib/lp/services/webapp/tests/test_view_model.py
@@ -123,7 +123,7 @@ class TestJsonModelView(BrowserTestCase):
         lp.services.webapp.tests.ProductModelTestView = ProductModelTestView
         self.configZCML()
         browser = self.getUserBrowser(self.url)
-        cache = json.loads(browser.contents.decode())
+        cache = json.loads(browser.contents)
         self.assertThat(cache, KeysEqual("related_features", "context"))
 
     def test_JsonModel_custom_cache(self):
@@ -140,7 +140,7 @@ class TestJsonModelView(BrowserTestCase):
         lp.services.webapp.tests.ProductModelTestView = ProductModelTestView
         self.configZCML()
         browser = self.getUserBrowser(self.url)
-        cache = json.loads(browser.contents.decode())
+        cache = json.loads(browser.contents)
         self.assertThat(
             cache, KeysEqual("related_features", "context", "target_info")
         )
@@ -165,7 +165,7 @@ class TestJsonModelView(BrowserTestCase):
         lp.services.webapp.tests.ProductModelTestView = ProductModelTestView
         self.configZCML()
         browser = self.getUserBrowser(self.url)
-        cache = json.loads(browser.contents.decode())
+        cache = json.loads(browser.contents)
         self.assertThat(
             cache, KeysEqual("related_features", "context", "target_info")
         )
diff --git a/lib/lp/services/webapp/url.py b/lib/lp/services/webapp/url.py
index ba3df8a..59c68dc 100644
--- a/lib/lp/services/webapp/url.py
+++ b/lib/lp/services/webapp/url.py
@@ -90,7 +90,7 @@ def urlparse(url, scheme="", allow_fragments=True):
 
     The url parameter should contain ASCII characters only. This
     function ensures that the original urlparse is called always with a
-    str object, and never unicode (Python 2) or bytes (Python 3).
+    str object, and never bytes.
 
         >>> tuple(urlparse("http://foo.com/bar";))
         ('http', 'foo.com', '/bar', '', '', '')
@@ -120,7 +120,7 @@ def urlsplit(url, scheme="", allow_fragments=True):
 
     The url parameter should contain ASCII characters only. This
     function ensures that the original urlsplit is called always with a
-    str object, and never unicode (Python 2) or bytes (Python 3).
+    str object, and never bytes.
 
         >>> tuple(urlsplit("http://foo.com/baz";))
         ('http', 'foo.com', '/baz', '', '')
diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
index 8d2a48a..819d770 100644
--- a/lib/lp/snappy/browser/tests/test_snap.py
+++ b/lib/lp/snappy/browser/tests/test_snap.py
@@ -653,9 +653,7 @@ class TestSnapAddView(BaseTestSnapView):
             ],
             "permissions": ["package_upload"],
         }
-        self.assertEqual(
-            expected_body, json.loads(call.request.body.decode("UTF-8"))
-        )
+        self.assertEqual(expected_body, json.loads(call.request.body))
         self.assertEqual(303, browser.responseStatusCode)
         parsed_location = urlsplit(browser.headers["Location"])
         self.assertEqual(
@@ -1737,9 +1735,7 @@ class TestSnapEditView(BaseTestSnapView):
             "packages": [{"name": "two", "series": self.snappyseries.name}],
             "permissions": ["package_upload"],
         }
-        self.assertEqual(
-            expected_body, json.loads(call.request.body.decode("UTF-8"))
-        )
+        self.assertEqual(expected_body, json.loads(call.request.body))
         self.assertEqual(303, browser.responseStatusCode)
         parsed_location = urlsplit(browser.headers["Location"])
         self.assertEqual(
@@ -1820,9 +1816,7 @@ class TestSnapAuthorizeView(BaseTestSnapView):
                 ],
                 "permissions": ["package_upload"],
             }
-            self.assertEqual(
-                expected_body, json.loads(call.request.body.decode("UTF-8"))
-            )
+            self.assertEqual(expected_body, json.loads(call.request.body))
             self.assertEqual(
                 {"root": root_macaroon_raw}, self.snap.store_secrets
             )
diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
index 32a2d5c..9f671b8 100644
--- a/lib/lp/snappy/tests/test_snap.py
+++ b/lib/lp/snappy/tests/test_snap.py
@@ -4892,9 +4892,7 @@ class TestSnapWebservice(TestCaseWithFactory):
                 ],
                 "permissions": ["package_upload"],
             }
-            self.assertEqual(
-                expected_body, json.loads(call.request.body.decode("UTF-8"))
-            )
+            self.assertEqual(expected_body, json.loads(call.request.body))
             self.assertEqual({"root": root_macaroon_raw}, snap.store_secrets)
         return response, root_macaroon.third_party_caveats()[0]
 
diff --git a/lib/lp/snappy/tests/test_snapstoreclient.py b/lib/lp/snappy/tests/test_snapstoreclient.py
index d6f75bd..ddbf457 100644
--- a/lib/lp/snappy/tests/test_snapstoreclient.py
+++ b/lib/lp/snappy/tests/test_snapstoreclient.py
@@ -227,9 +227,7 @@ class RequestMatches(Matcher):
             if mismatch is not None:
                 return mismatch
         if self.json_data is not None:
-            mismatch = Equals(self.json_data).match(
-                json.loads(request.body.decode("UTF-8"))
-            )
+            mismatch = Equals(self.json_data).match(json.loads(request.body))
             if mismatch is not None:
                 return mismatch
         if self.form_data is not None:
diff --git a/lib/lp/soyuz/wsgi/archiveauth.py b/lib/lp/soyuz/wsgi/archiveauth.py
index 006143d..17440e8 100644
--- a/lib/lp/soyuz/wsgi/archiveauth.py
+++ b/lib/lp/soyuz/wsgi/archiveauth.py
@@ -11,10 +11,8 @@ __all__ = [
 ]
 
 import crypt
-import string
 import sys
 import time
-from random import SystemRandom
 from xmlrpc.client import Fault, ServerProxy
 
 import six
@@ -49,16 +47,6 @@ def _get_archive_reference(environ):
         _log(environ, "No archive reference found in URL '%s'.", path)
 
 
-_sr = SystemRandom()
-
-
-def _crypt_sha256(word):
-    """crypt.crypt(word, crypt.METHOD_SHA256), backported from Python 3.5."""
-    saltchars = string.ascii_letters + string.digits + "./"
-    salt = "$5$" + "".join(_sr.choice(saltchars) for _ in range(16))
-    return crypt.crypt(word, salt)
-
-
 _memcache_client = memcache_client_factory(timeline=False)
 
 
@@ -91,7 +79,9 @@ def check_password(environ, user, password):
         proxy.checkArchiveAuthToken(archive_reference, user, password)
         # Cache positive responses for a minute to reduce database load.
         _memcache_client.set(
-            memcache_key, _crypt_sha256(password), int(time.time()) + 60
+            memcache_key,
+            crypt.crypt(password, crypt.METHOD_SHA256),
+            int(time.time()) + 60,
         )
         _log(environ, "%s@%s: Authorized.", user, archive_reference)
         return True
diff --git a/lib/lp/soyuz/wsgi/tests/test_archiveauth.py b/lib/lp/soyuz/wsgi/tests/test_archiveauth.py
index 3ac5ff9..16e8a2b 100644
--- a/lib/lp/soyuz/wsgi/tests/test_archiveauth.py
+++ b/lib/lp/soyuz/wsgi/tests/test_archiveauth.py
@@ -106,12 +106,6 @@ class TestWSGIArchiveAuth(TestCaseWithFactory):
         self.assertEqual({}, self.memcache_fixture._cache)
         self.assertLogs("No archive found for '~nonexistent/unknown/bad'.")
 
-    def test_crypt_sha256(self):
-        crypted_password = archiveauth._crypt_sha256("secret")
-        self.assertEqual(
-            crypted_password, crypt.crypt("secret", crypted_password)
-        )
-
     def makeArchiveAndToken(self):
         archive = self.factory.makeArchive(private=True)
         archive_path = "/%s/%s/ubuntu" % (archive.owner.name, archive.name)
diff --git a/lib/lp/testing/swift/fakeswift.py b/lib/lp/testing/swift/fakeswift.py
index 325c390..68126cb 100644
--- a/lib/lp/testing/swift/fakeswift.py
+++ b/lib/lp/testing/swift/fakeswift.py
@@ -107,9 +107,7 @@ class FakeKeystone(resource.Resource):
         if "application/json" not in request.getHeader("content-type"):
             request.setResponseCode(http.BAD_REQUEST)
             return b""
-        # XXX cjwatson 2020-06-15: Python 3.5 doesn't allow this to be a
-        # binary file; 3.6 does.
-        credentials = json.loads(request.content.read().decode("UTF-8"))
+        credentials = json.loads(request.content.read())
         if "auth" not in credentials:
             request.setResponseCode(http.FORBIDDEN)
             return b""
diff --git a/lib/lp/translations/vocabularies.py b/lib/lp/translations/vocabularies.py
index a564444..d9c03dc 100644
--- a/lib/lp/translations/vocabularies.py
+++ b/lib/lp/translations/vocabularies.py
@@ -18,7 +18,6 @@ from storm.locals import Desc, Not, Or
 from zope.schema.vocabulary import SimpleTerm
 
 from lp.registry.interfaces.distroseries import IDistroSeries
-from lp.services.compat import tzname
 from lp.services.webapp.vocabulary import (
     NamedStormVocabulary,
     StormVocabularyBase,
@@ -102,10 +101,7 @@ class FilteredLanguagePackVocabularyBase(StormVocabularyBase):
 
     def toTerm(self, obj):
         return SimpleTerm(
-            obj,
-            obj.id,
-            "%s %s"
-            % (obj.date_exported.strftime("%F %T"), tzname(obj.date_exported)),
+            obj, obj.id, "%s" % obj.date_exported.strftime("%F %T %Z")
         )
 
     @property
@@ -143,12 +139,8 @@ class FilteredLanguagePackVocabulary(FilteredLanguagePackVocabularyBase):
         return SimpleTerm(
             obj,
             obj.id,
-            "%s %s (%s)"
-            % (
-                obj.date_exported.strftime("%F %T"),
-                tzname(obj.date_exported),
-                obj.type.title,
-            ),
+            "%s (%s)"
+            % (obj.date_exported.strftime("%F %T %Z"), obj.type.title),
         )
 
     @property
diff --git a/requirements/launchpad.txt b/requirements/launchpad.txt
index cc28c81..b7b7957 100644
--- a/requirements/launchpad.txt
+++ b/requirements/launchpad.txt
@@ -121,9 +121,6 @@ patiencediff==0.2.2
 pexpect==4.8.0
 pgbouncer==0.0.9
 pickleshare==0.7.5
-# pkginfo 1.7.0 dropped Python 3.5 support, but we need the features of the
-# newer version, and both our and the package's test suite show no
-# incompatibilities
 pkginfo==1.8.2
 prettytable==0.7.2
 prompt-toolkit==2.0.10

Follow ups