Repository: awdeorio/mailmerge (language: python)

Issue:
Remove sh dependency
The `sh` module seems to be unmaintained (last commit was in 2017) and it is not being tested against the latest Python versions.

As of Python 3.5, much of the functionality in `sh` can be replicated with [`subprocess.run`](https://docs.python.org/3/library/subprocess.html#subprocess.run).

First, verify that a backport exists.

EDIT: The Click library's [testing](https://click.palletsprojects.com/en/8.0.x/testing/) functionality is a better option for many of the tests that currently use `sh`.  This [SO](https://stackoverflow.com/questions/26767104/testing-click-python-applications) post is helpful.


Discussion / hints:

Fix patch:
diff --git a/setup.py b/setup.py
index 3893b9a..5cc5d1f 100644
--- a/setup.py
+++ b/setup.py
@@ -42,7 +42,6 @@
             "pytest",
             "pytest-cov",
             "pytest-mock",
-            "sh",
         ],
     },
     python_requires='>=3.6',


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 44e578d..60c01cd 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -6,15 +6,12 @@
 pytest tmpdir docs:
 http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture
 """
+import copy
 import re
 from pathlib import Path
 import textwrap
-import sh
-import pytest
-
-
-# The sh library triggers lot of false no-member errors
-# pylint: disable=no-member
+import click.testing
+from mailmerge.__main__ import main
 
 
 def test_no_options(tmpdir):
@@ -23,70 +20,74 @@ def test_no_options(tmpdir):
     Run mailmerge at the CLI with no options.  Do this in an empty temporary
     directory to ensure that mailmerge doesn't find any default input files.
     """
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
-        sh.mailmerge()
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert 'Error: can\'t find template "mailmerge_template.txt"' in stderr
-    assert "https://github.com/awdeorio/mailmerge" in stderr
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, [])
+    assert result.exit_code == 1
+    assert 'Error: can\'t find template "mailmerge_template.txt"' in \
+        result.output
+    assert "https://github.com/awdeorio/mailmerge" in result.output
 
 
 def test_sample(tmpdir):
     """Verify --sample creates sample input files."""
+    runner = click.testing.CliRunner()
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--sample")
+        result = runner.invoke(main, ["--sample"])
+    assert not result.exception
+    assert result.exit_code == 0
     assert Path(tmpdir/"mailmerge_template.txt").exists()
     assert Path(tmpdir/"mailmerge_database.csv").exists()
     assert Path(tmpdir/"mailmerge_server.conf").exists()
-    assert output.stderr.decode("utf-8") == ""
-    assert "Created sample template" in output
-    assert "Created sample database" in output
-    assert "Created sample config" in output
+    assert "Created sample template" in result.output
+    assert "Created sample database" in result.output
+    assert "Created sample config" in result.output
 
 
 def test_sample_clobber_template(tmpdir):
     """Verify --sample won't clobber template if it already exists."""
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
         Path("mailmerge_template.txt").touch()
-        sh.mailmerge("--sample")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Error: file exists: mailmerge_template.txt" in stderr
+        result = runner.invoke(main, ["--sample"])
+    assert result.exit_code == 1
+    assert "Error: file exists: mailmerge_template.txt" in result.output
 
 
 def test_sample_clobber_database(tmpdir):
     """Verify --sample won't clobber database if it already exists."""
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
         Path("mailmerge_database.csv").touch()
-        sh.mailmerge("--sample")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Error: file exists: mailmerge_database.csv" in stderr
+        result = runner.invoke(main, ["--sample"])
+    assert result.exit_code == 1
+    assert "Error: file exists: mailmerge_database.csv" in result.output
 
 
 def test_sample_clobber_config(tmpdir):
     """Verify --sample won't clobber config if it already exists."""
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
         Path("mailmerge_server.conf").touch()
-        sh.mailmerge("--sample")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Error: file exists: mailmerge_server.conf" in stderr
+        result = runner.invoke(main, ["--sample"])
+    assert result.exit_code == 1
+    assert "Error: file exists: mailmerge_server.conf" in result.output
 
 
 def test_defaults(tmpdir):
     """When no options are provided, use default input file names."""
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--sample"])
+    assert not result.exception
+    assert result.exit_code == 0
     with tmpdir.as_cwd():
