← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/turnip:python3-take3 into turnip:master

 

Thiago F. Pappacena has proposed merging ~pappacena/turnip:python3-take3 into turnip:master.

Commit message:
Turnip running on Python3

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/turnip/+git/turnip/+merge/380040

This is a continuation of the work done by twom and cjwatson.

Running `make check` runs smoothly on a bionic lxc, with just one deprecation warning deeply hidden on Twisted and a warning at the end saying that a `/dev/null` file descriptor was not properly closed.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/turnip:python3-take3 into turnip:master.
diff --git a/Makefile b/Makefile
index c43f2ee..87fe97a 100644
--- a/Makefile
+++ b/Makefile
@@ -49,7 +49,7 @@ endif
 	 echo "allow_hosts = ''"; \
 	 echo 'find_links = file://$(realpath $(PIP_SOURCE_DIR))/') \
 		>$(ENV)/.pydistutils.cfg
-	$(VIRTUALENV) --never-download $(ENV)
+	$(VIRTUALENV) -p python3 --never-download $(ENV)
 	$(PIP) install $(PIP_ARGS) -r bootstrap-requirements.txt
 	$(PIP) install $(PIP_ARGS) -c requirements.txt \
 		-e '.[test,deploy]'
diff --git a/requirements.txt b/requirements.txt
index b706f3c..7e7c073 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,13 +1,13 @@
 appdirs==1.4.3
 asn1crypto==0.24.0
-attrs==17.4.0
-Automat==0.6.0
-beautifulsoup4==4.3.2
-cffi==1.11.4
+attrs==19.3.0
+Automat==20.2.0
+beautifulsoup4==4.6.3
+cffi==1.14.0
 constantly==15.1.0
 contextlib2==0.4.0
-cornice==0.19.0
-cryptography==2.1.4
+cornice==3.6.1
+cryptography==2.4.2
 docutils==0.14
 enum34==1.1.6
 envdir==0.7
@@ -16,8 +16,8 @@ fixtures==1.0.0
 flake8==2.4.0
 gmpy==1.17
 gunicorn==19.3.0
-hyperlink==18.0.0
-idna==2.6
+hyperlink==19.0.0
+idna==2.9
 incremental==17.5.0
 ipaddress==1.0.19
 lazr.sshserver==0.1.8
@@ -26,35 +26,32 @@ m2r==0.1.14
 mccabe==0.3
 mistune==0.8.3
 Paste==2.0.2
-PasteDeploy==1.5.2
+PasteDeploy==2.1.0
 pbr==1.8.1
 pep8==1.5.7
-pyasn1==0.1.7
-pycparser==2.10
+pyasn1==0.4.8
+pycparser==2.19
 pycrypto==2.6.1
 pyflakes==0.8.1
 pygit2==0.27.4
-pyramid==1.5.4
+pyramid==1.10.4
 python-mimeparse==0.1.4
-# XXX: deryck 2012-08-10
-# See lp:~deryck/python-openid/python-openid-fix1034376 which
-# reapplied a patch from wgrant to get codehosting going again.
-python-openid==2.2.5-fix1034376
-PyYAML==3.11
+python3-openid==3.1.0
+PyYAML==5.3
 repoze.lru==0.6
 setuptools-scm==1.17.0
 simplejson==3.6.5
-six==1.9.0
-testtools==1.8.1
+six==1.14.0
+testtools==2.3.0
 traceback2==1.4.0
 translationstring==1.3
-Twisted[conch]==18.4.0
+Twisted[conch]==18.9.0
 unittest2==1.0.1
-venusian==1.0
+venusian==3.0.0
 waitress==0.8.9
-WebOb==1.4
+WebOb==1.8.6
 WebTest==2.0.18
 zope.component==4.2.1
-zope.deprecation==4.1.2
+zope.deprecation==4.4.0
 zope.event==4.0.3
-zope.interface==4.5.0
+zope.interface==4.7.1
diff --git a/setup.py b/setup.py
index cd8f4fb..28d15fa 100755
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,7 @@ requires = [
     'lazr.sshserver>=0.1.7',
     'Paste',
     'pygit2>=0.27.4,<0.28.0',
-    'python-openid',
+    'python3-openid',
     'PyYAML',
     'Twisted[conch]',
     'waitress',
diff --git a/system-dependencies.txt b/system-dependencies.txt
index 845d453..8461133 100644
--- a/system-dependencies.txt
+++ b/system-dependencies.txt
@@ -1,8 +1,10 @@
 build-essential
 cgit
 git
+libgit2-dev
 libffi-dev
 libgit2-27
 libssl-dev
-python-dev
+python3-dev
+python-virtualenv
 virtualenv
diff --git a/turnip/api/store.py b/turnip/api/store.py
index 11b4756..e84a83d 100644
--- a/turnip/api/store.py
+++ b/turnip/api/store.py
@@ -56,9 +56,21 @@ def format_commit(git_object):
         raise GitError('Invalid type: object {} is not a commit.'.format(
             git_object.oid.hex))
     parents = [parent.hex for parent in git_object.parent_ids]
+    # XXX cjwatson 2018-11-15: A regression in pygit2 0.27.1 means that we
+    # have to decode the commit message ourselves.  See:
+    # https://github.com/libgit2/pygit2/issues/839
+    if git_object.message_encoding is not None:
+        message = git_object.raw_message.decode(
+            encoding=git_object.message_encoding, errors="strict")
+    else:
+        # If the encoding is not explicit, it may not be UTF-8, so it is not
+        # safe to decode it strictly.
+        message = git_object.raw_message.decode(
+            encoding="UTF-8", errors="replace")
+
     return {
         'sha1': git_object.oid.hex,
-        'message': git_object.message,
+        'message': message,
         'author': format_signature(git_object.author),
         'committer': format_signature(git_object.committer),
         'parents': parents,
@@ -335,7 +347,17 @@ def get_refs(repo_store, repo_name, exclude_prefixes=None):
     """Return all refs for a git repository."""
     with open_repo(repo_store, repo_name) as repo:
         refs = {}
-        for ref in repo.listall_references():
+        for ref_obj in repo.listall_reference_objects():
+            try:
+                ref = ref_obj.name
+                git_object = repo.references[ref].peel()
+            except UnicodeDecodeError:
+                pass
+            else:
+                if not any(ref.startswith(exclude_prefix)
+                           for exclude_prefix in (exclude_prefixes or [])):
+                    refs.update(format_ref(ref, git_object))
+            """
             git_object = repo.references[ref].peel()
             # Filter non-unicode refs, as refs are treated as unicode
             # given json is unable to represent arbitrary byte strings.
@@ -347,6 +369,7 @@ def get_refs(repo_store, repo_name, exclude_prefixes=None):
                 if not any(ref.startswith(exclude_prefix)
                            for exclude_prefix in (exclude_prefixes or [])):
                     refs.update(format_ref(ref, git_object))
+            """
         return refs
 
 
diff --git a/turnip/api/tests/test_api.py b/turnip/api/tests/test_api.py
index 2ff34d0..f603c09 100644
--- a/turnip/api/tests/test_api.py
+++ b/turnip/api/tests/test_api.py
@@ -55,7 +55,7 @@ class ApiTestCase(TestCase):
             repo.references[observed].peel().oid)
 
     def get_ref(self, ref):
-        resp = self.app.get('/repo/{}/{}'.format(self.repo_path, ref))
+        resp = self.app.get('/repo/{}/{}'.format(self.repo_path, quote(ref)))
         return resp.json
 
     def test_repo_init(self):
@@ -217,7 +217,7 @@ class ApiTestCase(TestCase):
         """Ensure non-unicode refs are dropped from ref collection."""
         factory = RepoFactory(self.repo_store)
         commit_oid = factory.add_commit('foo', 'foobar.txt')
-        tag = '\xe9\xe9\xe9'  # latin-1
+        tag = b'\xe9\xe9\xe9'  # latin-1
         tag_message = 'tag message'
         factory.add_tag(tag, tag_message, commit_oid)
 
@@ -229,8 +229,8 @@ class ApiTestCase(TestCase):
         """Ensure unicode refs are included in ref collection."""
         factory = RepoFactory(self.repo_store)
         commit_oid = factory.add_commit('foo', 'foobar.txt')
-        tag = u'おいしいイカ'.encode('utf-8')
-        tag_message = u'かわいい タコ'.encode('utf-8')
+        tag = 'おいしいイカ'
+        tag_message = 'かわいい タコ'
         factory.add_tag(tag, tag_message, commit_oid)
 
         resp = self.app.get('/repo/{}/refs'.format(self.repo_path))
@@ -281,13 +281,13 @@ class ApiTestCase(TestCase):
     def test_repo_get_unicode_ref(self):
         factory = RepoFactory(self.repo_store)
         commit_oid = factory.add_commit('foo', 'foobar.txt')
-        tag_name = u'☃'.encode('utf-8')
-        tag_message = u'☃'.encode('utf-8')
+        tag_name = '☃'
+        tag_message = '☃'
         factory.add_tag(tag_name, tag_message, commit_oid)
 
         tag = 'refs/tags/{}'.format(tag_name)
         resp = self.get_ref(tag)
-        self.assertTrue(tag.decode('utf-8') in resp)
+        self.assertTrue(tag in resp)
 
     def test_repo_get_tag(self):
         RepoFactory(self.repo_store, num_commits=1, num_tags=1).build()
@@ -335,7 +335,7 @@ class ApiTestCase(TestCase):
     def test_repo_diff_non_unicode_commits(self):
         """Ensure non utf-8 chars are handled but stripped from diff."""
         factory = RepoFactory(self.repo_store)
-        message = 'not particularly sensible latin-1: \xe9\xe9\xe9.'
+        message = b'not particularly sensible latin-1: \xe9\xe9\xe9.'
         oid = factory.add_commit(message, 'foo.py')
         oid2 = factory.add_commit('a sensible commit message', 'foo.py', [oid])
 
@@ -406,7 +406,7 @@ class ApiTestCase(TestCase):
             self.repo_path, quote('{}^'.format(c2)), c2)
         resp = self.app.get(path)
         self.assertIn(
-            b'diff --git a/foo.txt b/bar.txt\n', resp.json_body['patch'])
+            'diff --git a/foo.txt b/bar.txt\n', resp.json_body['patch'])
 
     def test_repo_diff_rename_and_change_content_conflict(self):
         # Create repo1 with foo.txt.
