diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..551dad9 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,60 @@ +# GitHub Continuous Integration Configuration +name: CI + +# Define conditions for when to run this action +on: + pull_request: # Run on all pull requests + push: # Run on all pushes to master + branches: + - main + - develop + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs. Each job has an id, for +# example, one of our jobs is "lint" +jobs: + test: + name: Tests ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + # Define OS and Python versions to use. 3.x is the latest minor version. + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.x"] + os: [ubuntu-latest] + + # Sequence of tasks for this job + steps: + # Check out latest code + # Docs: https://github.com/actions/checkout + - name: Checkout code + uses: actions/checkout@v2 + + # Set up Python + # Docs: https://github.com/actions/setup-python + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + # Install dependencies + # https://github.com/ymyzk/tox-gh-actions#workflow-configuration + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install coverage tox tox-gh-actions + + # Run tests + # https://github.com/ymyzk/tox-gh-actions#workflow-configuration + - name: Run tests + run: tox + - name: Combine coverage + run: coverage xml + + # Upload coverage report + # https://github.com/codecov/codecov-action + - name: Upload coverage report + uses: codecov/codecov-action@v1 + with: + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 2ebcb05..43f35e2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ build/ /tmp/ /.coverage* *,cover +coverage.xml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b345a05..0000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Travis Configuration -# -# Travis runs the tests configured in tox.ini using tox-travis. Tox is a -# automation tool for running tests in isolated virtual environments. -# https://github.com/tox-dev/tox-travis -# -# Code coverage reporting -# https://github.com/codecov/example-python - -language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "3.9" - -install: pip install --upgrade tox-travis codecov - -script: tox - -after_script: - - pip install --upgrade codecov - - codecov - -branches: - only: - - master - - develop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81f316b..8097d6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,14 +15,6 @@ $ which mailmerge /Users/awdeorio/src/mailmerge/env/bin/mailmerge ``` -### Python2 development environment -Mailmerge is tested to work in both Python 2 and Python 3. Set up a Python 2 virtual environment. -```console -$ virtualenv -p python2 env2 -$ source env2/bin/activate -$ pip install -e .[dev,test] -``` - ## Testing and code quality Run unit tests ```console @@ -42,8 +34,7 @@ $ pylint mailmerge tests setup.py $ check-manifest ``` -Test Python 2 and Python 3 compatibility. This will automatically create virtual environments and run all style and functional tests in each environment. Use `pyenv` to provide different versions of Python. +Run linters and tests in a clean environment. This will automatically create a temporary virtual environment. ```console -$ eval "$(pyenv init -)" $ tox ``` diff --git a/README.md b/README.md index 2104f89..470ed17 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ Mailmerge ========= -[![PyPI](https://img.shields.io/pypi/v/mailmerge.svg)](https://pypi.org/project/mailmerge/) -[![Build Status](https://travis-ci.com/awdeorio/mailmerge.svg?branch=develop)](https://travis-ci.com/awdeorio/mailmerge) +[![CI main](https://github.com/awdeorio/mailmerge/workflows/CI/badge.svg?branch=develop)](https://github.com/awdeorio/mailmerge/actions?query=branch%3Adevelop) [![codecov](https://codecov.io/gh/awdeorio/mailmerge/branch/develop/graph/badge.svg)](https://codecov.io/gh/awdeorio/mailmerge) +[![PyPI](https://img.shields.io/pypi/v/mailmerge.svg)](https://pypi.org/project/mailmerge/) A simple, command line mail merge tool. diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py index d9648db..a364548 100644 --- a/mailmerge/__main__.py +++ b/mailmerge/__main__.py @@ -3,33 +3,15 @@ Andrew DeOrio """ -from __future__ import print_function import sys import time -import codecs import textwrap +from pathlib import Path +import csv import click from .template_message import TemplateMessage from .sendmail_client import SendmailClient from . import exceptions -from . import utils - -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path - -# Python 2 UTF8 support requires csv backport -try: - from backports import csv -except ImportError: - import csv - -# Python 2 UTF8 file redirection -# http://www.macfreek.nl/memory/Encoding_of_Python_stdout -if sys.stdout.encoding != 'UTF-8' and not hasattr(sys.stdout, "buffer"): - sys.stdout = codecs.getwriter('utf-8')(sys.stdout, 'strict') @click.command(context_settings={"help_option_names": ['-h', '--help']}) @@ -128,37 +110,28 @@ def main(sample, dry_run, limit, no_limit, resume, break time.sleep(1) print_bright_white_on_cyan( - ">>> message {message_num}" - .format(message_num=message_num), + f">>> message {message_num}", output_format, ) print_message(message, output_format) print_bright_white_on_cyan( - ">>> message {message_num} sent" - .format(message_num=message_num), + f">>> message {message_num} sent", output_format, ) message_num += 1 except exceptions.MailmergeError as error: - hint_text = '\nHint: "--resume {}"'.format(message_num) - sys.exit( - "Error on message {message_num}\n" - "{error}" - "{hint}" - .format( - message_num=message_num, - error=error, - hint=(hint_text if message_num > 1 else ""), - ) - ) + hint_text = "" + if message_num > 1: + hint_text = f'\nHint: "--resume {message_num}"' + sys.exit(f"Error on message {message_num}\n{error}{hint_text}") # Hints for user if not no_limit: + pluralizer = "" if limit == 1 else "s" print( - ">>> Limit was {limit} message{pluralizer}. " + f">>> Limit was {limit} message{pluralizer}. " "To remove the limit, use the --no-limit option." - .format(limit=limit, pluralizer=("" if limit == 1 else "s")) ) if dry_run: print( @@ -180,40 +153,40 @@ def check_input_files(template_path, database_path, config_path, sample): sys.exit(0) if not template_path.exists(): - sys.exit(textwrap.dedent(u"""\ + sys.exit(textwrap.dedent(f"""\ Error: can't find template "{template_path}". Create a sample (--sample) or specify a file (--template). See https://github.com/awdeorio/mailmerge for examples.\ - """.format(template_path=template_path))) + """)) if not database_path.exists(): - sys.exit(textwrap.dedent(u"""\ + sys.exit(textwrap.dedent(f"""\ Error: can't find database "{database_path}". Create a sample (--sample) or specify a file (--database). See https://github.com/awdeorio/mailmerge for examples.\ - """.format(database_path=database_path))) + """)) if not config_path.exists(): - sys.exit(textwrap.dedent(u"""\ + sys.exit(textwrap.dedent(f"""\ Error: can't find config "{config_path}". Create a sample (--sample) or specify a file (--config). See https://github.com/awdeorio/mailmerge for examples.\ - """.format(config_path=config_path))) + """)) def create_sample_input_files(template_path, database_path, config_path): """Create sample template, database and server config.""" for path in [template_path, database_path, config_path]: if path.exists(): - sys.exit("Error: file exists: {}".format(path)) + sys.exit(f"Error: file exists: {path}") with template_path.open("w") as template_file: - template_file.write(textwrap.dedent(u"""\ + template_file.write(textwrap.dedent("""\ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self @@ -223,13 +196,13 @@ def create_sample_input_files(template_path, database_path, config_path): Your number is {{number}}. """)) with database_path.open("w") as database_file: - database_file.write(textwrap.dedent(u"""\ + database_file.write(textwrap.dedent("""\ email,name,number myself@mydomain.com,"Myself",17 bob@bobdomain.com,"Bob",42 """)) with config_path.open("w") as config_file: - config_file.write(textwrap.dedent(u"""\ + config_file.write(textwrap.dedent("""\ # Mailmerge SMTP Server Config # https://github.com/awdeorio/mailmerge # @@ -273,17 +246,13 @@ def create_sample_input_files(template_path, database_path, config_path): # port = 25 # ratelimit = 0 """)) - print(textwrap.dedent(u"""\ + print(textwrap.dedent(f"""\ Created sample template email "{template_path}" Created sample database "{database_path}" Created sample config file "{config_path}" Edit these files, then run mailmerge again.\ - """.format( - template_path=template_path, - database_path=database_path, - config_path=config_path, - ))) + """)) def read_csv_database(database_path): @@ -292,20 +261,25 @@ def read_csv_database(database_path): We'll use a class to modify the csv library's default dialect ('excel') to enable strict syntax checking. This will trigger errors for things like unclosed quotes. + + We open the file with the utf-8-sig encoding, which skips a byte order mark + (BOM), if any. Sometimes Excel will save CSV files with a BOM. See Issue + #93 https://github.com/awdeorio/mailmerge/issues/93 + """ class StrictExcel(csv.excel): # Our helper class is really simple # pylint: disable=too-few-public-methods, missing-class-docstring strict = True - with database_path.open(mode="r", encoding="utf-8") as database_file: + with database_path.open(encoding="utf-8-sig") as database_file: reader = csv.DictReader(database_file, dialect=StrictExcel) try: for row in reader: yield row except csv.Error as err: raise exceptions.MailmergeError( - "{}:{}: {}".format(database_path, reader.line_num, err) + f"{database_path}:{reader.line_num}: {err}" ) @@ -343,11 +317,11 @@ def print_message(message, output_format): assert output_format in ["colorized", "text", "raw"] if output_format == "raw": - print(utils.flatten_message(message)) + print(message) return for header, value in message.items(): - print(u"{header}: {value}".format(header=header, value=value)) + print(f"{header}: {value}") print() for part in message.walk(): if part.get_content_maintype() == "multipart": @@ -356,8 +330,7 @@ def print_message(message, output_format): if message.is_multipart(): # Only print message part dividers for multipart messages print_cyan( - ">>> message part: {content_type}" - .format(content_type=part.get_content_type()), + f">>> message part: {part.get_content_type()}", output_format, ) charset = str(part.get_charset()) @@ -365,14 +338,12 @@ def print_message(message, output_format): print() elif is_attachment(part): print_cyan( - ">>> message part: attachment {filename}" - .format(filename=part.get_filename()), + f">>> message part: attachment {part.get_filename()}", output_format, ) else: print_cyan( - ">>> message part: {content_type}" - .format(content_type=part.get_content_type()), + f">>> message part: {part.get_content_type()}", output_format, ) diff --git a/mailmerge/sendmail_client.py b/mailmerge/sendmail_client.py index 129f394..0ec5205 100644 --- a/mailmerge/sendmail_client.py +++ b/mailmerge/sendmail_client.py @@ -10,7 +10,6 @@ import getpass import datetime from . import exceptions -from . import utils # Type to store info read from config file @@ -20,13 +19,9 @@ ) -class SendmailClient(object): +class SendmailClient: """Represent a client connection to an SMTP server.""" - # We need to inherit from object for Python 2 compantibility - # https://python-future.org/compatible_idioms.html#custom-class-behaviour - # pylint: disable=bad-option-value,useless-object-inheritance - def __init__(self, config_path, dry_run=False): """Read configuration from server configuration file.""" self.config_path = config_path @@ -47,9 +42,7 @@ def read_config(self): username = parser.get("smtp_server", "username", fallback=None) ratelimit = parser.getint("smtp_server", "ratelimit", fallback=0) except (configparser.Error, ValueError) as err: - raise exceptions.MailmergeError( - "{}: {}".format(self.config_path, err) - ) + raise exceptions.MailmergeError(f"{self.config_path}: {err}") # Coerce legacy option "security = Never" if security == "Never": @@ -58,15 +51,14 @@ def read_config(self): # Verify security type if security not in [None, "SSL/TLS", "STARTTLS"]: raise exceptions.MailmergeError( - "{}: unrecognized security type: '{}'" - .format(self.config_path, security) + f"{self.config_path}: unrecognized security type: '{security}'" ) # Verify username if security is not None and username is None: raise exceptions.MailmergeError( - "{}: username is required for security type '{}'" - .format(self.config_path, security) + f"{self.config_path}: username is required for " + f"security type '{security}'" ) # Save validated configuration @@ -75,12 +67,7 @@ def read_config(self): ) def sendmail(self, sender, recipients, message): - """Send email message. - - Note that we can't use the elegant smtp.send_message(message)" because - Python 2 doesn't support it. Both Python 2 and Python 3 support - smtp.sendmail(sender, recipients, flattened_message_str). - """ + """Send email message.""" if self.dry_run: return @@ -93,13 +80,14 @@ def sendmail(self, sender, recipients, message): # Ask for password if necessary if self.config.security is not None and self.password is None: - prompt = ">>> password for {} on {}: ".format( - self.config.username, self.config.host) - self.password = getpass.getpass(prompt) + self.password = getpass.getpass( + f">>> password for {self.config.username} on " + f"{self.config.host}: " + ) # Send try: - message_flattened = utils.flatten_message(message) + message_flattened = str(message) host, port = self.config.host, self.config.port if self.config.security == "SSL/TLS": with smtplib.SMTP_SSL(host, port) as smtp: @@ -117,18 +105,16 @@ def sendmail(self, sender, recipients, message): smtp.sendmail(sender, recipients, message_flattened) except smtplib.SMTPAuthenticationError as err: raise exceptions.MailmergeError( - "{}:{} failed to authenticate user '{}': {}" - .format(host, port, self.config.username, err) + f"{host}:{port} failed to authenticate " + f"user '{self.config.username}': {err}" ) except smtplib.SMTPException as err: raise exceptions.MailmergeError( - "{}:{} failed to send message: {}" - .format(host, port, err) + f"{host}:{port} failed to send message: {err}" ) except socket.error as err: raise exceptions.MailmergeError( - "{}:{} failed to connect to server: {}" - .format(host, port, err) + f"{host}:{port} failed to connect to server: {err}" ) # Update timestamp of last sent message diff --git a/mailmerge/template_message.py b/mailmerge/template_message.py index 3bfa5e5..936041c 100644 --- a/mailmerge/template_message.py +++ b/mailmerge/template_message.py @@ -1,5 +1,3 @@ -# coding=utf-8 -# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/ """ Represent a templated email message. @@ -7,28 +5,20 @@ """ import re +from pathlib import Path from xml.etree import ElementTree -import future.backports.email as email -import future.backports.email.mime -import future.backports.email.mime.application -import future.backports.email.mime.multipart -import future.backports.email.mime.text -import future.backports.email.parser -import future.backports.email.utils -import future.backports.email.generator +import email +import email.mime +import email.mime.application +import email.mime.multipart +import email.mime.text import html5lib import markdown import jinja2 from . import exceptions -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path - -class TemplateMessage(object): +class TemplateMessage: """Represent a templated email message. This object combines an email.message object with the template abilities of @@ -38,10 +28,6 @@ class TemplateMessage(object): # The external interface to this class is pretty simple. We don't need # more than one public method. # pylint: disable=too-few-public-methods - # - # We need to inherit from object for Python 2 compantibility - # https://python-future.org/compatible_idioms.html#custom-class-behaviour - # pylint: disable=bad-option-value,useless-object-inheritance def __init__(self, template_path): """Initialize variables and Jinja2 template.""" @@ -52,11 +38,8 @@ def __init__(self, template_path): self._attachment_content_ids = {} # Configure Jinja2 template engine with the template dirname as root. - # - # Note: jinja2's FileSystemLoader does not support pathlib Path objects - # in Python 2. https://github.com/pallets/jinja/pull/1064 template_env = jinja2.Environment( - loader=jinja2.FileSystemLoader(str(template_path.parent)), + loader=jinja2.FileSystemLoader(template_path.parent), undefined=jinja2.StrictUndefined, ) self.template = template_env.get_template( @@ -68,9 +51,7 @@ def render(self, context): try: raw_message = self.template.render(context) except jinja2.exceptions.TemplateError as err: - raise exceptions.MailmergeError( - "{}: {}".format(self.template_path, err) - ) + raise exceptions.MailmergeError(f"{self.template_path}: {err}") self._message = email.message_from_string(raw_message) self._transform_encoding(raw_message) self._transform_recipients() @@ -187,13 +168,10 @@ def _transform_markdown(self): # Render Markdown to HTML and add the HTML as the last part of the # multipart/alternative message as per RFC 2046. # - # Note: We need to use u"..." to ensure that unicode string - # substitution works properly in Python 2. - # # https://docs.python.org/3/library/email.mime.html#email.mime.text.MIMEText html = markdown.markdown(text, extensions=['nl2br']) - html_payload = future.backports.email.mime.text.MIMEText( - u"{}".format(html), + html_payload = email.mime.text.MIMEText( + f"{html}", _subtype="html", _charset=encoding, ) @@ -248,7 +226,7 @@ def _transform_attachments(self): ) part.add_header( 'Content-Disposition', - 'attachment; filename="{}"'.format(basename), + f'attachment; filename="{basename}"' ) # When processing inline images in the email body, we will @@ -294,7 +272,7 @@ def _transform_attachment_references(self): if src in self._attachment_content_ids: cid = self._attachment_content_ids[src] - url = "cid:{}".format(cid) + url = f"cid:{cid}" img.set('src', url) # Only clear the header if we are transforming an # attachment reference. See comment below for context. @@ -336,9 +314,7 @@ def _resolve_attachment_path(self, path): # Check that the attachment exists if not path.exists(): - raise exceptions.MailmergeError( - "Attachment not found: {}".format(path) - ) + raise exceptions.MailmergeError(f"Attachment not found: {path}") return path diff --git a/mailmerge/utils.py b/mailmerge/utils.py deleted file mode 100644 index 344a5a8..0000000 --- a/mailmerge/utils.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Utility functions used by multiple mailmerge modules. - -Andrew DeOrio -""" -import io -import base64 -import future.backports.email as email -import future.backports.email.base64mime -import future.backports.email.generator # pylint: disable=unused-import -import future.builtins - - -def flatten_message(message): - """Return message as string. - - We can't use Python 3's message.__str__() because it doesn't work on Python - 2. We can't use message.as_string() because it errors on UTF-8 headers. - - Based on Python 2 documentation - https://docs.python.org/2/library/email.message.html - - """ - stream = io.StringIO() - generator = email.generator.Generator( - stream, - mangle_from_=False, - maxheaderlen=78, - policy=message.policy.clone(cte_type=u"7bit"), - ) - generator.flatten(message) - text = stream.getvalue() - return text - - -# Monkey patch the future.backports.email library to avoid a bug that shows up -# when flattening an email header containing base64-encoded UTF8 characters. -# This error only shows up on Python 2. -# -# We'll patch one function from future/backports/email/base64mime.py . One -# line is modified: -# if isinstance(header_bytes, str): # Old -# if isinstance(header_bytes, future.builtins.str): # New -def header_encode_patched(header_bytes, charset='iso-8859-1'): - """Encode a single header line with Base64 encoding in a given charset. - - charset names the character set to use to encode the header. It defaults - to iso-8859-1. Base64 encoding is defined in RFC 2045. - """ - # Avoid false positive from usage of future library - # pylint: disable=no-member - - if not header_bytes: - return "" - if isinstance(header_bytes, future.builtins.str): - header_bytes = header_bytes.encode(charset) - encoded = base64.b64encode(header_bytes).decode("ascii") - return '=?%s?b?%s?=' % (charset, encoded) - - -# Overwrite email library's buggy function -future.backports.email.base64mime.header_encode = header_encode_patched diff --git a/setup.py b/setup.py index 7379c42..a828bc6 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,12 @@ """Mailmerge build and install configuration.""" -import os -import io +from pathlib import Path import setuptools # Read the contents of README file -PROJECT_DIR = os.path.abspath(os.path.dirname(__file__)) -with io.open(os.path.join(PROJECT_DIR, "README.md"), encoding="utf-8") as f: - LONG_DESCRIPTION = f.read() +PROJECT_DIR = Path(__file__).parent +README = PROJECT_DIR/"README.md" +LONG_DESCRIPTION = README.open(encoding="utf8").read() setuptools.setup( @@ -15,7 +14,7 @@ description="A simple, command line mail merge tool", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", - version="2.2.0", + version="2.2.1", author="Andrew DeOrio", author_email="awdeorio@umich.edu", url="https://github.com/awdeorio/mailmerge/", @@ -23,21 +22,10 @@ packages=["mailmerge"], keywords=["mail merge", "mailmerge", "email"], install_requires=[ - "backports.csv;python_version<'3.0'", "click", - "configparser;python_version<'3.6'", - - # We mock the time when testing the rate limit feature - "freezegun", - - # The attachments feature relies on a bug fix in the future library - # https://github.com/awdeorio/mailmerge/pull/56 - "future>0.18.0", - "jinja2", "markdown", - "pathlib2;python_version<'3.6'", - "html5" + "html5lib" ], extras_require={ "dev": [ @@ -47,22 +35,16 @@ ], "test": [ "check-manifest", - "codecov>=1.4.0", - "mock;python_version<'3.0'", + "freezegun", "pycodestyle", "pydocstyle", "pylint", "pytest", "pytest-cov", - - # Work around a dependency bug (I think) in pytest + python3.4 - "typing;python_version=='3.4'", - - "sh", + "pytest-mock", ], }, - - # Python command line utilities will be installed in a PATH-accessible bin/ + python_requires='>=3.6', entry_points={ "console_scripts": [ "mailmerge = mailmerge.__main__:main", diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d5fb037..39207d2 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,99 +1,88 @@ -# coding=utf-8 -# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/ """ Tests for helper functions. Andrew DeOrio """ import textwrap +from pathlib import Path import pytest from mailmerge.__main__ import enumerate_range, read_csv_database from mailmerge import MailmergeError -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path - -# Every test_enumerate_range_* unit test uses a list comprehension to yield all -# the values from the generator implementation. -# pylint: disable=unnecessary-comprehension - def test_enumerate_range_default(): """Verify default start and stop.""" - output = [i for i in enumerate_range(["a", "b", "c"])] + output = list(enumerate_range(["a", "b", "c"])) assert output == [(0, "a"), (1, "b"), (2, "c")] def test_enumerate_range_stop_none(): """Verify stop=None.""" - output = [i for i in enumerate_range(["a", "b", "c"], stop=None)] + output = list(enumerate_range(["a", "b", "c"], stop=None)) assert output == [(0, "a"), (1, "b"), (2, "c")] def test_enumerate_range_stop_value(): """Verify stop=value.""" - output = [i for i in enumerate_range(["a", "b", "c"], stop=1)] + output = list(enumerate_range(["a", "b", "c"], stop=1)) assert output == [(0, "a")] def test_enumerate_range_stop_zero(): """Verify stop=0.""" - output = [i for i in enumerate_range(["a", "b", "c"], stop=0)] + output = list(enumerate_range(["a", "b", "c"], stop=0)) assert output == [] def test_enumerate_range_stop_too_big(): """Verify stop when value is greater than length.""" - output = [i for i in enumerate_range(["a", "b", "c"], stop=10)] + output = list(enumerate_range(["a", "b", "c"], stop=10)) assert output == [(0, "a"), (1, "b"), (2, "c")] def test_enumerate_range_start_zero(): """Verify start=0.""" - output = [i for i in enumerate_range(["a", "b", "c"], start=0)] + output = list(enumerate_range(["a", "b", "c"], start=0)) assert output == [(0, "a"), (1, "b"), (2, "c")] def test_enumerate_range_start_value(): """Verify start=1.""" - output = [i for i in enumerate_range(["a", "b", "c"], start=1)] + output = list(enumerate_range(["a", "b", "c"], start=1)) assert output == [(1, "b"), (2, "c")] def test_enumerate_range_start_last_one(): """Verify start=length - 1.""" - output = [i for i in enumerate_range(["a", "b", "c"], start=2)] + output = list(enumerate_range(["a", "b", "c"], start=2)) assert output == [(2, "c")] def test_enumerate_range_start_length(): """Verify start=length.""" - output = [i for i in enumerate_range(["a", "b", "c"], start=3)] + output = list(enumerate_range(["a", "b", "c"], start=3)) assert output == [] def test_enumerate_range_start_too_big(): """Verify start past the end.""" - output = [i for i in enumerate_range(["a", "b", "c"], start=10)] + output = list(enumerate_range(["a", "b", "c"], start=10)) assert output == [] def test_enumerate_range_start_stop(): """Verify start and stop together.""" - output = [i for i in enumerate_range(["a", "b", "c"], start=1, stop=2)] + output = list(enumerate_range(["a", "b", "c"], start=1, stop=2)) assert output == [(1, "b")] def test_csv_bad(tmpdir): """CSV with unmatched quote.""" database_path = Path(tmpdir/"database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ a,b 1,"2 - """)) + """), encoding="utf8") with pytest.raises(MailmergeError): next(read_csv_database(database_path)) @@ -105,22 +94,22 @@ def test_csv_quotes_commas(tmpdir): https://docs.python.org/3.7/library/csv.html#csv.Dialect.doublequote """ database_path = Path(tmpdir/"database.csv") - database_path.write_text(textwrap.dedent(u'''\ + database_path.write_text(textwrap.dedent('''\ email,message one@test.com,"Hello, ""world""" - ''')) + '''), encoding="utf8") row = next(read_csv_database(database_path)) - assert row["email"] == u"one@test.com" + assert row["email"] == "one@test.com" assert row["message"] == 'Hello, "world"' def test_csv_utf8(tmpdir): """CSV with quotes and commas.""" database_path = Path(tmpdir/"database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email,message Laȝamon ,Laȝamon emoji \xf0\x9f\x98\x80 klâwen - """)) + """), encoding="utf8") row = next(read_csv_database(database_path)) - assert row["email"] == u"Laȝamon " - assert row["message"] == u"Laȝamon emoji \xf0\x9f\x98\x80 klâwen" + assert row["email"] == "Laȝamon " + assert row["message"] == "Laȝamon emoji \xf0\x9f\x98\x80 klâwen" diff --git a/tests/test_main.py b/tests/test_main.py index 7ca35c2..ed4d5f7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,3 @@ -# coding=utf-8 -# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/ """ System tests. @@ -8,19 +6,14 @@ pytest tmpdir docs: http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture """ +import copy +import shutil import re +from pathlib import Path import textwrap -import sh -import pytest - -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path - -# The sh library triggers lot of false no-member errors -# pylint: disable=no-member +import click.testing +from mailmerge.__main__ import main +from . import utils def test_no_options(tmpdir): @@ -29,392 +22,401 @@ 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(): - 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, ["--sample"]) + assert not result.exception + assert result.exit_code == 0 + with tmpdir.as_cwd(): + 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): """Verify --limit with bad value.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com Hello world - """)) + """), encoding="utf8") # Simple database with two entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email one@test.com two@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Verify --limit 1 --no-limit results in no limit.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com Hello world - """)) + """), encoding="utf8") # Simple database with two entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email one@test.com two@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Template mismatch with database header should produce an error.""" # Template has a bad key template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{error_not_in_database}} SUBJECT: Testing mailmerge FROM: from@test.com Hello world - """)) + """), encoding="utf8") # Normal database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email to@test.com - """)) + """), encoding="utf8") # Normal, unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Database read error should produce a sane error.""" # Normal template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com {{message}} - """)) + """), encoding="utf8") # Database with unmatched quote database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message "hello world - """)) + """), encoding="utf8") # Normal, unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Config containing an error should produce an error.""" # Normal template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com - """)) + """), encoding="utf8") # Normal database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ dummy asdf - """)) + """), encoding="utf8") # Server config is missing host config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] port = 25 - """)) + """), encoding="utf8") # 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): """Verify attachments feature output.""" # First attachment attachment1_path = Path(tmpdir/"attachment1.txt") - attachment1_path.write_text(u"Hello world\n") + attachment1_path.write_text("Hello world\n", encoding="utf8") # Second attachment attachment2_path = Path(tmpdir/"attachment2.txt") - attachment2_path.write_text(u"Hello mailmerge\n") + attachment2_path.write_text("Hello mailmerge\n", encoding="utf8") # Template with attachment header template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com ATTACHMENT: attachment1.txt ATTACHMENT: attachment2.txt Hello world - """)) + """), encoding="utf8") # Simple database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email to@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Message is utf-8 encoded when only the template contains utf-8 chars.""" # Template with UTF-8 characters and emoji template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com Laȝamon 😀 klâwen - """)) + """), encoding="utf8") # Simple database without utf-8 characters database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email to@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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(u"""\ + assert stdout == textwrap.dedent("""\ >>> message 1 TO: to@test.com FROM: from@test.com @@ -435,39 +437,41 @@ def test_utf8_database(tmpdir): """Message is utf-8 encoded when only the databse contains utf-8 chars.""" # Simple template without UTF-8 characters template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com {{message}} - """)) + """), encoding="utf8") # Database with utf-8 characters and emoji database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message Laȝamon 😀 klâwen - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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(u"""\ + assert stdout == textwrap.dedent("""\ >>> message 1 TO: to@test.com FROM: from@test.com @@ -488,45 +492,48 @@ def test_utf8_headers(tmpdir): """Message is utf-8 encoded when headers contain utf-8 chars.""" # Template with UTF-8 characters and emoji in headers template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: Laȝamon FROM: klâwen SUBJECT: Laȝamon 😀 klâwen {{message}} - """)) + """), encoding="utf8") # Simple database without utf-8 characters database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message hello - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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(u"""\ + assert stdout == textwrap.dedent("""\ >>> message 1 TO: =?utf-8?b?TGHInWFtb24gPHRvQHRlc3QuY29tPg==?= FROM: =?utf-8?b?a2zDondlbiA8ZnJvbUB0ZXN0LmNvbT4=?= @@ -544,401 +551,198 @@ 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(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 - - - -

{{message}}

- - - - - """)) - - # 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,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 - 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 string, which will be different each time - stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) - - # The long output string below is the correct answer with Python 3. With - # Python 2, we get a few differences in newlines. We'll just query-replace - # those known mismatches so that the equality test passes. - stdout = stdout.replace( - "TGHInWFtb24g8J+YgCBrbMOid2VuCgoK", - "TGHInWFtb24g8J+YgCBrbMOid2VuCgo=", - ) - stdout = stdout.replace( - "Pgo8L2h0bWw+Cgo=", - "Pgo8L2h0bWw+Cg==", - ) - stdout = stdout.replace('Hello, "world"\n\n\n\n', 'Hello, "world"\n\n\n') - stdout = stdout.replace('\n\n\n', '\n\n') - stdout = re.sub(r'Content-Id:.*', '', stdout) - - # Verify stdout and stderr after above corrections - assert 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 - - - -

Hello, "world"

- - - - --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 - 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 template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com {{message}} - """)) + """), encoding="utf8") # Database with two entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message hello world - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Verify --resume <= 0 prints an error message.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com {{message}} - """)) + """), encoding="utf8") # Database with two entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message hello world - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Verify --resume > database does nothing.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com {{message}} - """)) + """), encoding="utf8") # Database with two entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message hello world - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Verify *no* --resume hint when error is after first message.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com {{message}} - """)) + """), encoding="utf8") # Database with error on second entry database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message hello "world - """)) + """), encoding="utf8") # Server config missing port config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com - """)) + """), encoding="utf8") # 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): """Verify --resume hint after CSV error.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com {{message}} - """)) + """), encoding="utf8") # Database with unmatched quote on second entry database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ message hello "world - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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): """Verify output with a MIME type that's not text or an attachment.""" # Template containing a pdf template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com MIME-Version: 1.0 @@ -953,34 +757,34 @@ def test_other_mime_type(tmpdir): Content-Type: application/pdf DUMMY - """)) + """), encoding="utf8") # Simple database with two entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email one@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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 = stdout.replace("\n\n\n\n", "\n\n\n") + stdout = copy.deepcopy(result.output) stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) - assert stdout == textwrap.dedent(u"""\ + assert stdout == textwrap.dedent("""\ \x1b[7m\x1b[1m\x1b[36m>>> message 1\x1b(B\x1b[m TO: one@test.com FROM: from@test.com @@ -997,3 +801,61 @@ def test_other_mime_type(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_database_bom(tmpdir): + """Bug fix CSV with a byte order mark (BOM). + + It looks like Excel will sometimes save a file with Byte Order Mark + (BOM). When the mailmerge database contains a BOM, it can't seem to find + the first header key. + https://github.com/awdeorio/mailmerge/issues/93 + + """ + # Simple template + template_path = Path(tmpdir/"mailmerge_template.txt") + template_path.write_text(textwrap.dedent("""\ + TO: {{email}} + FROM: My Self + + Hello {{name}} + """), encoding="utf8") + + # Copy database containing a BOM + database_path = Path(tmpdir/"mailmerge_database.csv") + database_with_bom = utils.TESTDATA/"mailmerge_database_with_BOM.csv" + shutil.copyfile(database_with_bom, database_path) + + # 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 + """), encoding="utf8") + + # Run mailmerge + runner = click.testing.CliRunner() + with tmpdir.as_cwd(): + result = runner.invoke(main, ["--output-format", "text"]) + assert not result.exception + assert result.exit_code == 0 + + # Verify output + stdout = copy.deepcopy(result.output) + stdout = re.sub(r"Date:.+", "Date: REDACTED", stdout, re.MULTILINE) + assert stdout == textwrap.dedent("""\ + >>> message 1 + TO: to@test.com + FROM: My Self + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Date: REDACTED + + Hello My Name + + >>> message 1 sent + >>> 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 diff --git a/tests/test_main_output.py b/tests/test_main_output.py index c759801..a9f2d54 100644 --- a/tests/test_main_output.py +++ b/tests/test_main_output.py @@ -1,5 +1,3 @@ -# coding=utf-8 -# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/ """ System tests focused on CLI output. @@ -8,27 +6,20 @@ pytest tmpdir docs: http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture """ +import copy import os import re import textwrap -import sh -import pytest - -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path - -# The sh library triggers lot of false no-member errors -# pylint: disable=no-member +from pathlib import Path +import click.testing +from mailmerge.__main__ import main def test_stdout(tmpdir): """Verify stdout and stderr with dry run on simple input files.""" # Simple template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self @@ -36,42 +27,44 @@ def test_stdout(tmpdir): Hi, {{name}}, Your number is {{number}}. - """)) + """), encoding="utf8") # Simple database database_path = Path(tmpdir/"database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email,name,number myself@mydomain.com,"Myself",17 bob@bobdomain.com,"Bob",42 - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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(u"""\ + assert stdout == textwrap.dedent("""\ >>> message 1 TO: myself@mydomain.com SUBJECT: Testing mailmerge @@ -106,40 +99,42 @@ def test_stdout_utf8(tmpdir): """Verify human-readable output when template contains utf-8.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com Laȝamon 😀 klâwen - """)) + """), encoding="utf8") # Simple database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email myself@mydomain.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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(u"""\ + assert stdout == textwrap.dedent("""\ >>> message 1 TO: to@test.com FROM: from@test.com @@ -163,32 +158,32 @@ def test_stdout_utf8_redirect(tmpdir): """ # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com Laȝamon 😀 klâwen - """)) + """), encoding="utf8") # Simple database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email myself@mydomain.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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 @@ -198,47 +193,57 @@ def test_english(tmpdir): """Verify correct English, message vs. messages.""" # Blank message template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com - """)) + """), encoding="utf8") # Database with 2 entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ dummy 1 2 - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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(): - 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", "1"]) + assert not result.exception + assert result.exit_code == 0 + assert "Limit was 1 message." in result.output + with tmpdir.as_cwd(): + 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 @@ -248,43 +253,45 @@ def test_output_format_raw(tmpdir): """Verify raw output format.""" # Attachment attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com Laȝamon 😀 klâwen - """)) + """), encoding="utf8") # Simple database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email to@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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 @@ -306,44 +313,46 @@ def test_output_format_text(tmpdir): """Verify text output format.""" # Attachment attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com Laȝamon 😀 klâwen - """)) + """), encoding="utf8") # Simple database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email to@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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 stdout == textwrap.dedent(u"""\ + assert result.stderr == "" + assert stdout == textwrap.dedent("""\ >>> message 1 TO: to@test.com FROM: from@test.com @@ -364,11 +373,11 @@ def test_output_format_colorized(tmpdir): """Verify colorized output format.""" # Attachment attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # HTML template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com MIME-Version: 1.0 @@ -390,40 +399,37 @@ def test_output_format_colorized(tmpdir):

Laȝamon 😀 klâwen

- """)) + """), encoding="utf8") # Simple database database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email to@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 - """)) + """), encoding="utf8") # 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) - # The long output string below is the correct answer with Python 3. With - # Python 2, we get a few differences in newlines. We'll just query-replace - # those known mismatches so that the equality test passes. - stdout = stdout.replace("\n\n\n\n", "\n\n\n") - # Verify output. The funny looking character sequences are colors. - assert stderr == "" - assert stdout == textwrap.dedent(u"""\ + assert result.stderr == "" + assert stdout == textwrap.dedent("""\ \x1b[7m\x1b[1m\x1b[36m>>> message 1\x1b(B\x1b[m TO: to@test.com FROM: from@test.com @@ -446,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("Hello world\n", encoding="utf8") + + # Second attachment + attachment2_path = Path(tmpdir/"attachment2.csv") + attachment2_path.write_text("hello,mailmerge\n", encoding="utf8") + + # 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 + + + +

{{message}}

+ + + + + """), encoding="utf8") + + # 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,Laȝamon 😀 klâwen + '''), encoding="utf8") + + # 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 + """), encoding="utf8") + + # 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("""\ + >>> 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 + + + +

Hello, "world"

+ + + + --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 + 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 76b29d8..7ac8360 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -1,5 +1,3 @@ -# coding=utf-8 -# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/ """ Tests for SMTP server rate limit feature. @@ -7,48 +5,30 @@ """ import textwrap import datetime -import future.backports.email as email -import future.backports.email.parser # pylint: disable=unused-import +from pathlib import Path +import email +import email.parser import freezegun import pytest -import click import click.testing from mailmerge import SendmailClient, MailmergeRateLimitError from mailmerge.__main__ import main -try: - from unittest import mock # Python 3 -except ImportError: - import mock # Python 2 -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path - -# The sh library triggers lot of false no-member errors -# pylint: disable=no-member - -# We're going to use mock_SMTP because it mimics the real SMTP library -# pylint: disable=invalid-name - - -@mock.patch('smtplib.SMTP') -def test_sendmail_ratelimit(mock_SMTP, tmp_path): +def test_sendmail_ratelimit(mocker, tmp_path): """Verify SMTP library calls.""" config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 ratelimit = 60 - """)) + """), encoding="utf8") sendmail_client = SendmailClient( config_path, dry_run=False, ) - message = email.message_from_string(u""" + message = email.message_from_string(""" TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com @@ -56,13 +36,16 @@ def test_sendmail_ratelimit(mock_SMTP, tmp_path): Hello world """) + # Mock SMTP + mock_smtp = mocker.patch('smtplib.SMTP') + # First message sendmail_client.sendmail( sender="from@test.com", recipients=["to@test.com"], message=message, ) - smtp = mock_SMTP.return_value.__enter__.return_value + smtp = mock_smtp.return_value.__enter__.return_value assert smtp.sendmail.call_count == 1 # Second message exceeds the rate limit, doesn't try to send a message @@ -89,34 +72,36 @@ def test_sendmail_ratelimit(mock_SMTP, tmp_path): assert smtp.sendmail.call_count == 2 -@mock.patch('smtplib.SMTP') -def test_stdout_ratelimit(mock_SMTP, tmpdir): +def test_stdout_ratelimit(mocker, tmpdir): """Verify SMTP server ratelimit parameter.""" # Simple template template_path = Path(tmpdir/"mailmerge_template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com Hello world - """)) + """), encoding="utf8") # Simple database with two entries database_path = Path(tmpdir/"mailmerge_database.csv") - database_path.write_text(textwrap.dedent(u"""\ + database_path.write_text(textwrap.dedent("""\ email one@test.com two@test.com - """)) + """), encoding="utf8") # Simple unsecure server config config_path = Path(tmpdir/"mailmerge_server.conf") - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 ratelimit = 60 - """)) + """), encoding="utf8") + + # Mock SMTP + mock_smtp = mocker.patch('smtplib.SMTP') # Run mailmerge before = datetime.datetime.now() @@ -131,10 +116,10 @@ def test_stdout_ratelimit(mock_SMTP, tmpdir): ) after = datetime.datetime.now() assert after - before > datetime.timedelta(seconds=1) - smtp = mock_SMTP.return_value.__enter__.return_value + 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 diff --git a/tests/test_sendmail_client.py b/tests/test_sendmail_client.py index 3495970..088f34e 100644 --- a/tests/test_sendmail_client.py +++ b/tests/test_sendmail_client.py @@ -6,26 +6,16 @@ import textwrap import socket import smtplib +import email +import email.parser import pytest -import future.backports.email as email -import future.backports.email.parser # pylint: disable=unused-import from mailmerge import SendmailClient, MailmergeError -try: - from unittest import mock # Python 3 -except ImportError: - import mock # Python 2 - -# We're going to use mock_SMTP because it mimics the real SMTP library -# pylint: disable=invalid-name - - -@mock.patch('smtplib.SMTP') -def test_smtp(mock_SMTP, tmp_path): +def test_smtp(mocker, tmp_path): """Verify SMTP library calls.""" config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 @@ -34,7 +24,7 @@ def test_smtp(mock_SMTP, tmp_path): config_path, dry_run=False, ) - message = email.message_from_string(u""" + message = email.message_from_string(""" TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com @@ -42,6 +32,8 @@ def test_smtp(mock_SMTP, tmp_path): Hello world """) + # Execute sendmail with mock SMTP + mock_smtp = mocker.patch('smtplib.SMTP') sendmail_client.sendmail( sender="from@test.com", recipients=["to@test.com"], @@ -49,16 +41,14 @@ def test_smtp(mock_SMTP, tmp_path): ) # Mock smtp object with function calls recorded - smtp = mock_SMTP.return_value.__enter__.return_value + smtp = mock_smtp.return_value.__enter__.return_value assert smtp.sendmail.call_count == 1 -@mock.patch('smtplib.SMTP') -@mock.patch('getpass.getpass') -def test_dry_run(mock_getpass, mock_SMTP, tmp_path): +def test_dry_run(mocker, tmp_path): """Verify no sendmail() calls when dry_run=True.""" config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 @@ -68,7 +58,7 @@ def test_dry_run(mock_getpass, mock_SMTP, tmp_path): config_path, dry_run=True, ) - message = email.message_from_string(u""" + message = email.message_from_string(""" TO: test@test.com SUBJECT: Testing mailmerge FROM: test@test.com @@ -76,6 +66,9 @@ def test_dry_run(mock_getpass, mock_SMTP, tmp_path): Hello world """) + # Execute sendmail with mock SMTP and getpass + mock_smtp = mocker.patch('smtplib.SMTP') + mock_getpass = mocker.patch('getpass.getpass') sendmail_client.sendmail( sender="from@test.com", recipients=["to@test.com"], @@ -84,16 +77,14 @@ def test_dry_run(mock_getpass, mock_SMTP, tmp_path): # Verify SMTP wasn't called and password wasn't used assert mock_getpass.call_count == 0 - smtp = mock_SMTP.return_value.__enter__.return_value + smtp = mock_smtp.return_value.__enter__.return_value assert smtp.sendmail.call_count == 0 -@mock.patch('smtplib.SMTP_SSL') -@mock.patch('getpass.getpass') -def test_no_dry_run(mock_getpass, mock_SMTP_SSL, tmp_path): +def test_no_dry_run(mocker, tmp_path): """Verify --no-dry-run calls SMTP sendmail().""" config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 465 @@ -101,7 +92,7 @@ def test_no_dry_run(mock_getpass, mock_SMTP_SSL, tmp_path): username = admin """)) sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u""" + message = email.message_from_string(""" TO: test@test.com SUBJECT: Testing mailmerge FROM: test@test.com @@ -109,10 +100,12 @@ def test_no_dry_run(mock_getpass, mock_SMTP_SSL, tmp_path): Hello world """) - # Mock the password entry + # Mock the password entry and SMTP + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') + mock_getpass = mocker.patch('getpass.getpass') mock_getpass.return_value = "password" - # Send a message + # Execute sendmail sendmail_client.sendmail( sender="from@test.com", recipients=["to@test.com"], @@ -121,14 +114,14 @@ def test_no_dry_run(mock_getpass, mock_SMTP_SSL, tmp_path): # Verify function calls for password and sendmail() assert mock_getpass.call_count == 1 - smtp = mock_SMTP_SSL.return_value.__enter__.return_value + smtp = mock_smtp_ssl.return_value.__enter__.return_value assert smtp.sendmail.call_count == 1 def test_bad_config_key(tmp_path): """Verify config file with bad key throws an exception.""" config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] badkey = open-smtp.example.com """)) @@ -139,7 +132,7 @@ def test_bad_config_key(tmp_path): def test_security_error(tmp_path): """Verify config file with bad security type throws an exception.""" config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = smtp.mail.umich.edu port = 465 @@ -150,14 +143,11 @@ def test_security_error(tmp_path): SendmailClient(config_path, dry_run=False) -@mock.patch('smtplib.SMTP') -@mock.patch('smtplib.SMTP_SSL') -@mock.patch('getpass.getpass') -def test_security_open(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): +def test_security_open(mocker, tmp_path): """Verify open (Never) security configuration.""" # Config for no security SMTP server config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 @@ -165,7 +155,12 @@ def test_security_open(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): # Simple template sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u"Hello world") + message = email.message_from_string("Hello world") + + # Mock SMTP and getpass + mock_smtp = mocker.patch('smtplib.SMTP') + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') + mock_getpass = mocker.patch('getpass.getpass') # Send a message sendmail_client.sendmail( @@ -176,19 +171,18 @@ def test_security_open(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): # Verify SMTP library calls assert mock_getpass.call_count == 0 - assert mock_SMTP.call_count == 1 - assert mock_SMTP_SSL.call_count == 0 - smtp = mock_SMTP.return_value.__enter__.return_value + assert mock_smtp.call_count == 1 + assert mock_smtp_ssl.call_count == 0 + smtp = mock_smtp.return_value.__enter__.return_value assert smtp.sendmail.call_count == 1 assert smtp.login.call_count == 0 -@mock.patch('smtplib.SMTP') -def test_security_open_legacy(mock_SMTP, tmp_path): +def test_security_open_legacy(mocker, tmp_path): """Verify legacy "security = Never" configuration.""" # Config SMTP server with "security = Never" legacy option config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = open-smtp.example.com port = 25 @@ -197,7 +191,10 @@ def test_security_open_legacy(mock_SMTP, tmp_path): # Simple template sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u"Hello world") + message = email.message_from_string("Hello world") + + # Mock SMTP + mock_smtp = mocker.patch('smtplib.SMTP') # Send a message sendmail_client.sendmail( @@ -207,18 +204,15 @@ def test_security_open_legacy(mock_SMTP, tmp_path): ) # Verify SMTP library calls - smtp = mock_SMTP.return_value.__enter__.return_value + smtp = mock_smtp.return_value.__enter__.return_value assert smtp.sendmail.call_count == 1 -@mock.patch('smtplib.SMTP') -@mock.patch('smtplib.SMTP_SSL') -@mock.patch('getpass.getpass') -def test_security_starttls(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): +def test_security_starttls(mocker, tmp_path): """Verify open (Never) security configuration.""" # Config for STARTTLS SMTP server config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = newman.eecs.umich.edu port = 25 @@ -228,9 +222,14 @@ def test_security_starttls(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): # Simple template sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u"Hello world") + message = email.message_from_string("Hello world") + + # Mock SMTP + mock_smtp = mocker.patch('smtplib.SMTP') + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') # Mock the password entry + mock_getpass = mocker.patch('getpass.getpass') mock_getpass.return_value = "password" # Send a message @@ -242,23 +241,20 @@ def test_security_starttls(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): # Verify SMTP library calls assert mock_getpass.call_count == 1 - assert mock_SMTP.call_count == 1 - assert mock_SMTP_SSL.call_count == 0 - smtp = mock_SMTP.return_value.__enter__.return_value + assert mock_smtp.call_count == 1 + assert mock_smtp_ssl.call_count == 0 + smtp = mock_smtp.return_value.__enter__.return_value assert smtp.ehlo.call_count == 2 assert smtp.starttls.call_count == 1 assert smtp.login.call_count == 1 assert smtp.sendmail.call_count == 1 -@mock.patch('smtplib.SMTP') -@mock.patch('smtplib.SMTP_SSL') -@mock.patch('getpass.getpass') -def test_security_ssl(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): +def test_security_ssl(mocker, tmp_path): """Verify open (Never) security configuration.""" # Config for SSL SMTP server config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = smtp.mail.umich.edu port = 465 @@ -268,9 +264,14 @@ def test_security_ssl(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): # Simple template sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u"Hello world") + message = email.message_from_string("Hello world") + + # Mock SMTP + mock_smtp = mocker.patch('smtplib.SMTP') + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') # Mock the password entry + mock_getpass = mocker.patch('getpass.getpass') mock_getpass.return_value = "password" # Send a message @@ -282,9 +283,9 @@ def test_security_ssl(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): # Verify SMTP library calls assert mock_getpass.call_count == 1 - assert mock_SMTP.call_count == 0 - assert mock_SMTP_SSL.call_count == 1 - smtp = mock_SMTP_SSL.return_value.__enter__.return_value + assert mock_smtp.call_count == 0 + assert mock_smtp_ssl.call_count == 1 + smtp = mock_smtp_ssl.return_value.__enter__.return_value assert smtp.ehlo.call_count == 0 assert smtp.starttls.call_count == 0 assert smtp.login.call_count == 1 @@ -294,7 +295,7 @@ def test_security_ssl(mock_getpass, mock_SMTP_SSL, mock_SMTP, tmp_path): def test_missing_username(tmp_path): """Verify exception on missing username.""" config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = smtp.mail.umich.edu port = 465 @@ -304,13 +305,11 @@ def test_missing_username(tmp_path): SendmailClient(config_path, dry_run=False) -@mock.patch('smtplib.SMTP_SSL') -@mock.patch('getpass.getpass') -def test_smtp_login_error(mock_getpass, mock_SMTP_SSL, tmp_path): +def test_smtp_login_error(mocker, tmp_path): """Login failure.""" # Config for SSL SMTP server config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = smtp.gmail.com port = 465 @@ -320,13 +319,17 @@ def test_smtp_login_error(mock_getpass, mock_SMTP_SSL, tmp_path): # Simple template sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u"Hello world") + message = email.message_from_string("Hello world") + + # Mock SMTP and getpass + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') # Mock the password entry + mock_getpass = mocker.patch('getpass.getpass') mock_getpass.return_value = "password" # Configure SMTP login() to raise an exception - mock_SMTP_SSL.return_value.__enter__.return_value.login = mock.Mock( + mock_smtp_ssl.return_value.__enter__.return_value.login = mocker.Mock( side_effect=smtplib.SMTPAuthenticationError( code=535, msg=( @@ -356,13 +359,11 @@ def test_smtp_login_error(mock_getpass, mock_SMTP_SSL, tmp_path): ) in str(err.value) -@mock.patch('smtplib.SMTP_SSL') -@mock.patch('getpass.getpass') -def test_smtp_sendmail_error(mock_getpass, mock_SMTP_SSL, tmp_path): +def test_smtp_sendmail_error(mocker, tmp_path): """Failure during SMTP protocol.""" # Config for SSL SMTP server config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = smtp.gmail.com port = 465 @@ -372,13 +373,17 @@ def test_smtp_sendmail_error(mock_getpass, mock_SMTP_SSL, tmp_path): # Simple template sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u"Hello world") + message = email.message_from_string("Hello world") + + # Mock SMTP + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') # Mock the password entry + mock_getpass = mocker.patch('getpass.getpass') mock_getpass.return_value = "password" # Configure SMTP sendmail() to raise an exception - mock_SMTP_SSL.return_value.__enter__.return_value.sendmail = mock.Mock( + mock_smtp_ssl.return_value.__enter__.return_value.sendmail = mocker.Mock( side_effect=smtplib.SMTPException("Dummy error message") ) @@ -394,13 +399,11 @@ def test_smtp_sendmail_error(mock_getpass, mock_SMTP_SSL, tmp_path): assert "Dummy error message" in str(err.value) -@mock.patch('smtplib.SMTP_SSL') -@mock.patch('getpass.getpass') -def test_socket_error(mock_getpass, mock_SMTP_SSL, tmp_path): +def test_socket_error(mocker, tmp_path): """Failed socket connection.""" # Config for SSL SMTP server config_path = tmp_path/"server.conf" - config_path.write_text(textwrap.dedent(u"""\ + config_path.write_text(textwrap.dedent("""\ [smtp_server] host = smtp.gmail.com port = 465 @@ -410,13 +413,17 @@ def test_socket_error(mock_getpass, mock_SMTP_SSL, tmp_path): # Simple template sendmail_client = SendmailClient(config_path, dry_run=False) - message = email.message_from_string(u"Hello world") + message = email.message_from_string("Hello world") + + # Mock SMTP + mock_smtp_ssl = mocker.patch('smtplib.SMTP_SSL') # Mock the password entry + mock_getpass = mocker.patch('getpass.getpass') mock_getpass.return_value = "password" # Configure SMTP_SSL constructor to raise an exception - mock_SMTP_SSL.return_value.__enter__ = mock.Mock( + mock_smtp_ssl.return_value.__enter__ = mocker.Mock( side_effect=socket.error("Dummy error message") ) diff --git a/tests/test_template_message.py b/tests/test_template_message.py index 2e02706..18471b4 100644 --- a/tests/test_template_message.py +++ b/tests/test_template_message.py @@ -1,5 +1,3 @@ -# coding=utf-8 -# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/ """ Tests for TemplateMessage. @@ -10,29 +8,24 @@ import shutil import textwrap import collections +from pathlib import Path import pytest import markdown import html5lib from mailmerge import TemplateMessage, MailmergeError from . import utils -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path - def test_simple(tmp_path): """Render a simple template.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com Hello {{name}}! - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "name": "world", @@ -46,13 +39,13 @@ def test_simple(tmp_path): def test_no_substitutions(tmp_path): """Render a template with an empty context.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com Hello world! - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({}) assert sender == "from@test.com" @@ -64,14 +57,14 @@ def test_no_substitutions(tmp_path): def test_multiple_substitutions(tmp_path): """Render a template with multiple context variables.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} FROM: from@test.com Hi, {{name}}, Your number is {{number}}. - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "email": "myself@mydomain.com", @@ -88,7 +81,7 @@ def test_multiple_substitutions(tmp_path): def test_bad_jinja(tmp_path): """Bad jinja template should produce an error.""" template_path = tmp_path / "template.txt" - template_path.write_text(u"TO: {{error_not_in_database}}") + template_path.write_text("TO: {{error_not_in_database}}") template_message = TemplateMessage(template_path) with pytest.raises(MailmergeError): template_message.render({"name": "Bob", "number": 17}) @@ -97,7 +90,7 @@ def test_bad_jinja(tmp_path): def test_cc_bcc(tmp_path): """CC recipients should receive a copy.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self @@ -105,7 +98,7 @@ def test_cc_bcc(tmp_path): BCC: Secret Hello world - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "email": "myself@mydomain.com", @@ -152,7 +145,7 @@ def html_docs_equal(e_1, e_2): def test_html(tmp_path): """Verify HTML template results in a simple rendered message.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com @@ -163,7 +156,7 @@ def test_html(tmp_path):

{{message}}

- """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "message": "Hello world" @@ -190,7 +183,7 @@ def test_html(tmp_path): def test_html_plaintext(tmp_path): """Verify HTML and plaintest multipart template.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com @@ -213,7 +206,7 @@ def test_html_plaintext(tmp_path):

{{message}}

- """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "message": "Hello world" @@ -258,7 +251,7 @@ def extract_text_from_markdown_payload(plaintext_part, mime_type): def test_markdown(tmp_path): """Markdown messages should be converted to HTML.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} SUBJECT: Testing mailmerge FROM: Bob @@ -293,7 +286,7 @@ def test_markdown(tmp_path): Here's an image not attached with the email: ![python logo not attached]( http://pluspng.com/img-png/python-logo-png-open-2000.png) - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "email": "myself@mydomain.com", @@ -340,7 +333,7 @@ def test_markdown_encoding(tmp_path): https://github.com/awdeorio/mailmerge/issues/59 """ template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} SUBJECT: Testing mailmerge FROM: test@example.com @@ -348,7 +341,7 @@ def test_markdown_encoding(tmp_path): Hi, {{name}}, æøå - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) _, _, message = template_message.render({ "email": "myself@mydomain.com", @@ -368,11 +361,11 @@ def test_markdown_encoding(tmp_path): # Verify content, which is base64 encoded plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") htmltext = html_part.get_payload(decode=True).decode("utf-8") - assert plaintext == u"Hi, Myself,\næøå" + assert plaintext == "Hi, Myself,\næøå" assert html_docs_equal( html5lib.parse(htmltext), html5lib.parse( - u"

