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

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

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
~~~~~~~~~~~~~~~~
* **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
-----

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
* 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.

View File

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

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
__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(",")

View File

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

View File

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

View File

@@ -171,11 +171,13 @@ class AnymailConfigurationError(ImproperlyConfigured):
class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
"""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 = (
"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)

View File

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

View File

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

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
<!--
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.
(Styling seems to change more often than basic page structure,
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
${SITE_PACKAGES}/docutils/writers/html5_polyglot/template.txt
@@ -15,13 +17,13 @@
%(head)s
<!-- template (stylesheet) omitted -->
<link rel="stylesheet" href="/static/css/warehouse-ltr.f2d4f304.css">
<link rel="stylesheet" href="/static/css/fontawesome.6002a161.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="/static/css/warehouse-ltr.a42ccb04.css">
<link rel="stylesheet" href="/static/css/fontawesome.d37999f3.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="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

View File

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

View File

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

View File

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

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~=2.9.0
readme-renderer~=37.3
sphinx~=4.0
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]
force-exclude = '^/tests/test_settings/settings_.*\.py'
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
# 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

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.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(

View File

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

View File

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

33
tox.ini
View File

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