@@ -576,7 +576,7 @@ class ApiTestCase(TestCase):
         resp = self.app.get('/repo/{}/compare-merge/{}:{}'.format(
             self.repo_path, c1, c2))
         self.assertIn(
-            b'diff --git a/foo.txt b/bar.txt\n', resp.json_body['patch'])
+            'diff --git a/foo.txt b/bar.txt\n', resp.json_body['patch'])
 
     def test_repo_get_commit(self):
         factory = RepoFactory(self.repo_store)
@@ -636,12 +636,12 @@ class ApiTestCase(TestCase):
     def test_repo_get_log_signatures(self):
         """Ensure signatures are correct."""
         factory = RepoFactory(self.repo_store)
-        committer = factory.makeSignature(u'村上 春樹'.encode('utf-8'),
-                                          u'tsukuru@猫の町.co.jp'.encode('utf-8'),
+        committer = factory.makeSignature('村上 春樹'.encode('utf-8'),
+                                          'tsukuru@猫の町.co.jp',
                                           encoding='utf-8')
         author = factory.makeSignature(
-            u'Владимир Владимирович Набоков'.encode('utf-8'),
-            u'Набоко@zembla.ru'.encode('utf-8'), encoding='utf-8')
+            'Владимир Владимирович Набоков'.encode('utf-8'),
+            'Набоко@zembla.ru', encoding='utf-8')
         oid = factory.add_commit('Obfuscate colophon.', 'path.foo',
                                  author=author, committer=committer)
         resp = self.app.get('/repo/{}/log/{}'.format(self.repo_path, oid))
@@ -671,7 +671,7 @@ class ApiTestCase(TestCase):
     def test_repo_get_non_unicode_log(self):
         """Ensure that non-unicode data is discarded."""
         factory = RepoFactory(self.repo_store)
