From bd458c1887941243b7ca58262808b5699614b960 Mon Sep 17 00:00:00 2001 From: Yaroslav Biletskiy Date: Wed, 18 Mar 2026 22:26:46 +0100 Subject: [PATCH 1/3] Removed _prepare_files logic Original idea of _prepare logic was to ensure that different types of incoming files data is properly transformed into httpx files format. As a result additional logic introduced a bug with attachments. Deeper comparison of 'requests' and 'httpx' libraries shows that they use the same approach to 'files' parameter. So we can drop additional logic and use the same approach as in sync version of Endpoint. --- mailgun/client.py | 68 +---------------------------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/mailgun/client.py b/mailgun/client.py index 2d9ef45..c8c2a1f 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -15,10 +15,8 @@ from __future__ import annotations -import io import json import sys -from collections import defaultdict from typing import TYPE_CHECKING from typing import Any from urllib.parse import urljoin @@ -632,70 +630,6 @@ def __init__( self._auth = auth self._client = client - @staticmethod - def _prepare_files(files: Any) -> dict[str, Any] | None: - """Convert files to httpx format: {"field": (filename, file_obj, content_type)}.""" - min_length = 2 - httpx_files = None - if not files: - return httpx_files - - if isinstance(files, dict): - # Convert dict[str, bytes] to httpx format - # httpx expects: {"field": (filename, file_obj, content_type)} - httpx_files = {} - for key, value in files.items(): - if isinstance(value, bytes): - httpx_files[key] = (key, io.BytesIO(value), "application/octet-stream") - elif isinstance(value, tuple) and len(value) >= min_length: - # Already in tuple format: (filename, content, ...) - filename = value[0] - content = value[1] - content_type = ( - value[2] if len(value) > min_length else "application/octet-stream" - ) - if isinstance(content, bytes): - httpx_files[key] = (filename, io.BytesIO(content), content_type) - else: - httpx_files[key] = value - else: - httpx_files[key] = value - elif isinstance(files, list): - # Convert list of tuples to httpx dict format - files_dict: dict[str, list[tuple[str, Any, str]]] = defaultdict(list) - for item in files: - if isinstance(item, tuple) and len(item) >= min_length: - field_name = item[0] - file_data = item[1] - if isinstance(file_data, tuple) and len(file_data) >= min_length: - filename = file_data[0] - content = file_data[1] - content_type = ( - file_data[2] - if len(file_data) > min_length - else "application/octet-stream" - ) - if isinstance(content, bytes): - files_dict[field_name].append( - (filename, io.BytesIO(content), content_type), - ) - else: - files_dict[field_name].append(file_data) - elif isinstance(file_data, bytes): - files_dict[field_name].append( - (field_name, io.BytesIO(file_data), "application/octet-stream"), - ) - else: - files_dict[field_name].append(file_data) - - httpx_files = { - field: file_list[0] if len(file_list) == 1 else file_list - for field, file_list in files_dict.items() - } - else: - httpx_files = files - return httpx_files - async def api_call( self, auth: tuple[str, str] | None, @@ -742,7 +676,7 @@ async def api_call( "url": url, "params": filters, "data": data, - "files": self._prepare_files(files), + "files": files, "headers": headers, "auth": auth, "timeout": timeout, From 89c685feedd52b8d9104e5ffabec1bba6fc1396f Mon Sep 17 00:00:00 2001 From: Yaroslav Biletskiy Date: Wed, 18 Mar 2026 22:30:32 +0100 Subject: [PATCH 2/3] Makefile fix --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7e5a879..e3b6838 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,7 @@ environment: ## handles environment creation conda run --name $(CONDA_ENV_NAME) pip install . environment-dev: ## Handles environment creation - conda env create -n $(CONDA_ENV_NAME)-dev -y --file environment-dev.yml + conda env create -n $(CONDA_ENV_NAME)-dev -y --file environment-dev.yaml conda run --name $(CONDA_ENV_NAME)-dev pip install -e . install: clean ## install the package to the active Python's site-packages From 29a9637cea5ccb632ae728e7c45c5b5dc04459f9 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:26:23 +0200 Subject: [PATCH 3/3] Add async tests and post_message example --- .pre-commit-config.yaml | 25 ++----------- mailgun/examples/async_client_examples.py | 44 ++++++++++++++++++++++- tests/tests.py | 27 ++++++++++++++ 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3df5f8..5acf1b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -146,7 +146,7 @@ repos: # args: ['--desc', 'on'] - repo: https://github.com/semgrep/pre-commit - rev: 'v1.146.0' + rev: 'v1.156.0' hooks: - id: semgrep name: "馃敀 security 路 Static analysis (semgrep)" @@ -160,14 +160,6 @@ repos: - id: typos name: "馃摑 spelling 路 Check typos" -# - repo: https://github.com/codespell-project/codespell -# rev: v2.4.1 -# hooks: -# - id: codespell -# name: "馃摑 spelling 路 Fix common misspellings" -# args: [--write] -# exclude: ^tests - # CI/CD validation - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.36.0 @@ -260,6 +252,7 @@ repos: hooks: - id: interrogate name: "馃摑 docs 路 Check docstring coverage" + exclude: ^tests args: [ --verbose, --fail-under=53, --ignore-init-method ] # Python type checking @@ -287,20 +280,6 @@ repos: - id: validate-pyproject name: "馃悕 config 路 Validate pyproject.toml" - - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.8.0 - hooks: - - id: pyproject-fmt - name: "馃悕 config 路 Format pyproject.toml" - -# - repo: https://github.com/mgedmin/check-manifest -# rev: '0.50' -# hooks: -# - id: check-manifest -# name: "馃悕 馃摝 packaging 路 Verify MANIFEST" -# args: [--no-build-isolation, --ignore-bad-ideas=MANIFEST.in] -# additional_dependencies: [setuptools, wheel, setuptools-scm] - - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: diff --git a/mailgun/examples/async_client_examples.py b/mailgun/examples/async_client_examples.py index e4d1225..d74c7e0 100644 --- a/mailgun/examples/async_client_examples.py +++ b/mailgun/examples/async_client_examples.py @@ -2,12 +2,22 @@ import asyncio import os +from pathlib import Path from mailgun.client import AsyncClient key: str = os.environ["APIKEY"] domain: str = os.environ["DOMAIN"] +html: str = """ + + + + +
+ Hello! +
+""" client: AsyncClient = AsyncClient(auth=("api", key)) @@ -21,6 +31,37 @@ async def get_domains() -> None: print(data.json()) +async def post_message() -> None: + # Messages + # POST //messages + data = { + "from": os.environ["MESSAGES_FROM"], + "to": os.environ["MESSAGES_TO"], + "cc": os.environ["MESSAGES_CC"], + "subject": "Hello World", + "html": html, + "o:tag": "Python test", + } + # It is strongly recommended that you open files in binary mode. + # Because the Content-Length header may be provided for you, + # and if it does this value will be set to the number of bytes in the file. + # Errors may occur if you open the file in text mode. + files = [ + ( + "attachment", + ("test1.txt", Path("mailgun/doc_tests/files/test1.txt").read_bytes()), + ), + ( + "attachment", + ("test2.txt", Path("mailgun/doc_tests/files/test2.txt").read_bytes()), + ), + ] + + async with AsyncClient(auth=("api", key)) as _client: + req = await _client.messages.create(data=data, files=files, domain=domain) + print(req.json()) + + async def events_rejected_or_failed() -> None: """ GET //events @@ -85,7 +126,7 @@ async def main(): """Main coroutine that orchestrates the execution of other coroutines.""" print("=== Starting async operations ===\n") - # Example 1: Running coroutines sequentially + # # Example 1: Running coroutines sequentially print("Example 1: Sequential execution") await get_domains() await events_rejected_or_failed() @@ -93,6 +134,7 @@ async def main(): # Example 2: Running coroutines concurrently with gather print("Example 2: Concurrent execution with gather()") await asyncio.gather( + post_message(), post_template(), post_analytics_logs(), ) diff --git a/tests/tests.py b/tests/tests.py index 9c29a42..ab5dbf0 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -2778,6 +2778,7 @@ async def asyncSetUp(self) -> None: self.data: dict[str, str] = { "from": os.environ["MESSAGES_FROM"], "to": os.environ["MESSAGES_TO"], + "cc": os.environ["MESSAGES_CC"], "subject": "Hello Vasyl Bodaj", "text": "Congratulations!, you just sent an email with Mailgun! You are truly awesome!", "o:tag": "Python test", @@ -2796,6 +2797,32 @@ async def test_post_wrong_message(self) -> None: req = await self.client.messages.create(data={"from": "sdsdsd"}, domain=self.domain) self.assertEqual(req.status_code, 400) + async def test_post_message(self) -> None: + data = { + "from": self.data["from"], + "to": self.data["to"], + "cc": self.data["cc"], + "subject": "Hello World", + "html": """ + + + + +
+ Hello! +
+""", + "o:tag": "Python test", + } + attachments = [ + ("inline", ("test.txt", b"Hello, this is a test file.")), + ("inline", ("test2.txt", b"Hello, this is also a test file.")), + ] + req = await self.client.messages.create(data=data, files=attachments, domain=self.domain) + self.assertEqual(req.status_code, 200) + self.assertIn("id", req.json()) + self.assertIn("Queued", req.json()["message"]) + class AsyncDomainTests(unittest.IsolatedAsyncioTestCase): """Async tests for Mailgun Domain API using AsyncClient."""