Чтение всех писем по частям#
Почта отдаёт по два письма. HTTP-клиент может следовать смещению, курсору или готовой ссылке;
браузер видит первую пару в HTML и просит страницу подгрузить вторую. Во всех четырёх случаях
результат один: письма 42, 43, 44 и 45 в исходном порядке.
Вариант |
Адрес следующей порции |
Условие остановки |
|---|---|---|
HTTP, |
|
достигнут |
HTTP, |
|
курсор равен |
HTTP, |
готовый |
ссылка равна |
Браузер, |
кнопка |
четвёртый элемент появился в коллекции |
→ Request · GET /api/v1/threads/status/smart?offset=0&limit=2
Authorization: Bearer session-1
← Response · 200 OK
{
"status": "ok",
"body": {
"items": [
{"id": 42, "sender": "ada@mail.example", "recipient": "reader@mail.example", "subject": "Планы на пятницу"},
{"id": 43, "sender": "grace@mail.example", "recipient": "reader@mail.example", "subject": "Сверка протокола"}
],
"total": 4,
"next_offset": 2,
"next_cursor": "cursor-2",
"next_url": "/api/v1/threads/status/smart?offset=2&limit=2"
}
}
Три операции обращаются к одной ручке и разбирают одну модель ответа. Различается только
__pages__: стратегия называет поля запроса и функцию, которая читает указатель следующей
порции.
# examples/mail/http/messages.py
@dataclass(frozen=True, slots=True, kw_only=True)
class ListByOffset(HttpOperation[MessagePage]):
__http__ = Http.get(
"/api/v1/threads/status/smart",
success={200: Json(MessagePageEnvelope)},
security=MAIL_BEARER,
)
__pages__ = Pages.offset(
MessagePage,
offset="offset",
limit="limit",
items=lambda page: page.items,
total=lambda page: page.total,
)
offset: Query[int] = 0
limit: Query[int] = 2
@dataclass(frozen=True, slots=True, kw_only=True)
class ListByCursor(HttpOperation[MessagePage]):
__http__ = Http.get(
"/api/v1/threads/status/smart",
success={200: Json(MessagePageEnvelope)},
security=MAIL_BEARER,
)
__pages__ = Pages.cursor(
MessagePage,
cursor="cursor",
items=lambda page: page.items,
next_cursor=lambda page: page.next_cursor,
)
cursor: Query[Omittable[str]] = UNSET
limit: Query[int] = 2
@dataclass(frozen=True, slots=True, kw_only=True)
class ListByNextUrl(HttpOperation[MessagePage]):
__http__ = Http.get(
"/api/v1/threads/status/smart",
success={200: Json(MessagePageEnvelope)},
security=MAIL_BEARER,
)
__pages__ = Pages.next_url(
MessagePage,
items=lambda page: page.items,
next_url=lambda page: page.next_url,
)
offset: Query[int] = 0
limit: Query[int] = 2
class MessagesApi(SyncApi):
base_url = "https://mail.example"
by_offset = op(ListByOffset)
by_cursor = op(ListByCursor)
by_next_url = op(ListByNextUrl)
Цикл не попадает в прикладной код. Метод items() отправляет каждую следующую операцию с той же
авторизацией, разбирает ответ обычным способом и отдаёт сообщения по одному.
# examples/mail/http/messages.py
def read_with_three_strategies() -> tuple[list[int], list[int], list[int]]:
identity = Identity(auth=(MAIL_BEARER.static("session-1"),))
with mail_client() as client:
api = MessagesApi(client, identity=identity)
offset = [message.id for message in api.by_offset.items()]
cursor = [message.id for message in api.by_cursor.items()]
next_url = [message.id for message in api.by_next_url.items()]
return offset, cursor, next_url
Файл примера целиком
"""Read the same mail feed with offset, cursor and next-URL pagination."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from pydantic import BaseModel
from eazy_sdk import UNSET, Http, HttpOperation, Identity, Omittable, Query, SyncApi, op
from eazy_sdk.pagination import Pages
from eazy_sdk.response import Const, Json, Payload
from examples.mail.http._client import mail_client
from examples.mail.http.send import MAIL_BEARER
class Message(BaseModel):
id: int
sender: str
recipient: str
subject: str
class MessagePage(BaseModel):
items: list[Message]
total: int
next_offset: int | None
next_cursor: str | None
next_url: str | None
class MessagePageEnvelope(BaseModel):
status: Annotated[str, Const("ok")]
body: Payload[MessagePage]
# region docs: http-message-pages
# examples/mail/http/messages.py
@dataclass(frozen=True, slots=True, kw_only=True)
class ListByOffset(HttpOperation[MessagePage]):
__http__ = Http.get(
"/api/v1/threads/status/smart",
success={200: Json(MessagePageEnvelope)},
security=MAIL_BEARER,
)
__pages__ = Pages.offset(
MessagePage,
offset="offset",
limit="limit",
items=lambda page: page.items,
total=lambda page: page.total,
)
offset: Query[int] = 0
limit: Query[int] = 2
@dataclass(frozen=True, slots=True, kw_only=True)
class ListByCursor(HttpOperation[MessagePage]):
__http__ = Http.get(
"/api/v1/threads/status/smart",
success={200: Json(MessagePageEnvelope)},
security=MAIL_BEARER,
)
__pages__ = Pages.cursor(
MessagePage,
cursor="cursor",
items=lambda page: page.items,
next_cursor=lambda page: page.next_cursor,
)
cursor: Query[Omittable[str]] = UNSET
limit: Query[int] = 2
@dataclass(frozen=True, slots=True, kw_only=True)
class ListByNextUrl(HttpOperation[MessagePage]):
__http__ = Http.get(
"/api/v1/threads/status/smart",
success={200: Json(MessagePageEnvelope)},
security=MAIL_BEARER,
)
__pages__ = Pages.next_url(
MessagePage,
items=lambda page: page.items,
next_url=lambda page: page.next_url,
)
offset: Query[int] = 0
limit: Query[int] = 2
class MessagesApi(SyncApi):
base_url = "https://mail.example"
by_offset = op(ListByOffset)
by_cursor = op(ListByCursor)
by_next_url = op(ListByNextUrl)
# endregion docs: http-message-pages
# region docs: http-message-flow
# examples/mail/http/messages.py
def read_with_three_strategies() -> tuple[list[int], list[int], list[int]]:
identity = Identity(auth=(MAIL_BEARER.static("session-1"),))
with mail_client() as client:
api = MessagesApi(client, identity=identity)
offset = [message.id for message in api.by_offset.items()]
cursor = [message.id for message in api.by_cursor.items()]
next_url = [message.id for message in api.by_next_url.items()]
return offset, cursor, next_url
# endregion docs: http-message-flow
def main() -> None:
offset, cursor, next_url = read_with_three_strategies()
print(f"offset: {offset}")
print(f"cursor: {cursor}")
print(f"next URL: {next_url}")
if __name__ == "__main__":
main()
Запуск: uv run python -m examples.mail.http.messages.
offset: [42, 43, 44, 45]
cursor: [42, 43, 44, 45]
next URL: [42, 43, 44, 45]
Первая страница входящих содержит два элемента и кнопку:
<ul data-collection="messages"><li data-message-id="42"><b>ada@mail.example</b> Планы на пятницу</li><li data-message-id="43"><b>grace@mail.example</b> Сверка протокола</li></ul><button data-action="more">Ещё</button>
each(...) связывает поле карты со всеми видимыми li. Сначала операция читает снимок
коллекции, затем нажимает кнопку и ждёт элемент с индексом 3. После этого новый снимок уже
содержит обе порции.
# examples/mail/browser/messages.py
class InboxPage:
messages: Annotated[Elements, each(css(MESSAGE))]
more: Annotated[Element, css('[data-action="more"]')]
@dataclass(frozen=True, slots=True)
class LoadedMessages:
first_batch: tuple[int, ...]
all_messages: tuple[int, ...]
async def _ids(messages: Elements) -> tuple[int, ...]:
values = [await item.attribute("data-message-id") async for item in messages]
return tuple(int(value) for value in values if value is not None)
async def _load_more(content: InboxPage) -> LoadedMessages:
first_batch = await _ids(content.messages)
await content.more.click()
await content.messages.nth(3).text()
return LoadedMessages(first_batch, await _ids(content.messages))
async def _mailbox_missing(content: InboxPage) -> NoReturn:
_ = content
raise TimeoutError("the inbox did not appear")
@dataclass(frozen=True, slots=True, kw_only=True)
class ReadInbox(BrowserOperation[InboxPage, LoadedMessages]):
__browser__ = Browser.goto(
"/inbox/",
InboxPage,
at=css(MESSAGE),
outcomes=outcomes(
when(visible(css(MESSAGE)), then=_load_more),
otherwise=_mailbox_missing,
),
)
class InboxApi(AsyncBrowserApi):
read = op(ReadInbox)
Вызов открывает /inbox/ в настоящем Chromium. JavaScript страницы отправляет второй запрос к
тому же MailSite и добавляет полученные узлы в список.
# examples/mail/browser/messages.py
async def read_all_messages() -> LoadedMessages:
async with playwright_mail() as runtime:
return await InboxApi(runtime.client).read(options=OPTIONS)
Файл примера целиком
"""Read the initial inbox collection and load the next batch in Chromium."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Annotated, NoReturn
from eazy_sdk import op
from eazy_sdk_browser import (
AsyncBrowserApi,
Browser,
BrowserCallOptions,
BrowserOperation,
Element,
Elements,
css,
each,
outcomes,
visible,
when,
)
from examples.mail.site._playwright import playwright_mail
OPTIONS = BrowserCallOptions(timeout=5.0, element_timeout=5.0)
MESSAGE = '[data-collection="messages"] li'
# region docs: browser-message-collection
# examples/mail/browser/messages.py
class InboxPage:
messages: Annotated[Elements, each(css(MESSAGE))]
more: Annotated[Element, css('[data-action="more"]')]
@dataclass(frozen=True, slots=True)
class LoadedMessages:
first_batch: tuple[int, ...]
all_messages: tuple[int, ...]
async def _ids(messages: Elements) -> tuple[int, ...]:
values = [await item.attribute("data-message-id") async for item in messages]
return tuple(int(value) for value in values if value is not None)
async def _load_more(content: InboxPage) -> LoadedMessages:
first_batch = await _ids(content.messages)
await content.more.click()
await content.messages.nth(3).text()
return LoadedMessages(first_batch, await _ids(content.messages))
async def _mailbox_missing(content: InboxPage) -> NoReturn:
_ = content
raise TimeoutError("the inbox did not appear")
@dataclass(frozen=True, slots=True, kw_only=True)
class ReadInbox(BrowserOperation[InboxPage, LoadedMessages]):
__browser__ = Browser.goto(
"/inbox/",
InboxPage,
at=css(MESSAGE),
outcomes=outcomes(
when(visible(css(MESSAGE)), then=_load_more),
otherwise=_mailbox_missing,
),
)
class InboxApi(AsyncBrowserApi):
read = op(ReadInbox)
# endregion docs: browser-message-collection
# region docs: browser-message-flow
# examples/mail/browser/messages.py
async def read_all_messages() -> LoadedMessages:
async with playwright_mail() as runtime:
return await InboxApi(runtime.client).read(options=OPTIONS)
# endregion docs: browser-message-flow
async def run() -> None:
messages = await read_all_messages()
print(f"first batch: {list(messages.first_batch)}")
print(f"after more: {list(messages.all_messages)}")
def main() -> None:
asyncio.run(run())
if __name__ == "__main__":
main()
Запуск: uv run python -m examples.mail.browser.messages.
first batch: [42, 43]
after more: [42, 43, 44, 45]
Примечание
Elements.count() и Elements.texts() читают снимок без ожидания. Когда нужен будущий узел,
обращайтесь к nth(i): действие над ним ждёт появления элемента в пределах таймаута.