Repository: freedomofpress/securedrop

Issue:
Migrate securedrop-admin to Python 3
## Description

We need to migrate away from Python 2 entirely, as it EOLs in 2020. This ticket is to migrate the `securedrop-admin` CLI tool to Python 3. The good news is that Python 3 is in Tails already (indeed the SecureDrop GUI updater in Tails uses Python 3), so this is a migration that can be done relatively soon. 


Discussion:
I've been working on this issue and have the code, install scripts, and tests ported over to python3 ([which can be viewed here](https://github.com/freedomofpress/securedrop/compare/develop...kneitinger:3489-migrate-securedrop-admin-to-py3)), with all tests passing , but am curious about if some other considerations should be included in this.
- `prompt_toolkit` recently had an API-breaking update, would it be helpful to include that update as well?
- `bootstrap.py` seems like it could be easier to maintain and modify if it were an ansible playbook, is there any specific reason the current design choice was made? Edit: in thinking about it more, I feel it is favorable to have an install script that doesn't have any dependencies to run it.
Thanks
@kneitinger How much work do you think it will be to move to the newer `prompt_toolkit`? 
I do not think it would be too much work at all to transition over to the new interface, but if I recall correctly my question was more feeling out if you folks have any internal processes about package versions used.
After the tooling has been migrated to Py3, we'll have to handle the transition of the virtualenv on Admin Workstations from py2 -> py3, as well. Will open a separate issue to track. 

Resolving patch:
diff --git a/admin/bootstrap.py b/admin/bootstrap.py
--- a/admin/bootstrap.py
+++ b/admin/bootstrap.py
@@ -26,7 +26,7 @@
 sdlog = logging.getLogger(__name__)
 
 DIR = os.path.dirname(os.path.realpath(__file__))
-VENV_DIR = os.path.join(DIR, ".venv")
+VENV_DIR = os.path.join(DIR, ".venv3")
 
 
 def setup_logger(verbose=False):
@@ -52,7 +52,7 @@ def run_command(command):
     popen = subprocess.Popen(command,
                              stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT)
-    for stdout_line in iter(popen.stdout.readline, ""):
+    for stdout_line in iter(popen.stdout.readline, b""):
         yield stdout_line
     popen.stdout.close()
     return_code = popen.wait()
@@ -94,21 +94,21 @@ def install_apt_dependencies(args):
     apt_command = ['sudo', 'su', '-c',
                    "apt-get update && \
                    apt-get -q -o=Dpkg::Use-Pty=0 install -y \
-                   python-virtualenv \
-                   python-yaml \
-                   python-pip \
+                   python3-virtualenv \
+                   python3-yaml \
+                   python3-pip \
                    ccontrol \
                    virtualenv \
                    libffi-dev \
                    libssl-dev \
-                   libpython2.7-dev",
+                   libpython3-dev",
                    ]
 
     try:
         # Print command results in real-time, to keep Admin apprised
         # of progress during long-running command.
         for output_line in run_command(apt_command):
-            print(output_line.rstrip())
+            print(output_line.decode('utf-8').rstrip())
     except subprocess.CalledProcessError:
         # Tails supports apt persistence, which was used by SecureDrop
         # under Tails 2.x. If updates are being applied, don't try to pile
@@ -142,7 +142,7 @@ def envsetup(args):
         sdlog.info("Setting up virtualenv")
         try:
             sdlog.debug(subprocess.check_output(
-                maybe_torify() + ['virtualenv', VENV_DIR],
+                maybe_torify() + ['virtualenv', '--python=python3', VENV_DIR],
                 stderr=subprocess.STDOUT))
         except subprocess.CalledProcessError as e:
             sdlog.debug(e.output)
@@ -161,7 +161,7 @@ def envsetup(args):
 
 def install_pip_self(args):
     pip_install_cmd = [
-        os.path.join(VENV_DIR, 'bin', 'pip'),
+        os.path.join(VENV_DIR, 'bin', 'pip3'),
         'install', '-e', DIR
     ]
     try:
