s-netkit 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
netkit/__init__.py ADDED
@@ -0,0 +1,240 @@
1
+ """netkit — СЕТЕВОЙ слой китов. Всё, чем интеграция ходит наружу.
2
+
3
+ ФОРМА ГРАФА::
4
+
5
+ clikit adapterkit <- ветки-оболочки
6
+ \\ /
7
+ \\ /
8
+ librarykit <- ОРКЕСТРАТОР (сессии, браузер, склад)
9
+ |
10
+ netkit <- СЕТЬ (транспорт, лимиты, повторы)
11
+ |
12
+ corekit <- ОСНОВАНИЕ (значения и правила)
13
+
14
+ ЧТО СЮДА ПЕРЕЕХАЛО ИЗ librarykit И ПО КАКОМУ ПРИЗНАКУ. Ровно одно: **модуль
15
+ живёт разговором с чужим сервером**. Не «использует httpx», а именно отвечает за
16
+ доставку, темп и живучесть обмена:
17
+
18
+ * :mod:`netkit.transport` — исполнители запроса поверх httpx (async + sync
19
+ близнец), choke-point `HttpClient`/`SyncHttpClient`, REST-клиент к своему
20
+ backend, permissive-рецепты;
21
+ * :mod:`netkit.stream` — persistent-каналы (WebSocket) — `StreamTransport`;
22
+ * :mod:`netkit.rpc` — codec-слой RPC (`JsonCodec`/`PrefixedJsonCodec`);
23
+ * :mod:`netkit.graphql` — GraphQL-клиент поверх транспорта;
24
+ * :mod:`netkit.limit` — `RateLimiter` + token-bucket (проактивный темп);
25
+ * :mod:`netkit.retry` — политики повторов (header-driven и фиксированная);
26
+ * :mod:`netkit.ladder` — ЛЕСТНИЦА ДЕГРАДАЦИИ: чем выполнять запросы и чем
27
+ добывать состояние, с памятью ступени и событиями спуска;
28
+ * :mod:`netkit.pagination` / :mod:`netkit.upload` / :mod:`netkit.forms` —
29
+ листание ресурса, resumable-догрузка, form-urlencoded кодек;
30
+ * :mod:`netkit.errmap` — ответ сервера → доменная ошибка;
31
+ * :mod:`netkit.declare` — ОБЪЯВЛЕНИЕ транспорта (http/ws/rpc/graphql) с ЕДИНЫМ
32
+ поведением лимитов и повторов поверх любого вида.
33
+
34
+ ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ: браузера, антибота, хранилища сессий, keyring,
35
+ шифрования, путей состояния. Всё, что живёт ВЫШЕ, приходит сюда СЛОТАМИ
36
+ (:mod:`netkit.providers`) — не импортом. Поэтому netkit ставится и работает без
37
+ оркестратора, а зависимости идут строго вниз: ``netkit → corekit``.
38
+
39
+ ЛЕНИВЫЙ ФАСАД. Корень пакета не исполняет НИ ОДНОГО подмодуля: `import netkit`
40
+ не тянет ни httpx, ни stamina, ни asyncio. Имена резолвятся по PEP 562 при
41
+ первом обращении (см. `_LAZY_NAMES` и `__getattr__` ниже) — платит тот, кому
42
+ нужно. Это не украшение: `librarykit` реэкспортирует отсюда десятки имён своими
43
+ жадными импортами, и любой жадный импорт здесь мгновенно вернул бы 264 мс httpx
44
+ в стоимость `import librarykit` (зафиксировано fitness-тестами обоих китов).
45
+
46
+ СОВМЕСТИМОСТЬ. Ни одно имя отсюда не «переехало» для потребителя: `librarykit`
47
+ и его подмодули (`librarykit.transport`, `librarykit.ladder`, `librarykit.limit`,
48
+ …) реэкспортируют ТЕ ЖЕ объекты (не копии), поэтому ``isinstance``/``except``/
49
+ ``is``-сравнения работают через любой из путей.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ from importlib import import_module as _import_module
54
+ from importlib.util import find_spec as _find_spec
55
+ from typing import TYPE_CHECKING
56
+
57
+ if TYPE_CHECKING: # pragma: no cover — только статическая видимость для IDE/mypy
58
+ from typing import Any
59
+
60
+ __version__ = "0.0.1"
61
+
62
+ #: Ленивые ИМЕНА: имя в корне → модуль, из которого оно берётся. Все они входят в
63
+ #: `__all__`, поэтому `from netkit import *`, `getattr` и `dir()` видят полную
64
+ #: поверхность, ничего при этом не исполняя.
65
+ _LAZY_NAMES: dict[str, str] = {
66
+ # contract: формы «талии» сетевого слоя + реэкспорт значений основания
67
+ "PaginationMode": "netkit.contract",
68
+ "AuthMode": "netkit.contract",
69
+ "TransportKind": "netkit.contract",
70
+ "RequestExecutionMode": "netkit.contract",
71
+ "DEFAULT_TENANT": "netkit.contract",
72
+ "SessionRef": "netkit.contract",
73
+ "ExecutionContext": "netkit.contract",
74
+ "Creds": "netkit.contract",
75
+ "RequestBody": "netkit.contract",
76
+ "QuotaScope": "netkit.contract",
77
+ "QuotaInfo": "netkit.contract",
78
+ "LimitSpec": "netkit.contract",
79
+ "ResponseLike": "netkit.contract",
80
+ "Transport": "netkit.contract",
81
+ "SyncTransport": "netkit.contract",
82
+ "HttpRequestExecutor": "netkit.contract",
83
+ "SyncHttpRequestExecutor": "netkit.contract",
84
+ "StreamTransport": "netkit.contract",
85
+ "Codec": "netkit.contract",
86
+ "Auth": "netkit.contract",
87
+ "Refreshable": "netkit.contract",
88
+ "ProactiveRefreshable": "netkit.contract",
89
+ "ApplyRefreshable": "netkit.contract",
90
+ "SyncRefreshable": "netkit.contract",
91
+ "SyncProactiveRefreshable": "netkit.contract",
92
+ "ErrorMapper": "netkit.contract",
93
+ "Paginator": "netkit.contract",
94
+ # transport: исполнители запроса + choke-point'ы + REST-клиент
95
+ "HttpxTransport": "netkit.transport",
96
+ "HttpxRequestExecutor": "netkit.transport",
97
+ "HttpxSyncTransport": "netkit.transport",
98
+ "HttpxSyncRequestExecutor": "netkit.transport",
99
+ "HttpClient": "netkit.transport",
100
+ "SyncHttpClient": "netkit.transport",
101
+ "RestHttpClient": "netkit.transport",
102
+ "NoopAuth": "netkit.transport",
103
+ "PermissiveErrorMapper": "netkit.transport",
104
+ "build_permissive_http_client": "netkit.transport",
105
+ "build_permissive_sync_http_client": "netkit.transport",
106
+ "aclose_http_client": "netkit.transport",
107
+ "close_sync_http_client": "netkit.transport",
108
+ "RefreshCallback": "netkit.transport",
109
+ # limit / retry: темп и живучесть обмена
110
+ "LimitWindow": "netkit.limit",
111
+ "LimitPolicy": "netkit.limit",
112
+ "LimitScope": "netkit.limit",
113
+ "RateLimiter": "netkit.limit",
114
+ "RetryPolicy": "netkit.retry",
115
+ "DEFAULT_RETRY": "netkit.retry",
116
+ "SimpleRetryPolicy": "netkit.retry",
117
+ "DEFAULT_SIMPLE_RETRY": "netkit.retry",
118
+ # errmap: ответ сервера → доменная ошибка
119
+ "ErrorMap": "netkit.errmap",
120
+ "ErrorMapBuilder": "netkit.errmap",
121
+ "BodyRule": "netkit.errmap",
122
+ "ExcFactory": "netkit.errmap",
123
+ "AuthExpired": "netkit.errmap",
124
+ "build_error_map": "netkit.errmap",
125
+ "DEFAULT_ERROR_MAP": "netkit.errmap",
126
+ # rpc / stream / graphql: не-REST виды обмена
127
+ "JsonCodec": "netkit.rpc",
128
+ "PrefixedJsonCodec": "netkit.rpc",
129
+ "RpcClient": "netkit.rpc",
130
+ "StubStreamTransport": "netkit.stream",
131
+ "WebSocketsStreamTransport": "netkit.stream",
132
+ "StreamClosedError": "netkit.stream",
133
+ "GraphQLClient": "netkit.graphql",
134
+ # pagination / upload / forms: листание, догрузка, кодек тел
135
+ "CursorPaginator": "netkit.pagination",
136
+ "PageParams": "netkit.pagination",
137
+ "DEFAULT_PAGE_PARAMS": "netkit.pagination",
138
+ "ItemsExtractor": "netkit.pagination",
139
+ "CursorExtractor": "netkit.pagination",
140
+ "default_extract_items": "netkit.pagination",
141
+ "default_extract_cursor": "netkit.pagination",
142
+ "with_page_params": "netkit.pagination",
143
+ "FlatPage": "netkit.pagination",
144
+ "parse_flat_page": "netkit.pagination",
145
+ "next_flat_offset": "netkit.pagination",
146
+ "ChunkedUploader": "netkit.upload",
147
+ "UploadResult": "netkit.upload",
148
+ "FormCodec": "netkit.forms",
149
+ # declare: объявление транспорта + единое поведение лимитов и повторов
150
+ "TransportSpec": "netkit.declare",
151
+ "DeclaredTransport": "netkit.declare",
152
+ "declare": "netkit.declare",
153
+ "register_kind": "netkit.declare",
154
+ "kinds": "netkit.declare",
155
+ "UnknownTransportKind": "netkit.declare",
156
+ "KIND_HTTP": "netkit.declare",
157
+ "KIND_WS": "netkit.declare",
158
+ "KIND_RPC": "netkit.declare",
159
+ "KIND_GRAPHQL": "netkit.declare",
160
+ # providers: слоты верхнего слоя (браузер, склад, диагностика, egress)
161
+ "provider": "netkit.providers",
162
+ "register_provider": "netkit.providers",
163
+ "unregister_provider": "netkit.providers",
164
+ "registered_slots": "netkit.providers",
165
+ "ProviderMissing": "netkit.providers",
166
+ "SLOTS": "netkit.providers",
167
+ "SLOT_BROWSER_MINT": "netkit.providers",
168
+ "SLOT_BROWSER_PROVIDER": "netkit.providers",
169
+ "SLOT_ENGINE_PROFILE_DIR": "netkit.providers",
170
+ "SLOT_TRANSPORT_FACTORY": "netkit.providers",
171
+ "SLOT_DIAGNOSE": "netkit.providers",
172
+ "SLOT_EGRESS_PROXY": "netkit.providers",
173
+ "SLOT_JSON_STORE": "netkit.providers",
174
+ "SLOT_FILE_LOCK": "netkit.providers",
175
+ "SLOT_STATE_ROOT": "netkit.providers",
176
+ "SLOT_PATH_SLUG": "netkit.providers",
177
+ # ladder: лестница деградации (её поверхность полностью — в netkit.ladder)
178
+ "TransportLadder": "netkit.ladder",
179
+ "SyncTransportLadder": "netkit.ladder",
180
+ "ServiceSpec": "netkit.ladder",
181
+ "MinterLadder": "netkit.ladder",
182
+ "ExecutorStep": "netkit.ladder",
183
+ "MinterStep": "netkit.ladder",
184
+ "DegradationEvent": "netkit.ladder",
185
+ "subscribe_degradation": "netkit.ladder",
186
+ "unsubscribe_degradation": "netkit.ladder",
187
+ "emit_degradation": "netkit.ladder",
188
+ }
189
+
190
+ #: Подсказки по extras: модуль → что поставить, если внутри не хватило вендора.
191
+ _EXTRA_HINTS: dict[str, str] = {
192
+ "netkit.stream": "s-netkit[ws]",
193
+ }
194
+
195
+
196
+ def __getattr__(name: str) -> Any:
197
+ """Ленивый резолв имён корня (PEP 562).
198
+
199
+ Порядок: (1) карта ленивых ИМЁН `_LAZY_NAMES`; (2) РЕАЛЬНЫЙ подмодуль с таким
200
+ именем — чтобы `import netkit; netkit.transport.HttpClient` работало без
201
+ отдельного импорта подмодуля. Промах — обычная `AttributeError` (иначе
202
+ `hasattr()` у потребителя взрывался бы вместо возврата False).
203
+ """
204
+ target = _LAZY_NAMES.get(name)
205
+ if target is None:
206
+ if not name.startswith("_"):
207
+ try:
208
+ found = _find_spec(f"{__name__}.{name}") is not None
209
+ except (ImportError, ValueError):
210
+ found = False
211
+ if found:
212
+ module = _import_module(f"{__name__}.{name}")
213
+ globals()[name] = module
214
+ return module
215
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
216
+ try:
217
+ module = _import_module(target)
218
+ except ModuleNotFoundError as exc:
219
+ missing = exc.name or ""
220
+ if missing.startswith(__name__):
221
+ raise # сломан сам кит — не маскируем подсказкой про extras
222
+ hint = _EXTRA_HINTS.get(target)
223
+ raise ImportError(
224
+ f"{name!r} требует модуль {target!r}, но тому не хватает пакета {missing!r}."
225
+ + (f" Поставь extra: `pip install '{hint}'`." if hint else "")
226
+ ) from exc
227
+ value = getattr(module, name)
228
+ # Кэш в globals(): второй доступ идёт напрямую, мимо хука. Присваивание в dict
229
+ # атомарно, повторный импорт из другого потока вернёт ТОТ ЖЕ объект из
230
+ # sys.modules — гонка идемпотентна (важно под free-threading).
231
+ globals()[name] = value
232
+ return value
233
+
234
+
235
+ def __dir__() -> list[str]:
236
+ """Полная поверхность корня: жадные атрибуты + `__all__` + ленивые имена."""
237
+ return sorted(set(globals()) | set(__all__) | set(_LAZY_NAMES))
238
+
239
+
240
+ __all__ = ["__version__", *sorted(_LAZY_NAMES)]
netkit/contract.py ADDED
@@ -0,0 +1,380 @@
1
+ """Сетевые структурные контракты «талии песочных часов» (кит netkit).
2
+
3
+ Здесь живут `Protocol`-формы СЕТЕВОГО слоя: чем выполняется запрос (`Transport`/
4
+ `SyncTransport`), как выглядит его результат (`ResponseLike`), чем подмешиваются
5
+ и обновляются credentials (`Auth`/`Refreshable`/`ProactiveRefreshable`), как
6
+ кодируется RPC-кадр (`Codec`), как живёт persistent-канал (`StreamTransport`),
7
+ как ответ превращается в доменную ошибку (`ErrorMapper`) и как листается ресурс
8
+ (`Paginator`).
9
+
10
+ ОТКУДА ОНИ ПРИЕХАЛИ. Раньше все эти формы лежали в
11
+ `netkit.contract` вперемешку с браузерными (`BrowserBackend`) и складскими
12
+ (`SessionStoreProtocol`). Сеть выделена в отдельный кит — значит и её контракт
13
+ должен жить в ките сети, иначе `netkit` зависел бы от `librarykit` (цикл).
14
+ `netkit.contract` теперь ИМПОРТИРУЕТ эти имена отсюда и реэкспортирует ТЕМИ
15
+ ЖЕ объектами: `from netkit.contract import Transport` даёт ровно тот объект,
16
+ что и `from netkit.contract import Transport` — `isinstance`/`is` не разъезжаются.
17
+
18
+ Значения и перечисления («талия» без реализации — `TransportKind`, `SessionRef`,
19
+ `LimitSpec`, `ExecutionContext`, …) живут ЭТАЖОМ НИЖЕ, в `corekit`, и
20
+ реэкспортируются здесь, чтобы сетевому слою хватало одного импорта.
21
+
22
+ ЗАКОН СЛОЁВ: только stdlib, `corekit` и `httpx` (типовая граница HTTP; httpx
23
+ фигурирует ИСКЛЮЧИТЕЛЬНО в аннотациях, см. `TYPE_CHECKING` ниже — рантайм его не
24
+ тянет). Ни `librarykit`, ни `adapterkit`, ни `clikit` отсюда не импортируются
25
+ НИКОГДА: `corekit <- netkit <- ...` — направление зависимостей однонаправленное.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import AsyncIterator, Mapping, Sequence
30
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
31
+
32
+ # ОСНОВАНИЕ: значения и перечисления «талии». Реэкспортируются ниже через
33
+ # `__all__` — сетевому слою (и его потребителям) хватает одного импорта, а
34
+ # объекты остаются ТЕМИ ЖЕ, что в `corekit` (identity не дублируется).
35
+ from corekit.context import DEFAULT_TENANT, ExecutionContext
36
+ from corekit.dto import Creds, RequestBody, SessionRef
37
+ from corekit.enums import AuthMode, PaginationMode, RequestExecutionMode, TransportKind
38
+ from corekit.limits import LimitSpec, QuotaInfo, QuotaScope
39
+
40
+ if TYPE_CHECKING: # pragma: no cover — только для тайпчекера, не в рантайме
41
+ # httpx фигурирует ИСКЛЮЧИТЕЛЬНО в аннотациях методов (`-> httpx.Response`).
42
+ # PEP 563 (`from __future__ import annotations`) оставляет их строками,
43
+ # поэтому рантайму имя не нужно — `import netkit.contract` не стоит 264 мс.
44
+ import httpx
45
+
46
+ # =========================================================================== #
47
+ # Транспорт / авторизация / маппинг ошибок #
48
+ # =========================================================================== #
49
+
50
+
51
+ @runtime_checkable
52
+ class ResponseLike(Protocol):
53
+ """Протокол-агностичный результат запроса: status/headers/json/text.
54
+
55
+ «Талия» поверх конкретного ответного типа: `httpx.Response` СТРУКТУРНО уже
56
+ совпадает с этой формой (`status_code: int`, `headers: Mapping`, `json()`,
57
+ `text: str`), поэтому REST-путь продолжает возвращать `httpx.Response` без
58
+ оборачивания. Не-HTTP транспорты (RPC поверх не-JSON envelope, будущие WS-
59
+ ответы) реализуют ту же форму своим лёгким DTO — `BaseAdapter`/потребитель
60
+ кодится против `ResponseLike`, а не против httpx.
61
+
62
+ `json()` возвращает разобранное тело (`Any`); реализация без JSON-семантики
63
+ вправе поднять ошибку или вернуть сырой payload. `headers` — case-insensitive
64
+ маппинг (как у httpx), но протокол требует лишь `Mapping[str, str]`.
65
+ """
66
+
67
+ @property
68
+ def status_code(self) -> int:
69
+ """Статус-код результата (HTTP-код либо его протокольный эквивалент)."""
70
+ ...
71
+
72
+ @property
73
+ def headers(self) -> Mapping[str, str]:
74
+ """Заголовки/метаданные результата (case-insensitive у HTTP-реализаций)."""
75
+ ...
76
+
77
+ @property
78
+ def text(self) -> str:
79
+ """Тело результата как текст (для не-JSON envelope и диагностики)."""
80
+ ...
81
+
82
+ def json(self) -> Any:
83
+ """Разобранное тело результата (обычно JSON); реализация вправе сузить."""
84
+ ...
85
+
86
+
87
+ @runtime_checkable
88
+ class Transport(Protocol):
89
+ """Контракт транспорта: один способ доставить HTTP-запрос и вернуть ответ.
90
+
91
+ Реализации (`HttpxTransport`, `CurlCffiTransport`, `BrowserTransport`)
92
+ инкапсулируют свой движок и retry, но дают единую async-сигнатуру. Per-
93
+ instance, НИКОГДА не глобальный (урок Stripe v8). Маппинг кодов в ошибки —
94
+ НЕ здесь (это делает `HttpClient`/`ErrorMapper`); транспорт возвращает сырой
95
+ `httpx.Response`, бросая `TransportError` только на сетевых сбоях.
96
+ """
97
+
98
+ async def request(
99
+ self,
100
+ method: str,
101
+ url: str,
102
+ *,
103
+ params: Mapping[str, Any] | None = None,
104
+ json: Any | None = None,
105
+ data: Any | None = None,
106
+ content: bytes | str | None = None,
107
+ headers: Mapping[str, str] | None = None,
108
+ files: Any | None = None,
109
+ cookies: Mapping[str, str] | None = None,
110
+ ) -> httpx.Response:
111
+ """Выполнить запрос и вернуть HTTP-ответ (без маппинга в доменные ошибки).
112
+
113
+ ``content`` — сырое тело (bytes/str) для не-form/не-json envelope (ручной
114
+ urlencode, protobuf, NDJSON). Взаимоисключимо с ``json``/``data``/``files``
115
+ (валидирует httpx).
116
+ """
117
+ ...
118
+
119
+
120
+ @runtime_checkable
121
+ class SyncTransport(Protocol):
122
+ """Синхронный сиблинг `Transport`: один способ доставить HTTP-запрос (БЕЗ await).
123
+
124
+ Та же роль и контракт, что у `Transport` (несёт запрос, retry внутри, маппинг
125
+ в доменные ошибки — НЕ здесь), но синхронная сигнатура `def request` поверх
126
+ `httpx.Client`. Нужен навыкам, которые принципиально синхронны (Playwright sync
127
+ API, sync-CLI без event-loop) — им незачем тянуть asyncio ради одного клиента.
128
+ Реализация — `HttpxSyncTransport`. Per-instance, НИКОГДА не глобальный.
129
+ """
130
+
131
+ def request(
132
+ self,
133
+ method: str,
134
+ url: str,
135
+ *,
136
+ params: Mapping[str, Any] | None = None,
137
+ json: Any | None = None,
138
+ data: Any | None = None,
139
+ content: bytes | str | None = None,
140
+ headers: Mapping[str, str] | None = None,
141
+ files: Any | None = None,
142
+ cookies: Mapping[str, str] | None = None,
143
+ ) -> httpx.Response:
144
+ """Выполнить запрос и вернуть HTTP-ответ (без маппинга в доменные ошибки).
145
+
146
+ ``content`` — сырое тело (bytes/str), как у async-сиблинга ``Transport``.
147
+ """
148
+ ...
149
+
150
+
151
+ # Первичные ролевые имена «исполнителя HTTP-запроса» (было `Transport`/`SyncTransport`).
152
+ # Это HTTP-executor, а не абстрактный транспорт: тот же Protocol-объект → isinstance и
153
+ # аннотации совпадают; старые имена остаются deprecated-псевдонимами.
154
+ HttpRequestExecutor = Transport
155
+ SyncHttpRequestExecutor = SyncTransport
156
+
157
+
158
+ @runtime_checkable
159
+ class SyncRefreshable(Protocol):
160
+ """Синхронный сиблинг `Refreshable`: обновить протухшие credentials (БЕЗ await).
161
+
162
+ `Refreshable.refresh` асинхронна — `SyncHttpClient` не может её await'ить, поэтому
163
+ для refresh-on-401 в sync-клиенте нужен синхронный вариант. Проверка:
164
+ ``isinstance(auth, SyncRefreshable)``.
165
+ """
166
+
167
+ def refresh(self) -> bool:
168
+ """Обновить протухшие credentials. True — успех, False — обновить нельзя."""
169
+ ...
170
+
171
+
172
+ @runtime_checkable
173
+ class SyncProactiveRefreshable(Protocol):
174
+ """Синхронный сиблинг `ProactiveRefreshable`: проактивный throttled refresh (БЕЗ await).
175
+
176
+ Для sync-клиента: choke-point дёргает `maybe_refresh` ПЕРЕД каждым запросом, тот
177
+ сам решает по TTL/throttle, ротировать ли. Проверка:
178
+ ``isinstance(auth, SyncProactiveRefreshable)``.
179
+ """
180
+
181
+ def maybe_refresh(self) -> bool:
182
+ """Обновиться, ЕСЛИ пора по TTL/throttle. True — обновились; False — рано/нельзя."""
183
+ ...
184
+
185
+
186
+ @runtime_checkable
187
+ class StreamTransport(Protocol):
188
+ """Контракт persistent-транспорта для WS-подобных каналов.
189
+
190
+ `Transport.request` — один req→resp; для WebSocket это не годится: соединение
191
+ живёт долго, кадры идут в обе стороны асинхронно. `StreamTransport` отделяет
192
+ жизненный цикл (`connect`/`close`) от обмена (`send`/`recv`). Реализации
193
+ (`StubStreamTransport`, `WebSocketsStreamTransport` под extra `[ws]`)
194
+ инкапсулируют свой движок; per-instance, НИКОГДА не глобальный.
195
+
196
+ `recv` — async-iterator семантика: ``async for frame in transport.recv():``
197
+ отдаёт входящие кадры (str/bytes) до закрытия канала, после чего итератор
198
+ исчерпывается (`StopAsyncIteration`). На сетевом сбое реализация поднимает
199
+ `TransportError`. Маппинг payload в доменные DTO — НЕ здесь (это codec/адаптер).
200
+ """
201
+
202
+ async def connect(
203
+ self,
204
+ url: str,
205
+ *,
206
+ headers: Mapping[str, str] | None = None,
207
+ protocols: Sequence[str] | None = None,
208
+ ) -> None:
209
+ """Открыть persistent-соединение к `url` (handshake/upgrade)."""
210
+ ...
211
+
212
+ async def send(self, data: str | bytes) -> None:
213
+ """Отправить один кадр в открытый канал (текст или бинарь)."""
214
+ ...
215
+
216
+ def recv(self) -> AsyncIterator[str | bytes]:
217
+ """Async-итератор входящих кадров до закрытия канала."""
218
+ ...
219
+
220
+ async def close(self, *, code: int = 1000, reason: str = "") -> None:
221
+ """Закрыть канал (graceful close-handshake), идемпотентно."""
222
+ ...
223
+
224
+
225
+ @runtime_checkable
226
+ class Codec(Protocol):
227
+ """Контракт сериализации RPC: payload⇄provod, не обязательно JSON.
228
+
229
+ Обобщает `HttpClient.get_json/post_json` (жёстко зашитый `response.json()`)
230
+ до подключаемого слоя. `encode_request` превращает доменный payload в тело
231
+ запроса (`json`/`data` для транспорта); `decode_response` разбирает сырой
232
+ `ResponseLike` обратно в доменный объект. Дефолт — `JsonCodec`; пример иного —
233
+ NotebookLM `batchexecute` (не-JSON envelope ``)]}'`` + вложенный массив).
234
+ """
235
+
236
+ def encode_request(self, payload: Any) -> RequestBody:
237
+ """Сериализовать payload в тело запроса для транспорта."""
238
+ ...
239
+
240
+ def decode_response(self, response: ResponseLike) -> Any:
241
+ """Разобрать сырой ответ в доменный объект (обратная к encode)."""
242
+ ...
243
+
244
+
245
+ @runtime_checkable
246
+ class Auth(Protocol):
247
+ """Контракт авторизации: подмешивает учётные данные в исходящий запрос.
248
+
249
+ `apply` — чистая мутация-обогащение `httpx.Request` (заголовок Authorization,
250
+ cookies, подпись). `refresh` — опциональный async-метод обновления протухших
251
+ credentials (OAuth2/cookie-renew); реализации без refresh его просто не
252
+ объявляют — потребитель проверяет наличие через `runtime` (hasattr) или
253
+ `isinstance(obj, Refreshable)`.
254
+ """
255
+
256
+ def apply(self, request: httpx.Request) -> httpx.Request:
257
+ """Обогатить запрос учётными данными и вернуть его (тот же или новый)."""
258
+ ...
259
+
260
+
261
+ @runtime_checkable
262
+ class Refreshable(Protocol):
263
+ """Опциональная способность `Auth` обновлять credentials (OAuth2/cookie).
264
+
265
+ Выделено в отдельный `Protocol`, чтобы базовый `Auth` не навязывал refresh
266
+ реализациям без него (token-auth). Проверка: ``isinstance(auth, Refreshable)``.
267
+ """
268
+
269
+ async def refresh(self) -> bool:
270
+ """Обновить протухшие credentials. True — успех, False — обновить нельзя."""
271
+ ...
272
+
273
+
274
+ @runtime_checkable
275
+ class ProactiveRefreshable(Protocol):
276
+ """Способность `Auth` обновляться ПРОАКТИВНО (до ошибки), throttled.
277
+
278
+ Для сессий с ротируемым freshness-токеном (Google ``__Secure-1PSIDTS`` и
279
+ подобные): токен тихо стухает, если его периодически не «толкать», а
280
+ реактивного refresh-on-401 не хватает (нужен спец-вызов ротации ДО смерти).
281
+ `maybe_refresh` дёргается choke-point'ом (`HttpClient`) ПЕРЕД каждым запросом
282
+ и сам решает по TTL/throttle, делать ли реальную ротацию (дёшево, если рано).
283
+
284
+ Отделено от `Refreshable`: `refresh` — реактивная (форс на 401), `maybe_refresh`
285
+ — проактивная (throttled). Реализация (`librarykit.refresh.RotatingAuth`) обычно
286
+ умеет обе. Проверка: ``isinstance(auth, ProactiveRefreshable)``.
287
+ """
288
+
289
+ async def maybe_refresh(self) -> bool:
290
+ """Обновиться, ЕСЛИ пора по TTL/throttle. True — обновились; False — рано/нельзя."""
291
+ ...
292
+
293
+
294
+ @runtime_checkable
295
+ class ApplyRefreshable(Auth, Refreshable, Protocol):
296
+ """Комбинированный контракт: `Auth.apply` + `Refreshable.refresh` в одном объекте.
297
+
298
+ Узкая «талия» для мест, которым нужен объект, УМЕЮЩИЙ И подмешать credentials
299
+ (`apply`), И обновить их (`refresh`): например `primary` в
300
+ `refresh.RefreshableChain` (`RotatingAuth`/`OAuth2Auth`/`CsrfHeaderAuth`
301
+ структурно подходят — у них есть оба метода). Раньше такие места типизировались
302
+ широким `Auth` + `getattr`-дак-тайпингом по `refresh`; combined-Protocol делает
303
+ контракт явным и `isinstance`-проверяемым (``isinstance(auth, ApplyRefreshable)``),
304
+ рантайм-поведение потребителя не меняя.
305
+ """
306
+
307
+
308
+ @runtime_checkable
309
+ class ErrorMapper(Protocol):
310
+ """Контракт маппинга HTTP-ответа в доменную ошибку (декларативная таблица).
311
+
312
+ Реализация (`ErrorMap`) держит правила ``{status | code | body-predicate ->
313
+ ErrorSubclass}`` и решает, является ли ответ ошибкой. Возвращает экземпляр
314
+ исключения (для `raise`) либо `None`, если ответ успешен и ошибки нет.
315
+ """
316
+
317
+ def map(self, response: httpx.Response) -> Exception | None:
318
+ """Вернуть исключение для не-успешного ответа либо `None`, если ОК."""
319
+ ...
320
+
321
+
322
+ # =========================================================================== #
323
+ # Пагинация #
324
+ # =========================================================================== #
325
+
326
+
327
+ @runtime_checkable
328
+ class Paginator(Protocol):
329
+ """Контракт листания результата (offset | cursor | page) — обёртка tweepy-стиля.
330
+
331
+ Скрывает механику следующей страницы за единым async-итератором сырых страниц.
332
+ Конкретная реализация выбирается по `PaginationMode` эндпоинта; маппинг
333
+ страницы в доменные DTO — НЕ здесь (это чистые мапперы адаптера).
334
+ """
335
+
336
+ def paginate(
337
+ self,
338
+ method: str,
339
+ url: str,
340
+ *,
341
+ params: Mapping[str, Any] | None = None,
342
+ mode: PaginationMode = PaginationMode.CURSOR,
343
+ limit: int | None = None,
344
+ ) -> AsyncIterator[httpx.Response]:
345
+ """Итерировать сырые страницы-ответы согласно `mode` до исчерпания/`limit`."""
346
+ ...
347
+
348
+
349
+ __all__ = [
350
+ # перечисления (реэкспорт corekit)
351
+ "PaginationMode",
352
+ "AuthMode",
353
+ "TransportKind",
354
+ "RequestExecutionMode",
355
+ # DTO-значения (реэкспорт corekit)
356
+ "DEFAULT_TENANT",
357
+ "SessionRef",
358
+ "ExecutionContext",
359
+ "Creds",
360
+ "RequestBody",
361
+ "QuotaScope",
362
+ "QuotaInfo",
363
+ "LimitSpec",
364
+ # граничные Protocol сетевого слоя
365
+ "ResponseLike",
366
+ "Transport",
367
+ "SyncTransport",
368
+ "HttpRequestExecutor", # первичное ролевое имя (алиас Transport)
369
+ "SyncHttpRequestExecutor", # первичное ролевое имя (алиас SyncTransport)
370
+ "StreamTransport",
371
+ "Codec",
372
+ "Auth",
373
+ "Refreshable",
374
+ "ProactiveRefreshable",
375
+ "ApplyRefreshable",
376
+ "SyncRefreshable",
377
+ "SyncProactiveRefreshable",
378
+ "ErrorMapper",
379
+ "Paginator",
380
+ ]