diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca1fdc4..49c0b72 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,8 +51,59 @@ jobs: tests: strategy: matrix: - os: [ubuntu-20.04, windows-latest] - python: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.7", "pypy-3.8"] + include: + # Python 3.6-3.10: Test with Django 3.2 + - os: ubuntu-20.04 + python: "3.6" + tox-env: py-django32 + - os: windows-latest + python: "3.6" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.7" + tox-env: py-django32 + - os: windows-latest + python: "3.7" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.8" + tox-env: py-django32 + - os: windows-latest + python: "3.8" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.9" + tox-env: py-django32 + - os: windows-latest + python: "3.9" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.10" + tox-env: py-django32 + - os: windows-latest + python: "3.10" + tox-env: py-django32 + # Python 3.11-3.12: Test with Django 4.2 + - os: ubuntu-20.04 + python: "3.11" + tox-env: py-django42 + - os: windows-latest + python: "3.11" + tox-env: py-django42 + - os: ubuntu-20.04 + python: "3.12" + tox-env: py-django42 + - os: windows-latest + python: "3.12" + tox-env: py-django42 + # PyPy: Test with Django 3.2 + # Note: PyPy 3.7 excluded due to asgiref incompatibility with Django 3.2 + - os: ubuntu-20.04 + python: "pypy-3.8" + tox-env: py-django32 + - os: windows-latest + python: "pypy-3.8" + tox-env: py-django32 name: ${{ matrix.python }} on ${{ matrix.os }} tests runs-on: ${{ matrix.os }} @@ -68,7 +119,7 @@ jobs: - run: pip install tox coverage - name: Run Python ${{ matrix.python }} tox job - run: tox -e py-django32 + run: tox -e ${{ matrix.tox-env }} - run: coverage combine - run: coverage report @@ -83,7 +134,7 @@ jobs: tests-django: strategy: matrix: - django_version: ["22", "32", "40"] + django_version: ["22", "32", "40", "42", "50", "60"] name: Django ${{ matrix.django_version }} tests runs-on: ubuntu-20.04 diff --git a/setup.py b/setup.py index e63fd49..84e5f52 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,9 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Communications :: Email", diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index d9c6cc0..0d1e27e 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -1,8 +1,10 @@ """Basic ways to send emails.""" + import mimetypes import os from base64 import b64encode from email.header import decode_header +from email.message import EmailMessage, Message from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -38,13 +40,56 @@ def prepare_attachments(attachment): if len(attachment) == 4: result["ContentID"] = attachment[3] elif isinstance(attachment, MIMEBase): - payload = attachment.get_payload() + # Check MIMEBase BEFORE Message since MIMEBase is a subclass of Message content_type = attachment.get_content_type() + # Special case for message/rfc822 # Even if RFC implies such attachments being not base64-encoded, # Postmark requires all attachments to be encoded in this way - if content_type == "message/rfc822" and not isinstance(payload, str): - payload = b64encode(payload[0].get_payload(decode=True)).decode() + if content_type == "message/rfc822": + raw_payload = attachment.get_payload() + if isinstance(raw_payload, list) and len(raw_payload) > 0: + # Django creates RFC822 with a list containing a Message object + # Get the string representation and encode it + inner_message = raw_payload[0] + # Use get_payload() to get the actual content of the inner message + if hasattr(inner_message, "get_payload"): + message_content = inner_message.get_payload() + if isinstance(message_content, bytes): + payload = b64encode(message_content).decode() + else: + payload = b64encode(str(message_content).encode()).decode() + else: + payload = b64encode(str(inner_message).encode()).decode() + else: + # Fallback: encode whatever payload we have + payload = b64encode(str(raw_payload).encode()).decode() + else: + # Check if payload is already base64 encoded + transfer_encoding = attachment.get("Content-Transfer-Encoding", "") + + # Standard MIME attachments created with encoders.encode_base64() will have + # Content-Transfer-Encoding: base64 and payload as base64 string. + # For compatibility, if no encoding is specified, assume it's already base64. + if not transfer_encoding or transfer_encoding.lower() == "base64": + # Payload is already base64 encoded (or assumed to be), use as-is + payload = attachment.get_payload() + else: + # Payload has a different encoding (7bit, quoted-printable, etc.) + # Decode it and re-encode to base64 + raw_payload = attachment.get_payload(decode=True) + if raw_payload: + payload = b64encode(raw_payload).decode() + else: + # If decode fails, try to get raw payload and encode it + payload = attachment.get_payload() + if isinstance(payload, bytes): + payload = b64encode(payload).decode() + elif isinstance(payload, str): + payload = b64encode(payload.encode()).decode() + else: + payload = b64encode(str(payload).encode()).decode() + result = { "Name": attachment.get_filename() or "attachment.txt", "Content": payload, @@ -57,6 +102,61 @@ def prepare_attachments(attachment): if (attachment.get("Content-Disposition") or "").startswith("inline"): content_id = "cid:%s" % content_id result["ContentID"] = content_id + elif isinstance(attachment, (EmailMessage, Message)): + # Handle EmailMessage and Message objects (from email.message module) + # These can come from Django's message.message() or deconstruct_multipart + # Note: MIMEBase extends Message, so we check MIMEBase first above + payload = attachment.get_payload(decode=True) + if payload is None: + # For multipart or string payloads + payload = attachment.get_payload() + + content_type = attachment.get_content_type() + + # Special handling for message/rfc822 + if content_type == "message/rfc822": + # The payload could be a Message object or already bytes/str + if hasattr(payload, "as_bytes"): + # It's a Message object, serialize it + content = b64encode(payload.as_bytes()).decode() + elif hasattr(payload, "as_string"): + # Older Message API + content = b64encode(payload.as_string().encode("utf-8")).decode() + elif isinstance(payload, bytes): + content = b64encode(payload).decode() + elif isinstance(payload, str): + content = b64encode(payload.encode("utf-8")).decode() + else: + # Fallback: serialize the entire attachment + content = b64encode(attachment.as_bytes()).decode() + elif isinstance(payload, bytes): + content = b64encode(payload).decode() + elif isinstance(payload, str): + content = b64encode(payload.encode("utf-8")).decode() + else: + # For multipart messages or other complex payloads, serialize the entire message + content = b64encode(attachment.as_bytes()).decode() + + filename = attachment.get_filename() + if filename is None: + # Generate filename based on content type + if content_type == "message/rfc822": + filename = "message.eml" + else: + filename = "attachment.txt" + + result = { + "Name": filename, + "Content": content, + "ContentType": content_type, + } + content_id = attachment.get("Content-ID") + if content_id: + if content_id.startswith("<") and content_id.endswith(">"): + content_id = content_id[1:-1] + if (attachment.get("Content-Disposition") or "").startswith("inline"): + content_id = "cid:%s" % content_id + result["ContentID"] = content_id elif isinstance(attachment, str): content_type = guess_content_type(attachment) filename = os.path.basename(attachment) @@ -76,21 +176,33 @@ def deconstruct_multipart_recursive(seen, text, html, attachments, message): if message in seen: return seen.add(message) - if isinstance(message, MIMEMultipart): + content_type = message.get_content_type() + + # Special case: message/rfc822 should be treated as an attachment, not walked into + if content_type == "message/rfc822": + # Mark inner messages as seen so they don't get processed separately + payload = message.get_payload() + if isinstance(payload, list): + for part in payload: + seen.add(part) + attachments.append(message) + elif message.is_multipart(): for part in message.walk(): deconstruct_multipart_recursive(seen, text, html, attachments, part) else: - content_type = message.get_content_type() if content_type == "text/plain" and not text: - text.append(message.get_payload(decode=True).decode("utf8")) + # Use get_content() for EmailMessage, fall back to get_payload for MIME + if isinstance(message, EmailMessage): + text.append(message.get_content()) + else: + text.append(message.get_payload(decode=True).decode("utf8")) elif content_type == "text/html" and not html: - html.append(message.get_payload(decode=True).decode("utf8")) + # Use get_content() for EmailMessage, fall back to get_payload for MIME + if isinstance(message, EmailMessage): + html.append(message.get_content()) + else: + html.append(message.get_payload(decode=True).decode("utf8")) else: - # Ignore underlying messages inside `message/rfc822` payload, because the message itself will be passed - # as an attachment - if content_type == "message/rfc822": - for part in message.get_payload(): - seen.add(part) attachments.append(message) @@ -123,11 +235,15 @@ def as_dict(self): :return: """ data = super().as_dict() - data["Headers"] = [{"Name": name, "Value": value} for name, value in data["Headers"].items()] + data["Headers"] = [ + {"Name": name, "Value": value} for name, value in data["Headers"].items() + ] for field in ("To", "Cc", "Bcc"): if field in data: data[field] = list_to_csv(data[field]) - data["Attachments"] = [prepare_attachments(attachment) for attachment in data["Attachments"]] + data["Attachments"] = [ + prepare_attachments(attachment) for attachment in data["Attachments"] + ] return data def attach(self, *payloads): @@ -167,12 +283,16 @@ def maybe_decode(value, encoding): def prepare_header(value): if value is None: return value - return SEPARATOR.join([maybe_decode(value, encoding) for value, encoding in decode_header(value)]) + return SEPARATOR.join( + [maybe_decode(value, encoding) for value, encoding in decode_header(value)] + ) class Email(BaseEmail): def __init__(self, **kwargs): - assert kwargs.get("TextBody") or kwargs.get("HtmlBody"), "Provide either email TextBody or HtmlBody or both" + assert kwargs.get("TextBody") or kwargs.get("HtmlBody"), ( + "Provide either email TextBody or HtmlBody or both" + ) super().__init__(**kwargs) @classmethod @@ -258,7 +378,10 @@ def send(self, **extra): :rtype: `list` """ emails = self.as_dict(**extra) - responses = [self._manager._send_batch_with_template(*batch) for batch in chunks(emails, self.MAX_SIZE)] + responses = [ + self._manager._send_batch_with_template(*batch) + for batch in chunks(emails, self.MAX_SIZE) + ] return sum(responses, []) @@ -285,7 +408,7 @@ def _construct_email(self, email, **extra): """Converts incoming data to properly structured dictionary.""" if isinstance(email, dict): email = Email(manager=self._manager, **email) - elif isinstance(email, (MIMEText, MIMEMultipart)): + elif isinstance(email, (EmailMessage, MIMEText, MIMEMultipart)): email = Email.from_mime(email, self._manager) elif not isinstance(email, Email): raise ValueError @@ -299,7 +422,9 @@ def send(self, **extra): :rtype: `list` """ emails = self.as_dict(**extra) - responses = [self._manager._send_batch(*batch) for batch in chunks(emails, self.MAX_SIZE)] + responses = [ + self._manager._send_batch(*batch) for batch in chunks(emails, self.MAX_SIZE) + ] return sum(responses, []) @@ -321,7 +446,9 @@ def _send_with_template(self, **kwargs): return self.call("POST", "/email/withTemplate/", data=kwargs) def _send_batch_with_template(self, *email_templates): - return self.call("POST", "/email/batchWithTemplates/", data={"Messages": email_templates}) + return self.call( + "POST", "/email/batchWithTemplates/", data={"Messages": email_templates} + ) def _send_batch(self, *emails): """Low-level batch send call.""" @@ -348,7 +475,7 @@ def send( ): """Sends a single email. - :param message: :py:class:`Email` or ``email.mime.text.MIMEText`` instance. + :param message: :py:class:`Email`, ``email.message.EmailMessage``, or ``email.mime.text.MIMEText`` instance. :param str From: The sender email address. :param To: Recipient's email address. Multiple recipients could be specified as a list or string with comma separated values. @@ -371,7 +498,9 @@ def send( :return: Information about sent email. :rtype: `dict` """ - assert not (message and (From or To)), "You should specify either message or From and To parameters" + assert not (message and (From or To)), ( + "You should specify either message or From and To parameters" + ) assert TrackLinks in ("None", "HtmlAndText", "HtmlOnly", "TextOnly") if message is None: message = self.Email( @@ -391,10 +520,12 @@ def send( Attachments=Attachments, MessageStream=MessageStream, ) - elif isinstance(message, (MIMEText, MIMEMultipart)): + elif isinstance(message, (EmailMessage, Message, MIMEText, MIMEMultipart)): message = Email.from_mime(message, self) elif not isinstance(message, Email): - raise TypeError("message should be either Email or MIMEText or MIMEMultipart instance") + raise TypeError( + "message should be either Email, EmailMessage, Message, MIMEText or MIMEMultipart instance" + ) return message.send() def send_with_template( diff --git a/test/conftest.py b/test/conftest.py index 1007a9e..c0c5c41 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,6 @@ import os from contextlib import contextmanager -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from betamax import Betamax @@ -21,12 +21,16 @@ def pytest_addoption(parser): - parser.addoption("--record", action="store_true", help="Runs cleanup for recording session") + parser.addoption( + "--record", action="store_true", help="Runs cleanup for recording session" + ) def pytest_unconfigure(config): if config.getoption("--record"): - replace_real_credentials(CASSETTE_DIR, SERVER_TOKEN, "X-Postmark-Server-Token", DEFAULT_SERVER_TOKEN) + replace_real_credentials( + CASSETTE_DIR, SERVER_TOKEN, "X-Postmark-Server-Token", DEFAULT_SERVER_TOKEN + ) replace_real_credentials( CASSETTE_DIR, ACCOUNT_TOKEN, @@ -84,13 +88,18 @@ def server(postmark): @pytest.fixture def email(postmark): - return postmark.emails.Email(From="sender@example.com", To="receiver@example.com", TextBody="text") + return postmark.emails.Email( + From="sender@example.com", To="receiver@example.com", TextBody="text" + ) @pytest.fixture def email_template(postmark): return postmark.emails.EmailTemplate( - From="sender@example.com", To="receiver@example.com", TemplateId=983381, TemplateModel={} + From="sender@example.com", + To="receiver@example.com", + TemplateId=983381, + TemplateModel={}, ) @@ -262,3 +271,10 @@ def attachment(inbound_webhook): @pytest.fixture def delivery_webhook(): return Delivery.from_json(DELIVERY_WEBHOOK) + + +@pytest.fixture +def postmark_request(): + """Mock the requests.Session.request method used by PostmarkClient.""" + with patch("requests.Session.request") as mock_request: + yield mock_request diff --git a/test/django/test_backend.py b/test/django/test_backend.py index 8f98fdd..eab65c2 100644 --- a/test/django/test_backend.py +++ b/test/django/test_backend.py @@ -55,7 +55,10 @@ def test_send_mail(postmark_request, settings): "From": "sender@example.com", }, ) - assert postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] == settings.POSTMARK["TOKEN"] + assert ( + postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] + == settings.POSTMARK["TOKEN"] + ) @pytest.mark.parametrize( @@ -75,7 +78,10 @@ def test_reply_to_cc_bcc(postmark_request, kwarg, key): **{kwarg: ["r1@example.com", "r2@example.com"]}, ) message.send() - assert postmark_request.call_args[1]["json"][0][key] == "r1@example.com, r2@example.com" + assert ( + postmark_request.call_args[1]["json"][0][key] + == "r1@example.com, r2@example.com" + ) EXAMPLE_BATCH_RESPONSE = [ @@ -106,7 +112,9 @@ class TestMassSend: def batch_send(self): @contextmanager def manager(return_value=EXAMPLE_BATCH_RESPONSE): - with patch("postmarker.models.emails.EmailBatch.send", return_value=return_value) as send: + with patch( + "postmarker.models.emails.EmailBatch.send", return_value=return_value + ) as send: yield send return manager @@ -130,7 +138,10 @@ def test_multiple_exceptions_propagation(self, batch_send): with batch_send(EXAMPLE_BATCH_RESPONSE * 2): with pytest.raises(PostmarkerException) as exc: send_mass_mail(self.messages * 2, fail_silently=False) - assert str(exc.value) == "[[406] Bla bla, inactive recipient, [406] Bla bla, inactive recipient]" + assert ( + str(exc.value) + == "[[406] Bla bla, inactive recipient, [406] Bla bla, inactive recipient]" + ) @pytest.fixture @@ -155,9 +166,17 @@ def test_send_mail_with_attachment(postmark_request, message): """ message.attach("hello.txt", "Hello World", "text/plain") message.send() + # Postmark API requires all attachment content to be base64 encoded + expected_content = "SGVsbG8gV29ybGQ=" # base64.b64encode(b"Hello World").decode() assert postmark_request.call_args[1]["json"][0] == { "TextBody": "text_content", - "Attachments": [{"Name": "hello.txt", "Content": "Hello World", "ContentType": "text/plain"}], + "Attachments": [ + { + "Name": "hello.txt", + "Content": expected_content, + "ContentType": "text/plain", + } + ], "From": "sender@example.com", "HtmlBody": None, "ReplyTo": None, @@ -322,7 +341,10 @@ def test_missing_api_key(settings): def test_test_mode(settings, postmark_request): settings.POSTMARK = {"TEST_MODE": True} send_mail(**SEND_KWARGS) - assert postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] == TEST_TOKEN + assert ( + postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] + == TEST_TOKEN + ) def test_extra_options(settings, postmark_request): diff --git a/test/models/test_emails.py b/test/models/test_emails.py index 4cc5dde..609cfa5 100644 --- a/test/models/test_emails.py +++ b/test/models/test_emails.py @@ -15,7 +15,9 @@ def get_attachment_path(filename): - return os.path.abspath(os.path.join(os.path.dirname(__file__), "attachments/%s" % filename)) + return os.path.abspath( + os.path.join(os.path.dirname(__file__), "attachments/%s" % filename) + ) if platform.system() in {"Linux", "Darwin"}: @@ -114,7 +116,9 @@ def test_mime_text(self, postmark): } def test_minimum_mime(self, postmark): - message = get_mime_message("Text", From="sender@example.com", To="receiver@example.com") + message = get_mime_message( + "Text", From="sender@example.com", To="receiver@example.com" + ) response = postmark.emails.send(message=message) assert response == { "ErrorCode": 0, @@ -127,12 +131,17 @@ def test_minimum_mime(self, postmark): def test_invalid(self, postmark): with pytest.raises(TypeError) as exc: postmark.emails.send(message=object()) - assert str(exc.value) == "message should be either Email or MIMEText or MIMEMultipart instance" + assert ( + str(exc.value) + == "message should be either Email, EmailMessage, Message, MIMEText or MIMEMultipart instance" + ) def test_message_and_kwargs(self, postmark, email): with pytest.raises(AssertionError) as exc: postmark.emails.send(message=email, From="test@test.com") - assert str(exc.value).startswith("You should specify either message or From and To parameters") + assert str(exc.value).startswith( + "You should specify either message or From and To parameters" + ) def test_send_email(self, postmark, email, postmark_request): postmark.emails.send(message=email) @@ -146,20 +155,30 @@ def test_send_email(self, postmark, email, postmark_request): ["first@example.com", "second@example.com"], ), ) - def test_multiple_addresses(self, postmark, minimal_data, postmark_request, field, value): + def test_multiple_addresses( + self, postmark, minimal_data, postmark_request, field, value + ): minimal_data[field] = value postmark.emails.send(**minimal_data) - assert postmark_request.call_args[1]["json"][field] == "first@example.com,second@example.com" + assert ( + postmark_request.call_args[1]["json"][field] + == "first@example.com,second@example.com" + ) def test_headers(self, postmark, minimal_data, postmark_request): minimal_data["Headers"] = {"Test": 1} postmark.emails.send(**minimal_data) - assert postmark_request.call_args[1]["json"]["Headers"] == [{"Name": "Test", "Value": 1}] + assert postmark_request.call_args[1]["json"]["Headers"] == [ + {"Name": "Test", "Value": 1} + ] def test_message_stream(self, postmark, minimal_data, postmark_request): minimal_data["MessageStream"] = "example-message-stream" postmark.emails.send(**minimal_data) - assert postmark_request.call_args[1]["json"]["MessageStream"] == "example-message-stream" + assert ( + postmark_request.call_args[1]["json"]["MessageStream"] + == "example-message-stream" + ) @pytest.mark.parametrize("attachment", SUPPORTED_ATTACHMENTS) def test_attachments(self, postmark, minimal_data, postmark_request, attachment): @@ -269,7 +288,9 @@ def test_set_header(self, email): assert email.Headers == {} email["X-Accept-Language"] = "en-us, en" assert email.Headers == {"X-Accept-Language": "en-us, en"} - assert email.as_dict()["Headers"] == [{"Name": "X-Accept-Language", "Value": "en-us, en"}] + assert email.as_dict()["Headers"] == [ + {"Name": "X-Accept-Language", "Value": "en-us, en"} + ] def test_unset_header(self, email): email["X-Accept-Language"] = "en-us, en" @@ -284,7 +305,9 @@ def test_body(self): To="receiver@example.com", Subject="Postmark test", ) - assert str(exc.value).startswith("Provide either email TextBody or HtmlBody or both") + assert str(exc.value).startswith( + "Provide either email TextBody or HtmlBody or both" + ) @pytest.mark.parametrize("attachment", SUPPORTED_ATTACHMENTS) def test_attach(self, email, postmark_request, attachment): @@ -382,7 +405,9 @@ def test_str(self, delivery_webhook): class TestTemplateBatchSend: def test_template_email_instance(self, postmark, email_template, postmark_request): postmark.emails.send_template_batch(email_template) - assert postmark_request.call_args[1]["json"] == {"Messages": (email_template.as_dict(),)} + assert postmark_request.call_args[1]["json"] == { + "Messages": (email_template.as_dict(),) + } def test_dict(self, postmark, postmark_request): template_dict = { diff --git a/test/tornado/conftest.py b/test/tornado/conftest.py index 9d02104..fd596be 100644 --- a/test/tornado/conftest.py +++ b/test/tornado/conftest.py @@ -1,83 +1,83 @@ +import sys from functools import partial import pytest -from requests import Response -from tornado.web import Application, RequestHandler -from postmarker.tornado import PostmarkMixin - - -class BaseHandler(PostmarkMixin, RequestHandler): - def get(self): - self.write(str(self.get_value())) - - -class Handler(BaseHandler): - def get_value(self): - return self.postmark_client.server_token - - -class MaxRetriesHandler(BaseHandler): - def get_value(self): - return self.postmark_client.max_retries - - -class SendHandler(BaseHandler): - def get_value(self): - return self.send( - From="sender@example.com", - To="receiver@example.com", - Subject="Postmark test", - HtmlBody="
Hello dear Postmark user.", - )["Message"] - - -class SendBatchHandler(BaseHandler): - def get_value(self): - return self.send_batch( - { - "From": "sender@example.com", - "To": "receiver@example.com", - "Subject": "Postmark test", - "HtmlBody": "Hello dear Postmark user.", - } - )[0]["Message"] - - -class ReuseHandler(BaseHandler): - def get_value(self): - return self.postmark_client is self.postmark_client - - -@pytest.fixture -def app(): - return Application( - [ - (r"/", Handler), - (r"/send/", SendHandler), - (r"/send_batch/", SendBatchHandler), - (r"/reuse/", ReuseHandler), - (r"/max_retries/", MaxRetriesHandler), - ], - postmark_server_token="Test token", - ) - - -@pytest.fixture -def postmark_request(postmark_request): - postmark_request.return_value = Response() - postmark_request.return_value.status_code = 200 - return postmark_request - - -@pytest.fixture -def http_client(http_client, base_url): - """Makes original http_client synchronous, to gather coverage data.""" - original_fetch = http_client.fetch - - def _fetch(url): - fetch = partial(original_fetch, base_url + url) - return http_client.io_loop.run_sync(fetch) - - http_client.fetch = _fetch - return http_client +# Skip all tornado tests if pytest-tornado is not installed +# This happens when using generic py-djangoXX tox environments +try: + import pytest_tornado # noqa: F401 + from requests import Response + from tornado.web import Application, RequestHandler + from postmarker.tornado import PostmarkMixin + + class BaseHandler(PostmarkMixin, RequestHandler): + def get(self): + self.write(str(self.get_value())) + + class Handler(BaseHandler): + def get_value(self): + return self.postmark_client.server_token + + class MaxRetriesHandler(BaseHandler): + def get_value(self): + return self.postmark_client.max_retries + + class SendHandler(BaseHandler): + def get_value(self): + return self.send( + From="sender@example.com", + To="receiver@example.com", + Subject="Postmark test", + HtmlBody="Hello dear Postmark user.", + )["Message"] + + class SendBatchHandler(BaseHandler): + def get_value(self): + return self.send_batch( + { + "From": "sender@example.com", + "To": "receiver@example.com", + "Subject": "Postmark test", + "HtmlBody": "Hello dear Postmark user.", + } + )[0]["Message"] + + class ReuseHandler(BaseHandler): + def get_value(self): + return self.postmark_client is self.postmark_client + + @pytest.fixture + def app(): + return Application( + [ + (r"/", Handler), + (r"/send/", SendHandler), + (r"/send_batch/", SendBatchHandler), + (r"/reuse/", ReuseHandler), + (r"/max_retries/", MaxRetriesHandler), + ], + postmark_server_token="Test token", + ) + + @pytest.fixture + def postmark_request(postmark_request): + postmark_request.return_value = Response() + postmark_request.return_value.status_code = 200 + return postmark_request + + @pytest.fixture + def http_client(http_client, base_url): + """Makes original http_client synchronous, to gather coverage data.""" + original_fetch = http_client.fetch + + def _fetch(url): + fetch = partial(original_fetch, base_url + url) + return http_client.io_loop.run_sync(fetch) + + http_client.fetch = _fetch + return http_client + +except ImportError: + # pytest-tornado not available, collect but skip all tests in this directory + collect_ignore_glob = ["*.py"] diff --git a/test/tornado/test_handlers.py b/test/tornado/test_handlers.py index 349f1fd..d8d1b74 100644 --- a/test/tornado/test_handlers.py +++ b/test/tornado/test_handlers.py @@ -1,5 +1,13 @@ +import sys + import pytest +# pytest-tornado is not compatible with Python 3.12+ +pytestmark = pytest.mark.skipif( + sys.version_info >= (3, 12), + reason="pytest-tornado is not compatible with Python 3.12+", +) + MOCK_SEND_BATCH_RESPONSE = ( b'[{"ErrorCode": 0, "To": "receiver@example.com", "SubmittedAt": ' b'"2016-10-06T10:05:30.570118-04:00", "Message": "Test job accepted",' diff --git a/tox.ini b/tox.ini index 0c1f848..4050058 100644 --- a/tox.ini +++ b/tox.ini @@ -1,23 +1,28 @@ [tox] isolated_build = true -envlist = py{36,37,38,39,310,py37,py38}-django{22,32,40},coverage-report +envlist = py{36,37,38,39,310,311,312,313,py37,py38}-django{22,32,40,42,50,60},coverage-report [testenv] passenv = SERVER_TOKEN ACCOUNT_TOKEN deps = + setuptools + urllib3<2.0.0 requests pytest pytest-django - pytest-tornado coverage betamax betamax_serializers tornado + py36,py37,py38,py39,py310,py311,pypy37,pypy38: pytest-tornado django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 + django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + django60: Django>=6.0,<6.1 commands = coverage run --source postmarker -m pytest --ds test.django.settings {posargs:test} @@ -35,7 +40,7 @@ description = Report coverage over all measured test runs. basepython = python3.7 deps = coverage skip_install = true -depends = py{36,37,38,39,310} +depends = py{36,37,38,39,310,311,312,313} commands = coverage combine coverage report