Hi, Myself,
\næøå

" + "

Hi, Myself,
\næøå

" ) ) @@ -408,17 +401,17 @@ def test_attachment_simple(tmpdir): """Verify a simple attachment.""" # Simple attachment attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: attachment.txt Hello world - """)) + """), encoding="utf8") # Render in tmpdir with tmpdir.as_cwd(): @@ -444,17 +437,17 @@ def test_attachment_relative(tmpdir): """Attachment with a relative file path is relative to template dir.""" # Simple attachment attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: attachment.txt Hello world - """)) + """), encoding="utf8") # Render template_message = TemplateMessage(template_path) @@ -475,17 +468,17 @@ def test_attachment_absolute(tmpdir): # Simple attachment lives in sub directory attachments_dir = tmpdir.mkdir("attachments") attachment_path = Path(attachments_dir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent(f"""\ TO: to@test.com FROM: from@test.com - ATTACHMENT: {filename} + ATTACHMENT: {attachment_path} Hello world - """.format(filename=attachment_path))) + """), encoding="utf8") # Render in tmpdir with tmpdir.as_cwd(): @@ -504,17 +497,17 @@ def test_attachment_template(tmpdir): # Simple attachment lives in sub directory attachments_dir = tmpdir.mkdir("attachments") attachment_path = Path(attachments_dir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: {{filename}} Hello world - """)) + """), encoding="utf8") # Render in tmpdir with tmpdir.as_cwd(): @@ -534,13 +527,13 @@ def test_attachment_not_found(tmpdir): """Attachment file not found.""" # Template specifying an attachment that doesn't exist template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: attachment.txt Hello world - """)) + """), encoding="utf8") # Render in tmpdir, which lacks attachment.txt template_message = TemplateMessage(template_path) @@ -552,13 +545,13 @@ def test_attachment_not_found(tmpdir): def test_attachment_blank(tmpdir): """Attachment header without a filename is an error.""" template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: Hello world - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) with pytest.raises(MailmergeError) as err: with tmpdir.as_cwd(): @@ -569,13 +562,13 @@ def test_attachment_blank(tmpdir): def test_attachment_tilde_path(tmpdir): """Attachment with home directory tilde notation file path.""" template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: ~/attachment.txt Hello world - """)) + """), encoding="utf8") # Render will throw an error because we didn't create a file in the # user's home directory. We'll just check the filename. @@ -595,7 +588,7 @@ def test_attachment_multiple(tmp_path): # Create template .txt file template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self @@ -606,7 +599,7 @@ def test_attachment_multiple(tmp_path): Hi, {{name}}, Your number is {{number}}. - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "email": "myself@mydomain.com", @@ -653,14 +646,14 @@ def test_attachment_multiple(tmp_path): def test_attachment_empty(tmp_path): """Err on empty attachment field.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com ATTACHMENT: Hello world - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) with pytest.raises(MailmergeError): template_message.render({}) @@ -673,18 +666,18 @@ def test_contenttype_attachment_html_body(tmpdir): """ # Simple attachment attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # HTML template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: attachment.txt CONTENT-TYPE: text/html Hello world - """)) + """), encoding="utf8") # Render in tmpdir with tmpdir.as_cwd(): @@ -704,18 +697,18 @@ def test_contenttype_attachment_markdown_body(tmpdir): """ # Simple attachment attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # HTML template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: attachment.txt CONTENT-TYPE: text/markdown Hello **world** - """)) + """), encoding="utf8") # Render in tmpdir with tmpdir.as_cwd(): @@ -745,18 +738,18 @@ def test_duplicate_headers_attachment(tmp_path): """ # Simple attachment attachment_path = Path(tmp_path/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple message template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com> ATTACHMENT: attachment.txt {{message}} - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) _, _, message = template_message.render({ "message": "Hello world" @@ -772,7 +765,7 @@ def test_duplicate_headers_markdown(tmp_path): Duplicate headers are rejected by some SMTP servers. """ template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com @@ -781,7 +774,7 @@ def test_duplicate_headers_markdown(tmp_path): ``` Message as code block: {{message}} ``` - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) _, _, message = template_message.render({ "message": "hello world", @@ -797,7 +790,7 @@ def test_attachment_image_in_markdown(tmp_path): # Create template .txt file template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: {{email}} SUBJECT: Testing mailmerge FROM: My Self @@ -805,7 +798,7 @@ def test_attachment_image_in_markdown(tmp_path): CONTENT-TYPE: text/markdown ![](./attachment_3.jpg) - """)) + """), encoding="utf8") template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ "email": "myself@mydomain.com" @@ -840,27 +833,28 @@ def test_attachment_image_in_markdown(tmp_path): assert filename == "attachment_3.jpg" assert len(content) == 697 - expected = html5lib.parse( - '' - '

' - ''.format(cid=cid)) + expected = html5lib.parse(textwrap.dedent(f"""\ + +

+ + """)) assert html_docs_equal(html5lib.parse(htmltext), expected) def test_content_id_header_for_attachments(tmpdir): """All attachments should get a content-id header""" attachment_path = Path(tmpdir/"attachment.txt") - attachment_path.write_text(u"Hello world\n") + attachment_path.write_text("Hello world\n", encoding="utf8") # Simple template template_path = Path(tmpdir/"template.txt") - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com ATTACHMENT: attachment.txt Hello world - """)) + """), encoding="utf8") # Render in tmpdir with tmpdir.as_cwd(): diff --git a/tests/test_template_message_encodings.py b/tests/test_template_message_encodings.py index 643c927..96ccf8e 100644 --- a/tests/test_template_message_encodings.py +++ b/tests/test_template_message_encodings.py @@ -1,5 +1,3 @@ -# coding=utf-8 -# Python 2 source containing unicode https://www.python.org/dev/peps/pep-0263/ """ Tests for TemplateMessage with different encodings. @@ -13,7 +11,7 @@ def test_utf8_template(tmp_path): """Verify UTF8 support in email template.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com SUBJECT: Testing mailmerge FROM: from@test.com @@ -47,7 +45,7 @@ def test_utf8_template(tmp_path): # Verify content plaintext = message.get_payload(decode=True).decode("utf-8") - assert plaintext == textwrap.dedent(u"""\ + assert plaintext == textwrap.dedent("""\ From the Tagelied of Wolfram von Eschenbach (Middle High German): Sîne klâwen durh die wolken sint geslagen, @@ -66,7 +64,7 @@ def test_utf8_database(tmp_path): """Verify UTF8 support when template is rendered with UTF-8 value.""" # Simple template template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com @@ -76,7 +74,7 @@ def test_utf8_database(tmp_path): # Render template with context containing unicode characters template_message = TemplateMessage(template_path) sender, recipients, message = template_message.render({ - "name": u"Laȝamon", + "name": "Laȝamon", }) # Verify sender and recipients @@ -91,13 +89,13 @@ def test_utf8_database(tmp_path): # Verify content plaintext = message.get_payload(decode=True).decode("utf-8") - assert plaintext == u"Hi Laȝamon" + assert plaintext == "Hi Laȝamon" def test_utf8_to(tmp_path): """Verify UTF8 support in TO field.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: Laȝamon FROM: from@test.com @@ -110,13 +108,13 @@ def test_utf8_to(tmp_path): # Verify recipient name and email assert recipients == ["to@test.com"] - assert message["to"] == u"Laȝamon " + assert message["to"] == "Laȝamon " def test_utf8_from(tmp_path): """Verify UTF8 support in FROM field.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: Laȝamon @@ -128,14 +126,14 @@ def test_utf8_from(tmp_path): }) # Verify sender name and email - assert sender == u"Laȝamon " - assert message["from"] == u"Laȝamon " + assert sender == "Laȝamon " + assert message["from"] == "Laȝamon " def test_utf8_subject(tmp_path): """Verify UTF8 support in SUBJECT field.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com SUBJECT: Laȝamon @@ -148,13 +146,13 @@ def test_utf8_subject(tmp_path): }) # Verify subject - assert message["subject"] == u"Laȝamon" + assert message["subject"] == "Laȝamon" def test_emoji(tmp_path): """Verify emoji are encoded.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: test@test.com SUBJECT: Testing mailmerge FROM: test@test.com @@ -170,13 +168,13 @@ def test_emoji(tmp_path): # Verify content plaintext = message.get_payload(decode=True).decode("utf-8") - assert plaintext == u"Hi 😀" + assert plaintext == "Hi 😀" def test_emoji_markdown(tmp_path): """Verify emoji are encoded in Markdown formatted messages.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: test@example.com SUBJECT: Testing mailmerge FROM: test@example.com @@ -203,11 +201,11 @@ def test_emoji_markdown(tmp_path): # Verify content, which is base64 encoded grinning face emoji plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") htmltext = html_part.get_payload(decode=True).decode("utf-8") - assert plaintext == u'```\nemoji_string = \U0001f600\n```' + assert plaintext == '```\nemoji_string = \U0001f600\n```' assert htmltext == ( - u"

" - u"emoji_string = \U0001f600" - u"

" + "

" + "emoji_string = \U0001f600" + "

" ) @@ -219,7 +217,7 @@ def test_emoji_database(tmp_path): encoded message. """ template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: test@test.com SUBJECT: Testing mailmerge FROM: test@test.com @@ -228,7 +226,7 @@ def test_emoji_database(tmp_path): """)) template_message = TemplateMessage(template_path) _, _, message = template_message.render({ - "emoji": u"😀" # grinning face + "emoji": "😀" # grinning face }) # Verify encoding @@ -237,13 +235,13 @@ def test_emoji_database(tmp_path): # Verify content plaintext = message.get_payload(decode=True).decode("utf-8") - assert plaintext == u"Hi 😀" + assert plaintext == "Hi 😀" def test_encoding_us_ascii(tmp_path): """Render a simple template with us-ascii encoding.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com @@ -259,7 +257,7 @@ def test_encoding_us_ascii(tmp_path): def test_encoding_utf8(tmp_path): """Render a simple template with UTF-8 encoding.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com @@ -270,7 +268,7 @@ def test_encoding_utf8(tmp_path): assert message.get_charset() == "utf-8" assert message.get_content_charset() == "utf-8" plaintext = message.get_payload(decode=True).decode("utf-8") - assert plaintext == u"Hello Laȝamon" + assert plaintext == "Hello Laȝamon" def test_encoding_is8859_1(tmp_path): @@ -279,7 +277,7 @@ def test_encoding_is8859_1(tmp_path): Mailmerge will coerce the encoding to UTF-8. """ template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com @@ -290,7 +288,7 @@ def test_encoding_is8859_1(tmp_path): assert message.get_charset() == "utf-8" assert message.get_content_charset() == "utf-8" plaintext = message.get_payload(decode=True).decode("utf-8") - assert plaintext == u"Hello L'Haÿ-les-Roses" + assert plaintext == "Hello L'Haÿ-les-Roses" def test_encoding_mismatch(tmp_path): @@ -299,7 +297,7 @@ def test_encoding_mismatch(tmp_path): Header says us-ascii, but it contains utf-8. """ template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com Content-Type: text/plain; charset="us-ascii" @@ -311,13 +309,13 @@ def test_encoding_mismatch(tmp_path): assert message.get_charset() == "utf-8" assert message.get_content_charset() == "utf-8" plaintext = message.get_payload(decode=True).decode("utf-8") - assert plaintext == u"Hello Laȝamon" + assert plaintext == "Hello Laȝamon" def test_encoding_multipart(tmp_path): """Render a utf-8 template with multipart encoding.""" template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com MIME-Version: 1.0 @@ -359,7 +357,7 @@ def test_encoding_multipart(tmp_path): assert plaintext_part.get_content_type() == "text/plain" plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") plaintext = plaintext.strip() - assert plaintext == u"Hello Laȝamon" + assert plaintext == "Hello Laȝamon" # Verify html part assert html_part.get_charset() == "utf-8" @@ -367,7 +365,7 @@ def test_encoding_multipart(tmp_path): assert html_part.get_content_type() == "text/html" htmltext = html_part.get_payload(decode=True).decode("utf-8") htmltext = re.sub(r"\s+", "", htmltext) # Strip whitespace - assert htmltext == u"

HelloLaȝamon

" + assert htmltext == "

HelloLaȝamon

" def test_encoding_multipart_mismatch(tmp_path): @@ -376,7 +374,7 @@ def test_encoding_multipart_mismatch(tmp_path): Content-Type headers say "us-ascii", but the message contains utf-8. """ template_path = tmp_path / "template.txt" - template_path.write_text(textwrap.dedent(u"""\ + template_path.write_text(textwrap.dedent("""\ TO: to@test.com FROM: from@test.com MIME-Version: 1.0 @@ -418,7 +416,7 @@ def test_encoding_multipart_mismatch(tmp_path): assert plaintext_part.get_content_type() == "text/plain" plaintext = plaintext_part.get_payload(decode=True).decode("utf-8") plaintext = plaintext.strip() - assert plaintext == u"Hello Laȝamon" + assert plaintext == "Hello Laȝamon" # Verify html part assert html_part.get_charset() == "utf-8" @@ -426,4 +424,4 @@ def test_encoding_multipart_mismatch(tmp_path): assert html_part.get_content_type() == "text/html" htmltext = html_part.get_payload(decode=True).decode("utf-8") htmltext = re.sub(r"\s+", "", htmltext) # Strip whitespace - assert htmltext == u"

HelloLaȝamon

" + assert htmltext == "

HelloLaȝamon

" diff --git a/tests/testdata/mailmerge_database_with_BOM.csv b/tests/testdata/mailmerge_database_with_BOM.csv new file mode 100644 index 0000000..fb9c879 --- /dev/null +++ b/tests/testdata/mailmerge_database_with_BOM.csv @@ -0,0 +1,2 @@ +name,email +My Name,to@test.com diff --git a/tests/utils.py b/tests/utils.py index 390429b..f84837c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,11 +4,7 @@ Andrew DeOrio """ -# Python 2 pathlib support requires backport -try: - from pathlib2 import Path -except ImportError: - from pathlib import Path +from pathlib import Path # Directories containing test input files diff --git a/tox.ini b/tox.ini index cafb57f..fdb21ed 100644 --- a/tox.ini +++ b/tox.ini @@ -1,31 +1,24 @@ -# Local host configuration. Run linters, one Python 2 and one Python 3 version +# Local host configuration with one Python 3 version [tox] -envlist = lint, py2, py3 +envlist = py36, py37, py38, py39 -# Travis configuration. Maps Travis Python version to tox env targets. Because -# pydocstyle no longer runs on Python 2, we run linters only on Python 3. -[tox:travis] -2.7 = py2 -3.4 = py3 -3.5 = py3 -3.6 = py3, lint -3.7 = py3, lint -3.8 = py3, lint -3.9 = py3, lint +# GitHub Actions configuration with multiple Python versions +# https://github.com/ymyzk/tox-gh-actions#tox-gh-actions-configuration +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39 # Run unit tests [testenv] setenv = - PYTHONPATH = {toxinidir} + PYTHONPATH = {toxinidir} extras = test commands = - pytest --verbose --cov mailmerge - -# Run linters -[testenv:lint] -basepython = python3 -commands = - check-manifest - pycodestyle mailmerge tests setup.py - pydocstyle mailmerge tests setup.py - pylint mailmerge setup.py tests + pycodestyle mailmerge tests setup.py + pydocstyle mailmerge tests setup.py + pylint mailmerge tests setup.py + check-manifest + pytest -vvs --cov mailmerge