-        message = '\xe9\xe9\xe9'  # latin-1
+        message = b'\xe9\xe9\xe9'  # latin-1
         oid = factory.add_commit(message, 'foo.py')
         resp = self.app.get('/repo/{}/log/{}'.format(self.repo_path, oid))
         self.assertEqual(message.decode('utf-8', 'replace'),
@@ -809,15 +809,15 @@ class ApiTestCase(TestCase):
         factory.add_commit('b\n', 'dir/file', parents=[c1])
         resp = self.app.get('/repo/{}/blob/dir/file'.format(self.repo_path))
         self.assertEqual(2, resp.json['size'])
-        self.assertEqual('b\n', base64.b64decode(resp.json['data']))
+        self.assertEqual(b'b\n', base64.b64decode(resp.json['data']))
         resp = self.app.get('/repo/{}/blob/dir/file?rev=master'.format(
             self.repo_path))
         self.assertEqual(2, resp.json['size'])
-        self.assertEqual('b\n', base64.b64decode(resp.json['data']))
+        self.assertEqual(b'b\n', base64.b64decode(resp.json['data']))
         resp = self.app.get('/repo/{}/blob/dir/file?rev={}'.format(
             self.repo_path, c1.hex))
         self.assertEqual(2, resp.json['size'])
-        self.assertEqual('a\n', base64.b64decode(resp.json['data']))
+        self.assertEqual(b'a\n', base64.b64decode(resp.json['data']))
 
     def test_repo_blob_missing_commit(self):
         """Trying to get a blob from a non-existent commit returns HTTP 404."""
@@ -861,7 +861,7 @@ class ApiTestCase(TestCase):
         resp = self.app.get('/repo/{}/blob/dir/file?rev=tag-name'.format(
             self.repo_path))
         self.assertEqual(2, resp.json['size'])
-        self.assertEqual('a\n', base64.b64decode(resp.json['data']))
+        self.assertEqual(b'a\n', base64.b64decode(resp.json['data']))
 
     def test_repo_blob_from_non_commit(self):
         """Trying to get a blob from a non-commit returns HTTP 404."""
diff --git a/turnip/api/tests/test_helpers.py b/turnip/api/tests/test_helpers.py
index dac6950..0ca1551 100644
--- a/turnip/api/tests/test_helpers.py
+++ b/turnip/api/tests/test_helpers.py
@@ -5,6 +5,7 @@ import contextlib
 import fnmatch
 import itertools
 import os
+import subprocess
 import uuid
 
 from six.moves import urllib
@@ -13,7 +14,6 @@ from pygit2 import (
     clone_repository,
     init_repository,
     GIT_FILEMODE_BLOB,
-    GIT_OBJ_COMMIT,
     IndexEntry,
     Repository,
     Signature,
@@ -96,9 +96,9 @@ class RepoFactory(object):
 
     def add_tag(self, tag_name, tag_message, oid):
         """Create a tag from tag_name and oid."""
-        repo = self.repo
-        repo.create_tag(tag_name, oid, GIT_OBJ_COMMIT,
-                        self.committer, tag_message)
+        subprocess.check_call(
+            ['git', '-C', self.repo_path,
+             'tag', '-m', tag_message, tag_name, oid.hex])
 
     def makeSignature(self, name, email, encoding='utf-8'):
         """Return an author or committer signature."""
diff --git a/turnip/api/tests/test_store.py b/turnip/api/tests/test_store.py
index f035eca..c1a4803 100644
--- a/turnip/api/tests/test_store.py
+++ b/turnip/api/tests/test_store.py
@@ -118,7 +118,7 @@ class InitTestCase(TestCase):
         store.init_repo(repo_path)
         repo_config = pygit2.Repository(repo_path).config
         with open('git.config.yaml') as f:
-            yaml_config = yaml.load(f)
+            yaml_config = yaml.load(f, Loader=yaml.SafeLoader)
 
         self.assertEqual(bool(yaml_config['core.logallrefupdates']),
                          bool(repo_config['core.logallrefupdates']))
diff --git a/turnip/api/views.py b/turnip/api/views.py
index adede04..95640ca 100644
--- a/turnip/api/views.py
+++ b/turnip/api/views.py
@@ -6,7 +6,7 @@ import re
 from subprocess import CalledProcessError
 
 from cornice.resource import resource
-from cornice.util import extract_json_data
+from cornice.validators import extract_cstruct
 from pygit2 import GitError
 import pyramid.httpexceptions as exc
 
@@ -14,6 +14,10 @@ from turnip.config import config
 from turnip.api import store
 
 
+def extract_json_data(request):
+    return extract_cstruct(request)['body']
+
+
 def is_valid_path(repo_store, repo_path):
     """Ensure path in within repo root and has not been subverted."""
     return os.path.realpath(repo_path).startswith(os.path.realpath(repo_store))
@@ -43,7 +47,7 @@ class BaseAPI(object):
 class RepoAPI(BaseAPI):
     """Provides HTTP API for repository actions."""
 
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(RepoAPI, self).__init__()
         self.request = request
 
@@ -127,7 +131,7 @@ class RepoAPI(BaseAPI):
 class RepackAPI(BaseAPI):
     """Provides HTTP API for repository repacking."""
 
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(RepackAPI, self).__init__()
         self.request = request
 
@@ -158,7 +162,7 @@ class RepackAPI(BaseAPI):
 class RefAPI(BaseAPI):
     """Provides HTTP API for git references."""
 
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(RefAPI, self).__init__()
         self.request = request
 
@@ -190,14 +194,14 @@ class DiffAPI(BaseAPI):
     to `git diff $(git-merge-base A B) B`.
     {name} can be two : separated repositories, for a cross repository diff.
     """
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(DiffAPI, self).__init__()
         self.request = request
 
     @validate_path
     def get(self, repo_store, repo_name):
         """Returns diff of two commits."""
-        commits = re.split('(\.{2,3})', self.request.matchdict['commits'])
+        commits = re.split(r'(\.{2,3})', self.request.matchdict['commits'])
         context_lines = int(self.request.params.get('context_lines', 3))
         if not len(commits) == 3:
             return exc.HTTPBadRequest()
@@ -222,7 +226,7 @@ class DiffMergeAPI(BaseAPI):
     {head} will be merged into {base} and the diff from {base} returned.
     {name} can be two : separated repositories, for a cross repository diff.
     """
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(DiffMergeAPI, self).__init__()
         self.request = request
 
@@ -247,7 +251,7 @@ class DiffMergeAPI(BaseAPI):
 class CommitAPI(BaseAPI):
     """Provides HTTP API for git commits."""
 
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(CommitAPI, self).__init__()
         self.request = request
 
@@ -275,7 +279,7 @@ class CommitAPI(BaseAPI):
 class LogAPI(BaseAPI):
     """Provides HTTP API for git logs."""
 
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(LogAPI, self).__init__()
         self.request = request
 
@@ -297,7 +301,7 @@ class LogAPI(BaseAPI):
 class DetectMergesAPI(BaseAPI):
     """Provides HTTP API for detecting merges."""
 
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(DetectMergesAPI, self).__init__()
         self.request = request
 
@@ -326,7 +330,7 @@ class DetectMergesAPI(BaseAPI):
 class BlobAPI(BaseAPI):
     """Provides HTTP API for fetching blobs."""
 
-    def __init__(self, request):
+    def __init__(self, request, context=None):
         super(BlobAPI, self).__init__()
         self.request = request
 
diff --git a/turnip/helpers.py b/turnip/helpers.py
index a423aff..1ea5298 100644
--- a/turnip/helpers.py
+++ b/turnip/helpers.py
@@ -14,7 +14,7 @@ def compose_path(root, path):
     # Construct the full path, stripping any leading slashes so we
     # resolve absolute paths within the root.
     full_path = os.path.abspath(os.path.join(
-        root, path.lstrip(os.path.sep.encode('utf-8'))))
+        root, path.lstrip(os.path.sep)))
     if not full_path.startswith(os.path.abspath(root)):
         raise ValueError('Path not contained within root')
     return full_path
