diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39f6dbd..baed104 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,19 +24,24 @@ jobs: steps: - name: Get code uses: actions/checkout@v3 + - name: Setup Python uses: actions/setup-python@v4 with: python-version: "3.10" + - name: Install build requirements + run: | + python -m pip install --upgrade build hatch twine + - name: Get version # (This will end the workflow if git and source versions don't match.) id: version run: | - VERSION="$(python setup.py --version)" + VERSION="$(python -m hatch version)" TAG="v$VERSION" GIT_TAG="$(git tag -l --points-at "$GITHUB_REF" 'v*')" - if [ "$GIT_TAG" != "$TAG" ]; then + if [ "x$GIT_TAG" != "x$TAG" ]; then echo "::error ::package version '$TAG' does not match git tag '$GIT_TAG'" exit 1 fi @@ -44,21 +49,18 @@ jobs: echo "tag=$TAG" >> $GITHUB_OUTPUT echo "anchor=${TAG//[^[:alnum:]]/-}" >> $GITHUB_OUTPUT - - name: Install build requirements - run: | - pip install twine wheel - name: Build run: | rm -rf build dist django_anymail.egg-info - python setup.py sdist bdist_wheel - twine check dist/* + python -m build + python -m twine check dist/* - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | - twine upload dist/* + python -m twine upload dist/* - name: Release to GitHub env: diff --git a/ADDING_ESPS.md b/ADDING_ESPS.md index 6fd7b81..cd12c06 100644 --- a/ADDING_ESPS.md +++ b/ADDING_ESPS.md @@ -38,6 +38,20 @@ tests regularly. That requires the ESP have a free tier (testing is extremely low volume), a sandbox API, or that they offer developer accounts for open source projects like Anymail. +## Boilerplate + +You should add entries for your ESP in: + +- pyproject.toml: + - in the `[project]` metadata section under `description` and `keywords` + - in the `[project.optional-dependencies]` section +- integration-test.yml in the test matrix +- tox.ini in the `[testenv]` section under `setenv` + - if your ESP requires any extra dependencies, also update the tox.ini + `[testenv] extras` and the "partial installation" at the bottom of + `[tox] envlist` +- README.rst in the list of ESPs + ## EmailBackend and payload Anymail abstracts a lot of common functionality into its base classes; @@ -99,9 +113,6 @@ Need to parse JSON in the API response? Use `self.deserialize_json_response()` Good starting points: Test backend; SparkPost -Don't forget add an `'extras_require'` entry for your ESP in setup.py. -Also update `'tests_require'`. - If the client lib supports the notion of a reusable API "connection" (or session), you should override `open()` and `close()` to provide API state caching. See the notes in the base implementation. diff --git a/AUTHORS.txt b/AUTHORS.txt deleted file mode 100644 index a8c8226..0000000 --- a/AUTHORS.txt +++ /dev/null @@ -1,5 +0,0 @@ -Anymail -======= - -Please see https://github.com/anymail/django-anymail/graphs/contributors -for the complete list of Anymail contributors. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9a91ae5..526fdd0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,9 +33,25 @@ vNext Breaking changes ~~~~~~~~~~~~~~~~ +* **Amazon SES:** The "extra name" for installation must now be spelled with + a hyphen rather than an underscore: ``django-anymail[amazon-ses]``. + Be sure to update any dependencies specification (pip install, requirements.txt, + etc.) that had been using ``[amazon_ses]``. + * Require Python 3.7 or later. + * Require urllib3 1.25 or later (released 2019-04-29). +Other +~~~~~ + +* Modernize packaging. (Change from setup.py and setuptools + to pyproject.toml and hatchling.) Other than the ``amazon-ses`` + naming normalization noted above, the new packaging should have + no impact. If you have trouble installing django-anymail v10 where + v9 worked, please report an issue including the exact install + command and pip version you are using. + v9.2 ----- diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index cbcb0ed..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.rst AUTHORS.txt LICENSE -recursive-include anymail *.py diff --git a/README.rst b/README.rst index 9add0e2..344f847 100644 --- a/README.rst +++ b/README.rst @@ -5,9 +5,8 @@ Anymail: Django email integration for transactional ESPs * Github: project page, exactly as it appears here * Docs: shared-intro section gets included in docs/index.rst quickstart section gets included in docs/quickstart.rst - * PyPI: project page (via setup.py long_description), - with several edits to freeze it to the specific PyPI release - (see long_description_from_readme in setup.py) + * PyPI: project page (via pyproject.toml readme; see also + hatch_build.py which edits in the release version number) You can use docutils 1.0 markup, but *not* any Sphinx additions. GitHub rst supports code-block, but *no other* block directives. diff --git a/anymail/__init__.py b/anymail/__init__.py index f7032f1..6758502 100644 --- a/anymail/__init__.py +++ b/anymail/__init__.py @@ -1,7 +1,17 @@ -# Expose package version at root of package -from django import VERSION as DJANGO_VERSION +from ._version import VERSION, __version__ -from ._version import VERSION, __version__ # NOQA: F401 +__all__ = [ + "VERSION", + "__version__", +] -if DJANGO_VERSION < (3, 2, 0): - default_app_config = "anymail.apps.AnymailBaseConfig" +try: + import django +except ImportError: + # (don't require django just to get package version) + pass +else: + if django.VERSION < (3, 2, 0): + # (No longer required -- and causes deprecation warning -- in Django 3.2+) + default_app_config = "anymail.apps.AnymailBaseConfig" + __all__.append("default_app_config") diff --git a/anymail/_version.py b/anymail/_version.py index dc1bbca..33b122b 100644 --- a/anymail/_version.py +++ b/anymail/_version.py @@ -1,7 +1,7 @@ -VERSION = (9, 2) +# Don't import this file directly (unless you are a build system). +# Instead, load version info from the package root. #: major.minor.patch or major.minor.devN -__version__ = ".".join([str(x) for x in VERSION]) +__version__ = "10.0.dev0" -#: Sphinx's X.Y "version" -__minor_version__ = ".".join([str(x) for x in VERSION[:2]]) +VERSION = __version__.split(",") diff --git a/anymail/backends/amazon_ses.py b/anymail/backends/amazon_ses.py index 8c97969..24fe004 100644 --- a/anymail/backends/amazon_ses.py +++ b/anymail/backends/amazon_ses.py @@ -13,7 +13,7 @@ try: from botocore.exceptions import BotoCoreError, ClientError, ConnectionError except ImportError as err: raise AnymailImproperlyInstalled( - missing_package="boto3", backend="amazon_ses" + missing_package="boto3", install_extra="amazon-ses" ) from err diff --git a/anymail/backends/amazon_sesv2.py b/anymail/backends/amazon_sesv2.py index 0399e4c..539ee71 100644 --- a/anymail/backends/amazon_sesv2.py +++ b/anymail/backends/amazon_sesv2.py @@ -14,7 +14,7 @@ try: from botocore.exceptions import BotoCoreError, ClientError, ConnectionError except ImportError as err: raise AnymailImproperlyInstalled( - missing_package="boto3", backend="amazon_sesv2" + missing_package="boto3", install_extra="amazon-ses" ) from err diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 9cb5111..98dc9e0 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -171,11 +171,13 @@ class AnymailConfigurationError(ImproperlyConfigured): class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError): """Exception for Anymail missing package dependencies""" - def __init__(self, missing_package, backend=""): + def __init__(self, missing_package, install_extra=""): + # install_extra must be the package "optional extras name" for the ESP + # (not the backend's esp_name) message = ( "The %s package is required to use this ESP, but isn't installed.\n" - "(Be sure to use `pip install django-anymail[%s]` " - "with your desired ESPs.)" % (missing_package, backend) + '(Be sure to use `pip install "django-anymail[%s]"` ' + "with your desired ESP name(s).)" % (missing_package, install_extra) ) super().__init__(message) diff --git a/anymail/webhooks/amazon_ses.py b/anymail/webhooks/amazon_ses.py index 699c51f..a8a390b 100644 --- a/anymail/webhooks/amazon_ses.py +++ b/anymail/webhooks/amazon_ses.py @@ -33,11 +33,11 @@ except ImportError: # This module gets imported by anymail.urls, so don't complain about boto3 missing # unless one of the Amazon SES webhook views is actually used and needs it boto3 = _LazyError( - AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses") + AnymailImproperlyInstalled(missing_package="boto3", install_extra="amazon-ses") ) ClientError = object _get_anymail_boto3_params = _LazyError( - AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses") + AnymailImproperlyInstalled(missing_package="boto3", install_extra="amazon-ses") ) diff --git a/anymail/webhooks/postal.py b/anymail/webhooks/postal.py index deaa8bd..d94a678 100644 --- a/anymail/webhooks/postal.py +++ b/anymail/webhooks/postal.py @@ -31,7 +31,9 @@ except ImportError: # This module gets imported by anymail.urls, so don't complain about cryptography # missing unless one of the Postal webhook views is actually used and needs it error = _LazyError( - AnymailImproperlyInstalled(missing_package="cryptography", backend="postal") + AnymailImproperlyInstalled( + missing_package="cryptography", install_extra="postal" + ) ) serialization = error hashes = error diff --git a/docs/_readme/docutils.cfg b/docs/_readme/docutils.cfg deleted file mode 100644 index a5ab359..0000000 --- a/docs/_readme/docutils.cfg +++ /dev/null @@ -1,30 +0,0 @@ -# docutils (rst2html) config for generating static HTML that approximates -# PyPI package description rendering (as of 3/2018). -# -# Usage (in package root dir): -# python setup.py --long-description | rst2html.py --config=docs/_readme/docutils.cfg > ${OUTDIR}/readme.html -# -# Requires docutils and pygments (both are installed with Sphinx) - -[general] -# Duplicate docutils config used by PyPA readme_renderer. -# https://github.com/pypa/readme_renderer/blob/master/readme_renderer/rst.py -cloak_email_addresses = True -doctitle_xform = True -sectsubtitle_xform = True -initial_header_level = 2 -file_insertion_enabled = False -math_output = MathJax -raw_enabled = False -smart_quotes = True -strip_comments = True -syntax_highlight = short - -# Halt rendering and throw an exception if there was any errors or warnings from docutils. -halt_level = 2 -# DON'T Disable all system messages from being reported. -# (We're not running inside readme_renderer, so *do* want to see warnings and errors.) -# report_level = 5 - -# Approximate PyPI's layout and styles: -template = docs/_readme/template.txt diff --git a/docs/_readme/render.py b/docs/_readme/render.py new file mode 100644 index 0000000..86a6366 --- /dev/null +++ b/docs/_readme/render.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# Render a README file (roughly) as it would appear on PyPI + +import argparse +import sys +from importlib.metadata import PackageNotFoundError, metadata +from pathlib import Path +from typing import Dict, Optional + +import readme_renderer.rst +from docutils.core import publish_string +from docutils.utils import SystemMessage + +# Docutils template.txt in our directory: +DEFAULT_TEMPLATE_FILE = Path(__file__).with_name("template.txt").absolute() + + +def get_package_readme(package: str) -> str: + # Note: "description" was added to metadata in Python 3.10 + return metadata(package)["description"] + + +class ReadMeHTMLWriter(readme_renderer.rst.Writer): + translator_class = readme_renderer.rst.ReadMeHTMLTranslator + + def interpolation_dict(self) -> Dict[str, str]: + result = super().interpolation_dict() + # clean the same parts as readme_renderer.rst.render: + clean = readme_renderer.rst.clean + result["docinfo"] = clean(result["docinfo"]) + result["body"] = result["fragment"] = clean(result["fragment"]) + return result + + +def render(source_text: str, warning_stream=sys.stderr) -> Optional[str]: + # Adapted from readme_renderer.rst.render + settings = readme_renderer.rst.SETTINGS.copy() + settings.update( + { + "warning_stream": warning_stream, + "template": DEFAULT_TEMPLATE_FILE, + # Input and output are text str (we handle decoding/encoding): + "input_encoding": "unicode", + "output_encoding": "unicode", + # Exit with error on docutils warning or above. + # (There's discussion of having readme_renderer ignore warnings; + # this ensures they'll be treated as errors here.) + "halt_level": 2, # (docutils.utils.Reporter.WARNING_LEVEL) + # Report all docutils warnings or above. + # (The readme_renderer default suppresses this output.) + "report_level": 2, # (docutils.utils.Reporter.WARNING_LEVEL) + } + ) + + writer = ReadMeHTMLWriter() + + try: + return publish_string( + source_text, + writer=writer, + settings_overrides=settings, + ) + except SystemMessage: + warning_stream.write("Error rendering readme source.\n") + return None + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Render readme file as it would appear on PyPI" + ) + input_group = parser.add_mutually_exclusive_group(required=True) + input_group.add_argument( + "-p", "--package", help="Source readme from package's metadata" + ) + input_group.add_argument( + "-i", + "--input", + help="Source readme.rst file ('-' for stdin)", + type=argparse.FileType("r"), + ) + parser.add_argument( + "-o", + "--output", + help="Output file (default: stdout)", + type=argparse.FileType("w"), + default="-", + ) + + args = parser.parse_args(argv) + if args.package: + try: + source_text = get_package_readme(args.package) + except PackageNotFoundError: + print(f"Package not installed: {args.package!r}", file=sys.stderr) + sys.exit(2) + if source_text is None: + print(f"No metadata readme for {args.package!r}", file=sys.stderr) + sys.exit(2) + else: + source_text = args.input.read() + rendered = render(source_text) + if rendered is None: + sys.exit(2) + args.output.write(rendered) + + +if __name__ == "__main__": + main() diff --git a/docs/_readme/template.txt b/docs/_readme/template.txt index effe895..0069528 100644 --- a/docs/_readme/template.txt +++ b/docs/_readme/template.txt @@ -1,11 +1,13 @@ %(head_prefix)s - - - - - + + - + + %(body_prefix)s diff --git a/docs/conf.py b/docs/conf.py index 246ecef..41e0718 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,6 +13,8 @@ import os import sys from pathlib import Path +from anymail import VERSION as PACKAGE_VERSION + ON_READTHEDOCS = os.environ.get("READTHEDOCS", None) == "True" DOCS_PATH = Path(__file__).parent PROJECT_ROOT_PATH = DOCS_PATH.parent @@ -22,15 +24,6 @@ PROJECT_ROOT_PATH = DOCS_PATH.parent # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, PROJECT_ROOT_PATH.resolve()) -# define __version__ and __minor_version__ from ../anymail/_version.py, -# but without importing from anymail (which would make docs dependent on Django, etc.) -__version__ = "UNSET" -__minor_version__ = "UNSET" -version_path = PROJECT_ROOT_PATH / "anymail/_version.py" -code = compile(version_path.read_text(), version_path, "exec") -exec(code) - - # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. @@ -61,10 +54,10 @@ copyright = "Anymail contributors" # |version| and |release|, also used in various other places throughout the # built documents. # -# The short X.Y version. -version = __minor_version__ # The full version, including alpha/beta/rc tags. -release = __version__ +release = ".".join(PACKAGE_VERSION) +# The short X.Y version. +version = ".".join(PACKAGE_VERSION[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/contributing.rst b/docs/contributing.rst index bb0a3a2..0c34391 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -20,13 +20,13 @@ The `Anymail source code`_ is on GitHub. Contributors ------------ -See `AUTHORS.txt`_ for a list of some of the people who have helped +See the `contributor chart`_ for a list of some of the people who have helped improve Anymail. Anymail evolved from the `Djrill`_ project. Special thanks to the folks from `brack3t`_ who developed the original version of Djrill. -.. _AUTHORS.txt: https://github.com/anymail/django-anymail/blob/main/AUTHORS.txt +.. _contributor chart: https://github.com/anymail/django-anymail/graphs/contributors .. _brack3t: http://brack3t.com/ .. _Djrill: https://github.com/brack3t/Djrill @@ -72,48 +72,42 @@ Anymail is `tested via GitHub Actions`_ against several combinations of Django and Python versions. Tests are run at least once a week, to check whether ESP APIs and other dependencies have changed out from under Anymail. -For local development, the recommended test command is -:shell:`tox -e django31-py38-all,django20-py35-all,lint`, which tests a representative -combination of Python and Django versions. It also runs :pypi:`flake8` and other -code-style checkers. Some other test options are covered below, but using this -tox command catches most problems, and is a good pre-pull-request check. - -Most of the included tests verify that Anymail constructs the expected ESP API -calls, without actually calling the ESP's API or sending any email. So these tests -don't require API keys, but they *do* require :pypi:`mock` and all ESP-specific -package requirements. - -To run the tests, you can: +To run the tests locally, use :pypi:`tox`: .. code-block:: console - $ python setup.py test # (also installs test dependencies if needed) + ## install tox and other development requirements: + $ python -m pip install -r requirements-dev.txt -Or: + ## test a representative combination of Python and Django versions: + $ tox -e lint,django42-py311-all,django30-py37-all,docs + + ## you can also run just some test cases, e.g.: + $ tox -e django42-py311-all tests.test_mailgun_backend tests.test_utils + + ## to test more Python/Django versions: + $ tox --parallel auto # ALL 20+ envs! (in parallel if possible) + +(If your system doesn't come with the necessary Python versions, `pyenv`_ is helpful +to install and manage them. Or use the :shell:`--skip-missing-interpreters` tox option.) + +If you don't want to use tox (or have trouble getting it working), you can run +the tests in your current Python environment: .. code-block:: console - $ pip install mock boto3 # install test dependencies + ## install the testing requirements (if any): + $ python -m pip install -r tests/requirements.txt + + ## run the tests: $ python runtests.py ## this command can also run just a few test cases, e.g.: $ python runtests.py tests.test_mailgun_backend tests.test_mailgun_webhooks -Or to test against multiple versions of Python and Django all at once, use :pypi:`tox`. -You'll need some version of Python 3 available. (If your system doesn't come -with that, `pyenv`_ is a helpful way to install and manage multiple Python versions.) - - .. code-block:: console - - $ pip install tox # (if you haven't already) - $ tox -e django31-py38-all,django20-py35-all,lint # test recommended environments - - ## you can also run just some test cases, e.g.: - $ tox -e django31-py38-all,django20-py35-all tests.test_mailgun_backend tests.test_utils - - ## to test more Python/Django versions: - $ tox --parallel auto # ALL 20+ envs! (in parallel if possible) - $ tox --skip-missing-interpreters # if some Python versions aren't installed +Most of the included tests verify that Anymail constructs the expected ESP API +calls, without actually calling the ESP's API or sending any email. (So these +tests don't require any API keys.) In addition to the mocked tests, Anymail has integration tests which *do* call live ESP APIs. These tests are normally skipped; to run them, set environment variables with the necessary @@ -123,20 +117,19 @@ API keys or other settings. For example: $ export ANYMAIL_TEST_MAILGUN_API_KEY='your-Mailgun-API-key' $ export ANYMAIL_TEST_MAILGUN_DOMAIN='mail.example.com' # sending domain for that API key - $ tox -e django31-py38-all tests.test_mailgun_integration + $ tox -e django42-py311-all tests.test_mailgun_integration Check the ``*_integration_tests.py`` files in the `tests source`_ to see which variables are required for each ESP. Depending on the supported features, the integration tests for a particular ESP send around 5-15 individual messages. For ESPs that don't offer a sandbox, these will be real sends charged to your account (again, see the notes in each test case). -Be sure to specify a particular testenv with tox's `-e` option, or tox may repeat the tests +Be sure to specify a particular testenv with tox's :shell:`-e` option, or tox will repeat the tests for all 20+ supported combinations of Python and Django, sending hundreds of messages. .. _pyenv: https://github.com/pyenv/pyenv .. _tested via GitHub Actions: https://github.com/anymail/django-anymail/actions?query=workflow:test .. _tests source: https://github.com/anymail/django-anymail/blob/main/tests -.. _.travis.yml: https://github.com/anymail/django-anymail/blob/main/.travis.yml Documentation @@ -155,14 +148,14 @@ It's easiest to build Anymail's docs using tox: .. code-block:: console - $ pip install tox # (if you haven't already) + $ python -m pip install -r requirements-dev.txt $ tox -e docs # build the docs using Sphinx You can run Python's simple HTTP server to view them: .. code-block:: console - $ (cd .tox/docs/_html; python3 -m http.server 8123 --bind 127.0.0.1) + $ (cd .tox/docs/_html; python -m http.server 8123 --bind 127.0.0.1) ... and then open http://localhost:8123/ in a browser. Leave the server running, and just re-run the tox command and refresh your browser as you make changes. diff --git a/docs/esps/amazon_ses.rst b/docs/esps/amazon_ses.rst index 9b1c7b7..84a573b 100644 --- a/docs/esps/amazon_ses.rst +++ b/docs/esps/amazon_ses.rst @@ -34,14 +34,21 @@ Installation ------------ You must ensure the :pypi:`boto3` package is installed to use Anymail's Amazon SES -backend. Either include the ``amazon_ses`` option when you install Anymail: +backend. Either include the ``amazon-ses`` option when you install Anymail: .. code-block:: console - $ pip install "django-anymail[amazon_ses]" + $ pip install "django-anymail[amazon-ses]" or separately run ``pip install boto3``. +.. versionchanged:: 10.0 + + In earlier releases, the "extra name" could use an underscore + (``django-anymail[amazon_ses]``). That now causes pip to warn + that "django-anymail does not provide the extra 'amazon_ses'", + and may result in a broken installation that is missing boto3. + To send mail with Anymail's Amazon SES backend, set: .. code-block:: python diff --git a/docs/requirements.txt b/docs/requirements.txt index 6bae48a..ea25bff 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,7 @@ +# Packages required only for building docs + # (Pygments defaulted "python" to Python 2 before v2.5.0; it doesn't use semver) Pygments~=2.9.0 +readme-renderer~=37.3 sphinx~=4.0 sphinx-rtd-theme~=0.5.2 diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..fe06808 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,43 @@ +# Hatch custom build hook that generates dynamic readme. + +import re +from pathlib import Path + +from hatchling.metadata.plugin.interface import MetadataHookInterface + + +def freeze_readme_versions(text: str, version: str) -> str: + """ + Rewrite links in readme text to refer to specific version. + (This assumes version X.Y will be tagged "vX.Y" in git.) + """ + release_tag = f"v{version}" + return re.sub( + # (?<=...) is "positive lookbehind": must be there, but won't get replaced + # GitHub Actions build status: branch=main --> branch=vX.Y.Z: + r"(?<=branch[=:])main" + # ReadTheDocs links: /stable --> /vX.Y.Z: + r"|(?<=/)stable" + # ReadTheDocs badge: version=stable --> version=vX.Y.Z: + r"|(?<=version=)stable", + release_tag, + text, + ) + + +class CustomMetadataHook(MetadataHookInterface): + def update(self, metadata): + """ + Update the project table's metadata. + """ + readme_path = Path(self.root) / self.config["readme"] + content_type = self.config.get("content-type", "text/x-rst") + version = metadata["version"] + + readme_text = readme_path.read_text() + readme_text = freeze_readme_versions(readme_text, version) + + metadata["readme"] = { + "content-type": content_type, + "text": readme_text, + } diff --git a/pyproject.toml b/pyproject.toml index 3cb6736..39f28ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,103 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "django-anymail" +dynamic = ["readme", "version"] +license = {file = "LICENSE"} + +authors = [ + {name = "Mike Edmunds", email = "medmunds@gmail.com"}, + {name = "Anymail Contributors"}, +] +description = """\ + Django email backends and webhooks for Amazon SES, MailerSend, Mailgun, \ + Mailjet, Mandrill, Postal, Postmark, SendGrid, SendinBlue, and SparkPost\ + """ +# readme: see tool.hatch.metadata.hooks.custom below +keywords = [ + "Django", "email", "email backend", + "ESP", "transactional mail", + "Amazon SES", + "MailerSend", "Mailgun", "Mailjet", "Mandrill", + "Postal", "Postmark", + "SendGrid", "SendinBlue", "SparkPost", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: BSD License", + "Topic :: Communications :: Email", + "Topic :: Software Development :: Libraries :: Python Modules", + "Intended Audience :: Developers", + "Framework :: Django", + "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + # not yet registered: "Framework :: Django :: 4.2", + "Environment :: Web Environment", +] + +requires-python = ">=3.7" +dependencies = [ + "django>=2.0", + "requests>=2.4.3", + "urllib3>=1.25.0", # requests dependency: fixes RFC 7578 header encoding +] + +[project.optional-dependencies] +# ESP-specific additional dependencies. +# (For simplicity, requests is included in the base dependencies.) +# (Do not use underscores in extra names: they get normalized to hyphens.) +amazon-ses = ["boto3"] +mailersend = [] +mailgun = [] +mailjet = [] +mandrill = [] +postmark = [] +sendgrid = [] +sendinblue = [] +sparkpost = [] +postal = [ + # Postal requires cryptography for verifying webhooks. + # Cryptography's wheels are broken on darwin-arm64 before Python 3.9. + "cryptography; sys_platform != 'darwin' or platform_machine != 'arm64' or python_version >= '3.9'" +] + +[project.urls] +Homepage = "https://github.com/anymail/django-anymail" +Documentation = "https://anymail.dev/en/stable/" +Source = "https://github.com/anymail/django-anymail" +Changelog = "https://anymail.dev/en/stable/changelog/" +Tracker = "https://github.com/anymail/django-anymail/issues" + +[tool.hatch.build] +packages = ["anymail"] +# Hatch automatically includes pyproject.toml, LICENSE, and hatch_build.py. +# Help it find the dynamic readme source (otherwise wheel will only build with +# `hatch build`, not with `python -m build`): +force-include = {"README.rst" = "README.rst"} + +[tool.hatch.metadata.hooks.custom] +# Provides dynamic readme +path = "hatch_build.py" +readme = "README.rst" + +[tool.hatch.version] +path = "anymail/_version.py" + + [tool.black] force-exclude = '^/tests/test_settings/settings_.*\.py' max-line-length = 88 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..753eb1c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Requirements for developing (not just using) the package + +hatch +pre-commit +tox<4 diff --git a/runtests.py b/runtests.py index d53fbd9..6a74088 100755 --- a/runtests.py +++ b/runtests.py @@ -1,8 +1,6 @@ #!/usr/bin/env python -# python setup.py test -# or -# runtests.py [tests.test_x tests.test_y.SomeTestCase ...] +# usage: python runtests.py [tests.test_x tests.test_y.SomeTestCase ...] import os import re diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9bdc1d5..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[metadata] -license_file = LICENSE - -[bdist_wheel] diff --git a/setup.py b/setup.py deleted file mode 100644 index c5d463b..0000000 --- a/setup.py +++ /dev/null @@ -1,134 +0,0 @@ -import re -from codecs import open # to use a consistent encoding -from collections import OrderedDict -from os import path - -from setuptools import setup - -here = path.abspath(path.dirname(__file__)) - -# get versions from anymail/_version.py, -# but without importing from anymail (which would break setup) -with open(path.join(here, "anymail/_version.py"), encoding="utf-8") as f: - code = compile(f.read(), "anymail/_version.py", "exec") - _version = {} - exec(code, _version) - version = _version["__version__"] # X.Y or X.Y.Z or X.Y.Z.dev1 etc. - release_tag = "v%s" % version # vX.Y or vX.Y.Z - - -def long_description_from_readme(rst): - # Freeze external links (on PyPI) to refer to this X.Y or X.Y.Z tag. - # (This relies on tagging releases with 'vX.Y' or 'vX.Y.Z' in GitHub.) - rst = re.sub( - # (?<=...) is "positive lookbehind": must be there, but won't get replaced - # GitHub Actions build status: branch=main --> branch=vX.Y.Z: - r"(?<=branch[=:])main" - # ReadTheDocs links: /stable --> /vX.Y.Z: - r"|(?<=/)stable" - # ReadTheDocs badge: version=stable --> version=vX.Y.Z: - r"|(?<=version=)stable", - release_tag, - rst, - ) - return rst - - -with open(path.join(here, "README.rst"), encoding="utf-8") as f: - long_description = long_description_from_readme(f.read()) - - -# Additional requirements for development/build/release -requirements_dev = [ - "pre-commit", - "sphinx", - "sphinx-rtd-theme", - "tox", - "twine", - "wheel", -] - -# Additional requirements for running tests -requirements_test = [] - - -setup( - name="django-anymail", - version=version, - description=( - "Django email backends and webhooks for Amazon SES, MailerSend, Mailgun," - " Mailjet, Mandrill, Postal, Postmark, SendGrid, SendinBlue, and SparkPost" - ), - keywords=( - "Django, email, email backend, ESP, transactional mail," - " Amazon SES, MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark," - " SendGrid, SendinBlue, SparkPost" - ), - author="Mike Edmunds and Anymail contributors", - author_email="medmunds@gmail.com", - url="https://github.com/anymail/django-anymail", - license="BSD License", - packages=["anymail"], - zip_safe=False, - python_requires=">=3.7", - install_requires=[ - "django>=2.0", - "requests>=2.4.3", - "urllib3>=1.25.0", # requests dependency: fixes RFC 7578 header encoding - ], - extras_require={ - # This can be used if particular backends have unique dependencies. - # For simplicity, requests is included in the base requirements. - "amazon_ses": ["boto3"], - "mailersend": [], - "mailgun": [], - "mailjet": [], - "mandrill": [], - "postmark": [], - "sendgrid": [], - "sendinblue": [], - "sparkpost": [], - "postal": ["cryptography"], - # Development/test-only requirements - # (install with python -m pip -e '.[dev,test]') - "dev": requirements_dev, - "test": requirements_test, - }, - include_package_data=True, - test_suite="runtests.runtests", - tests_require=requirements_test, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Programming Language :: Python", - "Programming Language :: Python :: Implementation :: PyPy", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "License :: OSI Approved :: BSD License", - "Topic :: Communications :: Email", - "Topic :: Software Development :: Libraries :: Python Modules", - "Intended Audience :: Developers", - "Framework :: Django", - "Framework :: Django :: 3.0", - "Framework :: Django :: 3.1", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.0", - "Framework :: Django :: 4.1", - "Framework :: Django :: 4.2", - "Environment :: Web Environment", - ], - long_description=long_description, - long_description_content_type="text/x-rst", - project_urls=OrderedDict( - [ - ("Documentation", "https://anymail.dev/en/%s/" % release_tag), - ("Source", "https://github.com/anymail/django-anymail"), - ("Changelog", "https://anymail.dev/en/%s/changelog/" % release_tag), - ("Tracker", "https://github.com/anymail/django-anymail/issues"), - ] - ), -) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..1d35b7c --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +# Additional packages needed only for running tests diff --git a/tests/test_amazon_ses_backend.py b/tests/test_amazon_ses_backend.py index 5a4ec00..46337bc 100644 --- a/tests/test_amazon_ses_backend.py +++ b/tests/test_amazon_ses_backend.py @@ -7,6 +7,7 @@ from django.core import mail from django.core.mail import BadHeaderError from django.test import SimpleTestCase, override_settings, tag +from anymail import __version__ as ANYMAIL_VERSION from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature from anymail.inbound import AnymailInboundMessage from anymail.message import AnymailMessage, attach_inline_image_file @@ -812,8 +813,9 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase): config = client_params.pop("config") # no additional params passed to session.client('ses'): self.assertEqual(client_params, {}) - self.assertRegex( - config.user_agent_extra, r"django-anymail/\d(\.\w+){1,}-amazon-ses" + self.assertIn( + f"django-anymail/{ANYMAIL_VERSION}-amazon-ses", + config.user_agent_extra, ) @override_settings( diff --git a/tests/test_amazon_sesv2_backend.py b/tests/test_amazon_sesv2_backend.py index 12b589f..d99ac60 100644 --- a/tests/test_amazon_sesv2_backend.py +++ b/tests/test_amazon_sesv2_backend.py @@ -8,6 +8,7 @@ from django.core import mail from django.core.mail import BadHeaderError from django.test import SimpleTestCase, override_settings, tag +from anymail import __version__ as ANYMAIL_VERSION from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature from anymail.inbound import AnymailInboundMessage from anymail.message import AnymailMessage, attach_inline_image_file @@ -871,8 +872,9 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase): config = client_params.pop("config") # no additional params passed to session.client('ses'): self.assertEqual(client_params, {}) - self.assertRegex( - config.user_agent_extra, r"django-anymail/\d(\.\w+){1,}-amazon-ses" + self.assertIn( + f"django-anymail/{ANYMAIL_VERSION}-amazon-ses", + config.user_agent_extra, ) @override_settings( diff --git a/tests/test_sendinblue_integration.py b/tests/test_sendinblue_integration.py index 402c6d9..ecce1ad 100644 --- a/tests/test_sendinblue_integration.py +++ b/tests/test_sendinblue_integration.py @@ -120,10 +120,10 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): "attachment": [ { "name": "attachment1.txt", - # URL where Sendinblue can download - # the attachment content while sending: - "url": "https://raw.githubusercontent.com/anymail" - "/django-anymail/main/AUTHORS.txt", + # URL where Sendinblue can download the attachment content while + # sending (must be content-type: text/plain): + "url": "https://raw.githubusercontent.com/anymail/django-anymail/" + "main/docs/_readme/template.txt", } ] } diff --git a/tox.ini b/tox.ini index 90e17a7..5b09d65 100644 --- a/tox.ini +++ b/tox.ini @@ -28,9 +28,15 @@ envlist = djangoDev-py{310,311}-all # ... then partial installation (limit extras): django42-py311-{none,amazon_ses,postal} +# tox requires isolated builds to use pyproject.toml build config: +isolated_build = True [testenv] +args_are_paths = false +# Download latest version of pip/setuptools available on each Python version: +download = true deps = + -rtests/requirements.txt django30: django~=3.0.0 django31: django~=3.1.0 django32: django~=3.2.0 @@ -40,10 +46,10 @@ deps = django50: django~=5.0.0a0 djangoDev: https://github.com/django/django/tarball/main extras = - # install [test] extras, unconditionally - test - # install [esp_name] extras only when testing "all" or esp_name factor - all,amazon_ses: amazon_ses + # Install [esp-name] extras only when testing "all" or esp_name factor. + # (Only ESPs with extra dependencies need to be listed here. + # Careful: tox factors (on the left) use underscore; extra names use hyphen.) + all,amazon_ses: amazon-ses all,postal: postal setenv = # tell runtests.py to limit some test tags based on extras factor @@ -61,11 +67,9 @@ setenv = ignore_outcome = # CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false djangoDev: {env:TOX_OVERRIDE_IGNORE_OUTCOME:true} -args_are_paths = false -# Upgrade pip/wheel/setuptools: -download = true commands_pre = python -VV + python -m pip --version python -c 'import django; print("Django", django.__version__)' commands = python runtests.py {posargs} @@ -98,25 +102,22 @@ commands = pre-commit run --all-files [testenv:docs] -basepython = python3.8 -skip_install = true +basepython = python3.10 passenv = CONTINUOUS_INTEGRATION # (but not any of the live test API keys) setenv = DOCS_BUILD_DIR={envdir}/_html -whitelist_externals = /bin/bash deps = -rdocs/requirements.txt commands_pre = python -VV sphinx-build --version commands = - # Verify README.rst as used in setup.py long_description: - python setup.py check --restructuredtext --strict # Build and verify docs: sphinx-build -W -b dirhtml docs {env:DOCS_BUILD_DIR} - # Build README.rst into html: - /bin/bash -c 'python setup.py --long-description \ - | rst2html5.py --config=docs/_readme/docutils.cfg \ - > {env:DOCS_BUILD_DIR}/readme.html' + # Build and verify package metadata readme. + # Errors here are in README.rst: + python docs/_readme/render.py \ + --package django-anymail \ + --out {env:DOCS_BUILD_DIR}/readme.html