feat: add typed Python SDK (#213)

This commit is contained in:
KM Koushik
2025-09-09 05:50:08 +10:00
committed by GitHub
parent 92f56f1ebf
commit 3158ddc51c
15 changed files with 2054 additions and 2 deletions

View File

@@ -0,0 +1,43 @@
name: Publish Python package
on:
push:
branches:
- main
paths:
- "packages/python-sdk/**" # Trigger only changes within sdk/python
- ".github/workflows/release-python-package.yml"
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/python-sdk
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: snok/install-poetry@v1
with:
version: "1.8.2"
virtualenvs-create: false
- name: Build package with Poetry
run: poetry build --no-interaction
- name: List contents of dist
run: ls -la dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1.4.2
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
packages_dir: packages/python-sdk/dist

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ yarn-error.log*
prod_db.tar
bin
__pycache__

View File

@@ -27,6 +27,7 @@
"pages": [
"introduction",
"get-started/nodejs",
"get-started/python",
"get-started/local",
"get-started/self-hosting",
"get-started/smtp"

View File

@@ -0,0 +1,164 @@
---
title: Python SDK
description: Official UseSend Python SDK for sending emails and managing contacts.
icon: python
---
This guide shows how to install and use the official `usesend` Python SDK.
## Installation
Install from PyPI:
```bash
pip install usesend
```
## Initialize
```python
from usesend import UseSend, types
# Option A: pass values directly (helpful in scripts/tests)
client = UseSend("us_xxx")
# Option B: custom base URL (self-hosted)
client = UseSend("us_xxx", url="https://your-domain.example")
```
## Send an email
`EmailCreate` is a TypedDict for editor hints; at runtime you pass a regular dict. The client accepts `from` or `from_` (it normalizes `from_` to `from`).
```python
from usesend import UseSend, types
client = UseSend("us_xxx")
payload: types.EmailCreate = {
"to": "user@example.com",
"from": "no-reply@yourdomain.com",
"subject": "Welcome",
"html": "<strong>Hello!</strong>",
}
data, err = client.emails.send(payload)
print(data or err)
```
Attachments and scheduling:
```python
from datetime import datetime, timedelta
payload: types.EmailCreate = {
"to": ["user1@example.com", "user2@example.com"],
"from": "no-reply@yourdomain.com",
"subject": "Report",
"text": "See attached.",
"attachments": [
{"filename": "report.txt", "content": "SGVsbG8gd29ybGQ="}, # base64
],
"scheduledAt": datetime.utcnow() + timedelta(minutes=10),
}
data, err = client.emails.create(payload)
```
## Batch send
```python
items: list[types.EmailBatchItem] = [
{"to": "a@example.com", "from": "no-reply@yourdomain.com", "subject": "A", "html": "<p>A</p>"},
{"to": "b@example.com", "from": "no-reply@yourdomain.com", "subject": "B", "html": "<p>B</p>"},
]
data, err = client.emails.batch(items)
```
## Retrieve and manage emails
Get an email:
```python
email, err = client.emails.get("email_123")
```
Update schedule time:
```python
from datetime import datetime, timedelta
update: types.EmailUpdate = {"scheduledAt": datetime.utcnow() + timedelta(hours=1)}
data, err = client.emails.update("email_123", update)
```
Cancel a scheduled email:
```python
data, err = client.emails.cancel("email_123")
```
## Contacts
All contact operations require a contact book ID (`book_id`).
Create a contact:
```python
create: types.ContactCreate = {
"email": "user@example.com",
"firstName": "Jane",
"properties": {"plan": "pro"},
}
data, err = client.contacts.create("book_123", create)
```
Get a contact:
```python
contact, err = client.contacts.get("book_123", "contact_456")
```
Update a contact:
```python
update: types.ContactUpdate = {"subscribed": False}
data, err = client.contacts.update("book_123", "contact_456", update)
```
Upsert a contact:
```python
upsert: types.ContactUpsert = {
"email": "user@example.com",
"firstName": "Jane",
}
data, err = client.contacts.upsert("book_123", "contact_456", upsert)
```
Delete a contact:
```python
data, err = client.contacts.delete(book_id="book_123", contact_id="contact_456")
```
## Error handling
By default the client raises `UseSendHTTPError` for non-2xx responses. To handle errors as return values, pass `raise_on_error=False`.
```python
from usesend import UseSend, UseSendHTTPError
# Raises exceptions on errors (default)
client = UseSend("us_xxx")
try:
data, _ = client.emails.get("email_123")
except UseSendHTTPError as e:
print("request failed:", e)
# Returns (None, error) instead of raising
client = UseSend("us_xxx", raise_on_error=False)
data, err = client.emails.get("email_123")
if err:
print("error:", err)
```

View File

@@ -37,6 +37,13 @@ Quicklinks to set up your account and get started
href="/get-started/nodejs"
>
Learn how to use our SDK using NodeJS to send emails programmatically.
</Card>
<Card
title="Python"
icon="python"
href="/get-started/python"
>
Learn how to use our SDK using Python to send emails programmatically.
</Card>
<Card
title="SMTP support"

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 UseSend
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,61 @@
# UseSend Python SDK
A minimal Python SDK for the [UseSend](https://usesend.com) API, mirroring the structure of the JavaScript SDK.
## Installation
Install via pip or Poetry:
```
pip install usesend
# or
poetry add usesend
```
## Usage
```python
from usesend import UseSend, types
# By default: raises UseSendHTTPError on non-2xx.
client = UseSend("us_123")
# 1) TypedDict payload (autocomplete in IDEs). Use dict to pass 'from'.
payload: types.EmailCreate = {
"to": "test@example.com",
"from": "no-reply@example.com",
"subject": "Hello",
"html": "<strong>Hi!</strong>",
}
resp, _ = client.emails.send(payload=payload)
# 2) Or pass a plain dict (supports 'from')
resp, _ = client.emails.send(payload={
"to": "test@example.com",
"from": "no-reply@example.com",
"subject": "Hello",
"html": "<strong>Hi!</strong>",
})
# Toggle behavior if desired:
# - raise_on_error=False: return (None, error_dict) instead of raising
# No model parsing occurs; methods return plain dicts following the typed shapes.
client = UseSend("us_123", raise_on_error=False)
raw, err = client.emails.get(email_id="email_123")
if err:
print("error:", err)
else:
print("ok:", raw)
```
## Development
This package is managed with Poetry. Models are maintained in-repo under
`usesend/types.py` (readable names). Update this file as the API evolves.
It is published as `usesend` on PyPI.
Notes
- Human-friendly models are available under `usesend.types` (e.g., `EmailCreate`, `Contact`, `APIError`).
- Endpoint methods accept TypedDict payloads or plain dicts via the `payload=` keyword.

1139
packages/python-sdk/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
[tool.poetry]
name = "usesend"
version = "0.2.3"
description = "Python SDK for the UseSend API"
authors = ["UseSend"]
license = "MIT"
readme = "README.md"
packages = [{ include = "usesend" }]
include = ["usesend/py.typed"]
[tool.poetry.dependencies]
python = ">=3.8,<4.0"
requests = "^2.32.0"
typing_extensions = ">=4.7"
[tool.poetry.group.dev.dependencies]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -0,0 +1,6 @@
"""Python client for the UseSend API."""
from .usesend import UseSend, UseSendHTTPError
from . import types
__all__ = ["UseSend", "UseSendHTTPError", "types"]

View File

@@ -0,0 +1,69 @@
"""Contact resource client using TypedDict shapes (no Pydantic)."""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
from .types import (
APIError,
ContactDeleteResponse,
Contact,
ContactUpdate,
ContactUpdateResponse,
ContactUpsert,
ContactUpsertResponse,
ContactCreate,
ContactCreateResponse,
)
class Contacts:
"""Client for `/contactBooks` endpoints."""
def __init__(self, usesend: "UseSend") -> None:
self.usesend = usesend
def create(
self, book_id: str, payload: ContactCreate
) -> Tuple[Optional[ContactCreateResponse], Optional[APIError]]:
data, err = self.usesend.post(
f"/contactBooks/{book_id}/contacts",
payload,
)
return (data, err) # type: ignore[return-value]
def get(
self, book_id: str, contact_id: str
) -> Tuple[Optional[Contact], Optional[APIError]]:
data, err = self.usesend.get(
f"/contactBooks/{book_id}/contacts/{contact_id}"
)
return (data, err) # type: ignore[return-value]
def update(
self, book_id: str, contact_id: str, payload: ContactUpdate
) -> Tuple[Optional[ContactUpdateResponse], Optional[APIError]]:
data, err = self.usesend.patch(
f"/contactBooks/{book_id}/contacts/{contact_id}",
payload,
)
return (data, err) # type: ignore[return-value]
def upsert(
self, book_id: str, contact_id: str, payload: ContactUpsert
) -> Tuple[Optional[ContactUpsertResponse], Optional[APIError]]:
data, err = self.usesend.put(
f"/contactBooks/{book_id}/contacts/{contact_id}",
payload,
)
return (data, err) # type: ignore[return-value]
def delete(
self, *, book_id: str, contact_id: str
) -> Tuple[Optional[ContactDeleteResponse], Optional[APIError]]:
data, err = self.usesend.delete(
f"/contactBooks/{book_id}/contacts/{contact_id}"
)
return (data, err) # type: ignore[return-value]
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position

View File

@@ -0,0 +1,77 @@
"""Email resource client using TypedDict shapes (no Pydantic)."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from .types import (
APIError,
Attachment,
EmailBatchItem,
EmailBatchResponse,
EmailCancelResponse,
Email,
EmailUpdate,
EmailUpdateResponse,
EmailCreate,
EmailCreateResponse,
)
class Emails:
"""Client for `/emails` endpoints."""
def __init__(self, usesend: "UseSend") -> None:
self.usesend = usesend
# Basic operations -------------------------------------------------
def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
"""Alias for :meth:`create`."""
return self.create(payload)
def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
if isinstance(payload, dict):
payload = dict(payload)
# Normalize fields
body: Dict[str, Any] = dict(payload)
# Support accidental 'from_' usage
if "from_" in body and "from" not in body:
body["from"] = body.pop("from_")
# Convert scheduledAt to ISO 8601 if datetime
if isinstance(body.get("scheduledAt"), datetime):
body["scheduledAt"] = body["scheduledAt"].isoformat()
data, err = self.usesend.post("/emails", body)
return (data, err) # type: ignore[return-value]
def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
items: List[Dict[str, Any]] = []
for item in payload:
d = dict(item)
if "from_" in d and "from" not in d:
d["from"] = d.pop("from_")
if isinstance(d.get("scheduledAt"), datetime):
d["scheduledAt"] = d["scheduledAt"].isoformat()
items.append(d)
data, err = self.usesend.post("/emails/batch", items)
return (data, err) # type: ignore[return-value]
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
data, err = self.usesend.get(f"/emails/{email_id}")
return (data, err) # type: ignore[return-value]
def update(self, email_id: str, payload: EmailUpdate) -> Tuple[Optional[EmailUpdateResponse], Optional[APIError]]:
body: Dict[str, Any] = dict(payload)
if isinstance(body.get("scheduledAt"), datetime):
body["scheduledAt"] = body["scheduledAt"].isoformat()
data, err = self.usesend.patch(f"/emails/{email_id}", body)
return (data, err) # type: ignore[return-value]
def cancel(self, email_id: str) -> Tuple[Optional[EmailCancelResponse], Optional[APIError]]:
data, err = self.usesend.post(f"/emails/{email_id}/cancel", {})
return (data, err) # type: ignore[return-value]
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,317 @@
"""TypedDict models for the UseSend API.
Lightweight, Pydantic-free types for editor autocomplete and static checks.
At runtime these are plain dicts and lists.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Union, TypedDict
from typing_extensions import NotRequired, Required, Literal
# ---------------------------------------------------------------------------
# Domains
# ---------------------------------------------------------------------------
DomainStatus = Literal[
'NOT_STARTED',
'PENDING',
'SUCCESS',
'FAILED',
'TEMPORARY_FAILURE',
]
class Domain(TypedDict, total=False):
id: float
name: str
teamId: float
status: DomainStatus
region: str
clickTracking: bool
openTracking: bool
publicKey: str
dkimStatus: Optional[str]
spfDetails: Optional[str]
createdAt: str
updatedAt: str
dmarcAdded: bool
isVerifying: bool
errorMessage: Optional[str]
subdomain: Optional[str]
DomainList = List[Domain]
class DomainCreate(TypedDict):
name: str
region: str
class DomainCreateResponse(TypedDict, total=False):
id: float
name: str
teamId: float
status: DomainStatus
region: str
clickTracking: bool
openTracking: bool
publicKey: str
dkimStatus: Optional[str]
spfDetails: Optional[str]
createdAt: str
updatedAt: str
dmarcAdded: bool
isVerifying: bool
errorMessage: Optional[str]
subdomain: Optional[str]
class DomainVerifyResponse(TypedDict):
message: str
# ---------------------------------------------------------------------------
# Emails
# ---------------------------------------------------------------------------
EmailEventStatus = Literal[
'SCHEDULED',
'QUEUED',
'SENT',
'DELIVERY_DELAYED',
'BOUNCED',
'REJECTED',
'RENDERING_FAILURE',
'DELIVERED',
'OPENED',
'CLICKED',
'COMPLAINED',
'FAILED',
'CANCELLED',
]
class EmailEvent(TypedDict, total=False):
emailId: str
status: EmailEventStatus
createdAt: str
data: Optional[Any]
Email = TypedDict(
'Email',
{
'id': str,
'teamId': float,
'to': Union[str, List[str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'from': str,
'subject': str,
'html': str,
'text': str,
'createdAt': str,
'updatedAt': str,
'emailEvents': List[EmailEvent],
}
)
class EmailUpdate(TypedDict):
# Accept datetime or ISO string; client will JSON-encode
scheduledAt: Union[datetime, str]
class EmailUpdateResponse(TypedDict, total=False):
emailId: Optional[str]
EmailLatestStatus = Literal[
'SCHEDULED',
'QUEUED',
'SENT',
'DELIVERY_DELAYED',
'BOUNCED',
'REJECTED',
'RENDERING_FAILURE',
'DELIVERED',
'OPENED',
'CLICKED',
'COMPLAINED',
'FAILED',
'CANCELLED',
]
EmailListItem = TypedDict(
'EmailListItem',
{
'id': str,
'to': Union[str, List[str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'from': str,
'subject': str,
'html': str,
'text': str,
'createdAt': str,
'updatedAt': str,
'latestStatus': EmailLatestStatus,
'scheduledAt': str,
'domainId': float,
}
)
class EmailsList(TypedDict):
data: List[EmailListItem]
count: float
class Attachment(TypedDict):
filename: str
content: str
EmailCreate = TypedDict(
'EmailCreate',
{
'to': Required[Union[str, List[str]]],
'from': Required[str],
'subject': NotRequired[str],
'templateId': NotRequired[str],
'variables': NotRequired[Dict[str, str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'text': NotRequired[str],
'html': NotRequired[str],
'attachments': NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str],
}
)
class EmailCreateResponse(TypedDict, total=False):
emailId: Optional[str]
EmailBatchItem = TypedDict(
'EmailBatchItem',
{
'to': Required[Union[str, List[str]]],
'from': Required[str],
'subject': NotRequired[str],
'templateId': NotRequired[str],
'variables': NotRequired[Dict[str, str]],
'replyTo': NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]],
'text': NotRequired[str],
'html': NotRequired[str],
'attachments': NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str],
}
)
EmailBatch = List[EmailBatchItem]
class EmailBatchResponseItem(TypedDict):
emailId: str
class EmailBatchResponse(TypedDict):
data: List[EmailBatchResponseItem]
class EmailCancelResponse(TypedDict, total=False):
emailId: Optional[str]
# ---------------------------------------------------------------------------
# Contacts
# ---------------------------------------------------------------------------
class ContactCreate(TypedDict, total=False):
email: str
firstName: Optional[str]
lastName: Optional[str]
properties: Optional[Dict[str, str]]
subscribed: Optional[bool]
class ContactCreateResponse(TypedDict, total=False):
contactId: Optional[str]
class ContactListItem(TypedDict, total=False):
id: str
firstName: Optional[str]
lastName: Optional[str]
email: str
subscribed: Optional[bool]
properties: Dict[str, str]
contactBookId: str
createdAt: str
updatedAt: str
ContactList = List[ContactListItem]
class ContactUpdate(TypedDict, total=False):
firstName: Optional[str]
lastName: Optional[str]
properties: Optional[Dict[str, str]]
subscribed: Optional[bool]
class ContactUpdateResponse(TypedDict, total=False):
contactId: Optional[str]
class Contact(TypedDict, total=False):
id: str
firstName: Optional[str]
lastName: Optional[str]
email: str
subscribed: Optional[bool]
properties: Dict[str, str]
contactBookId: str
createdAt: str
updatedAt: str
class ContactUpsert(TypedDict, total=False):
email: str
firstName: Optional[str]
lastName: Optional[str]
properties: Optional[Dict[str, str]]
subscribed: Optional[bool]
class ContactUpsertResponse(TypedDict):
contactId: str
class ContactDeleteResponse(TypedDict):
success: bool
# ---------------------------------------------------------------------------
# Common
# ---------------------------------------------------------------------------
class APIError(TypedDict):
code: str
message: str

View File

@@ -0,0 +1,125 @@
"""Core client for interacting with the UseSend API.
Enhancements:
- Optional ``raise_on_error`` to raise ``UseSendHTTPError`` on non-2xx.
- Reusable ``requests.Session`` support for connection reuse.
"""
from __future__ import annotations
import os
from typing import Any, Dict, Optional, Tuple
import requests
DEFAULT_BASE_URL = "https://app.usesend.com"
class UseSendHTTPError(Exception):
"""HTTP error raised when ``raise_on_error=True`` and a request fails."""
def __init__(self, status_code: int, error: Dict[str, Any], method: str, path: str) -> None:
self.status_code = status_code
self.error = error
self.method = method
self.path = path
super().__init__(self.__str__())
def __str__(self) -> str: # pragma: no cover - presentation only
code = self.error.get("code", "UNKNOWN_ERROR")
message = self.error.get("message", "")
return f"{self.method} {self.path} -> {self.status_code} {code}: {message}"
class UseSend:
"""UseSend API client.
Parameters
----------
key:
API key issued by UseSend. If not provided, the client attempts to
read ``USESEND_API_KEY`` or ``UNSEND_API_KEY`` from the environment.
url:
Optional base URL for the API (useful for testing).
"""
def __init__(
self,
key: Optional[str] = None,
url: Optional[str] = None,
*,
raise_on_error: bool = True,
session: Optional[requests.Session] = None,
) -> None:
self.key = key or os.getenv("USESEND_API_KEY") or os.getenv("UNSEND_API_KEY")
if not self.key:
raise ValueError("Missing API key. Pass it to UseSend('us_123')")
base = os.getenv("USESEND_BASE_URL") or os.getenv("UNSEND_BASE_URL") or DEFAULT_BASE_URL
if url:
base = url
self.url = f"{base}/api/v1"
self.headers = {
"Authorization": f"Bearer {self.key}",
"Content-Type": "application/json",
}
self.raise_on_error = raise_on_error
self._session = session or requests.Session()
# Lazily initialise resource clients.
self.emails = Emails(self)
self.contacts = Contacts(self)
# ------------------------------------------------------------------
# Internal request helper
# ------------------------------------------------------------------
def _request(
self, method: str, path: str, json: Optional[Any] = None
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Perform an HTTP request and return ``(data, error)``."""
resp = self._session.request(
method, f"{self.url}{path}", headers=self.headers, json=json
)
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
if not resp.ok:
try:
payload = resp.json()
error = payload.get("error", default_error)
except Exception:
error = default_error
if self.raise_on_error:
raise UseSendHTTPError(resp.status_code, error, method, path)
return None, error
try:
return resp.json(), None
except Exception:
return None, default_error
# ------------------------------------------------------------------
# HTTP verb helpers
# ------------------------------------------------------------------
def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("POST", path, json=body)
def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("GET", path)
def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PUT", path, json=body)
def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PATCH", path, json=body)
def delete(
self, path: str, body: Optional[Any] = None
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("DELETE", path, json=body)
# Import here to avoid circular dependency during type checking
from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position
from .contacts import Contacts # noqa: E402 pylint: disable=wrong-import-position