diff --git a/turnip/pack/git.py b/turnip/pack/git.py
index 762be91..f255e84 100644
--- a/turnip/pack/git.py
+++ b/turnip/pack/git.py
@@ -36,7 +36,7 @@ from turnip.pack.helpers import (
 ERROR_PREFIX = b'ERR '
 VIRT_ERROR_PREFIX = b'turnip virt error: '
 
-SAFE_PARAMS = frozenset(['host'])
+SAFE_PARAMS = frozenset([b'host'])
 
 
 class RequestIDLogger(Logger):
@@ -410,7 +410,7 @@ class PackBackendProtocol(PackServerProtocol):
         self.extractRequestMeta(command, raw_pathname, params)
         self.command = command
         self.raw_pathname = raw_pathname
-        self.path = compose_path(self.factory.root, self.raw_pathname)
+        self.path = compose_path(self.factory.root, self.raw_pathname.decode())
 
         if command == b'turnip-set-symbolic-ref':
             self.expect_set_symbolic_ref = True
@@ -560,7 +560,7 @@ class PackVirtServerProtocol(PackProxyServerProtocol):
             auth_params = self.createAuthParams(params)
             self.log.info("Translating request.")
             translated = yield proxy.callRemote(
-                b'translatePath', pathname, permission,
+                'translatePath', pathname, permission,
                 auth_params).addTimeout(
                     self.factory.virtinfo_timeout, self.factory.reactor)
             self.log.info(
@@ -569,7 +569,7 @@ class PackVirtServerProtocol(PackProxyServerProtocol):
                 self.die(
                     VIRT_ERROR_PREFIX +
                     b'NOT_FOUND Repository does not exist.')
-            pathname = translated['path']
+            pathname = translated['path'].encode('utf-8')
         except xmlrpc.Fault as e:
             fault_type = translate_xmlrpc_fault(
                 e.faultCode).name.encode('UTF-8')
@@ -639,7 +639,7 @@ class PackFrontendServerProtocol(PackProxyServerProtocol):
         if set(params.keys()) - SAFE_PARAMS:
             self.die(b'Illegal request parameters')
             return
-        params[b'turnip-request-id'] = self.request_id
+        params[b'turnip-request-id'] = self.request_id.encode('utf-8')
         self.connectToBackend(command, pathname, params)
 
 
diff --git a/turnip/pack/helpers.py b/turnip/pack/helpers.py
index 43faa6e..95e0632 100644
--- a/turnip/pack/helpers.py
+++ b/turnip/pack/helpers.py
@@ -111,7 +111,7 @@ def ensure_config(repo_root):
     about concurrency.
     """
     with open('git.config.yaml') as config_file:
-        git_config_defaults = yaml.load(config_file)
+        git_config_defaults = yaml.load(config_file, Loader=yaml.SafeLoader)
     config = Repository(repo_root).config
     for key, val in git_config_defaults.items():
         config[key] = val
diff --git a/turnip/pack/hookrpc.py b/turnip/pack/hookrpc.py
index 91a03a4..c5b0d68 100644
--- a/turnip/pack/hookrpc.py
+++ b/turnip/pack/hookrpc.py
@@ -168,7 +168,7 @@ class HookRPCHandler(object):
             proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
             try:
                 result = yield proxy.callRemote(
-                    b'checkRefPermissions',
+                    'checkRefPermissions',
                     ref_path,
                     [xmlrpc_client.Binary(path) for path in missing],
                     auth_params).addTimeout(
@@ -228,7 +228,7 @@ class HookRPCHandler(object):
     @defer.inlineCallbacks
     def notify(self, path):
         proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
-        yield proxy.callRemote(b'notify', path).addTimeout(
+        yield proxy.callRemote('notify', path).addTimeout(
             self.virtinfo_timeout, self.reactor)
 
     @defer.inlineCallbacks
diff --git a/turnip/pack/hooks/hook.py b/turnip/pack/hooks/hook.py
index 30189b1..1dd4cb4 100755
--- a/turnip/pack/hooks/hook.py
+++ b/turnip/pack/hooks/hook.py
@@ -16,15 +16,22 @@ import socket
 import subprocess
 import sys
 
+from pygit2 import GIT_OID_HEX_ZERO
 
-# XXX twom 2018-10-23 This should be a pygit2 import, but
-# that currently causes CFFI warnings to be returned to the client.
-GIT_OID_HEX_ZERO = '0'*40
+
+def is_git_oid_hex_zero(value):
+    """Checks if the given value is the git initial hash (either str or bytes).
+
+    See pygit2.GIT_OID_HEX_ZERO constant for details.
+    """
+    if isinstance(value, bytes):
+        value = value.decode('utf8')
+    return value == GIT_OID_HEX_ZERO
 
 
 def check_ancestor(old, new):
     # This is a delete, setting the new ref.
-    if new == GIT_OID_HEX_ZERO:
+    if is_git_oid_hex_zero(new):
         return False
     # https://git-scm.com/docs/git-merge-base#_discussion
     return_code = subprocess.call(
@@ -34,7 +41,7 @@ def check_ancestor(old, new):
 
 def determine_permissions_outcome(old, ref, rule_lines):
     rule = rule_lines.get(ref, [])
-    if old == GIT_OID_HEX_ZERO:
+    if is_git_oid_hex_zero(old):
         # We are creating a new ref
         if 'create' in rule:
             return
@@ -79,7 +86,7 @@ def match_update_rules(rule_lines, ref_line):
     ref, old, new = ref_line
 
     # If it's a create, the old ref doesn't exist
-    if old == GIT_OID_HEX_ZERO:
+    if is_git_oid_hex_zero(old):
         return []
 
     # https://git-scm.com/docs/git-merge-base#_discussion
@@ -94,12 +101,12 @@ def match_update_rules(rule_lines, ref_line):
 
 
 def netstring_send(sock, s):
-    sock.send(b'%d:%s,' % (len(s), s))
+    sock.send(b'%d:%s,' % (len(s), s.encode('utf-8')))
 
 
 def netstring_recv(sock):
     c = sock.recv(1)
-    lengthstr = ''
+    lengthstr = b''
     while c != b':':
         assert c.isdigit()
         lengthstr += c
@@ -126,7 +133,7 @@ def rpc_invoke(sock, method, args):
 def check_ref_permissions(sock, rpc_key, ref_paths):
     ref_paths = [base64.b64encode(path).decode('UTF-8') for path in ref_paths]
     rule_lines = rpc_invoke(
-        sock, b'check_ref_permissions',
+        sock, 'check_ref_permissions',
         {'key': rpc_key, 'paths': ref_paths})
     return {
         base64.b64decode(path.encode('UTF-8')): permissions
@@ -143,25 +150,27 @@ if __name__ == '__main__':
     hook = os.path.basename(sys.argv[0])
     if hook == 'pre-receive':
         # Verify the proposed changes against rules from the server.
-        raw_paths = sys.stdin.readlines()
+        raw_paths = sys.stdin.buffer.readlines()
         ref_paths = [p.rstrip(b'\n').split(b' ', 2)[2] for p in raw_paths]
         rule_lines = check_ref_permissions(sock, rpc_key, ref_paths)
         errors = match_rules(rule_lines, raw_paths)
         for error in errors:
-            sys.stdout.write(error + b'\n')
+            sys.stdout.buffer.write(error + b'\n')
         sys.exit(1 if errors else 0)
     elif hook == 'post-receive':
         # Notify the server about the push if there were any changes.
         # Details of the changes aren't currently included.
-        if sys.stdin.readlines():
-            rpc_invoke(sock, b'notify_push', {'key': rpc_key})
+        if sys.stdin.buffer.readlines():
+            rpc_invoke(sock, 'notify_push', {'key': rpc_key})
         sys.exit(0)
     elif hook == 'update':
-        ref = sys.argv[1]
+        # https://stackoverflow.com/a/27185688
+        argvb = list(map(os.fsencode, sys.argv))
+        ref = argvb[1]
         rule_lines = check_ref_permissions(sock, rpc_key, [ref])
-        errors = match_update_rules(rule_lines, sys.argv[1:4])
+        errors = match_update_rules(rule_lines, argvb[1:4])
         for error in errors:
-            sys.stdout.write(error + b'\n')
+            sys.stdout.buffer.write(error + b'\n')
         sys.exit(1 if errors else 0)
     else:
         sys.stderr.write('Invalid hook name: %s' % hook)
diff --git a/turnip/pack/http.py b/turnip/pack/http.py
index 573a37d..af2cd58 100644
--- a/turnip/pack/http.py
+++ b/turnip/pack/http.py
@@ -281,12 +281,12 @@ class BaseSmartHTTPResource(resource.Resource):
         """
         params = {
             b'turnip-can-authenticate': b'yes',
-            b'turnip-request-id': str(uuid.uuid4()),
+            b'turnip-request-id': str(uuid.uuid4()).encode('utf-8'),
             }
         authenticated_params = yield self.authenticateUser(request)
         for key, value in authenticated_params.items():
             encoded_key = ('turnip-authenticated-' + key).encode('utf-8')
-            params[encoded_key] = unicode(value).encode('utf-8')
+            params[encoded_key] = str(value).encode('utf-8')
         params.update(self.extra_params)
         d = defer.Deferred()
         client_factory = factory(service, path, params, content, request, d)
@@ -309,7 +309,7 @@ class SmartHTTPRefsResource(BaseSmartHTTPResource):
 
     def render_GET(self, request):
         try:
-            service = request.args['service'][0]
+            service = request.args[b'service'][0]
         except (KeyError, IndexError):
             return fail_request(
                 request, b'Only git smart HTTP clients are supported.',
@@ -660,7 +660,7 @@ class HTTPAuthRootResource(BaseHTTPAuthResource):
         identity_url = session.get('identity_url', self.anonymous_id)
         proxy = xmlrpc.Proxy(self.root.virtinfo_endpoint, allowNone=True)
         d = proxy.callRemote(
-            b'translatePath', request.path, b'read',
+            'translatePath', request.path, b'read',
             {'uid': identity_url, 'can-authenticate': True})
         d.addTimeout(self.root.virtinfo_timeout, self.root.reactor)
         d.addCallback(self._translatePathCallback, request)
@@ -742,8 +742,8 @@ class SmartHTTPFrontendResource(resource.Resource):
     @staticmethod
     def _isGitRequest(request):
         if request.path.endswith(b'/info/refs'):
-            service = request.args.get('service', [])
-            if service and service[0].startswith('git-'):
+            service = request.args.get(b'service', [])
+            if service and service[0].startswith(b'git-'):
                 return True
         content_type = request.getHeader(b'Content-Type')
         if content_type is None:
@@ -782,7 +782,7 @@ class SmartHTTPFrontendResource(resource.Resource):
         proxy = xmlrpc.Proxy(self.virtinfo_endpoint)
         try:
             translated = yield proxy.callRemote(
-                b'authenticateWithPassword', user, password)
+                'authenticateWithPassword', user, password)
         except xmlrpc.Fault as e:
             code = translate_xmlrpc_fault(e.faultCode)
             if code == TurnipFaultCode.UNAUTHORIZED:
diff --git a/turnip/pack/ssh.py b/turnip/pack/ssh.py
index 3526bb9..87a7794 100644
--- a/turnip/pack/ssh.py
+++ b/turnip/pack/ssh.py
@@ -136,8 +136,9 @@ class SmartSSHSession(DoNothingSession):
         """
         params = {
             b'turnip-authenticated-user': self.avatar.username.encode('utf-8'),
-            b'turnip-authenticated-uid': str(self.avatar.user_id),
-            b'turnip-request-id': str(uuid.uuid4()),
+            b'turnip-authenticated-uid': str(
+                self.avatar.user_id).encode('utf-8'),
+            b'turnip-request-id': str(uuid.uuid4()).encode('utf-8'),
             }
         d = defer.Deferred()
         client_factory = factory(service, path, params, ssh_protocol, d)
@@ -149,7 +150,9 @@ class SmartSSHSession(DoNothingSession):
 
     def execCommand(self, protocol, command):
         """See `ISession`."""
-        words = shlex.split(command)
+        # shlex doesn't operate on, nor return bytes.
+        words = shlex.split(command.decode())
+        words = [x.encode('utf-8') for x in words]
         if len(words) > 1 and words[0] == "git":
             # Accept "git foo" as if the caller said "git-foo".  (This
             # matches the behaviour of "git shell".)
diff --git a/turnip/pack/tests/fake_servers.py b/turnip/pack/tests/fake_servers.py
index 0914637..2fb39d6 100644
--- a/turnip/pack/tests/fake_servers.py
+++ b/turnip/pack/tests/fake_servers.py
@@ -40,6 +40,7 @@ class FakeAuthServerService(xmlrpc.XMLRPC):
         self.keys[username].append((keytype, keytext))
 
     def xmlrpc_getUserAndSSHKeys(self, username):
+        username = str(username)
         if username not in self.keys:
             raise NoSuchPersonWithName(username)
         return {
@@ -67,10 +68,13 @@ class FakeVirtInfoService(xmlrpc.XMLRPC):
         self.ref_permissions_fault = None
 
     def xmlrpc_translatePath(self, pathname, permission, auth_params):
+        pathname = str(pathname)
         if self.require_auth and 'user' not in auth_params:
             raise xmlrpc.Fault(3, "Unauthorized")
 
-        self.translations.append((pathname, permission, auth_params))
+        if 'user' in auth_params:
+            auth_params['user'] = str(auth_params['user'])
+        self.translations.append((pathname, str(permission), auth_params))
         writable = False
         if pathname.startswith('/+rw'):
             writable = True
@@ -78,14 +82,14 @@ class FakeVirtInfoService(xmlrpc.XMLRPC):
 
         if permission != b'read' and not writable:
             raise xmlrpc.Fault(2, "Repository is read-only")
-        return {'path': hashlib.sha256(pathname).hexdigest()}
+        return {'path': hashlib.sha256(pathname.encode('utf-8')).hexdigest()}
 
     def xmlrpc_authenticateWithPassword(self, username, password):
-        self.authentications.append((username, password))
+        self.authentications.append((str(username), str(password)))
         return {'user': username}
 
     def xmlrpc_notify(self, path):
-        self.push_notifications.append(path)
+        self.push_notifications.append(str(path))
 
     def xmlrpc_checkRefPermissions(self, path, ref_paths, auth_params):
         self.ref_permissions_checks.append((path, ref_paths, auth_params))
diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py
index 8927b6f..4dc00fb 100644
--- a/turnip/pack/tests/test_functional.py
+++ b/turnip/pack/tests/test_functional.py
@@ -16,6 +16,7 @@ import random
 import shutil
 import stat
 import tempfile
+
 try:
     from urllib.parse import (
         urlsplit,
@@ -32,14 +33,18 @@ from fixtures import (
     TempDir,
     )
 from pygit2 import GIT_OID_HEX_ZERO
+from six.moves import xmlrpc_client
 from testtools import TestCase
 from testtools.content import text_content
 from testtools.deferredruntest import AsynchronousDeferredRunTest
 from testtools.matchers import (
     Equals,
     Is,
+    IsInstance,
+    MatchesAll,
     MatchesDict,
     MatchesListwise,
+    MatchesStructure,
     Not,
     StartsWith,
     )
@@ -48,12 +53,18 @@ from twisted.internet import (
     reactor,
     utils,
     )
+from twisted.internet.protocol import Protocol
 from twisted.web import (
     client,
     http_headers,
     server,
     xmlrpc,
     )
+from twisted.web.client import (
+    ResponseDone,
+    PartialDownloadError,
+    )
+from twisted.web.http import PotentialDataLoss
 
 from turnip.pack import helpers
 from turnip.pack.git import (
@@ -74,6 +85,38 @@ from turnip.pack.tests.fake_servers import (
 from turnip.version_info import version_info
 
 
+def readBody(response):
+    """Reads the content of the given response.
+
+    XXX: pappacena 2020-02-26: This function is mostly replicating
+    twisted.web.client.readBody, except that it doesn't raise
+    DeprecationWarning due to a strange behavior on protocol.transport not
+    having abortConnection method.
+    """
+    class ReadBodyProtocol(Protocol):
+        def __init__(self, deferred):
+            self.deferred = deferred
+            self.dataBuffer = []
+
+        def dataReceived(self, data):
+            self.dataBuffer.append(data)
+
+        def connectionLost(self, reason):
+            if reason.check(ResponseDone):
+                self.deferred.callback(b''.join(self.dataBuffer))
+            elif reason.check(PotentialDataLoss):
+                self.deferred.errback(
+                    PartialDownloadError(self.status, self.message,
+                                         b''.join(self.dataBuffer)))
+            else:
+                self.deferred.errback(reason)
+
+    d = defer.Deferred()
+    protocol = ReadBodyProtocol(d)
+    response.deliverBody(protocol)
+    return d
+
+
 class FunctionalTestMixin(object):
 
     run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=30)
@@ -92,20 +135,15 @@ class FunctionalTestMixin(object):
 
     def startHookRPC(self):
         self.hookrpc_handler = HookRPCHandler(self.virtinfo_url, 15)
-        # XXX cjwatson 2018-11-20: Use bytes so that shutil.rmtree doesn't
-        # get confused on Python 2.
-        dir = tempfile.mkdtemp(prefix=b'turnip-test-hook-')
+        dir = tempfile.mkdtemp(prefix='turnip-test-hook-')
         self.addCleanup(shutil.rmtree, dir, ignore_errors=True)
-
         self.hookrpc_sock_path = os.path.join(dir, 'hookrpc_sock')
         self.hookrpc_listener = reactor.listenUNIX(
             self.hookrpc_sock_path, HookRPCServerFactory(self.hookrpc_handler))
         self.addCleanup(self.hookrpc_listener.stopListening)
 
     def startPackBackend(self):
-        # XXX cjwatson 2018-11-20: Use bytes so that shutil.rmtree doesn't
-        # get confused on Python 2.
-        self.root = tempfile.mkdtemp(prefix=b'turnip-test-root-')
+        self.root = tempfile.mkdtemp(prefix='turnip-test-root-')
         self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
         self.backend_listener = reactor.listenTCP(
             0,
@@ -119,8 +157,10 @@ class FunctionalTestMixin(object):
         out, err, code = yield utils.getProcessOutputAndValue(
             command[0], command[1:], env=os.environ, path=path)
         if code != 0:
-            self.addDetail('stdout', text_content(out))
-            self.addDetail('stderr', text_content(err))
+            self.addDetail(
+                'stdout', text_content(out.decode('unicode_escape')))
+            self.addDetail(
+                'stderr', text_content(err.decode('unicode_escape')))
             self.assertEqual(0, code)
         defer.returnValue(out)
 
@@ -129,8 +169,10 @@ class FunctionalTestMixin(object):
         out, err, code = yield utils.getProcessOutputAndValue(
             command[0], command[1:], env=os.environ, path=path)
         if code == 0:
-            self.addDetail('stdout', text_content(out))
-            self.addDetail('stderr', text_content(err))
+            self.addDetail(
+                'stdout', text_content(out.decode('unicode_escape')))
+            self.addDetail(
+                'stderr', text_content(err.decode('unicode_escape')))
             self.assertNotEqual(0, code)
         defer.returnValue((out, err))
 
@@ -373,8 +415,8 @@ class FunctionalTestMixin(object):
             path=clone1)
         with open(os.path.join(clone1, 'bigfile'), 'w') as bigfile:
             # Use random contents to defeat compression.
-            bigfile.write(bytearray(
-                random.getrandbits(8) for _ in range(1024 * 1024)))
+            bigfile.write(
+                str(random.getrandbits(8) for _ in range(1024 * 1024)))
         yield self.assertCommandSuccess(
             (b'git', b'add', b'bigfile'), path=clone1)
         yield self.assertCommandSuccess(
@@ -625,7 +667,9 @@ class TestSmartHTTPFrontendFunctional(FrontendFunctionalTestMixin, TestCase):
                 "virtinfo_endpoint": self.virtinfo_url,
                 "virtinfo_timeout": "15",
                 "repo_store": self.root,
+                "main_site_root": b"/"
                 }))
+
         self.frontend_listener = reactor.listenTCP(0, frontend_site)
         self.port = self.frontend_listener.getHost().port
 
@@ -644,7 +688,7 @@ class TestSmartHTTPFrontendFunctional(FrontendFunctionalTestMixin, TestCase):
             b'HEAD', b'http://localhost:%d/' % self.port)
         self.assertEqual(302, response.code)
         self.assertEqual(
-            [version_info['revision_id']],
+            [version_info['revision_id'].encode('utf-8')],
             response.headers.getRawHeaders(b'X-Turnip-Revision'))
 
     def make_set_symbolic_ref_request(self, line):
@@ -682,8 +726,8 @@ class TestSmartHTTPFrontendFunctional(FrontendFunctionalTestMixin, TestCase):
         response = yield self.make_set_symbolic_ref_request(
             b'HEAD refs/heads/new-head')
         self.assertEqual(200, response.code)
-        body = yield client.readBody(response)
-        self.assertEqual((b'ACK HEAD\n', ''), helpers.decode_packet(body))
+        body = yield readBody(response)
+        self.assertEqual((b'ACK HEAD\n', b''), helpers.decode_packet(body))
         head_target = yield self.get_symbolic_ref(repo, b'HEAD')
         self.assertEqual(b'refs/heads/new-head', head_target)
         self.assertEqual(
@@ -698,9 +742,9 @@ class TestSmartHTTPFrontendFunctional(FrontendFunctionalTestMixin, TestCase):
         # This is a little weird since an error occurred, but it's
         # consistent with other HTTP pack protocol responses.
         self.assertEqual(200, response.code)
-        body = yield client.readBody(response)
+        body = yield readBody(response)
         self.assertEqual(
-            (b'ERR Symbolic ref target may not start with "-"\n', ''),
+            (b'ERR Symbolic ref target may not start with "-"\n', b''),
             helpers.decode_packet(body))
         head_target = yield self.get_symbolic_ref(repo, b'HEAD')
         self.assertEqual(b'refs/heads/master', head_target)
@@ -725,14 +769,14 @@ class TestSmartHTTPFrontendWithAuthFunctional(TestSmartHTTPFrontendFunctional):
         clone = os.path.join(test_root, 'clone')
         yield self.assertCommandSuccess((b'git', b'clone', self.ro_url, clone))
         self.assertEqual(
-            [(b'test-user', b'test-password')], self.virtinfo.authentications)
+            [('test-user', 'test-password')], self.virtinfo.authentications)
         self.assertThat(self.virtinfo.translations, MatchesListwise([
             MatchesListwise([
-                Equals(b'/test'), Equals(b'read'),
+                Equals('/test'), Equals('read'),
                 MatchesDict({
-                    b'can-authenticate': Is(True),
-                    b'request-id': Not(Is(None)),
-                    b'user': Equals(b'test-user'),
+                    'can-authenticate': Is(True),
+                    'request-id': Not(Is(None)),
+                    'user': Equals('test-user'),
                     })])]))
 
     @defer.inlineCallbacks
@@ -752,13 +796,26 @@ class TestSmartHTTPFrontendWithAuthFunctional(TestSmartHTTPFrontendFunctional):
             (b'git', b'push', b'origin', b'master'), path=clone)
         self.assertThat(self.virtinfo.ref_permissions_checks, MatchesListwise([
             MatchesListwise([
-                Equals(self.internal_name),
-                Equals([b'refs/heads/master']),
-                MatchesDict({
-                    b'can-authenticate': Is(True),
-                    b'request-id': Not(Is(None)),
-                    b'user': Equals(b'test-user'),
-                    })])]))
+                MatchesAll(
+                        IsInstance(xmlrpc_client.Binary),
+                        MatchesStructure.byEquality(
+                            data=self.internal_name.encode('utf-8'))),
+                MatchesListwise([
+                    MatchesAll(
+                        IsInstance(xmlrpc_client.Binary),
+                        MatchesStructure.byEquality(data=ref_path))
+                    for ref_path in [b'refs/heads/master']
+                    ]),
+                MatchesDict(
+                    {
+                        'can-authenticate': Equals(True),
+                        'user': MatchesAll(
+                            IsInstance(xmlrpc_client.Binary),
+                            MatchesStructure.byEquality(data=b'test-user')),
+                        'request-id': IsInstance(xmlrpc_client.Binary),
+                    }),
+                ]),
+            ]))
 
 
 class TestSmartSSHServiceFunctional(FrontendFunctionalTestMixin, TestCase):
@@ -806,7 +863,7 @@ class TestSmartSSHServiceFunctional(FrontendFunctionalTestMixin, TestCase):
             private_key_path=private_host_key, public_key_path=public_host_key,
             main_log="turnip", access_log="turnip.access",
             access_log_path=os.path.join(self.root, "access.log"),
-            strport=b'tcp:0', moduli_path="/etc/ssh/moduli")
+            strport='tcp:0', moduli_path="/etc/ssh/moduli")
         self.service.startService()
         self.addCleanup(self.service.stopService)
         socket = self.service.service._waitingForPort.result.socket
diff --git a/turnip/pack/tests/test_git.py b/turnip/pack/tests/test_git.py
index 42ffb1b..8d3053f 100644
--- a/turnip/pack/tests/test_git.py
+++ b/turnip/pack/tests/test_git.py
@@ -144,7 +144,7 @@ class TestPackBackendProtocol(TestCase):
             b'git-upload-pack', b'/foo.git', {b'host': b'example.com'})
         self.assertEqual(
             (b'git',
-             [b'git', b'upload-pack', os.path.join(self.root, b'foo.git')],
+             [b'git', b'upload-pack', os.path.join(self.root, 'foo.git')],
              {}),
             self.proto.test_process)
 
@@ -158,7 +158,7 @@ class TestPackBackendProtocol(TestCase):
         self.assertThat(
             self.proto.test_process, MatchesListwise([
                 Equals(b'git'),
-                Equals([b'git', b'receive-pack', repo_dir.encode('utf-8')]),
+                Equals([b'git', b'receive-pack', repo_dir]),
                 ContainsDict(
                     {b'TURNIP_HOOK_RPC_SOCK': Equals(self.hookrpc_sock)})]))
 
@@ -175,7 +175,7 @@ class TestPackBackendProtocol(TestCase):
             self.proto.test_process, MatchesListwise([
                 Equals(b'git'),
                 Equals([
-                    b'git', b'-C', repo_dir.encode('utf-8'), b'symbolic-ref',
+                    b'git', b'-C', repo_dir, b'symbolic-ref',
                     b'HEAD', b'refs/heads/master']),
                 ContainsDict(
                     {b'TURNIP_HOOK_RPC_SOCK': Equals(self.hookrpc_sock)})]))
diff --git a/turnip/pack/tests/test_hooks.py b/turnip/pack/tests/test_hooks.py
index ce92c3a..aa0ad70 100644
--- a/turnip/pack/tests/test_hooks.py
+++ b/turnip/pack/tests/test_hooks.py
@@ -211,7 +211,7 @@ class TestPreReceiveHook(HookTestMixin, TestCase):
         # An invalid ref is rejected.
         yield self.assertRejected(
             [(b'refs/heads/verboten', self.old_sha1, self.new_sha1)],
-            {'refs/heads/verboten': []},
+            {b'refs/heads/verboten': []},
             b"You do not have permission to push to refs/heads/verboten.\n")
 
     @defer.inlineCallbacks
@@ -221,9 +221,9 @@ class TestPreReceiveHook(HookTestMixin, TestCase):
             [(b'refs/heads/verboten', self.old_sha1, self.new_sha1),
              (b'refs/heads/master', self.old_sha1, self.new_sha1),
              (b'refs/heads/super-verboten', self.old_sha1, self.new_sha1)],
-            {'refs/heads/verboten': [],
-             'refs/heads/super-verboten': [],
-             'refs/heads/master': ['push']},
+            {b'refs/heads/verboten': [],
+             b'refs/heads/super-verboten': [],
+             b'refs/heads/master': ['push']},
             b"You do not have permission to push to refs/heads/verboten.\n"
             b"You do not have permission to push "
             b"to refs/heads/super-verboten.\n")
@@ -271,7 +271,7 @@ class TestUpdateHook(TestCase):
     def patched_ancestor_check(self, old, new):
         # Avoid a subprocess call to execute on a git repository
         # that we haven't created.
-        if old == 'old':
+        if old == b'old':
             return False
         return True
 
@@ -284,26 +284,26 @@ class TestUpdateHook(TestCase):
     def test_fast_forward(self):
         # If the old sha is a merge ancestor of the new
         self.assertEqual(
-            [], hook.match_update_rules({}, ['ref', 'somehex', 'new']))
+            [], hook.match_update_rules({}, [b'ref', b'somehex', b'new']))
 
     def test_rules_fall_through(self):
         # The default is to deny
-        output = hook.match_update_rules({}, ['ref', 'old', 'new'])
+        output = hook.match_update_rules({}, [b'ref', b'old', b'new'])
         self.assertEqual(
             [b'You do not have permission to force-push to ref.'], output)
 
     def test_no_matching_ref(self):
         # No matches means deny by default
         output = hook.match_update_rules(
-            {'notamatch': []},
-            [b'ref', 'old', 'new'])
+            {b'notamatch': []},
+            [b'ref', b'old', b'new'])
         self.assertEqual(
             [b'You do not have permission to force-push to ref.'], output)
 
     def test_no_matching_non_utf8_ref(self):
         # An unmatched non-UTF-8 ref is denied.
         output = hook.match_update_rules(
-            {}, [b'refs/heads/\x80', 'old', 'new'])
+            {}, [b'refs/heads/\x80', b'old', b'new'])
         self.assertEqual(
             [b'You do not have permission to force-push to refs/heads/\x80.'],
             output)
@@ -311,7 +311,7 @@ class TestUpdateHook(TestCase):
     def test_no_matching_utf8_ref(self):
         # An unmatched UTF-8 ref is denied.
         output = hook.match_update_rules(
-            {}, [u'refs/heads/géag'.encode('UTF-8'), 'old', 'new'])
+            {}, ['refs/heads/géag'.encode('UTF-8'), b'old', b'new'])
         self.assertEqual(
             [b'You do not have permission to force-push to '
              b'refs/heads/g\xc3\xa9ag.'],
@@ -320,8 +320,8 @@ class TestUpdateHook(TestCase):
     def test_matching_ref(self):
         # Permission given to force-push
         output = hook.match_update_rules(
-            {'ref': ['force_push']},
-            ['ref', 'old', 'new'])
+            {b'ref': ['force_push']},
+            [b'ref', b'old', b'new'])
         self.assertEqual([], output)
 
     def test_matching_non_utf8_ref(self):
@@ -341,46 +341,48 @@ class TestUpdateHook(TestCase):
     def test_no_permission(self):
         # User does not have permission to force-push
         output = hook.match_update_rules(
-            {'ref': ['create']},
-            ['ref', 'old', 'new'])
+            {b'ref': ['create']},
+            [b'ref', b'old', b'new'])
         self.assertEqual(
             [b'You do not have permission to force-push to ref.'], output)
 
 
 class TestDeterminePermissions(TestCase):
 
+    oid_bytes = pygit2.GIT_OID_HEX_ZERO.encode('utf-8')
+
     def test_no_match_fallthrough(self):
         # No matching rule is deny by default
         output = hook.determine_permissions_outcome(
-            'old', 'ref', {})
+            b'old', b'ref', {})
         self.assertEqual(b"You do not have permission to push to ref.", output)
 
     def test_match_no_permissions(self):
         output = hook.determine_permissions_outcome(
-            'old', 'ref', {'ref': []})
+            b'old', b'ref', {'ref': []})
         self.assertEqual(b"You do not have permission to push to ref.", output)
 
     def test_match_with_create(self):
         output = hook.determine_permissions_outcome(
-            pygit2.GIT_OID_HEX_ZERO, 'ref', {'ref': ['create']})
+            self.oid_bytes, b'ref', {b'ref': ['create']})
         self.assertIsNone(output)
 
     def test_match_no_create_perms(self):
         output = hook.determine_permissions_outcome(
-            pygit2.GIT_OID_HEX_ZERO, 'ref', {'ref': []})
+            self.oid_bytes, b'ref', {b'ref': []})
         self.assertEqual(b"You do not have permission to create ref.", output)
 
     def test_push(self):
         output = hook.determine_permissions_outcome(
-            'old', 'ref', {'ref': ['push']})
+            b'old', b'ref', {b'ref': ['push']})
         self.assertIsNone(output)
 
     def test_force_push(self):
         output = hook.determine_permissions_outcome(
-            'old', 'ref', {'ref': ['force_push']})
+            b'old', b'ref', {b'ref': ['force_push']})
         self.assertIsNone(output)
 
     def test_force_push_does_not_imply_create(self):
         output = hook.determine_permissions_outcome(
-            pygit2.GIT_OID_HEX_ZERO, 'ref', {'ref': ['force_push']})
+            pygit2.GIT_OID_HEX_ZERO, b'ref', {b'ref': ['force_push']})
         self.assertEqual(b"You do not have permission to create ref.", output)
diff --git a/turnip/pack/tests/test_http.py b/turnip/pack/tests/test_http.py
index 17e662d..1bb88be 100644
--- a/turnip/pack/tests/test_http.py
+++ b/turnip/pack/tests/test_http.py
@@ -33,7 +33,7 @@ class LessDummyRequest(requesthelper.DummyRequest):
 
     @property
     def value(self):
-        return "".join(self.written)
+        return b"".join(self.written)
 
     def write(self, data):
         self.startedWriting = 1
@@ -116,9 +116,9 @@ class ErrorTestMixin(object):
     @defer.inlineCallbacks
     def test_backend_immediately_dies(self):
         # If the backend disappears before it says anything, that's OK.
-        yield self.performRequest('')
+        yield self.performRequest(b'')
         self.assertEqual(200, self.request.responseCode)
-        self.assertEqual('', self.request.value)
+        self.assertEqual(b'', self.request.value)
 
     @defer.inlineCallbacks
     def test_backend_virt_error(self):
@@ -126,7 +126,7 @@ class ErrorTestMixin(object):
         yield self.performRequest(
             helpers.encode_packet(b'ERR turnip virt error: NOT_FOUND enoent'))
         self.assertEqual(404, self.request.responseCode)
-        self.assertEqual('enoent', self.request.value)
+        self.assertEqual(b'enoent', self.request.value)
 
     @defer.inlineCallbacks
     def test_backend_virt_error_unknown(self):
@@ -134,7 +134,7 @@ class ErrorTestMixin(object):
         yield self.performRequest(
             helpers.encode_packet(b'ERR turnip virt error: random yay'))
         self.assertEqual(500, self.request.responseCode)
-        self.assertEqual('yay', self.request.value)
+        self.assertEqual(b'yay', self.request.value)
 
 
 class TestSmartHTTPRefsResource(ErrorTestMixin, TestCase):
@@ -157,13 +157,13 @@ class TestSmartHTTPRefsResource(ErrorTestMixin, TestCase):
         yield self.performRequest(service=None)
         self.assertEqual(404, self.request.responseCode)
         self.assertEqual(
-            "Only git smart HTTP clients are supported.", self.request.value)
+            b"Only git smart HTTP clients are supported.", self.request.value)
 
     @defer.inlineCallbacks
     def test_unsupported_service(self):
         yield self.performRequest(service=b'foo')
         # self.assertEqual(403, self.request.responseCode)
-        self.assertEqual("Unsupported service.", self.request.value)
+        self.assertEqual(b"Unsupported service.", self.request.value)
 
     @defer.inlineCallbacks
     def test_backend_error(self):
@@ -173,7 +173,7 @@ class TestSmartHTTPRefsResource(ErrorTestMixin, TestCase):
         yield self.performRequest(
             helpers.encode_packet(b'ERR so borked'))
         self.assertEqual(500, self.request.responseCode)
-        self.assertEqual('so borked', self.request.value)
+        self.assertEqual(b'so borked', self.request.value)
 
     @defer.inlineCallbacks
     def test_good(self):
@@ -182,9 +182,9 @@ class TestSmartHTTPRefsResource(ErrorTestMixin, TestCase):
             b'And I am raw, since we got a good packet to start with.')
         self.assertEqual(200, self.request.responseCode)
         self.assertEqual(
-            '001e# service=git-upload-pack\n'
-            '0000001bI am git protocol data.'
-            'And I am raw, since we got a good packet to start with.',
+            b'001e# service=git-upload-pack\n'
+            b'0000001bI am git protocol data.'
+            b'And I am raw, since we got a good packet to start with.',
             self.request.value)
 
 
@@ -210,7 +210,7 @@ class TestSmartHTTPCommandResource(ErrorTestMixin, TestCase):
         yield self.performRequest(
             helpers.encode_packet(b'ERR so borked'))
         self.assertEqual(200, self.request.responseCode)
-        self.assertEqual('0011ERR so borked', self.request.value)
+        self.assertEqual(b'0011ERR so borked', self.request.value)
 
     @defer.inlineCallbacks
     def test_good(self):
@@ -219,8 +219,8 @@ class TestSmartHTTPCommandResource(ErrorTestMixin, TestCase):
             b'And I am raw, since we got a good packet to start with.')
         self.assertEqual(200, self.request.responseCode)
         self.assertEqual(
-            '001bI am git protocol data.'
-            'And I am raw, since we got a good packet to start with.',
+            b'001bI am git protocol data.'
+            b'And I am raw, since we got a good packet to start with.',
             self.request.value)
 
 
@@ -248,4 +248,4 @@ class TestHTTPAuthRootResource(TestCase):
         root.reactor.advance(15)
         self.assertTrue(d.called)
         self.assertEqual(504, request.responseCode)
-        self.assertEqual('Path translation timed out.', request.value)
+        self.assertEqual(b'Path translation timed out.', request.value)
diff --git a/turnip/tests/test_helpers.py b/turnip/tests/test_helpers.py
index 9dc4101..fdeb455 100644
--- a/turnip/tests/test_helpers.py
+++ b/turnip/tests/test_helpers.py
@@ -18,24 +18,24 @@ class TestComposePath(TestCase):
     def test_basic(self):
         # The path is resolved within the given root tree.
         self.assertEqual(
-            b'/foo/bar/baz/quux',
-            helpers.compose_path(b'/foo/bar', b'baz/quux'))
+            '/foo/bar/baz/quux',
+            helpers.compose_path('/foo/bar', 'baz/quux'))
 
     def test_absolute(self):
         # Even absolute paths are contained.
         self.assertEqual(
-            b'/foo/bar/baz/quux',
-            helpers.compose_path(b'/foo/bar', b'/baz/quux'))
+            '/foo/bar/baz/quux',
+            helpers.compose_path('/foo/bar', '/baz/quux'))
 
     def test_normalises(self):
         # Paths are normalised.
         self.assertEqual(
-            b'/foo/bar/baz/quux',
-            helpers.compose_path(b'///foo/.//bar', b'//baz/..//baz/./quux'))
+            '/foo/bar/baz/quux',
+            helpers.compose_path('///foo/.//bar', '//baz/..//baz/./quux'))
 
     def test_no_escape(self):
         # You can't get out.
         self.assertRaises(
-            ValueError, helpers.compose_path, b'/foo', b'../bar')
+            ValueError, helpers.compose_path, '/foo', '../bar')
         self.assertRaises(
-            ValueError, helpers.compose_path, b'/foo', b'/foo/../../bar')
+            ValueError, helpers.compose_path, '/foo', '/foo/../../bar')