-        sh.mailmerge("--sample")
-        output = sh.mailmerge()
-    assert output.stderr.decode("utf-8") == ""
-    assert "message 1 sent" in output
-    assert "Limit was 1 message" in output
-    assert "This was a dry run" in output
+        result = runner.invoke(main, [])
+    assert not result.exception
+    assert result.exit_code == 0
+    assert "message 1 sent" in result.output
+    assert "Limit was 1 message" in result.output
+    assert "This was a dry run" in result.output
 
 
 def test_bad_limit(tmpdir):
@@ -117,10 +118,11 @@ def test_bad_limit(tmpdir):
     """))
 
     # Run mailmerge
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_2) as error:
-        sh.mailmerge("--dry-run", "--limit", "-1")
-    stderr = error.value.stderr.decode("utf-8")
-    assert "Error: Invalid value" in stderr
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--dry-run", "--limit", "-1"])
+    assert result.exit_code == 2
+    assert "Error: Invalid value" in result.output
 
 
 def test_limit_combo(tmpdir):
@@ -151,63 +153,64 @@ def test_limit_combo(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner()
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--no-limit", "--limit", "1")
-    assert output.stderr.decode("utf-8") == ""
-    assert "message 1 sent" in output
-    assert "message 2 sent" in output
-    assert "Limit was 1" not in output
+        result = runner.invoke(main, ["--no-limit", "--limit", "1"])
+    assert not result.exception
+    assert result.exit_code == 0
+    assert "message 1 sent" in result.output
+    assert "message 2 sent" in result.output
+    assert "Limit was 1" not in result.output
 
 
 def test_template_not_found(tmpdir):
     """Verify error when template input file not found."""
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
-        sh.mailmerge("--template", "notfound.txt")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Error: can't find template" in stderr
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--template", "notfound.txt"])
+    assert result.exit_code == 1
+    assert "Error: can't find template" in result.output
 
 
 def test_database_not_found(tmpdir):
     """Verify error when database input file not found."""
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
         Path("mailmerge_template.txt").touch()
-        sh.mailmerge("--database", "notfound.csv")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Error: can't find database" in stderr
+        result = runner.invoke(main, ["--database", "notfound.csv"])
+    assert result.exit_code == 1
+    assert "Error: can't find database" in result.output
 
 
 def test_config_not_found(tmpdir):
     """Verify error when config input file not found."""
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
         Path("mailmerge_template.txt").touch()
         Path("mailmerge_database.csv").touch()
-        sh.mailmerge("--config", "notfound.conf")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Error: can't find config" in stderr
+        result = runner.invoke(main, ["--config", "notfound.conf"])
+    assert result.exit_code == 1
+    assert "Error: can't find config" in result.output
 
 
 def test_help():
     """Verify -h or --help produces a help message."""
-    output = sh.mailmerge("--help")
-    assert output.stderr.decode("utf-8") == ""
-    assert "Usage:" in output
-    assert "Options:" in output
-    output2 = sh.mailmerge("-h")  # Short option is an alias
-    assert output2.stderr.decode("utf-8") == ""
-    assert output == output2
+    runner = click.testing.CliRunner()
+    result1 = runner.invoke(main, ["--help"])
+    assert result1.exit_code == 0
+    assert "Usage:" in result1.stdout
+    assert "Options:" in result1.stdout
+    result2 = runner.invoke(main, ["-h"])  # Short option is an alias
+    assert result1.stdout == result2.stdout
 
 
 def test_version():
     """Verify --version produces a version."""
-    output = sh.mailmerge("--version")
-    assert output.stderr.decode("utf-8") == ""
-    assert "mailmerge, version" in output
+    runner = click.testing.CliRunner()
+    result = runner.invoke(main, ["--version"])
+    assert not result.exception
+    assert result.exit_code == 0
+    assert "version" in result.output
 
 
 def test_bad_template(tmpdir):
@@ -238,14 +241,14 @@ def test_bad_template(tmpdir):
     """))
 
     # Run mailmerge, which should exit 1
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
-        sh.mailmerge()
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, [])
+    assert result.exit_code == 1
 
     # Verify output
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "template.txt: 'error_not_in_database' is undefined" in stderr
+    assert "template.txt: 'error_not_in_database' is undefined" in \
+        result.output
 
 
 def test_bad_database(tmpdir):
@@ -275,14 +278,13 @@ def test_bad_database(tmpdir):
     """))
 
     # Run mailmerge, which should exit 1
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
-        sh.mailmerge()
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, [])
+    assert result.exit_code == 1
 
     # Verify output
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "database.csv:1: unexpected end of data" in stderr
+    assert "database.csv:1: unexpected end of data" in result.output
 
 
 def test_bad_config(tmpdir):
@@ -309,14 +311,14 @@ def test_bad_config(tmpdir):
     """))
 
     # Run mailmerge, which should exit 1
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
-        sh.mailmerge()
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, [])
+    assert result.exit_code == 1
 
     # Verify output
-    assert stdout == ""
-    assert "server.conf: No option 'host' in section: 'smtp_server'" in stderr
+    assert "server.conf: No option 'host' in section: 'smtp_server'" in \
+        result.output
 
 
 def test_attachment(tmpdir):
@@ -356,15 +358,17 @@ def test_attachment(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner()
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--output-format", "text")
+        result = runner.invoke(main, ["--output-format", "text"])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Verify output
-    assert output.stderr.decode("utf-8") == ""
-    assert ">>> message part: text/plain" in output
-    assert "Hello world" in output  # message
-    assert ">>> message part: attachment attachment1.txt" in output
-    assert ">>> message part: attachment attachment2.txt" in output
+    assert ">>> message part: text/plain" in result.output
+    assert "Hello world" in result.output  # message
+    assert ">>> message part: attachment attachment1.txt" in result.output
+    assert ">>> message part: attachment attachment2.txt" in result.output
 
 
 def test_utf8_template(tmpdir):
@@ -394,20 +398,22 @@ def test_utf8_template(tmpdir):
     """))
 
     # Run mailmerge
-    output = sh.mailmerge(
+    runner = click.testing.CliRunner()
+    result = runner.invoke(main, [
         "--template", template_path,
         "--database", database_path,
         "--config", config_path,
         "--dry-run",
         "--output-format", "text",
-    )
+    ])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Remove the Date string, which will be different each time
-    stdout = output.stdout.decode("utf-8")
+    stdout = copy.deepcopy(result.output)
     stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
 
     # Verify output
-    assert output.stderr.decode("utf-8") == ""
     assert stdout == textwrap.dedent("""\
         >>> message 1
         TO: to@test.com
@@ -452,15 +458,17 @@ def test_utf8_database(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner()
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--output-format", "text")
+        result = runner.invoke(main, ["--output-format", "text"])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Remove the Date string, which will be different each time
-    stdout = output.stdout.decode("utf-8")
+    stdout = copy.deepcopy(result.output)
     stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
 
     # Verify output
-    assert output.stderr.decode("utf-8") == ""
     assert stdout == textwrap.dedent("""\
         >>> message 1
         TO: to@test.com
@@ -506,20 +514,23 @@ def test_utf8_headers(tmpdir):
     """))
 
     # Run mailmerge
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-        "--dry-run",
-        "--output-format", "raw",
-    )
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, [
+            "--template", template_path,
+            "--database", database_path,
+            "--config", config_path,
+            "--dry-run",
+            "--output-format", "raw",
+        ])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Remove the Date string, which will be different each time
-    stdout = output.stdout.decode("utf-8")
+    stdout = copy.deepcopy(result.output)
     stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
 
     # Verify output
-    assert output.stderr.decode("utf-8") == ""
     assert stdout == textwrap.dedent("""\
         >>> message 1
         TO: =?utf-8?b?TGHInWFtb24gPHRvQHRlc3QuY29tPg==?=
@@ -538,192 +549,6 @@ def test_utf8_headers(tmpdir):
     """)  # noqa: E501
 
 
-def test_complicated(tmpdir):
-    """Complicated end-to-end test.
-
-    Includes templating, TO, CC, BCC, UTF8 characters, emoji, attachments,
-    encoding mismatch (header is us-ascii, characters used are utf-8).  Also,
-    multipart message in plaintext and HTML.
-    """
-    # First attachment
-    attachment1_path = Path(tmpdir/"attachment1.txt")
-    attachment1_path.write_text("Hello world\n")
-
-    # Second attachment
-    attachment2_path = Path(tmpdir/"attachment2.csv")
-    attachment2_path.write_text("hello,mailmerge\n")
-
-    # Template with attachment header
-    template_path = Path(tmpdir/"mailmerge_template.txt")
-    template_path.write_text(textwrap.dedent("""\
-        TO: {{email}}
-        FROM: from@test.com
-        CC: cc1@test.com, cc2@test.com
-        BCC: bcc1@test.com, bcc2@test.com
-        ATTACHMENT: attachment1.txt
-        ATTACHMENT: attachment2.csv
-        MIME-Version: 1.0
-        Content-Type: multipart/alternative; boundary="boundary"
-
-        This is a MIME-encoded message. If you are seeing this, your mail
-        reader is old.
-
-        --boundary
-        Content-Type: text/plain; charset=us-ascii
-
-        {{message}}
-
-
-        --boundary
-        Content-Type: text/html; charset=us-ascii
-
-        <html>
-          <body>
-            <p>{{message}}</p>
-          </body>
-        </html>
-
-
-    """))
-
-    # Database with utf-8, emoji, quotes, and commas.  Note that quotes are
-    # escaped with double quotes, not backslash.
-    # https://docs.python.org/3.7/library/csv.html#csv.Dialect.doublequote
-    database_path = Path(tmpdir/"mailmerge_database.csv")
-    database_path.write_text(textwrap.dedent('''\
-        email,message
-        one@test.com,"Hello, ""world"""
-        Lazamon<two@test.com>,Laȝamon 😀 klâwen
-    '''))
-
-    # Simple unsecure server config
-    config_path = Path(tmpdir/"mailmerge_server.conf")
-    config_path.write_text(textwrap.dedent("""\
-        [smtp_server]
-        host = open-smtp.example.com
-        port = 25
-    """))
-
-    # Run mailmerge in tmpdir with defaults, which includes dry run
-    with tmpdir.as_cwd():
-        output = sh.mailmerge(
-            "--no-limit",
-            "--output-format", "raw",
-        )
-
-    # Decode output and remove date
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
-
-    # Remove the Date and Content-ID strings, which will be different each time
-    stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
-    stdout = re.sub(r'Content-Id:.*', '', stdout)
-
-    # Verify stdout and stderr after above corrections
-    assert stderr == ""
-    assert stdout == textwrap.dedent("""\
-        >>> message 1
-        TO: one@test.com
-        FROM: from@test.com
-        CC: cc1@test.com, cc2@test.com
-        MIME-Version: 1.0
-        Content-Type: multipart/alternative; boundary="boundary"
-        Date: REDACTED
-
-        This is a MIME-encoded message. If you are seeing this, your mail
-        reader is old.
-
-        --boundary
-        MIME-Version: 1.0
-        Content-Type: text/plain; charset="us-ascii"
-        Content-Transfer-Encoding: 7bit
-
-        Hello, "world"
-
-
-        --boundary
-        MIME-Version: 1.0
-        Content-Type: text/html; charset="us-ascii"
-        Content-Transfer-Encoding: 7bit
-
-        <html>
-          <body>
-            <p>Hello, "world"</p>
-          </body>
-        </html>
-
-        --boundary
-        Content-Type: application/octet-stream; Name="attachment1.txt"
-        MIME-Version: 1.0
-        Content-Transfer-Encoding: base64
-        Content-Disposition: attachment; filename="attachment1.txt"
-
-
-        SGVsbG8gd29ybGQK
-
-        --boundary
-        Content-Type: application/octet-stream; Name="attachment2.csv"
-        MIME-Version: 1.0
-        Content-Transfer-Encoding: base64
-        Content-Disposition: attachment; filename="attachment2.csv"
-
-
-        aGVsbG8sbWFpbG1lcmdlCg==
-
-        --boundary--
-
-        >>> message 1 sent
-        >>> message 2
-        TO: Lazamon<two@test.com>
-        FROM: from@test.com
-        CC: cc1@test.com, cc2@test.com
-        MIME-Version: 1.0
-        Content-Type: multipart/alternative; boundary="boundary"
-        Date: REDACTED
-
-        This is a MIME-encoded message. If you are seeing this, your mail
-        reader is old.
-
-        --boundary
-        MIME-Version: 1.0
-        Content-Type: text/plain; charset="utf-8"
-        Content-Transfer-Encoding: base64
-
-        TGHInWFtb24g8J+YgCBrbMOid2VuCgo=
-
-        --boundary
-        MIME-Version: 1.0
-        Content-Type: text/html; charset="utf-8"
-        Content-Transfer-Encoding: base64
-
-        PGh0bWw+CiAgPGJvZHk+CiAgICA8cD5MYcidYW1vbiDwn5iAIGtsw6J3ZW48L3A+CiAgPC9ib2R5
-        Pgo8L2h0bWw+Cg==
-
-        --boundary
-        Content-Type: application/octet-stream; Name="attachment1.txt"
-        MIME-Version: 1.0
-        Content-Transfer-Encoding: base64
-        Content-Disposition: attachment; filename="attachment1.txt"
-
-
-        SGVsbG8gd29ybGQK
-
-        --boundary
-        Content-Type: application/octet-stream; Name="attachment2.csv"
-        MIME-Version: 1.0
-        Content-Transfer-Encoding: base64
-        Content-Disposition: attachment; filename="attachment2.csv"
-
-
-        aGVsbG8sbWFpbG1lcmdlCg==
-
-        --boundary--
-
-        >>> message 2 sent
-        >>> This was a dry run.  To send messages, use the --no-dry-run option.
-    """)
-
-
 def test_resume(tmpdir):
     """Verify --resume option starts "in the middle" of the database."""
     # Simple template
@@ -752,17 +577,17 @@ def test_resume(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner()
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--resume", "2", "--no-limit")
+        result = runner.invoke(main, ["--resume", "2", "--no-limit"])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Verify only second message was sent
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
-    assert stderr == ""
-    assert "hello" not in stdout
-    assert "message 1 sent" not in stdout
-    assert "world" in stdout
-    assert "message 2 sent" in stdout
+    assert "hello" not in result.output
+    assert "message 1 sent" not in result.output
+    assert "world" in result.output
+    assert "message 2 sent" in result.output
 
 
 def test_resume_too_small(tmpdir):
@@ -793,20 +618,17 @@ def test_resume_too_small(tmpdir):
     """))
 
     # Run "mailmerge --resume 0" and check output
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_2) as error:
-        sh.mailmerge("--resume", "0")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Invalid value" in stderr
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--resume", "0"])
+    assert result.exit_code == 2
+    assert "Invalid value" in result.output
 
     # Run "mailmerge --resume -1" and check output
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_2) as error:
-        sh.mailmerge("--resume", "-1")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "Invalid value" in stderr
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--resume", "-1"])
+    assert result.exit_code == 2
+    assert "Invalid value" in result.output
 
 
 def test_resume_too_big(tmpdir):
@@ -837,12 +659,12 @@ def test_resume_too_big(tmpdir):
     """))
 
     # Run and check output
+    runner = click.testing.CliRunner()
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--resume", "3", "--no-limit")
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
-    assert "sent message" not in stdout
-    assert stderr == ""
+        result = runner.invoke(main, ["--resume", "3", "--no-limit"])
+    assert not result.exception
+    assert result.exit_code == 0
+    assert "sent message" not in result.output
 
 
 def test_resume_hint_on_config_error(tmpdir):
@@ -872,12 +694,11 @@ def test_resume_hint_on_config_error(tmpdir):
     """))
 
     # Run and check output
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
-        sh.mailmerge()
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "--resume 1" not in stderr
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, [])
+    assert result.exit_code == 1
+    assert "--resume 1" not in result.output
 
 
 def test_resume_hint_on_csv_error(tmpdir):
@@ -908,12 +729,11 @@ def test_resume_hint_on_csv_error(tmpdir):
     """))
 
     # Run and check output
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
-        sh.mailmerge("--resume", "2", "--no-limit")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
-    assert "--resume 2" in stderr
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--resume", "2", "--no-limit"])
+    assert result.exit_code == 1
+    assert "--resume 2" in result.output
 
 
 def test_other_mime_type(tmpdir):
