Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/implement mypy type checking #394

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/mypy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: Run mypy
on:
pull_request:
push:
branches: ["main", "v[0-9]*"]
tags: ["v[0-9]*"]
workflow_dispatch:
jobs:
mypy:
runs-on: ubuntu-22.04
steps:
- name: apt update
run: sudo apt update
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
YPCrumble marked this conversation as resolved.
Show resolved Hide resolved
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip wheel
pip install -r requirements-dev.txt
YPCrumble marked this conversation as resolved.
Show resolved Hide resolved
- name: MyPy
run: mypy .
3 changes: 2 additions & 1 deletion anymail/backends/sendgrid.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import uuid
import warnings
from collections.abc import Mapping
from email.utils import quote as rfc822_quote

from requests.structures import CaseInsensitiveDict

from ..exceptions import AnymailConfigurationError, AnymailWarning
from ..message import AnymailRecipientStatus
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep
from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting, update_deep
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 it already found a bug!

from .base_requests import AnymailRequestsBackend, RequestsPayload


Expand Down
9 changes: 5 additions & 4 deletions anymail/backends/unisender_go.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ def __init__(
http_headers["Content-Type"] = "application/json"
http_headers["Accept"] = "application/json"
http_headers["X-API-KEY"] = backend.api_key
super().__init__(
message, defaults, backend, headers=http_headers, *args, **kwargs
)
kwargs["headers"] = http_headers
super().__init__(message, defaults, backend, *args, **kwargs)

def get_api_endpoint(self) -> str:
return "email/send.json"
Expand Down Expand Up @@ -297,7 +296,9 @@ def set_send_at(self, send_at: datetime | str) -> None:
# "Date and time in the format “YYYY-MM-DD hh:mm:ss” in the UTC time zone."
# If send_at is a datetime, it's guaranteed to be aware, but maybe not UTC.
# Convert to UTC, then strip tzinfo to avoid isoformat "+00:00" at end.
send_at_utc = send_at.astimezone(timezone.utc).replace(tzinfo=None)
send_at_utc = send_at.astimezone( # type:ignore[union-attr]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option here would be to replace the try/catch with if isinstance(send_at, datetime).

Question: Would you suggest first adding the type:ignore comments without changing code, and then going back to resolve each of them in later commits that change the code?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's an easy fix I'd recommend doing it now - I just didn't see the fix offhand and lean towards keeping MyPy from slowing things down vs. trying to figure out a type hint I can't grok right away, especially if I know the code is fine.

timezone.utc
).replace(tzinfo=None)
send_at_formatted = send_at_utc.isoformat(sep=" ", timespec="seconds")
assert len(send_at_formatted) == 19
except (AttributeError, TypeError):
Expand Down
2 changes: 1 addition & 1 deletion anymail/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class AnymailAPIError(AnymailError):
"""Exception for unsuccessful response from ESP's API."""


class AnymailRequestsAPIError(AnymailAPIError, HTTPError):
class AnymailRequestsAPIError(AnymailAPIError, HTTPError): # type: ignore[misc]
"""Exception for unsuccessful response from a requests API."""

def __init__(self, *args, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions anymail/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AnymailRecipientsType = list[dict[str, dict[str, str]]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary? (It doesn't seem to be referenced anywhere.)

4 changes: 3 additions & 1 deletion anymail/webhooks/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import typing
import warnings

from django.dispatch import Signal
from django.http import HttpResponse
from django.utils.crypto import constant_time_compare
from django.utils.decorators import method_decorator
Expand Down Expand Up @@ -30,7 +32,7 @@ def __init__(self, **kwargs):
# Subclass implementation:

# Where to send events: either ..signals.inbound or ..signals.tracking
signal = None
signal: typing.Optional[Signal] = None

def validate_request(self, request):
"""Check validity of webhook post, or raise AnymailWebhookValidationFailure.
Expand Down
8 changes: 4 additions & 4 deletions anymail/webhooks/postal.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@
missing_package="cryptography", install_extra="postal"
)
)
serialization = error
hashes = error
serialization = error # type: ignore[assignment]
hashes = error # type: ignore[assignment]
default_backend = error
padding = error
InvalidSignature = object
padding = error # type: ignore[assignment]
InvalidSignature = object # type:ignore[assignment,misc]


class PostalBaseWebhookView(AnymailBaseWebhookView):
Expand Down
24 changes: 24 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[mypy]
plugins = mypy_django_plugin.main
# TODO: Check all code in the future
check_untyped_defs = False
disallow_any_generics = False
disallow_incomplete_defs = False
disallow_subclassing_any = False
disallow_untyped_calls = False
disallow_untyped_defs = False
ignore_missing_imports = True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my experimenting, I was able to switch ignore_missing_imports back to False by:

  1. pip install -e '.[amazon-ses,resend,postal]' in the workflow, to pick up all the optional dependencies (see earlier comment)
  2. Adding boto3-stubs[s3,ses,sns] to requirements-dev.txt to pick up boto3 typing (see later comment)
  3. Adding some type:ignore comments on the _LazyImportErrors in webhooks/amazon_ses.py and webhooks/resend.py (like the ones you already added to webhooks/postal.py).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds great!

no_implicit_optional = True
no_implicit_reexport = True
strict = True
warn_return_any = False
# TODO: Remove this exception in the future
disable_error_code = var-annotated
exclude = docs

[mypy.plugins.django-stubs]
django_settings_module = "tests.test_settings.settings_5_0"

# TODO: Type check tests in the future
[mypy-tests.*]
ignore_errors = True
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Requirements for developing (not just using) the package

django-stubs
hatch
mypy
pre-commit
requests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requests should not be needed here, since it's already a package dependency. (But you need to pip install -e . to pick up package dependencies, per earlier comment.)

However, I did need to add boto3-stubs[s3,ses,sns] since boto3 isn't typed.

Question: How do library packages typically handle dependencies only needed for typing? Would it be helpful for django-anymail to declare a "typing" extra (pip install django-anymail[typing]) that adds the dependencies on django-stubs and boto3-stubs? Or do libraries usually assume that users will figure out on their own what typing stubs are required?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, for me this was just for my local environment. I generally expect to pip install -r requirements-dev.txt and my local machine would have the necessary packages to run a command like mypy ....that's what led me to make this change.

tox<4
types-requests
2 changes: 1 addition & 1 deletion tests/test_sparkpost_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class SparkPostBackendMockAPITestCase(RequestsBackendMockAPITestCase):
def setUp(self):
super().setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives(
self.message = AnymailMessage(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is no longer required (since you disabled mypy on tests).

But it raises a couple of questions: A lot of the tests use Anymail's added attributes directly on Django's EmailMessage or EmailMultiAlternatives, because this is a documented Anymail feature. (Anymail has always allowed duck typing for its added attributes.)

  • Is there some special way we should be testing this works with mypy? (Other than just a test on EmailMessage or EmailMultiAlternatives that sets all of Anymail's attributes with type:ignore comments?)
  • Is there some special way Anymail should be exposing this capability for library users? (Like an AnymailMessageProtocol?)

"Subject", "Text Body", "from@example.com", ["to@example.com"]
)

Expand Down
Loading