@@ -174,7 +174,7 @@ def install_pip_self(args):
 
 
 def install_pip_dependencies(args, pip_install_cmd=[
-        os.path.join(VENV_DIR, 'bin', 'pip'),
+        os.path.join(VENV_DIR, 'bin', 'pip3'),
         'install',
         # Specify requirements file.
         '-r', os.path.join(DIR, 'requirements.txt'),
@@ -197,7 +197,7 @@ def install_pip_dependencies(args, pip_install_cmd=[
         raise
 
     sdlog.debug(pip_output)
-    if "Successfully installed" in pip_output:
+    if "Successfully installed" in str(pip_output):
         sdlog.info("Python dependencies for securedrop-admin upgraded")
     else:
         sdlog.info("Python dependencies for securedrop-admin are up-to-date")
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -23,16 +23,13 @@
 instances.
 """
 
-from __future__ import print_function
 import argparse
 import logging
 import os
 import io
 import re
-import string
 import subprocess
 import sys
-import types
 import json
 import base64
 import prompt_toolkit
@@ -417,11 +414,11 @@ def __init__(self, args):
              SiteConfig.ValidateYesNo(),
              lambda x: x.lower() == 'yes',
              lambda config: True],
-            ['securedrop_supported_locales', [], types.ListType,
+            ['securedrop_supported_locales', [], list,
              u'Space separated list of additional locales to support '
              '(' + translations + ')',
              SiteConfig.ValidateLocales(self.args.app_path),
-             string.split,
+             str.split,
              lambda config: True],
             ['v2_onion_services', self.check_for_v2_onion(), bool,
              u'Do you want to enable v2 onion services (recommended only for SecureDrop instances installed before 1.0.0)?',  # noqa: E501
@@ -526,7 +523,7 @@ def validated_input(self, prompt, default, validator, transform):
             default = default and 'yes' or 'no'
         if type(default) is int:
             default = str(default)
-        if isinstance(default, types.ListType):
+        if isinstance(default, list):
             default = " ".join(default)
         if type(default) is not str:
             default = str(default)
@@ -534,7 +531,7 @@ def validated_input(self, prompt, default, validator, transform):
         if validator:
             kwargs['validator'] = validator
         value = prompt_toolkit.prompt(prompt,
-                                      default=default.decode('utf-8'),
+                                      default=default,
                                       **kwargs)
         if transform:
             return transform(value)
@@ -777,7 +774,7 @@ def check_for_updates(args):
 
     # Determine what branch we are on
     current_tag = subprocess.check_output(['git', 'describe'],
-                                          cwd=args.root).rstrip('\n')
+                                          cwd=args.root).decode('utf-8').rstrip('\n')  # noqa: E501
 
     # Fetch all branches
     git_fetch_cmd = ['git', 'fetch', '--all']
@@ -786,7 +783,7 @@ def check_for_updates(args):
     # Get latest tag
     git_all_tags = ["git", "tag"]
     all_tags = subprocess.check_output(git_all_tags,
-                                       cwd=args.root).rstrip('\n').split('\n')
+                                       cwd=args.root).decode('utf-8').rstrip('\n').split('\n')  # noqa: E501
 
     # Do not check out any release candidate tags
     all_prod_tags = [x for x in all_tags if 'rc' not in x]
@@ -837,7 +834,7 @@ def update(args):
     try:
         sig_result = subprocess.check_output(git_verify_tag_cmd,
                                              stderr=subprocess.STDOUT,
-                                             cwd=args.root)
+                                             cwd=args.root).decode('utf-8')
 
         good_sig_text = ['Good signature from "SecureDrop Release Signing ' +
                          'Key"',
@@ -869,7 +866,7 @@ def update(args):
                 sdlog.info("Signature verification failed.")
                 return 1
             except subprocess.CalledProcessError as e:
-                if 'not a valid ref' in e.output:
+                if 'not a valid ref' in e.output.decode('utf-8'):
                     # Then there is no duplicate branch.
                     sdlog.info("Signature verification successful.")
                 else:  # If any other exception occurs, we bail.
diff --git a/install_files/ansible-base/roles/tails-config/files/securedrop_init.py b/install_files/ansible-base/roles/tails-config/files/securedrop_init.py
--- a/install_files/ansible-base/roles/tails-config/files/securedrop_init.py
+++ b/install_files/ansible-base/roles/tails-config/files/securedrop_init.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/python3
 
 import grp
 import os
@@ -22,7 +22,7 @@
 path_persistent_desktop = '/lib/live/mount/persistence/TailsData_unlocked/dotfiles/Desktop/'  # noqa: E501
 path_securedrop_root = '/home/amnesia/Persistent/securedrop'
 path_securedrop_admin_venv = os.path.join(path_securedrop_root,
-                                          'admin/.venv/bin/python')
+                                          'admin/.venv3/bin/python')
 path_securedrop_admin_init = os.path.join(path_securedrop_root,
                                           'admin/securedrop_admin/__init__.py')
 path_gui_updater = os.path.join(path_securedrop_root,


Test patch:
diff --git a/admin/tests/test_integration.py b/admin/tests/test_integration.py
--- a/admin/tests/test_integration.py
+++ b/admin/tests/test_integration.py
@@ -197,137 +197,137 @@ def teardown_function(function):
 
 
 def verify_username_prompt(child):
-    child.expect("Username for SSH access to the servers:")
+    child.expect(b"Username for SSH access to the servers:")
 
 
 def verify_reboot_prompt(child):
     child.expect(
-        r"Daily reboot time of the server \(24\-hour clock\):", timeout=2)
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' 4'  # Expected default
+        rb"Daily reboot time of the server \(24\-hour clock\):", timeout=2)
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == '4'  # noqa: E501
 
 
 def verify_ipv4_appserver_prompt(child):
-    child.expect(r'Local IPv4 address for the Application Server\:', timeout=2)
+    child.expect(rb'Local IPv4 address for the Application Server\:', timeout=2)  # noqa: E501
     # Expected default
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' 10.20.2.2'
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == '10.20.2.2'  # noqa: E501
 
 
 def verify_ipv4_monserver_prompt(child):
-    child.expect(r'Local IPv4 address for the Monitor Server\:', timeout=2)
+    child.expect(rb'Local IPv4 address for the Monitor Server\:', timeout=2)
     # Expected default
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' 10.20.3.2'
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == '10.20.3.2'  # noqa: E501
 
 
 def verify_hostname_app_prompt(child):
-    child.expect(r'Hostname for Application Server\:', timeout=2)
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' app'  # Expected default
+    child.expect(rb'Hostname for Application Server\:', timeout=2)
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'app'  # noqa: E501
 
 
 def verify_hostname_mon_prompt(child):
-    child.expect(r'Hostname for Monitor Server\:', timeout=2)
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' mon'  # Expected default
+    child.expect(rb'Hostname for Monitor Server\:', timeout=2)
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'mon'  # noqa: E501
 
 
 def verify_dns_prompt(child):
-    child.expect(r'DNS server specified during installation\:', timeout=2)
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' 8.8.8.8'  # Expected default
+    child.expect(rb'DNS server specified during installation\:', timeout=2)
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == '8.8.8.8'  # noqa: E501
 
 
 def verify_app_gpg_key_prompt(child):
-    child.expect('Local filepath to public key for SecureDrop Application GPG public key\:', timeout=2)  # noqa: E501
+    child.expect(rb'Local filepath to public key for SecureDrop Application GPG public key\:', timeout=2)  # noqa: E501
 
 
 def verify_https_prompt(child):
-    child.expect('Whether HTTPS should be enabled on Source Interface \(requires EV cert\)\:', timeout=2)  # noqa: E501
+    child.expect(rb'Whether HTTPS should be enabled on Source Interface \(requires EV cert\)\:', timeout=2)  # noqa: E501
 
 
 def verify_https_cert_prompt(child):
-    child.expect(r'Local filepath to HTTPS certificate\:', timeout=2)
+    child.expect(rb'Local filepath to HTTPS certificate\:', timeout=2)
 
 
 def verify_https_cert_key_prompt(child):
-    child.expect(r'Local filepath to HTTPS certificate key\:', timeout=2)
+    child.expect(rb'Local filepath to HTTPS certificate key\:', timeout=2)
 
 
 def verify_https_cert_chain_file_prompt(child):
-    child.expect(r'Local filepath to HTTPS certificate chain file\:', timeout=2)  # noqa: E501
+    child.expect(rb'Local filepath to HTTPS certificate chain file\:', timeout=2)  # noqa: E501
 
 
 def verify_app_gpg_fingerprint_prompt(child):
-    child.expect('Full fingerprint for the SecureDrop Application GPG Key\:', timeout=2)  # noqa: E501
+    child.expect(rb'Full fingerprint for the SecureDrop Application GPG Key\:', timeout=2)  # noqa: E501
 
 
 def verify_ossec_gpg_key_prompt(child):
-    child.expect('Local filepath to OSSEC alerts GPG public key\:', timeout=2)  # noqa: E501
+    child.expect(rb'Local filepath to OSSEC alerts GPG public key\:', timeout=2)  # noqa: E501
 
 
 def verify_ossec_gpg_fingerprint_prompt(child):
-    child.expect('Full fingerprint for the OSSEC alerts GPG public key\:', timeout=2)  # noqa: E501
+    child.expect(rb'Full fingerprint for the OSSEC alerts GPG public key\:', timeout=2)  # noqa: E501
 
 
 def verify_admin_email_prompt(child):
-    child.expect('Admin email address for receiving OSSEC alerts\:', timeout=2)  # noqa: E501
+    child.expect(rb'Admin email address for receiving OSSEC alerts\:', timeout=2)  # noqa: E501
 
 
 def verify_journalist_gpg_key_prompt(child):
-    child.expect('Local filepath to journalist alerts GPG public key \(optional\)\:', timeout=2)  # noqa: E501
+    child.expect(rb'Local filepath to journalist alerts GPG public key \(optional\)\:', timeout=2)  # noqa: E501
 
 
 def verify_journalist_fingerprint_prompt(child):
-    child.expect('Full fingerprint for the journalist alerts GPG public key \(optional\)\:', timeout=2)  # noqa: E501
+    child.expect(rb'Full fingerprint for the journalist alerts GPG public key \(optional\)\:', timeout=2)  # noqa: E501
 
 
 def verify_journalist_email_prompt(child):
-    child.expect('Email address for receiving journalist alerts \(optional\)\:', timeout=2)  # noqa: E501
+    child.expect(rb'Email address for receiving journalist alerts \(optional\)\:', timeout=2)  # noqa: E501
 
 
 def verify_smtp_relay_prompt(child):
-    child.expect(r'SMTP relay for sending OSSEC alerts\:', timeout=2)
+    child.expect(rb'SMTP relay for sending OSSEC alerts\:', timeout=2)
     # Expected default
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' smtp.gmail.com'
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'smtp.gmail.com'  # noqa: E501
 
 
 def verify_smtp_port_prompt(child):
-    child.expect(r'SMTP port for sending OSSEC alerts\:', timeout=2)
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' 587'  # Expected default
+    child.expect(rb'SMTP port for sending OSSEC alerts\:', timeout=2)
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == '587'  # noqa: E501
 
 
 def verify_sasl_domain_prompt(child):
-    child.expect(r'SASL domain for sending OSSEC alerts\:', timeout=2)
+    child.expect(rb'SASL domain for sending OSSEC alerts\:', timeout=2)
     # Expected default
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' gmail.com'
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'gmail.com'  # noqa: E501
 
 
 def verify_sasl_username_prompt(child):
-    child.expect(r'SASL username for sending OSSEC alerts\:', timeout=2)
+    child.expect(rb'SASL username for sending OSSEC alerts\:', timeout=2)
 
 
 def verify_sasl_password_prompt(child):
-    child.expect(r'SASL password for sending OSSEC alerts\:', timeout=2)
+    child.expect(rb'SASL password for sending OSSEC alerts\:', timeout=2)
 
 
 def verify_ssh_over_lan_prompt(child):
-    child.expect(r'will be available over LAN only\:', timeout=2)
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' yes'  # Expected default
+    child.expect(rb'will be available over LAN only\:', timeout=2)
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'yes'  # noqa: E501
 
 
 def verify_locales_prompt(child):
-    child.expect('Space separated list of additional locales to support')  # noqa: E501
+    child.expect(rb'Space separated list of additional locales to support')  # noqa: E501
 
 
 def verify_v2_onion_for_first_time(child):
-    child.expect(r' installed before 1.0.0\)\?\:', timeout=2)  # noqa: E501
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' no'  # Expected default
+    child.expect(rb' installed before 1.0.0\)\?\:', timeout=2)  # noqa: E501
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'no'  # noqa: E501
 
 
 def verify_v3_onion_for_first_time(child):
-    child.expect(r'Do you want to enable v3 onion services \(recommended\)\?\:', timeout=2)  # noqa: E501
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' yes'  # Expected default
+    child.expect(rb'Do you want to enable v3 onion services \(recommended\)\?\:', timeout=2)  # noqa: E501
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'yes'  # noqa: E501
 
 
 def verify_v3_onion_when_v2_is_enabled(child):
-    child.expect(r'Do you want to enable v3 onion services \(recommended\)\?\:', timeout=2)  # noqa: E501
-    assert ANSI_ESCAPE.sub('', child.buffer) == ' yes'  # Expected default
+    child.expect(rb'Do you want to enable v3 onion services \(recommended\)\?\:', timeout=2)  # noqa: E501
+    assert ANSI_ESCAPE.sub('', child.buffer.decode("utf-8")).strip() == 'yes'  # noqa: E501
 
 
 def test_sdconfig_on_first_run():
@@ -709,7 +709,7 @@ def set_reliable_keyserver(gpgdir):
             f.write('keyserver hkps://keys.openpgp.org')
 
         # Ensure correct permissions on .gnupg home directory.
-        os.chmod(gpgdir, 0o700)
+        os.chmod(gpgdir, 0o0700)
 
 
 @flaky(max_runs=3)
@@ -721,7 +721,7 @@ def test_check_for_update_when_updates_needed(securedrop_git_repo):
     fullcmd = 'coverage run {0} --root {1} check_for_updates'.format(
                 cmd, ansible_base)
     child = pexpect.spawn(fullcmd)
-    child.expect('Update needed', timeout=20)
+    child.expect(b'Update needed', timeout=20)
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
     child.close()
@@ -745,7 +745,7 @@ def test_check_for_update_when_updates_not_needed(securedrop_git_repo):
     fullcmd = 'coverage run {0} --root {1} check_for_updates'.format(
         cmd, ansible_base)
     child = pexpect.spawn(fullcmd)
-    child.expect('All updates applied', timeout=20)
+    child.expect(b'All updates applied', timeout=20)
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
     child.close()
@@ -766,8 +766,8 @@ def test_update(securedrop_git_repo):
         cmd, ansible_base))
 
     output = child.read()
-    assert 'Updated to SecureDrop' in output
-    assert 'Signature verification successful' in output
+    assert b'Updated to SecureDrop' in output
+    assert b'Signature verification successful' in output
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
     child.close()
@@ -796,8 +796,8 @@ def test_update_fails_when_no_signature_present(securedrop_git_repo):
     child = pexpect.spawn('coverage run {0} --root {1} update'.format(
                           cmd, ansible_base))
     output = child.read()
-    assert 'Updated to SecureDrop' not in output
-    assert 'Signature verification failed' in output
+    assert b'Updated to SecureDrop' not in output
+    assert b'Signature verification failed' in output
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
     child.close()
@@ -830,9 +830,9 @@ def test_update_with_duplicate_branch_and_tag(securedrop_git_repo):
                           cmd, ansible_base))
     output = child.read()
     # Verify that we do not falsely check out a branch instead of a tag.
-    assert 'Switched to branch' not in output
-    assert 'Updated to SecureDrop' not in output
-    assert 'Signature verification failed' in output
+    assert b'Switched to branch' not in output
+    assert b'Updated to SecureDrop' not in output
+    assert b'Signature verification failed' in output
 
     child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
     child.close()
diff --git a/admin/tests/test_securedrop-admin-setup.py b/admin/tests/test_securedrop-admin-setup.py
--- a/admin/tests/test_securedrop-admin-setup.py
+++ b/admin/tests/test_securedrop-admin-setup.py
@@ -43,7 +43,7 @@ def test_not_verbose(self, capsys):
     def test_run_command(self):
         for output_line in bootstrap.run_command(
                 ['/bin/echo', 'something']):
-            assert output_line.strip() == 'something'
+            assert output_line.strip() == b'something'
 
         lines = []
         with pytest.raises(subprocess.CalledProcessError):
@@ -51,8 +51,8 @@ def test_run_command(self):
                     ['sh', '-c',
                      'echo in stdout ; echo in stderr >&2 ; false']):
                 lines.append(output_line.strip())
-        assert lines[0] == 'in stdout'
-        assert lines[1] == 'in stderr'
+        assert lines[0] == b'in stdout'
+        assert lines[1] == b'in stderr'
 
     def test_install_pip_dependencies_up_to_date(self, caplog):
         args = argparse.Namespace()
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -26,7 +26,6 @@
 import mock
 from prompt_toolkit.validation import ValidationError
 import pytest
-import string
 import subprocess
 import textwrap
 import yaml
@@ -59,8 +58,8 @@ def test_not_verbose(self, capsys):
     def test_check_for_updates_update_needed(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
-        current_tag = "0.6"
-        tags_available = "0.6\n0.6-rc1\n0.6.1\n"
+        current_tag = b"0.6"
+        tags_available = b"0.6\n0.6-rc1\n0.6.1\n"
 
         with mock.patch('subprocess.check_call'):
             with mock.patch('subprocess.check_output',
@@ -73,8 +72,8 @@ def test_check_for_updates_update_needed(self, tmpdir, caplog):
     def test_check_for_updates_higher_version(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
-        current_tag = "0.6"
-        tags_available = "0.1\n0.10.0\n0.6.2\n0.6\n0.6-rc1\n0.9.0\n"
+        current_tag = b"0.6"
+        tags_available = b"0.1\n0.10.0\n0.6.2\n0.6\n0.6-rc1\n0.9.0\n"
 
         with mock.patch('subprocess.check_call'):
             with mock.patch('subprocess.check_output',
@@ -88,8 +87,8 @@ def test_check_for_updates_ensure_newline_stripped(self, tmpdir, caplog):
         """Regression test for #3426"""
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
-        current_tag = "0.6.1\n"
-        tags_available = "0.6\n0.6-rc1\n0.6.1\n"
+        current_tag = b"0.6.1\n"
+        tags_available = b"0.6\n0.6-rc1\n0.6.1\n"
 
         with mock.patch('subprocess.check_call'):
             with mock.patch('subprocess.check_output',
@@ -102,8 +101,8 @@ def test_check_for_updates_ensure_newline_stripped(self, tmpdir, caplog):
     def test_check_for_updates_update_not_needed(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
-        current_tag = "0.6.1"
-        tags_available = "0.6\n0.6-rc1\n0.6.1\n"
+        current_tag = b"0.6.1"
+        tags_available = b"0.6\n0.6-rc1\n0.6.1\n"
 
         with mock.patch('subprocess.check_call'):
             with mock.patch('subprocess.check_output',
@@ -118,8 +117,8 @@ def test_check_for_updates_if_most_recent_tag_is_rc(self, tmpdir, caplog):
         verify that users will not accidentally check out this tag."""
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
-        current_tag = "0.6.1"
-        tags_available = "0.6\n0.6-rc1\n0.6.1\n0.6.1-rc1\n"
+        current_tag = b"0.6.1"
+        tags_available = b"0.6\n0.6-rc1\n0.6.1\n0.6.1-rc1\n"
 
         with mock.patch('subprocess.check_call'):
             with mock.patch('subprocess.check_output',
@@ -152,31 +151,31 @@ def test_get_release_key_from_valid_keyserver(self, tmpdir, caplog):
                 args, keyserver='test.com')
 
     @pytest.mark.parametrize("git_output",
-                             ['gpg: Signature made Tue 13 Mar '
-                              '2018 01:14:11 AM UTC\n'
-                              'gpg:                using RSA key '
-                              '22245C81E3BAEB4138B36061310F561200F4AD77\n'
-                              'gpg: Good signature from "SecureDrop Release '
-                              'Signing Key" [unknown]\n',
-
-                              'gpg: Signature made Thu 20 Jul '
-                              '2017 08:12:25 PM EDT\n'
-                              'gpg:                using RSA key '
-                              '22245C81E3BAEB4138B36061310F561200F4AD77\n'
-                              'gpg: Good signature from "SecureDrop Release '
-                              'Signing Key '
-                              '<securedrop-release-key@freedom.press>"\n',
-
-                              'gpg: Signature made Thu 20 Jul '
-                              '2017 08:12:25 PM EDT\n'
-                              'gpg:                using RSA key '
-                              '22245C81E3BAEB4138B36061310F561200F4AD77\n'
-                              'gpg: Good signature from "SecureDrop Release '
-                              'Signing Key" [unknown]\n'
-                              'gpg:                 aka "SecureDrop Release '
-                              'Signing Key '
-                              '<securedrop-release-key@freedom.press>" '
-                              '[unknown]\n'])
+                             [b'gpg: Signature made Tue 13 Mar '
+                              b'2018 01:14:11 AM UTC\n'
+                              b'gpg:                using RSA key '
+                              b'22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                              b'gpg: Good signature from "SecureDrop Release '
+                              b'Signing Key" [unknown]\n',
+
+                              b'gpg: Signature made Thu 20 Jul '
+                              b'2017 08:12:25 PM EDT\n'
+                              b'gpg:                using RSA key '
+                              b'22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                              b'gpg: Good signature from "SecureDrop Release '
+                              b'Signing Key '
+                              b'<securedrop-release-key@freedom.press>"\n',
+
+                              b'gpg: Signature made Thu 20 Jul '
+                              b'2017 08:12:25 PM EDT\n'
+                              b'gpg:                using RSA key '
+                              b'22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                              b'gpg: Good signature from "SecureDrop Release '
+                              b'Signing Key" [unknown]\n'
+                              b'gpg:                 aka "SecureDrop Release '
+                              b'Signing Key '
+                              b'<securedrop-release-key@freedom.press>" '
+                              b'[unknown]\n'])
     def test_update_signature_verifies(self, tmpdir, caplog, git_output):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
@@ -188,7 +187,7 @@ def test_update_signature_verifies(self, tmpdir, caplog, git_output):
                        side_effect=[
                            git_output,
                            subprocess.CalledProcessError(1, 'cmd',
-                                                         'not a valid ref')]),
+                                                         b'not a valid ref')]),
             ]
 
         for patcher in patchers:
@@ -208,11 +207,11 @@ def test_update_unexpected_exception_git_refs(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
-                      'gpg:                using RSA key '
-                      '22245C81E3BAEB4138B36061310F561200F4AD77\n'
-                      'gpg: Good signature from "SecureDrop Release '
-                      'Signing Key" [unknown]\n')
+        git_output = (b'gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      b'gpg:                using RSA key '
+                      b'22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                      b'gpg: Good signature from "SecureDrop Release '
+                      b'Signing Key" [unknown]\n')
 
         patchers = [
             mock.patch('securedrop_admin.check_for_updates',
@@ -222,7 +221,7 @@ def test_update_unexpected_exception_git_refs(self, tmpdir, caplog):
                        side_effect=[
                            git_output,
                            subprocess.CalledProcessError(1, 'cmd',
-                                                         'a random error')]),
+                                                         b'a random error')]),
             ]
 
         for patcher in patchers:
@@ -242,11 +241,11 @@ def test_update_signature_does_not_verify(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
-                      'gpg:                using RSA key '
-                      '22245C81E3BAEB4138B36061310F561200F4AD77\n'
-                      'gpg: BAD signature from "SecureDrop Release '
-                      'Signing Key" [unknown]\n')
+        git_output = (b'gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      b'gpg:                using RSA key '
+                      b'22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                      b'gpg: BAD signature from "SecureDrop Release '
+                      b'Signing Key" [unknown]\n')
 
         with mock.patch('securedrop_admin.check_for_updates',
                         return_value=(True, "0.6.1")):
@@ -263,11 +262,11 @@ def test_update_malicious_key_named_fingerprint(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
-                      'gpg:                using RSA key '
-                      '1234567812345678123456781234567812345678\n'
-                      'gpg: Good signature from "22245C81E3BAEB4138'
-                      'B36061310F561200F4AD77" [unknown]\n')
+        git_output = (b'gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      b'gpg:                using RSA key '
+                      b'1234567812345678123456781234567812345678\n'
+                      b'gpg: Good signature from "22245C81E3BAEB4138'
+                      b'B36061310F561200F4AD77" [unknown]\n')
 
         with mock.patch('securedrop_admin.check_for_updates',
                         return_value=(True, "0.6.1")):
@@ -284,11 +283,11 @@ def test_update_malicious_key_named_good_sig(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
-                      'gpg:                using RSA key '
-                      '1234567812345678123456781234567812345678\n'
-                      'gpg: Good signature from Good signature from '
-                      '"SecureDrop Release Signing Key" [unknown]\n')
+        git_output = (b'gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      b'gpg:                using RSA key '
+                      b'1234567812345678123456781234567812345678\n'
+                      b'gpg: Good signature from Good signature from '
+                      b'"SecureDrop Release Signing Key" [unknown]\n')
 
         with mock.patch('securedrop_admin.check_for_updates',
                         return_value=(True, "0.6.1")):
@@ -306,12 +305,12 @@ def test_update_malicious_key_named_good_sig_fingerprint(self, tmpdir,
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
-                      'gpg:                using RSA key '
-                      '1234567812345678123456781234567812345678\n'
-                      'gpg: Good signature from 22245C81E3BAEB4138'
-                      'B36061310F561200F4AD77 Good signature from '
-                      '"SecureDrop Release Signing Key" [unknown]\n')
+        git_output = (b'gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      b'gpg:                using RSA key '
+                      b'1234567812345678123456781234567812345678\n'
+                      b'gpg: Good signature from 22245C81E3BAEB4138'
+                      b'B36061310F561200F4AD77 Good signature from '
+                      b'"SecureDrop Release Signing Key" [unknown]\n')
 
         with mock.patch('securedrop_admin.check_for_updates',
                         return_value=(True, "0.6.1")):
@@ -438,7 +437,7 @@ def test_validate_ossec_email(self):
         assert validator.validate(Document('good@mail.com'))
         with pytest.raises(ValidationError) as e:
             validator.validate(Document('ossec@ossec.test'))
-        assert 'something other than ossec@ossec.test' in e.value.message
+        assert 'something other than ossec@ossec.test' in str(e)
 
     def test_validate_optional_email(self):
         validator = securedrop_admin.SiteConfig.ValidateOptionalEmail()
@@ -555,22 +554,22 @@ def test_validate_fingerprint(self):
         with pytest.raises(ValidationError) as e:
             validator.validate(Document(
                 "65A1B5FF195B56353CC63DFFCC40EF1228271441"))
-        assert 'TEST journalist' in e.value.message
+        assert 'TEST journalist' in str(e)
 
         with pytest.raises(ValidationError) as e:
             validator.validate(Document(
                 "600BC6D5142C68F35DDBCEA87B597104EDDDC102"))
-        assert 'TEST admin' in e.value.message
+        assert 'TEST admin' in str(e)
 
         with pytest.raises(ValidationError) as e:
             validator.validate(Document(
                 "0000"))
-        assert '40 hexadecimal' in e.value.message
+        assert '40 hexadecimal' in str(e)
 
         with pytest.raises(ValidationError) as e:
             validator.validate(Document(
                 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"))
-        assert '40 hexadecimal' in e.value.message
+        assert '40 hexadecimal' in str(e)
 
     def test_validate_optional_fingerprint(self):
         validator = securedrop_admin.SiteConfig.ValidateOptionalFingerprint()
@@ -603,7 +602,7 @@ def test_validate_locales(self):
         assert validator.validate(Document('en_US  fr_FR '))
         with pytest.raises(ValidationError) as e:
             validator.validate(Document('BAD'))
-        assert 'BAD' in e.value.message
+        assert 'BAD' in str(e)
 
     def test_save(self, tmpdir):
         site_config_path = join(str(tmpdir), 'site_config')
@@ -654,7 +653,7 @@ def test_validate_gpg_key(self, caplog):
             site_config.config = bad_config
             with pytest.raises(securedrop_admin.FingerprintException) as e:
                 site_config.validate_gpg_keys()
-            assert 'FAIL does not match' in e.value.message
+            assert 'FAIL does not match' in str(e)
 
     def test_journalist_alert_email(self):
         args = argparse.Namespace(site_config='INVALID',
@@ -680,13 +679,13 @@ def test_journalist_alert_email(self):
         with pytest.raises(
                 securedrop_admin.JournalistAlertEmailException) as e:
             site_config.validate_journalist_alert_email()
-        assert 'not be empty' in e.value.message
+        assert 'not be empty' in str(e)
 
         site_config.config['journalist_alert_email'] = 'bademail'
         with pytest.raises(
                 securedrop_admin.JournalistAlertEmailException) as e:
             site_config.validate_journalist_alert_email()
-        assert 'Must contain a @' in e.value.message
+        assert 'Must contain a @' in str(e)
 
         site_config.config['journalist_alert_email'] = 'good@email.com'
         assert site_config.validate_journalist_alert_email()
@@ -952,7 +951,7 @@ def auto_prompt(prompt, default, **kwargs):
             assert value == site_config.validated_input(
                 '', value, lambda: True, None)
             assert value.lower() == site_config.validated_input(
-                '', value, lambda: True, string.lower)
+                '', value, lambda: True, str.lower)
             assert 'yes' == site_config.validated_input(
                 '', True, lambda: True, None)
             assert 'no' == site_config.validated_input(