Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
25 changes: 2 additions & 23 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 1 addition & 67 deletions mailgun/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 43 additions & 1 deletion mailgun/examples/async_client_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """<body style="margin: 0; padding: 0;">
<table border="1" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
Hello!
</td>
</tr>
</table>
</body>"""

client: AsyncClient = AsyncClient(auth=("api", key))

Expand All @@ -21,6 +31,37 @@ async def get_domains() -> None:
print(data.json())


async def post_message() -> None:
# Messages
# POST /<domain>/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 /<domain>/events
Expand Down Expand Up @@ -85,14 +126,15 @@ 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()

# Example 2: Running coroutines concurrently with gather
print("Example 2: Concurrent execution with gather()")
await asyncio.gather(
post_message(),
post_template(),
post_analytics_logs(),
)
Expand Down
27 changes: 27 additions & 0 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": """<body style="margin: 0; padding: 0;">
<table border="1" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
Hello!
</td>
</tr>
</table>
</body>""",
"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."""
Expand Down
Loading