@@ -953,13 +773,14 @@ def test_other_mime_type(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner()
     with tmpdir.as_cwd():
-        output = sh.mailmerge()
+        result = runner.invoke(main, [])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Verify output
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
-    assert stderr == ""
+    stdout = copy.deepcopy(result.output)
     stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
     assert stdout == textwrap.dedent("""\
         \x1b[7m\x1b[1m\x1b[36m>>> message 1\x1b(B\x1b[m
diff --git a/tests/test_main_output.py b/tests/test_main_output.py
index 151c857..caa0531 100644
--- a/tests/test_main_output.py
+++ b/tests/test_main_output.py
@@ -6,15 +6,13 @@
 pytest tmpdir docs:
 http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture
 """
+import copy
 import os
 import re
 import textwrap
 from pathlib import Path
-import sh
-import pytest
-
-# The sh library triggers lot of false no-member errors
-# pylint: disable=no-member
+import click.testing
+from mailmerge.__main__ import main
 
 
 def test_stdout(tmpdir):
@@ -48,21 +46,23 @@ def test_stdout(tmpdir):
     """))
 
     # Run mailmerge
-    output = sh.mailmerge(
+    runner = click.testing.CliRunner(mix_stderr=False)
+    result = runner.invoke(main, [
         "--template", template_path,
         "--database", database_path,
         "--config", config_path,
         "--no-limit",
         "--dry-run",
         "--output-format", "text",
-    )
+    ])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Verify mailmerge output.  We'll filter out the Date header because it
     # won't match exactly.
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
-    assert stderr == ""
-    assert "Date:" in stdout
+    assert result.stderr == ""
+    assert "Date:" in result.stdout
+    stdout = copy.deepcopy(result.stdout)
     stdout = re.sub(r"Date.*\n", "", stdout)
     assert stdout == textwrap.dedent("""\
         >>> message 1
@@ -122,14 +122,16 @@ def test_stdout_utf8(tmpdir):
     """))
 
     # Run mailmerge with defaults, which includes dry-run
+    runner = click.testing.CliRunner(mix_stderr=False)
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--output-format", "text")
+        result = runner.invoke(main, ["--output-format", "text"])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Verify mailmerge output.  We'll filter out the Date header because it
     # won't match exactly.
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
-    assert stderr == ""
+    assert result.stderr == ""
+    stdout = copy.deepcopy(result.stdout)
     assert "Date:" in stdout
     stdout = re.sub(r"Date.*\n", "", stdout)
     assert stdout == textwrap.dedent("""\
@@ -179,9 +181,9 @@ def test_stdout_utf8_redirect(tmpdir):
     """))
 
     # Run mailmerge.  We only care that no exceptions occur.  Note that we
-    # can't use the sh.mailmerge() here, even with its redirection utility,
-    # because it doesn't accurately recreate the conditions of the bug where
-    # the redirect destination lacks utf-8 encoding.
+    # can't use the click test runner here because it doesn't accurately
+    # recreate the conditions of the bug where the redirect destination lacks
+    # utf-8 encoding.
     with tmpdir.as_cwd():
         exit_code = os.system("mailmerge > mailmerge.out")
     assert exit_code == 0
@@ -213,25 +215,35 @@ def test_english(tmpdir):
     """))
 
     # Run mailmerge with several limits
+    runner = click.testing.CliRunner()
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--limit", "0"])
+    assert not result.exception
+    assert result.exit_code == 0
+    assert "Limit was 0 messages." in result.output
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--limit", "1"])
+    assert not result.exception
+    assert result.exit_code == 0
+    assert "Limit was 1 message." in result.output
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--limit", "0")
-        assert "Limit was 0 messages." in output
-        output = sh.mailmerge("--limit", "1")
-        assert "Limit was 1 message." in output
-        output = sh.mailmerge("--limit", "2")
-        assert "Limit was 2 messages." in output
+        result = runner.invoke(main, ["--limit", "2"])
+    assert not result.exception
+    assert result.exit_code == 0
+    assert "Limit was 2 messages." in result.output
 
 
 def test_output_format_bad(tmpdir):
     """Verify bad output format."""
-    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_2) as error:
-        sh.mailmerge("--output-format", "bad")
-    stdout = error.value.stdout.decode("utf-8")
-    stderr = error.value.stderr.decode("utf-8")
-    assert stdout == ""
+    runner = click.testing.CliRunner(mix_stderr=False)
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, ["--output-format", "bad"])
+    assert result.exit_code == 2
+    assert result.stdout == ""
 
     # Remove single and double quotes from error message.  Different versions
     # of the click library use different formats.
+    stderr = copy.deepcopy(result.stderr)
     stderr = stderr.replace('"', "")
     stderr = stderr.replace("'", "")
     assert 'Invalid value for --output-format' in stderr
@@ -268,16 +280,18 @@ def test_output_format_raw(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner(mix_stderr=False)
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--output-format", "raw")
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        result = runner.invoke(main, ["--output-format", "raw"])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Remove the Date string, which will be different each time
+    stdout = copy.deepcopy(result.stdout)
     stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
 
     # Verify output
-    assert stderr == ""
+    assert result.stderr == ""
     assert stdout == textwrap.dedent("""\
         >>> message 1
         TO: to@test.com
@@ -326,16 +340,18 @@ def test_output_format_text(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner(mix_stderr=False)
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--output-format", "text")
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        result = runner.invoke(main, ["--output-format", "text"])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Remove the Date string, which will be different each time
+    stdout = copy.deepcopy(result.stdout)
     stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
 
     # Verify output
-    assert stderr == ""
+    assert result.stderr == ""
     assert stdout == textwrap.dedent("""\
         >>> message 1
         TO: to@test.com
@@ -401,16 +417,18 @@ def test_output_format_colorized(tmpdir):
     """))
 
     # Run mailmerge
+    runner = click.testing.CliRunner(mix_stderr=False)
     with tmpdir.as_cwd():
-        output = sh.mailmerge("--output-format", "colorized")
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        result = runner.invoke(main, ["--output-format", "colorized"])
+    assert not result.exception
+    assert result.exit_code == 0
 
     # Remove the Date string, which will be different each time
+    stdout = copy.deepcopy(result.stdout)
     stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
 
     # Verify output.  The funny looking character sequences are colors.
-    assert stderr == ""
+    assert result.stderr == ""
     assert stdout == textwrap.dedent("""\
         \x1b[7m\x1b[1m\x1b[36m>>> message 1\x1b(B\x1b[m
         TO: to@test.com
@@ -434,3 +452,189 @@ def test_output_format_colorized(tmpdir):
         >>> Limit was 1 message.  To remove the limit, use the --no-limit option.
         >>> This was a dry run.  To send messages, use the --no-dry-run option.
     """)  # noqa: E501
+
+
+def test_complicated(tmpdir):
+    """Complicated end-to-end test.
+
+    Includes templating, TO, CC, BCC, UTF8 characters, emoji, attachments,
+    encoding mismatch (header is us-ascii, characters used are utf-8).  Also,
+    multipart message in plaintext and HTML.
+    """
+    # First attachment
+    attachment1_path = Path(tmpdir/"attachment1.txt")
+    attachment1_path.write_text(u"Hello world\n")
+
+    # Second attachment
+    attachment2_path = Path(tmpdir/"attachment2.csv")
+    attachment2_path.write_text(u"hello,mailmerge\n")
+
+    # Template with attachment header
+    template_path = Path(tmpdir/"mailmerge_template.txt")
+    template_path.write_text(textwrap.dedent(u"""\
+        TO: {{email}}
+        FROM: from@test.com
+        CC: cc1@test.com, cc2@test.com
+        BCC: bcc1@test.com, bcc2@test.com
+        ATTACHMENT: attachment1.txt
+        ATTACHMENT: attachment2.csv
+        MIME-Version: 1.0
+        Content-Type: multipart/alternative; boundary="boundary"
+
+        This is a MIME-encoded message. If you are seeing this, your mail
+        reader is old.
+
+        --boundary
+        Content-Type: text/plain; charset=us-ascii
+
+        {{message}}
+
+
+        --boundary
+        Content-Type: text/html; charset=us-ascii
+
+        <html>
+          <body>
+            <p>{{message}}</p>
+          </body>
+        </html>
+
+
+    """))
+
+    # Database with utf-8, emoji, quotes, and commas.  Note that quotes are
+    # escaped with double quotes, not backslash.
+    # https://docs.python.org/3.7/library/csv.html#csv.Dialect.doublequote
+    database_path = Path(tmpdir/"mailmerge_database.csv")
+    database_path.write_text(textwrap.dedent(u'''\
+        email,message
+        one@test.com,"Hello, ""world"""
+        Lazamon<two@test.com>,Laȝamon 😀 klâwen
+    '''))
+
+    # Simple unsecure server config
+    config_path = Path(tmpdir/"mailmerge_server.conf")
+    config_path.write_text(textwrap.dedent(u"""\
+        [smtp_server]
+        host = open-smtp.example.com
+        port = 25
+    """))
+
+    # Run mailmerge in tmpdir with defaults, which includes dry run
+    runner = click.testing.CliRunner(mix_stderr=False)
+    with tmpdir.as_cwd():
+        result = runner.invoke(main, [
+            "--no-limit",
+            "--output-format", "raw",
+        ])
+    assert not result.exception
+    assert result.exit_code == 0
+
+    # Remove the Date and Content-ID strings, which will be different each time
+    stdout = copy.deepcopy(result.stdout)
+    stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE)
+    stdout = re.sub(r'Content-Id:.*', '', stdout)
+
+    # Verify stdout and stderr after above corrections
+    assert result.stderr == ""
+    assert stdout == textwrap.dedent(u"""\
+        >>> message 1
+        TO: one@test.com
+        FROM: from@test.com
+        CC: cc1@test.com, cc2@test.com
+        MIME-Version: 1.0
+        Content-Type: multipart/alternative; boundary="boundary"
+        Date: REDACTED
+
+        This is a MIME-encoded message. If you are seeing this, your mail
+        reader is old.
+
+        --boundary
+        MIME-Version: 1.0
+        Content-Type: text/plain; charset="us-ascii"
+        Content-Transfer-Encoding: 7bit
+
+        Hello, "world"
+
+
+        --boundary
+        MIME-Version: 1.0
+        Content-Type: text/html; charset="us-ascii"
+        Content-Transfer-Encoding: 7bit
+
+        <html>
+          <body>
+            <p>Hello, "world"</p>
+          </body>
+        </html>
+
+        --boundary
+        Content-Type: application/octet-stream; Name="attachment1.txt"
+        MIME-Version: 1.0
+        Content-Transfer-Encoding: base64
+        Content-Disposition: attachment; filename="attachment1.txt"
+
+
+        SGVsbG8gd29ybGQK
+
+        --boundary
+        Content-Type: application/octet-stream; Name="attachment2.csv"
+        MIME-Version: 1.0
+        Content-Transfer-Encoding: base64
+        Content-Disposition: attachment; filename="attachment2.csv"
+
+
+        aGVsbG8sbWFpbG1lcmdlCg==
+
+        --boundary--
+
+        >>> message 1 sent
+        >>> message 2
+        TO: Lazamon<two@test.com>
+        FROM: from@test.com
+        CC: cc1@test.com, cc2@test.com
+        MIME-Version: 1.0
+        Content-Type: multipart/alternative; boundary="boundary"
+        Date: REDACTED
+
+        This is a MIME-encoded message. If you are seeing this, your mail
+        reader is old.
+
+        --boundary
+        MIME-Version: 1.0
+        Content-Type: text/plain; charset="utf-8"
+        Content-Transfer-Encoding: base64
+
+        TGHInWFtb24g8J+YgCBrbMOid2VuCgo=
+
+        --boundary
+        MIME-Version: 1.0
+        Content-Type: text/html; charset="utf-8"
+        Content-Transfer-Encoding: base64
+
+        PGh0bWw+CiAgPGJvZHk+CiAgICA8cD5MYcidYW1vbiDwn5iAIGtsw6J3ZW48L3A+CiAgPC9ib2R5
+        Pgo8L2h0bWw+Cg==
+
+        --boundary
+        Content-Type: application/octet-stream; Name="attachment1.txt"
+        MIME-Version: 1.0
+        Content-Transfer-Encoding: base64
+        Content-Disposition: attachment; filename="attachment1.txt"
+
+
+        SGVsbG8gd29ybGQK
+
+        --boundary
+        Content-Type: application/octet-stream; Name="attachment2.csv"
+        MIME-Version: 1.0
+        Content-Transfer-Encoding: base64
+        Content-Disposition: attachment; filename="attachment2.csv"
+
+
+        aGVsbG8sbWFpbG1lcmdlCg==
+
+        --boundary--
+
+        >>> message 2 sent
+        >>> This was a dry run.  To send messages, use the --no-dry-run option.
+    """)
diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py
index 06ebc9d..0a5dae1 100644
--- a/tests/test_ratelimit.py
+++ b/tests/test_ratelimit.py
@@ -10,16 +10,11 @@
 import email.parser
 import freezegun
 import pytest
-import click
 import click.testing
 from mailmerge import SendmailClient, MailmergeRateLimitError
 from mailmerge.__main__ import main
 
 
-# The sh library triggers lot of false no-member errors
-# pylint: disable=no-member
-
-
 def test_sendmail_ratelimit(mocker, tmp_path):
     """Verify SMTP library calls."""
     config_path = tmp_path/"server.conf"
@@ -124,7 +119,7 @@ def test_stdout_ratelimit(mocker, tmpdir):
     smtp = mock_smtp.return_value.__enter__.return_value
     assert smtp.sendmail.call_count == 2
     assert result.exit_code == 0
-    # assert result.stderr == ""  # replace when we drop Python 3.4 support
+    assert result.stderr == ""
     assert ">>> message 1 sent" in result.stdout
     assert ">>> rate limit exceeded, waiting ..." in result.stdout
     assert ">>> message 2 sent" in result.stdout