Modernize packaging

Switch to pyproject.toml packaging, using hatchling.

- Replace all uses of setup.py with updated equivalent
- BREAKING: Change extra name `amazon_ses` to
  `amazon-ses`, to comply with Python packaging
  name normalization
- Use hatch custom build hook to freeze version number
  in readme (previously custom setup.py code)
- Move separate requirements for dev, docs, tests
  into their own requirements.txt files
- Fix AnymailImproperlyInstalled to correctly refer
  to package extra name
- Update testing documentation
- Update docs readme rendering to match PyPI
  (and avoid setup.py)
- In tox tests, use isolated builds and update pip
- Remove AUTHORS.txt (it just referred to GitHub)
This commit is contained in:
Mike Edmunds
2023-05-03 16:55:08 -07:00
committed by GitHub
parent 9fba58237d
commit e8df0ec8e0
31 changed files with 418 additions and 292 deletions

View File

@@ -24,19 +24,24 @@ jobs:
steps: steps:
- name: Get code - name: Get code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.10"
- name: Install build requirements
run: |
python -m pip install --upgrade build hatch twine
- name: Get version - name: Get version
# (This will end the workflow if git and source versions don't match.) # (This will end the workflow if git and source versions don't match.)
id: version id: version
run: | run: |
VERSION="$(python setup.py --version)" VERSION="$(python -m hatch version)"
TAG="v$VERSION" TAG="v$VERSION"
GIT_TAG="$(git tag -l --points-at "$GITHUB_REF" 'v*')" 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'" echo "::error ::package version '$TAG' does not match git tag '$GIT_TAG'"
exit 1 exit 1
fi fi
@@ -44,21 +49,18 @@ jobs:
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "anchor=${TAG//[^[:alnum:]]/-}" >> $GITHUB_OUTPUT echo "anchor=${TAG//[^[:alnum:]]/-}" >> $GITHUB_OUTPUT
- name: Install build requirements
run: |
pip install twine wheel
- name: Build - name: Build
run: | run: |
rm -rf build dist django_anymail.egg-info rm -rf build dist django_anymail.egg-info
python setup.py sdist bdist_wheel python -m build
twine check dist/* python -m twine check dist/*
- name: Publish to PyPI - name: Publish to PyPI
env: env:
TWINE_USERNAME: __token__ TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: | run: |
twine upload dist/* python -m twine upload dist/*
- name: Release to GitHub - name: Release to GitHub
env: env:

View File

@@ -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 extremely low volume), a sandbox API, or that they offer developer
accounts for open source projects like Anymail. 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 ## EmailBackend and payload
Anymail abstracts a lot of common functionality into its base classes; 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 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" If the client lib supports the notion of a reusable API "connection"
(or session), you should override `open()` and `close()` to provide (or session), you should override `open()` and `close()` to provide
API state caching. See the notes in the base implementation. API state caching. See the notes in the base implementation.

View File

@@ -1,5 +0,0 @@
Anymail
=======
Please see https://github.com/anymail/django-anymail/graphs/contributors
for the complete list of Anymail contributors.

View File

@@ -33,9 +33,25 @@ vNext
Breaking changes 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 Python 3.7 or later.
* Require urllib3 1.25 or later (released 2019-04-29). * 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 v9.2
----- -----

View File

@@ -1,2 +0,0 @@
include README.rst AUTHORS.txt LICENSE
recursive-include anymail *.py

View File

@@ -5,9 +5,8 @@ Anymail: Django email integration for transactional ESPs
* Github: project page, exactly as it appears here * Github: project page, exactly as it appears here
* Docs: shared-intro section gets included in docs/index.rst * Docs: shared-intro section gets included in docs/index.rst
quickstart section gets included in docs/quickstart.rst quickstart section gets included in docs/quickstart.rst
* PyPI: project page (via setup.py long_description), * PyPI: project page (via pyproject.toml readme; see also
with several edits to freeze it to the specific PyPI release hatch_build.py which edits in the release version number)
(see long_description_from_readme in setup.py)
You can use docutils 1.0 markup, but *not* any Sphinx additions. You can use docutils 1.0 markup, but *not* any Sphinx additions.
GitHub rst supports code-block, but *no other* block directives. GitHub rst supports code-block, but *no other* block directives.

View File

@@ -1,7 +1,17 @@
# Expose package version at root of package from ._version import VERSION, __version__
from django import VERSION as DJANGO_VERSION
from ._version import VERSION, __version__ # NOQA: F401 __all__ = [
"VERSION",
"__version__",
]
if DJANGO_VERSION < (3, 2, 0): try:
default_app_config = "anymail.apps.AnymailBaseConfig" 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")

View File

@@ -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 #: major.minor.patch or major.minor.devN
__version__ = ".".join([str(x) for x in VERSION]) __version__ = "10.0.dev0"
#: Sphinx's X.Y "version" VERSION = __version__.split(",")
__minor_version__ = ".".join([str(x) for x in VERSION[:2]])

View File

@@ -13,7 +13,7 @@ try:
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
except ImportError as err: except ImportError as err:
raise AnymailImproperlyInstalled( raise AnymailImproperlyInstalled(
missing_package="boto3", backend="amazon_ses" missing_package="boto3", install_extra="amazon-ses"
) from err ) from err

View File

@@ -14,7 +14,7 @@ try:
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
except ImportError as err: except ImportError as err:
raise AnymailImproperlyInstalled( raise AnymailImproperlyInstalled(
missing_package="boto3", backend="amazon_sesv2" missing_package="boto3", install_extra="amazon-ses"
) from err ) from err

View File

@@ -171,11 +171,13 @@ class AnymailConfigurationError(ImproperlyConfigured):
class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError): class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
"""Exception for Anymail missing package dependencies""" """Exception for Anymail missing package dependencies"""
def __init__(self, missing_package, backend="<backend>"): def __init__(self, missing_package, install_extra="<esp>"):
# install_extra must be the package "optional extras name" for the ESP
# (not the backend's esp_name)
message = ( message = (
"The %s package is required to use this ESP, but isn't installed.\n" "The %s package is required to use this ESP, but isn't installed.\n"
"(Be sure to use `pip install django-anymail[%s]` " '(Be sure to use `pip install "django-anymail[%s]"` '
"with your desired ESPs.)" % (missing_package, backend) "with your desired ESP name(s).)" % (missing_package, install_extra)
) )
super().__init__(message) super().__init__(message)

View File

@@ -33,11 +33,11 @@ except ImportError:
# This module gets imported by anymail.urls, so don't complain about boto3 missing # 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 # unless one of the Amazon SES webhook views is actually used and needs it
boto3 = _LazyError( boto3 = _LazyError(
AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses") AnymailImproperlyInstalled(missing_package="boto3", install_extra="amazon-ses")
) )
ClientError = object ClientError = object
_get_anymail_boto3_params = _LazyError( _get_anymail_boto3_params = _LazyError(
AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses") AnymailImproperlyInstalled(missing_package="boto3", install_extra="amazon-ses")
) )

View File

@@ -31,7 +31,9 @@ except ImportError:
# This module gets imported by anymail.urls, so don't complain about cryptography # 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 # missing unless one of the Postal webhook views is actually used and needs it
error = _LazyError( error = _LazyError(
AnymailImproperlyInstalled(missing_package="cryptography", backend="postal") AnymailImproperlyInstalled(
missing_package="cryptography", install_extra="postal"
)
) )
serialization = error serialization = error
hashes = error hashes = error

View File

@@ -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

109
docs/_readme/render.py Normal file
View File

@@ -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()

View File

@@ -1,11 +1,13 @@
%(head_prefix)s %(head_prefix)s
<!-- <!--
This approximates PyPI.org project page styling as of 8/2020, This approximates PyPI.org project page styling as of 5/2023,
and loads their compiled CSS that was in use at that time. and loads their compiled CSS that was in use at that time.
(Styling seems to change more often than basic page structure, (Styling seems to change more often than basic page structure,
so to update, it may be sufficient to copy in the current so to update, it may be sufficient to copy in the current
<link rel="stylesheet" ...> tags from any live package page.) <link rel="stylesheet" ...> tags from any live package page.
Be sure to convert or escape any percent chars in copied urls,
to avoid "not enough arguments for format string" errors.)
This extends the docutils base template found at This extends the docutils base template found at
${SITE_PACKAGES}/docutils/writers/html5_polyglot/template.txt ${SITE_PACKAGES}/docutils/writers/html5_polyglot/template.txt
@@ -15,13 +17,13 @@
%(head)s %(head)s
<!-- template (stylesheet) omitted --> <!-- template (stylesheet) omitted -->
<link rel="stylesheet" href="/static/css/warehouse-ltr.f2d4f304.css"> <link rel="stylesheet" href="/static/css/warehouse-ltr.a42ccb04.css">
<link rel="stylesheet" href="/static/css/fontawesome.6002a161.css"> <link rel="stylesheet" href="/static/css/fontawesome.d37999f3.css">
<link rel="stylesheet" href="/static/css/regular.98fbf39a.css">
<link rel="stylesheet" href="/static/css/solid.c3b5f0b5.css">
<link rel="stylesheet" href="/static/css/brands.2c303be1.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400italic,600,600italic,700,700italic|Source+Code+Pro:500"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400italic,600,600italic,700,700italic|Source+Code+Pro:500">
<link rel="icon" href="/static/images/favicon.6a76275d.ico" type="image/x-icon"> <noscript>
<link rel="stylesheet" href="/static/css/noscript.0673c9ea.css">
</noscript>
<link rel="icon" href="/static/images/favicon.35549fe8.ico" type="image/x-icon">
%(body_prefix)s %(body_prefix)s

View File

@@ -13,6 +13,8 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from anymail import VERSION as PACKAGE_VERSION
ON_READTHEDOCS = os.environ.get("READTHEDOCS", None) == "True" ON_READTHEDOCS = os.environ.get("READTHEDOCS", None) == "True"
DOCS_PATH = Path(__file__).parent DOCS_PATH = Path(__file__).parent
PROJECT_ROOT_PATH = DOCS_PATH.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. # documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, PROJECT_ROOT_PATH.resolve()) 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 ----------------------------------------------------- # -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # 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 # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
# #
# The short X.Y version.
version = __minor_version__
# The full version, including alpha/beta/rc tags. # 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 # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

View File

@@ -20,13 +20,13 @@ The `Anymail source code`_ is on GitHub.
Contributors 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. improve Anymail.
Anymail evolved from the `Djrill`_ project. Special thanks to the Anymail evolved from the `Djrill`_ project. Special thanks to the
folks from `brack3t`_ who developed the original version of Djrill. 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/ .. _brack3t: http://brack3t.com/
.. _Djrill: https://github.com/brack3t/Djrill .. _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 Python versions. Tests are run at least once a week, to check whether ESP APIs
and other dependencies have changed out from under Anymail. and other dependencies have changed out from under Anymail.
For local development, the recommended test command is To run the tests locally, use :pypi:`tox`:
: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:
.. code-block:: console .. 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 .. 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 $ python runtests.py
## this command can also run just a few test cases, e.g.: ## this command can also run just a few test cases, e.g.:
$ python runtests.py tests.test_mailgun_backend tests.test_mailgun_webhooks $ 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`. Most of the included tests verify that Anymail constructs the expected ESP API
You'll need some version of Python 3 available. (If your system doesn't come calls, without actually calling the ESP's API or sending any email. (So these
with that, `pyenv`_ is a helpful way to install and manage multiple Python versions.) tests don't require any API keys.)
.. 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
In addition to the mocked tests, Anymail has integration tests which *do* call live ESP APIs. 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 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_API_KEY='your-Mailgun-API-key'
$ export ANYMAIL_TEST_MAILGUN_DOMAIN='mail.example.com' # sending domain for that 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 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 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, 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). 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. for all 20+ supported combinations of Python and Django, sending hundreds of messages.
.. _pyenv: https://github.com/pyenv/pyenv .. _pyenv: https://github.com/pyenv/pyenv
.. _tested via GitHub Actions: https://github.com/anymail/django-anymail/actions?query=workflow:test .. _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 .. _tests source: https://github.com/anymail/django-anymail/blob/main/tests
.. _.travis.yml: https://github.com/anymail/django-anymail/blob/main/.travis.yml
Documentation Documentation
@@ -155,14 +148,14 @@ It's easiest to build Anymail's docs using tox:
.. code-block:: console .. 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 $ tox -e docs # build the docs using Sphinx
You can run Python's simple HTTP server to view them: You can run Python's simple HTTP server to view them:
.. code-block:: console .. 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 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. and just re-run the tox command and refresh your browser as you make changes.

View File

@@ -34,14 +34,21 @@ Installation
------------ ------------
You must ensure the :pypi:`boto3` package is installed to use Anymail's Amazon SES 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 .. code-block:: console
$ pip install "django-anymail[amazon_ses]" $ pip install "django-anymail[amazon-ses]"
or separately run ``pip install boto3``. 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: To send mail with Anymail's Amazon SES backend, set:
.. code-block:: python .. code-block:: python

View File

@@ -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 defaulted "python" to Python 2 before v2.5.0; it doesn't use semver)
Pygments~=2.9.0 Pygments~=2.9.0
readme-renderer~=37.3
sphinx~=4.0 sphinx~=4.0
sphinx-rtd-theme~=0.5.2 sphinx-rtd-theme~=0.5.2

43
hatch_build.py Normal file
View File

@@ -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,
}

View File

@@ -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] [tool.black]
force-exclude = '^/tests/test_settings/settings_.*\.py' force-exclude = '^/tests/test_settings/settings_.*\.py'
max-line-length = 88 max-line-length = 88

5
requirements-dev.txt Normal file
View File

@@ -0,0 +1,5 @@
# Requirements for developing (not just using) the package
hatch
pre-commit
tox<4

View File

@@ -1,8 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
# python setup.py test # usage: python runtests.py [tests.test_x tests.test_y.SomeTestCase ...]
# or
# runtests.py [tests.test_x tests.test_y.SomeTestCase ...]
import os import os
import re import re

View File

@@ -1,4 +0,0 @@
[metadata]
license_file = LICENSE
[bdist_wheel]

134
setup.py
View File

@@ -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"),
]
),
)

1
tests/requirements.txt Normal file
View File

@@ -0,0 +1 @@
# Additional packages needed only for running tests

View File

@@ -7,6 +7,7 @@ from django.core import mail
from django.core.mail import BadHeaderError from django.core.mail import BadHeaderError
from django.test import SimpleTestCase, override_settings, tag from django.test import SimpleTestCase, override_settings, tag
from anymail import __version__ as ANYMAIL_VERSION
from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature
from anymail.inbound import AnymailInboundMessage from anymail.inbound import AnymailInboundMessage
from anymail.message import AnymailMessage, attach_inline_image_file from anymail.message import AnymailMessage, attach_inline_image_file
@@ -812,8 +813,9 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase):
config = client_params.pop("config") config = client_params.pop("config")
# no additional params passed to session.client('ses'): # no additional params passed to session.client('ses'):
self.assertEqual(client_params, {}) self.assertEqual(client_params, {})
self.assertRegex( self.assertIn(
config.user_agent_extra, r"django-anymail/\d(\.\w+){1,}-amazon-ses" f"django-anymail/{ANYMAIL_VERSION}-amazon-ses",
config.user_agent_extra,
) )
@override_settings( @override_settings(

View File

@@ -8,6 +8,7 @@ from django.core import mail
from django.core.mail import BadHeaderError from django.core.mail import BadHeaderError
from django.test import SimpleTestCase, override_settings, tag from django.test import SimpleTestCase, override_settings, tag
from anymail import __version__ as ANYMAIL_VERSION
from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature
from anymail.inbound import AnymailInboundMessage from anymail.inbound import AnymailInboundMessage
from anymail.message import AnymailMessage, attach_inline_image_file from anymail.message import AnymailMessage, attach_inline_image_file
@@ -871,8 +872,9 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase):
config = client_params.pop("config") config = client_params.pop("config")
# no additional params passed to session.client('ses'): # no additional params passed to session.client('ses'):
self.assertEqual(client_params, {}) self.assertEqual(client_params, {})
self.assertRegex( self.assertIn(
config.user_agent_extra, r"django-anymail/\d(\.\w+){1,}-amazon-ses" f"django-anymail/{ANYMAIL_VERSION}-amazon-ses",
config.user_agent_extra,
) )
@override_settings( @override_settings(

View File

@@ -120,10 +120,10 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"attachment": [ "attachment": [
{ {
"name": "attachment1.txt", "name": "attachment1.txt",
# URL where Sendinblue can download # URL where Sendinblue can download the attachment content while
# the attachment content while sending: # sending (must be content-type: text/plain):
"url": "https://raw.githubusercontent.com/anymail" "url": "https://raw.githubusercontent.com/anymail/django-anymail/"
"/django-anymail/main/AUTHORS.txt", "main/docs/_readme/template.txt",
} }
] ]
} }

33
tox.ini
View File

@@ -28,9 +28,15 @@ envlist =
djangoDev-py{310,311}-all djangoDev-py{310,311}-all
# ... then partial installation (limit extras): # ... then partial installation (limit extras):
django42-py311-{none,amazon_ses,postal} django42-py311-{none,amazon_ses,postal}
# tox requires isolated builds to use pyproject.toml build config:
isolated_build = True
[testenv] [testenv]
args_are_paths = false
# Download latest version of pip/setuptools available on each Python version:
download = true
deps = deps =
-rtests/requirements.txt
django30: django~=3.0.0 django30: django~=3.0.0
django31: django~=3.1.0 django31: django~=3.1.0
django32: django~=3.2.0 django32: django~=3.2.0
@@ -40,10 +46,10 @@ deps =
django50: django~=5.0.0a0 django50: django~=5.0.0a0
djangoDev: https://github.com/django/django/tarball/main djangoDev: https://github.com/django/django/tarball/main
extras = extras =
# install [test] extras, unconditionally # Install [esp-name] extras only when testing "all" or esp_name factor.
test # (Only ESPs with extra dependencies need to be listed here.
# install [esp_name] extras only when testing "all" or esp_name factor # Careful: tox factors (on the left) use underscore; extra names use hyphen.)
all,amazon_ses: amazon_ses all,amazon_ses: amazon-ses
all,postal: postal all,postal: postal
setenv = setenv =
# tell runtests.py to limit some test tags based on extras factor # tell runtests.py to limit some test tags based on extras factor
@@ -61,11 +67,9 @@ setenv =
ignore_outcome = ignore_outcome =
# CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false # CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false
djangoDev: {env:TOX_OVERRIDE_IGNORE_OUTCOME:true} djangoDev: {env:TOX_OVERRIDE_IGNORE_OUTCOME:true}
args_are_paths = false
# Upgrade pip/wheel/setuptools:
download = true
commands_pre = commands_pre =
python -VV python -VV
python -m pip --version
python -c 'import django; print("Django", django.__version__)' python -c 'import django; print("Django", django.__version__)'
commands = commands =
python runtests.py {posargs} python runtests.py {posargs}
@@ -98,25 +102,22 @@ commands =
pre-commit run --all-files pre-commit run --all-files
[testenv:docs] [testenv:docs]
basepython = python3.8 basepython = python3.10
skip_install = true
passenv = passenv =
CONTINUOUS_INTEGRATION CONTINUOUS_INTEGRATION
# (but not any of the live test API keys) # (but not any of the live test API keys)
setenv = setenv =
DOCS_BUILD_DIR={envdir}/_html DOCS_BUILD_DIR={envdir}/_html
whitelist_externals = /bin/bash
deps = deps =
-rdocs/requirements.txt -rdocs/requirements.txt
commands_pre = commands_pre =
python -VV python -VV
sphinx-build --version sphinx-build --version
commands = commands =
# Verify README.rst as used in setup.py long_description:
python setup.py check --restructuredtext --strict
# Build and verify docs: # Build and verify docs:
sphinx-build -W -b dirhtml docs {env:DOCS_BUILD_DIR} sphinx-build -W -b dirhtml docs {env:DOCS_BUILD_DIR}
# Build README.rst into html: # Build and verify package metadata readme.
/bin/bash -c 'python setup.py --long-description \ # Errors here are in README.rst:
| rst2html5.py --config=docs/_readme/docutils.cfg \ python docs/_readme/render.py \
> {env:DOCS_BUILD_DIR}/readme.html' --package django-anymail \
--out {env:DOCS_BUILD_DIR}/readme.html