Skip to content

check typing with mypy#458

Open
terencehonles wants to merge 4 commits intoanymail:mainfrom
terencehonles:add-mypy-typing
Open

check typing with mypy#458
terencehonles wants to merge 4 commits intoanymail:mainfrom
terencehonles:add-mypy-typing

Conversation

@terencehonles
Copy link
Copy Markdown
Contributor

@terencehonles terencehonles commented Mar 9, 2026

This picks up stalled PR #394. I'm not sure if squashed PRs are the default, but it makes sense to squash all this work to get rid of the earlier churn, but keep the co-authorship.

Copy link
Copy Markdown
Contributor Author

@terencehonles terencehonles left a comment

Choose a reason for hiding this comment

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

Some comments about the changes I've made

Comment thread anymail/backends/amazon_ses.py
Comment thread anymail/backends/amazon_ses.py Outdated
super().init_payload()
# late-bind in finalize_payload:
self.recipients = {"to": [], "cc": [], "bcc": []}
self.recipients: dict[str, list[Any]] = {"to": [], "cc": [], "bcc": []}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm leaving narrowing Any to the appropriate type as a follow up PR, since I wanted mypy to pass without disabling too many strictness flags, but I didn't want to figure out what was the most narrow type definition would be.

esp=self.esp_name.lower(),
version=__version__,
orig=session.headers.get("User-Agent", ""),
orig=cast(str, session.headers.get("User-Agent", "")),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I assume the UA is generally a string, the alternative would be to use a byte string and never decode the existing UA and just encode all the other parameters since bytes doesn't have a .format but it can interpolate.

Comment thread anymail/backends/brevo.py
http_headers["Content-Type"] = "application/json"

super().__init__(
super().__init__( # type: ignore[misc]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I just squelched this error, but technically since *args is unbounded headers can come as a positional argument or a keyword argument. Moving RequestsPayload to use keyword only arguments will fix this issue, but is a breaking change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All of the payloads can be considered internal types.

fwiw, in other projects I'm starting to drop *args and **kwargs where they're not absolutely necessary. But maybe start with this change, then improve the API as a follow-on commit.

(btw, this is likely to be part of a breaking change release anyway. We might just add a blanket "internals have been reworked" changelog item.)

return HttpResponse()

def parse_json_body(self, request: HttpRequest) -> dict | list | None:
def parse_json_body(self, request: HttpRequest) -> dict:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This looks like it was a bad type definition because below it was only used as a dict and would break if it were a list or None.

Comment thread anymail/inbound.py
content_type = self.get_content_type()
content = self.get_content_bytes()
return SimpleUploadedFile(name, content, content_type)
return SimpleUploadedFile(name, content, content_type) # type: ignore[arg-type]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

name may be None here, but I don't know if the Django stubs type definition is wrong by requiring non None, if get_filename() might actually not be none, or if this code needs to change.

Comment thread anymail/inbound.py
)
# headersonly forces an empty string payload, which breaks things later:
msg.set_payload(None)
msg.set_payload(None) # type: ignore[call-overload]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The Python typeshed seems to be incorrect if this is valid, but I didn't look into the source to see what happens here, but I'm going to assume this is valid.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There were definitely some significant errors in the Python typeshed around mail last year (when I was looking at this for Django), particularly in legacy APIs like set_payload. I'm not sure the current status.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It looks like this is threading a needle which does work according to the source, but it's unlikely a PR will be accepted to change this. Looking into this more should this be calling the clear_content method instead? That will strip some headers, but I assume any content-* headers aren't valid without content.

Comment thread anymail/utils.py
BASIC_NUMERIC_TYPES = (int, float)


UNSET = type("UNSET", (object,), {}) # Used as non-None default value
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is basically a funny way to write class UNSET: pass, which the latter mypy can actually use since it's a concrete type, but this is used as a singleton which mypy doesn't like for is checks so I've replaced this implementation with an implementation I use for the same functionality including the type that mypy should use.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It looks like this needs to be pickle safe, so I've used the TYPE_CHECKING definition which is more like the original definition where there is a class defined, but this still creates a singleton of that class.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mean, the right thing to do here is a Sentinel, but that keeps getting deferred.

Comment thread anymail/utils.py
content = self.content
if isinstance(content, str):
content = content.encode(self.charset)
content = content.encode(self.charset or settings.DEFAULT_CHARSET)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

charset seems like it's only guaranteed to be non None if it's a "text/*" mime type, I assume this is coming from the user, so if for some reason the charset isn't defined the default makes sense, right?

Comment thread anymail/utils.py


class CaseInsensitiveCasePreservingDict(CaseInsensitiveDict):
_store: OrderedDict
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

typeshed doesn't define _store (trying to keep it private)

@terencehonles
Copy link
Copy Markdown
Contributor Author

@medmunds friendly ping (just making sure you also saw this PR)

medmunds and others added 3 commits March 11, 2026 14:04
Co-authored-by: Terence D. Honles <terence@honles.com>
Co-authored-by: Terence D. Honles <terence@honles.com>
@medmunds medmunds changed the base branch from main to next March 11, 2026 21:11
@medmunds
Copy link
Copy Markdown
Contributor

Thanks for picking this up! (And of course thanks to @YPCrumble for the earlier work.)

I won't have time to get into details before mid-next-week at the earliest. In the meantime:

  • I've created a "next" branch that drops older Python and Django versions, from your other PR, so typing can start with Python 3.10. Suggest rebasing this onto that. (I've already updated the target branch for this PR from main to next.)
  • Yes, please squash the earlier commits so we're starting from a simpler diff. You can use Co-Authored-By to preserve YPCrumble's credit.
  • I'll glance through your comments and see if I have any quick answers.

@terencehonles
Copy link
Copy Markdown
Contributor Author

I figured we might just target main with this and use GitHub's squash feature, but I can squash and retarget the PR

This change adds tox configuration to run mypy, and updates the type
information so test passes.

---

Co-Authored-By: Ian Campbell <ipcampbell@gmail.com>
@medmunds medmunds changed the base branch from next to main April 17, 2026 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants