Асинхронность
`AsyncOpenEmail`: все методы `OpenEmail` через await, на asyncio или trio.
Асинхронный клиент
У AsyncOpenEmail есть все методы OpenEmail с теми же аргументами и теми же типами возвращаемых значений, и каждый из них является корутиной, которую вы ждёте через await. Он создаётся с теми же именованными аргументами, читает те же переменные окружения для всего, что вы не указали, и выбрасывает те же ошибки.
import asyncio from openemail import AsyncOpenEmail async def main() -> None: async with AsyncOpenEmail() as client: sent = await client.emails.send({ 'from': 'Acme Billing <[email protected]>', 'to': '[email protected]', 'subject': 'Your September invoice', 'text': 'Your invoice is attached.', }) email = await client.emails.get(sent['id']) print(email['status'], email['sentAt']) asyncio.run(main())async with закрывает пул соединений, когда блок заканчивается, нормально или с исключением. Клиент, который живёт столько же, сколько процесс, строится один раз при запуске и закрывается через await client.aclose() при остановке. Одного клиента достаточно на всю программу: им может одновременно пользоваться любое число корутин в его цикле событий.
Готовый клиент openemail и init() синхронные, и асинхронного аналога у них нет. Постройте свой AsyncOpenEmail там, где стартует программа, и передайте его коду, которому он нужен, или храните его в состоянии приложения вашего фреймворка.
Проверка соответствия в пакете сравнивает два клиента метод за методом и падает, когда метод принимает разные аргументы в OpenEmail и AsyncOpenEmail, поэтому каждая страница метода описывает оба.
Постраничный обход с async for
list и list_all вызываются через await, как и все остальные методы. С iterate не так: он сразу возвращает асинхронный итератор, а async for запрашивает каждую страницу, когда цикл до неё доходит, так что выход из цикла прекращает запросы.
import asyncio from openemail import AsyncOpenEmail async def main() -> None: async with AsyncOpenEmail() as client: page = await client.emails.list(status='failed', limit=50) print(len(page['items']), page['nextCursor']) complaints = await client.suppressions.list_all(reason='complaint') print(len(complaints)) async for thread in client.threads.iterate(folder='inbox'): print(thread['id']) asyncio.run(main())Много вызовов одновременно
Один клиент ведёт любое число запросов одновременно. asyncio.gather запускает их вместе, а семафор ограничивает число выполняющихся запросов выбранным вами значением.
import asyncio from openemail import AsyncOpenEmailfrom openemail.types import SentEmailResource async def main() -> None: recipients = ['[email protected]', '[email protected]', '[email protected]'] gate = asyncio.Semaphore(8) async with AsyncOpenEmail() as client: async def welcome(address: str) -> SentEmailResource: async with gate: return await client.emails.send({ 'from': 'Acme <[email protected]>', 'to': address, 'subject': 'Welcome to Acme', 'text': 'Your workspace is ready.', }) results = await asyncio.gather( *(welcome(address) for address in recipients), return_exceptions=True, ) for address, result in zip(recipients, results): if isinstance(result, BaseException): print(address, 'failed:', result) else: print(address, result['status']) asyncio.run(main())Сегодня API не задаёт общего ограничения частоты обычных чтений и записей, так что ничто не замедлит всплеск запросов за вас, и такое ограничение может появиться позже. То, что API всё же считает, отвечает 429: месячный лимит отправок рабочего пространства, его дневные действия ИИ и 500 загрузок файлов в час, среди прочего. Ни один из этих ответов не несёт Retry-After, поэтому клиент сразу выбрасывает OpenEmailApiError с истинным is_rate_limited, а не повторяет запрос. return_exceptions=True заставляет gather вернуть все результаты, отклонённый вызов в виде его исключения, вместо того чтобы выбросить исключение при первом отказе, а цикл читает каждый из них.
Отмена задачи отменяет её запрос, а отправка, отменённая на лету, могла уже дойти до API. Дайте отправке, которую вы можете отменить и затем повторить, собственный idempotency_key=, чтобы повтор воспроизвёл первую отправку, а не отправил второе письмо.
asyncio и trio
Клиент ждёт и отсчитывает таймауты через anyio, а отправляет через httpx, и оба работают на любом из двух циклов событий, так что один и тот же main() запускается и под asyncio.run(main()), и под trio.run(main). trio не является зависимостью пакета, поэтому установите его сами, если используете.
Собственные gather и Semaphore из asyncio, как в примере выше, работают только на asyncio. Для кода, который должен работать на обоих, используйте create_task_group и Semaphore из anyio, от которого пакет уже зависит.
Асинхронный токен доступа
Приложение, которое человек подключил через OAuth, держит токен доступа вместо API-ключа и передаёт его как access_token=: сам токен или функцию, которая его возвращает. Функция выполняется перед каждым запросом, поэтому может обновить токен, когда срок его действия подходит к концу, и клиент никогда не придётся пересоздавать. В AsyncOpenEmail это может быть функция async, и клиент дожидается того, что она вернёт.
import asyncioimport timefrom dataclasses import dataclass from openemail import AsyncOpenEmail from acme.auth import refresh_access_token @dataclassclass CachedToken: value: str = '' expires_at: float = 0.0 cached = CachedToken() async def access_token() -> str: if cached.expires_at - time.time() < 60: cached.value, lifetime = await refresh_access_token() cached.expires_at = time.time() + lifetime return cached.value async def main() -> None: async with AsyncOpenEmail(access_token=access_token) as client: me = await client.me.get() print(me['object']) asyncio.run(main())Делайте функцию дешёвой, ведь её ждёт каждый запрос: возвращайте закэшированный токен и обновляйте его только ближе к истечению, как выше. OpenEmail не может ждать корутину, поэтому переданная ему функция async выбрасывает ValueError на первом запросе.
Одноразовые ящики
create_async_temp_mail() является асинхронным аналогом create_temp_mail(). Он не несёт API-ключа: create и list_domains вообще не отправляют учётных данных, а каждый другой метод принимает токен, который вернул create, как inbox_token=. Передайте inbox_token= самому create_async_temp_mail, чтобы получить клиент, привязанный к одному ящику.
import asyncio import httpxfrom openemail import create_async_temp_mail async def main() -> None: async with httpx.AsyncClient(follow_redirects=True) as http: temp = create_async_temp_mail(http_client=http) inbox = await temp.create({'ttlMinutes': 60}) print(inbox['address'], inbox['expiresAt']) async for message in temp.iterate_messages(inbox['id'], inbox_token=inbox['token']): print(message['from']['email'], message['subject']) asyncio.run(main())У клиента, который он возвращает, нет собственного aclose(). Чтобы закрыть его соединения по окончании работы, откройте httpx.AsyncClient через async with и передайте его как http_client=, как выше.
Собственный клиент httpx
http_client= принимает построенный вами httpx.AsyncClient: для прокси, лимитов соединений, собственных сертификатов или фиктивного транспорта в тестах. httpx.Client выбрасывает TypeError, потому что он предназначен для OpenEmail.
import asyncio import httpxfrom openemail import AsyncOpenEmail async def main() -> None: async with httpx.AsyncClient( proxy='http://proxy.internal:3128', limits=httpx.Limits(max_connections=20), follow_redirects=True, ) as http: client = AsyncOpenEmail(http_client=http, timeout=20) page = await client.threads.list(folder='inbox', limit=10) print(len(page['items'])) asyncio.run(main())Переданный вами клиент остаётся вашим: aclose() и конец async with закрывают только пул, который открыл SDK, так что закрывайте свой httpx.AsyncClient сами, здесь его собственным async with. Пул, который открывает SDK, следует перенаправлениям, поэтому задайте follow_redirects=True у своего, чтобы поведение совпадало. timeout= на клиенте или на отдельном вызове по-прежнему ограничивает каждую попытку, какой бы таймаут ни был у httpx.AsyncClient.
В тестах httpx.MockTransport отвечает на каждый запрос из вашей функции, так что в сеть ничего не уходит.
import asyncio import httpxfrom openemail import AsyncOpenEmail def answer(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={'object': 'list', 'data': [], 'hasMore': False, 'nextCursor': None}) async def main() -> None: async with httpx.AsyncClient(transport=httpx.MockTransport(answer)) as http: client = AsyncOpenEmail('oe_test_fixture', http_client=http) page = await client.suppressions.list() assert page['items'] == [] asyncio.run(main())