s-clientkit 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.
@@ -0,0 +1,338 @@
1
+ """ДЕКЛАРАЦИЯ СЕРВИСА — данные, а не код.
2
+
3
+ Здесь нет ни одного вызова к сети и ни одной ветки «как ходить». Только
4
+ описание: где сервис живёт, чем ему представляться, сколько можно стучать, какие
5
+ у него операции и — для СКРЫТОГО API — чем чеканить доступ и как деградировать.
6
+
7
+ ПОЧЕМУ ЭТО ПРИНЦИПИАЛЬНО. Пока адрес, схема авторизации и лимит зашиты в код
8
+ клиента, каждый новый сервис — это новый файл на 300 строк, который через месяц
9
+ разъедется с соседним. Когда они данные: подключение сервиса — правка TOML,
10
+ смерть `rpcid` — правка одной строки, а различие «белый/скрытый» перестаёт быть
11
+ различием ДЛЯ ПОТРЕБИТЕЛЯ (он в обоих случаях зовёт ``client.call("me")``).
12
+
13
+ ДВЕ ФОРМЫ, ОДНА МОДЕЛЬ. :func:`decl_from_data` принимает обычный ``Mapping``
14
+ (из TOML/JSON/YAML — чем прочитали, тем и прочитали), :func:`load_toml` —
15
+ готовый файл. Обратно — :func:`decl_to_data`. Round-trip проверяется тестом:
16
+ декларация, прошедшая туда-обратно, обязана быть равна исходной, иначе «данные»
17
+ были бы односторонним фасадом над кодом.
18
+
19
+ ТИП СЕРВИСА (:class:`ServiceKind`) — не украшение, а ВЫБОР СБОРКИ:
20
+
21
+ * ``WHITE`` — прямые запросы, авторизация заголовком, ни сессий, ни антибота;
22
+ * ``HIDDEN`` — доступ приходится ДЕРЖАТЬ: сессия, чеканка, лестница деградации,
23
+ адреса-кандидаты.
24
+
25
+ Проверка непротиворечивости живёт в :meth:`ServiceDecl.validate` и зовётся при
26
+ сборке: белый сервис с рецептом доступа — почти наверняка опечатка, и узнать об
27
+ этом надо при загрузке файла, а не на первом ночном запуске.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import tomllib
32
+ from collections.abc import Mapping, Sequence
33
+ from dataclasses import dataclass, field
34
+ from enum import StrEnum
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ __all__ = [
39
+ "AccessRecipe",
40
+ "AuthDecl",
41
+ "AuthScheme",
42
+ "EndpointDecl",
43
+ "LimitDecl",
44
+ "ServiceDecl",
45
+ "ServiceKind",
46
+ "decl_from_data",
47
+ "decl_to_data",
48
+ "load_toml",
49
+ ]
50
+
51
+
52
+ class ServiceKind(StrEnum):
53
+ """Как СОБИРАТЬ клиента: прямыми запросами или с удержанием доступа."""
54
+
55
+ WHITE = "white"
56
+ HIDDEN = "hidden"
57
+
58
+
59
+ class AuthScheme(StrEnum):
60
+ """Чем представляться сервису."""
61
+
62
+ NONE = "none"
63
+ API_KEY = "api_key"
64
+ BEARER = "bearer"
65
+ OAUTH = "oauth"
66
+ #: Скрытый API: авторизует САМА СЕССИЯ (cookies), заголовка-ключа нет.
67
+ SESSION = "session"
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class AuthDecl:
72
+ """Схема авторизации. Секрет тут НЕ ЛЕЖИТ — лежит ИМЯ, по которому его брать.
73
+
74
+ Декларация — данные, которые не стыдно положить в git; значение приходит из
75
+ хранилища секретов/env в момент сборки клиента.
76
+ """
77
+
78
+ scheme: AuthScheme = AuthScheme.NONE
79
+ #: Заголовок, в который уезжает значение (``Authorization``/``X-Api-Key``/…).
80
+ header: str = "Authorization"
81
+ #: Шаблон значения; ``{secret}`` подставляется. Для bearer — ``"Bearer {secret}"``.
82
+ template: str = "{secret}"
83
+ #: Имя секрета в `SecretStorePort`/env (НЕ сам секрет).
84
+ secret_name: str = ""
85
+ #: OAuth: адрес токен-эндпоинта.
86
+ token_url: str = ""
87
+ #: OAuth: за сколько секунд ДО истечения обновляться проактивно.
88
+ leeway: float = 60.0
89
+
90
+ def to_data(self) -> dict[str, Any]:
91
+ """Плоский словарь; умолчания опускаются (декларация читаемая)."""
92
+ data: dict[str, Any] = {"scheme": self.scheme.value}
93
+ for key, value, default in (
94
+ ("header", self.header, "Authorization"),
95
+ ("template", self.template, "{secret}"),
96
+ ("secret_name", self.secret_name, ""),
97
+ ("token_url", self.token_url, ""),
98
+ ("leeway", self.leeway, 60.0),
99
+ ):
100
+ if value != default:
101
+ data[key] = value
102
+ return data
103
+
104
+ @classmethod
105
+ def from_data(cls, data: Mapping[str, Any]) -> AuthDecl:
106
+ return cls(
107
+ scheme=AuthScheme(data.get("scheme", AuthScheme.NONE)),
108
+ header=str(data.get("header", "Authorization")),
109
+ template=str(data.get("template", "{secret}")),
110
+ secret_name=str(data.get("secret_name", "")),
111
+ token_url=str(data.get("token_url", "")),
112
+ leeway=float(data.get("leeway", 60.0)),
113
+ )
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class LimitDecl:
118
+ """Лимит вызовов: ``rate`` запросов в секунду при ёмкости ``burst``.
119
+
120
+ ``rate == 0`` — лимита нет (и тогда сборщик не строит гейт вовсе: платить
121
+ блокировкой за то, чего не объявляли, никто не обязан).
122
+ """
123
+
124
+ rate: float = 0.0
125
+ burst: int = 1
126
+
127
+ def to_data(self) -> dict[str, Any]:
128
+ data: dict[str, Any] = {}
129
+ if self.rate:
130
+ data["rate"] = self.rate
131
+ if self.burst != 1:
132
+ data["burst"] = self.burst
133
+ return data
134
+
135
+ @classmethod
136
+ def from_data(cls, data: Mapping[str, Any]) -> LimitDecl:
137
+ return cls(rate=float(data.get("rate", 0.0)), burst=int(data.get("burst", 1)))
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class EndpointDecl:
142
+ """Операция: ИМЯ, которым её зовут, и адрес(а), которыми она ходит.
143
+
144
+ ``candidates`` — запасные адреса ОДНОЙ И ТОЙ ЖЕ операции. Это про скрытый
145
+ API: там адрес (`rpcid`, внутренний путь) умирает без предупреждения, и
146
+ единственный способ пережить смерть без релиза — держать список. Для белого
147
+ API список обычно пуст: у белого адрес меняется по документации и с версией.
148
+ """
149
+
150
+ name: str
151
+ path: str = ""
152
+ method: str = "GET"
153
+ candidates: tuple[str, ...] = ()
154
+
155
+ def to_data(self) -> dict[str, Any]:
156
+ data: dict[str, Any] = {"name": self.name, "path": self.path}
157
+ if self.method != "GET":
158
+ data["method"] = self.method
159
+ if self.candidates:
160
+ data["candidates"] = list(self.candidates)
161
+ return data
162
+
163
+ @classmethod
164
+ def from_data(cls, data: Mapping[str, Any]) -> EndpointDecl:
165
+ return cls(
166
+ name=str(data["name"]),
167
+ path=str(data.get("path", "")),
168
+ method=str(data.get("method", "GET")).upper(),
169
+ candidates=tuple(str(c) for c in data.get("candidates", ())),
170
+ )
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class AccessRecipe:
175
+ """РЕЦЕПТ ДОСТУПА — только для скрытого API: чем доказывается вход и чем чеканить.
176
+
177
+ * ``session_markers`` — имена cookie, наличие которых означает «сессия есть».
178
+ Не «файл лежит», а именно маркеры: файл лежит и у мёртвой сессии;
179
+ * ``minter`` — имя способа чеканки в
180
+ :func:`authkit_client.ports.register_minter` (регистрирует `browserkit`);
181
+ * ``ladder`` — ЛЕСТНИЦА ДЕГРАДАЦИИ: что пробовать по порядку, когда
182
+ доступа нет. ``"session"`` (взять сохранённую) → ``"mint"`` (отчеканить
183
+ заново) → ``"antibot"`` (сходить обходным транспортом);
184
+ * ``probe_path`` — дешёвый адрес живой пробы доступа;
185
+ * ``dead_endpoint_markers`` / ``dead_session_markers`` — ДОМЕННЫЕ улики в
186
+ ТЕЛЕ ответа: «у нас мёртвый метод отвечает вот так». Общие улики
187
+ (``unknown rpcid``, ``please sign in``, …) уже знает
188
+ `corekit.diagnosis.access` — сюда пишут ТОЛЬКО то, чего он знать не может,
189
+ потому что это специфика конкретного сервиса. Транспортные улики
190
+ (обрыв выхода) не расширяются намеренно: их единственный источник —
191
+ `corekit.diagnosis.egress`, иначе два сервиса разъедутся в диагнозах на
192
+ одном тексте.
193
+ """
194
+
195
+ session_markers: tuple[str, ...] = ()
196
+ minter: str = ""
197
+ ladder: tuple[str, ...] = ("session", "mint")
198
+ probe_path: str = "/"
199
+ social: str = ""
200
+ dead_endpoint_markers: tuple[str, ...] = ()
201
+ dead_session_markers: tuple[str, ...] = ()
202
+
203
+ def to_data(self) -> dict[str, Any]:
204
+ data: dict[str, Any] = {}
205
+ if self.dead_endpoint_markers:
206
+ data["dead_endpoint_markers"] = list(self.dead_endpoint_markers)
207
+ if self.dead_session_markers:
208
+ data["dead_session_markers"] = list(self.dead_session_markers)
209
+ if self.session_markers:
210
+ data["session_markers"] = list(self.session_markers)
211
+ if self.minter:
212
+ data["minter"] = self.minter
213
+ if self.ladder != ("session", "mint"):
214
+ data["ladder"] = list(self.ladder)
215
+ if self.probe_path != "/":
216
+ data["probe_path"] = self.probe_path
217
+ if self.social:
218
+ data["social"] = self.social
219
+ return data
220
+
221
+ @classmethod
222
+ def from_data(cls, data: Mapping[str, Any]) -> AccessRecipe:
223
+ return cls(
224
+ session_markers=tuple(str(m) for m in data.get("session_markers", ())),
225
+ minter=str(data.get("minter", "")),
226
+ ladder=tuple(str(s) for s in data.get("ladder", ("session", "mint"))),
227
+ probe_path=str(data.get("probe_path", "/")),
228
+ social=str(data.get("social", "")),
229
+ dead_endpoint_markers=tuple(str(m) for m in data.get("dead_endpoint_markers", ())),
230
+ dead_session_markers=tuple(str(m) for m in data.get("dead_session_markers", ())),
231
+ )
232
+
233
+
234
+ @dataclass(frozen=True, slots=True)
235
+ class ServiceDecl:
236
+ """ПОЛНОЕ описание сервиса — единственный вход декларации в рантайм."""
237
+
238
+ name: str
239
+ kind: ServiceKind
240
+ base_url: str
241
+ auth: AuthDecl = field(default_factory=AuthDecl)
242
+ limits: LimitDecl = field(default_factory=LimitDecl)
243
+ endpoints: tuple[EndpointDecl, ...] = ()
244
+ access: AccessRecipe | None = None
245
+
246
+ def endpoint(self, name: str) -> EndpointDecl:
247
+ """Операция по имени. Нет — `KeyError` со списком известных."""
248
+ for ep in self.endpoints:
249
+ if ep.name == name:
250
+ return ep
251
+ known = ", ".join(sorted(e.name for e in self.endpoints)) or "—"
252
+ raise KeyError(f"операции {name!r} нет в декларации {self.name!r}; известны: {known}")
253
+
254
+ def validate(self) -> ServiceDecl:
255
+ """Проверить непротиворечивость. Возвращает себя — удобно в цепочке.
256
+
257
+ Ловим ровно то, что иначе всплывёт ночью в бою:
258
+
259
+ * белый сервис с рецептом доступа (или скрытый без него) — опечатка в
260
+ ``kind``, а не «гибкость»;
261
+ * скрытый сервис без чеканщика в лестнице — доступ держать нечем;
262
+ * пустой ``base_url``/дубли имён операций.
263
+ """
264
+ if not self.base_url:
265
+ raise ValueError(f"сервис {self.name!r}: пустой base_url")
266
+ names = [e.name for e in self.endpoints]
267
+ dupes = sorted({n for n in names if names.count(n) > 1})
268
+ if dupes:
269
+ raise ValueError(f"сервис {self.name!r}: операции объявлены дважды: {dupes}")
270
+ if self.kind is ServiceKind.WHITE:
271
+ if self.access is not None:
272
+ raise ValueError(
273
+ f"сервис {self.name!r} объявлен белым, но несёт рецепт доступа. "
274
+ "Белый путь не поднимает сессий и не чеканит — либо убери "
275
+ "[access], либо поставь kind = \"hidden\"."
276
+ )
277
+ if self.auth.scheme is AuthScheme.SESSION:
278
+ raise ValueError(
279
+ f"сервис {self.name!r}: схема 'session' — это авторизация "
280
+ "СЕССИЕЙ, она бывает только у скрытого API"
281
+ )
282
+ else:
283
+ if self.access is None:
284
+ raise ValueError(
285
+ f"сервис {self.name!r} объявлен скрытым, но рецепта доступа нет: "
286
+ "нечем ни доказать вход, ни отчеканить его заново"
287
+ )
288
+ if "mint" in self.access.ladder and not self.access.minter:
289
+ raise ValueError(
290
+ f"сервис {self.name!r}: в лестнице есть ступень 'mint', "
291
+ "но чеканщик не назван (`minter`)"
292
+ )
293
+ return self
294
+
295
+
296
+ def decl_to_data(decl: ServiceDecl) -> dict[str, Any]:
297
+ """Декларация → обычный словарь (то, что ложится в TOML/JSON)."""
298
+ data: dict[str, Any] = {
299
+ "name": decl.name,
300
+ "kind": decl.kind.value,
301
+ "base_url": decl.base_url,
302
+ }
303
+ auth = decl.auth.to_data()
304
+ if auth != {"scheme": AuthScheme.NONE.value}:
305
+ data["auth"] = auth
306
+ limits = decl.limits.to_data()
307
+ if limits:
308
+ data["limits"] = limits
309
+ if decl.endpoints:
310
+ data["endpoint"] = [e.to_data() for e in decl.endpoints]
311
+ if decl.access is not None:
312
+ data["access"] = decl.access.to_data()
313
+ return data
314
+
315
+
316
+ def decl_from_data(data: Mapping[str, Any]) -> ServiceDecl:
317
+ """Словарь → декларация. ЕДИНСТВЕННЫЙ вход данных в рантайм."""
318
+ raw_endpoints: Sequence[Mapping[str, Any]] = data.get("endpoint", ()) or ()
319
+ access = data.get("access")
320
+ return ServiceDecl(
321
+ name=str(data["name"]),
322
+ kind=ServiceKind(data.get("kind", ServiceKind.WHITE)),
323
+ base_url=str(data.get("base_url", "")),
324
+ auth=AuthDecl.from_data(data.get("auth", {})),
325
+ limits=LimitDecl.from_data(data.get("limits", {})),
326
+ endpoints=tuple(EndpointDecl.from_data(e) for e in raw_endpoints),
327
+ access=AccessRecipe.from_data(access) if access is not None else None,
328
+ )
329
+
330
+
331
+ def load_toml(path: str | Path) -> ServiceDecl:
332
+ """Прочитать декларацию из TOML-файла и проверить её.
333
+
334
+ Ошибка формата всплывает ЗДЕСЬ — при загрузке, а не на первом вызове в три
335
+ часа ночи.
336
+ """
337
+ raw = Path(path).read_bytes()
338
+ return decl_from_data(tomllib.loads(raw.decode("utf-8"))).validate()
clientkit/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Ошибки сборщика — ветви ЕДИНОЙ иерархии `corekit`.
2
+
3
+ Наследуемся от `corekit.errors._DomainError` (доменная база: один обязательный
4
+ ``message``, ``code`` из ``default_code``), а не от голого `Exception`: у
5
+ потребителя уже написан ``except CliError`` вокруг всей работы с сервисом, и
6
+ новый кит не имеет права выпасть мимо него. Тем, кому нужна разница между «не
7
+ достучались» и «сервис отказал по существу», доступен точный тип.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from corekit.errors import _DomainError
12
+
13
+ __all__ = ["AccessUnavailable", "ClientBuildError", "OperationFailed"]
14
+
15
+
16
+ class ClientBuildError(_DomainError):
17
+ """Декларация есть, но клиента из неё не собрать (противоречие/нехватка порта)."""
18
+
19
+ default_code = "CLIENT_BUILD_ERROR"
20
+
21
+
22
+ class AccessUnavailable(_DomainError): # noqa: N818 # доменное имя контракта (не *Error)
23
+ """Скрытый сервис: лестница пройдена до конца, доступа нет.
24
+
25
+ Отдельный тип намеренно: «не смогли ДОСТУЧАТЬСЯ» и «достучались, сервис
26
+ ответил ошибкой» требуют разных действий — в первом случае чинят вход, во
27
+ втором читают ответ. Одно имя на оба случая означало бы, что человек снова
28
+ идёт делать бесполезный ручной перевход.
29
+ """
30
+
31
+ default_code = "ACCESS_UNAVAILABLE"
32
+
33
+
34
+ class OperationFailed(_DomainError): # noqa: N818 # доменное имя контракта (не *Error)
35
+ """Сервис ответил, но по существу операции — отказом (не про доступ)."""
36
+
37
+ default_code = "OPERATION_FAILED"
clientkit/hidden.py ADDED
@@ -0,0 +1,194 @@
1
+ """СКРЫТЫЙ путь: клиент, который САМ держит доступ.
2
+
3
+ Разница с белым не в «сложнее», а в другом виде отказа. У белого API отказ — это
4
+ ответ сервиса по существу операции. У скрытого отказов ЧЕТЫРЕ вида, и они
5
+ требуют разных действий:
6
+
7
+ ============================== =========================================
8
+ сервер увёл на страницу входа сессия мертва → отчеканить заново
9
+ сервер не знает метода адрес мёртв → взять следующего кандидата
10
+ до сервера не дошли инфраструктура → человека звать не за чем
11
+ сервис ответил по существу обычный ответ
12
+ ============================== =========================================
13
+
14
+ Таксономию мы не изобретаем: диагноз ставит `corekit.diagnosis.access` по
15
+ ПОДПИСИ ОТВЕТА — та же функция, что у живой пробы и у реестра эндпоинтов. Две
16
+ таксономии на одних уликах разъехались бы за месяц.
17
+
18
+ ЧТО ЗДЕСЬ СОБИРАЕТСЯ ИЗ ГОТОВЫХ ЧАСТЕЙ (ни строчки заново):
19
+
20
+ * сессия — `authkit_client.sessions.SessionStore` (или любой `SessionStorePort`);
21
+ * чеканка — `authkit_client.ports.minted_state` (регистрирует `browserkit`,
22
+ этот модуль браузер НЕ импортирует);
23
+ * адреса-кандидаты — `adapterkit.endpoints.EndpointRegistry`, если adapterkit
24
+ установлен; иначе равнозначный внутренний реестр (:mod:`clientkit._candidates`).
25
+
26
+ ЛЕСТНИЦА ДЕГРАДАЦИИ идёт из ДЕКЛАРАЦИИ (``access.ladder``), а не из кода:
27
+ ``["session", "mint"]`` значит «сперва взять сохранённую, потом чеканить». Порядок
28
+ и состав правятся в TOML.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from typing import Any
33
+
34
+ from corekit import SessionRef
35
+ from corekit.diagnosis.access import AccessState, classify_access
36
+
37
+ from clientkit._base import ServiceClientBase
38
+ from clientkit._candidates import CandidateBook
39
+ from clientkit.declaration import EndpointDecl, ServiceDecl
40
+ from clientkit.errors import AccessUnavailable
41
+ from clientkit.ports import RequestPort
42
+
43
+ __all__ = ["HiddenApiClient"]
44
+
45
+
46
+ class HiddenApiClient(ServiceClientBase):
47
+ """Клиент скрытого API: держит сессию, переживает смерть адреса.
48
+
49
+ Аргументы сверх базовых:
50
+
51
+ * ``session_store`` — любой `SessionStorePort` (по умолчанию — поставляемый
52
+ `SessionStore`, но подменяется без правок этого файла);
53
+ * ``profile``/``account_id`` — координаты сессии в сторе;
54
+ * ``mint`` — ``async (SessionRef) -> storage_state``. По умолчанию —
55
+ :func:`authkit_client.ports.minted_state` с чеканщиком из декларации.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ decl: ServiceDecl,
61
+ transport: RequestPort,
62
+ *,
63
+ session_store: Any | None = None,
64
+ profile: str = "default",
65
+ account_id: str = "me",
66
+ mint: Any | None = None,
67
+ ) -> None:
68
+ super().__init__(decl, transport)
69
+ recipe = decl.access
70
+ assert recipe is not None # гарантировано ServiceDecl.validate()
71
+ self._recipe = recipe
72
+ self._store = session_store
73
+ self._ref = SessionRef(
74
+ profile=profile,
75
+ social=recipe.social or decl.name,
76
+ account_id=account_id,
77
+ )
78
+ self._mint = mint
79
+ self._cookies: dict[str, str] | None = None
80
+ self._book = CandidateBook.for_service(decl)
81
+ #: сколько раз пришлось чеканить — улика для тестов и отчётов
82
+ self.mints = 0
83
+
84
+ # ------------------------------------------------------------------ #
85
+ # доступ #
86
+ # ------------------------------------------------------------------ #
87
+ async def _headers(self) -> dict[str, str]:
88
+ """Cookie-заголовок из ЖИВОЙ сессии; нет сессии — пройти лестницу."""
89
+ if self._cookies is None:
90
+ self._cookies = await self._acquire_access()
91
+ return {"Cookie": "; ".join(f"{k}={v}" for k, v in self._cookies.items())}
92
+
93
+ async def _acquire_access(self, *, force_mint: bool = False) -> dict[str, str]:
94
+ """ЛЕСТНИЦА ДЕГРАДАЦИИ из декларации: ступень за ступенью, до первой годной.
95
+
96
+ ``force_mint`` пропускает ступень ``session``: она только что доказала
97
+ свою негодность (сервер увёл на вход), и брать её снова — гарантированный
98
+ второй отказ.
99
+ """
100
+ tried: list[str] = []
101
+ for step in self._recipe.ladder:
102
+ if step == "session" and not force_mint:
103
+ cookies = await self._cookies_from_store()
104
+ tried.append("session")
105
+ if cookies:
106
+ return cookies
107
+ elif step == "mint":
108
+ tried.append("mint")
109
+ state = await self._do_mint()
110
+ cookies = _cookies_from_state(state)
111
+ if cookies:
112
+ await self._save_state(state)
113
+ return cookies
114
+ raise AccessUnavailable(
115
+ f"сервис {self.decl.name!r}: лестница доступа пройдена целиком "
116
+ f"({' → '.join(tried) or '—'}), годного доступа нет. Маркеры сессии "
117
+ f"по декларации: {list(self._recipe.session_markers) or '—'}."
118
+ )
119
+
120
+ async def _cookies_from_store(self) -> dict[str, str]:
121
+ """Сохранённая сессия → cookies, ЕСЛИ в ней есть объявленные маркеры.
122
+
123
+ Маркеры, а не факт наличия файла: файл лежит и у мёртвой сессии — ровно
124
+ та ложь, из-за которой `login-status` годами отвечал по файлу.
125
+ """
126
+ store = self._store
127
+ if store is None:
128
+ return {}
129
+ state = await store.load_state(self._ref)
130
+ if not state:
131
+ return {}
132
+ cookies = _cookies_from_state(state)
133
+ markers = self._recipe.session_markers
134
+ if markers and not all(m in cookies for m in markers):
135
+ return {}
136
+ return cookies
137
+
138
+ async def _do_mint(self) -> dict[str, Any]:
139
+ """Отчеканить доступ. Браузер приходит ЧЕРЕЗ ПОРТ, импорта его тут нет."""
140
+ self.mints += 1
141
+ if self._mint is not None:
142
+ return await self._mint(self._ref)
143
+ from authkit_client.ports import minted_state
144
+
145
+ return await minted_state(self._recipe.minter, self._ref)
146
+
147
+ async def _save_state(self, state: dict[str, Any]) -> None:
148
+ if self._store is not None:
149
+ await self._store.save_state(self._ref, state)
150
+
151
+ # ------------------------------------------------------------------ #
152
+ # адреса-кандидаты #
153
+ # ------------------------------------------------------------------ #
154
+ async def _address(self, endpoint: EndpointDecl) -> str:
155
+ """Текущий ЖИВОЙ адрес операции (кандидаты — данные, а не код)."""
156
+ return self._book.active(endpoint)
157
+
158
+ async def _inspect(self, endpoint: EndpointDecl, response: Any) -> bool:
159
+ """Разобрать отказ по ПОДПИСИ и решить: чинить и повторять — или отдать.
160
+
161
+ Ровно три исхода, и все три диктуются `classify_access`:
162
+
163
+ * ``SESSION_DEAD`` → выбросить cookies, чеканить, повторить;
164
+ * ``ENDPOINT_DEAD`` → пометить адрес мёртвым, взять следующего, повторить;
165
+ * всё остальное → это ответ сервиса, повторять нечего.
166
+ """
167
+ verdict = classify_access(
168
+ response=response,
169
+ # ДОМЕННЫЕ улики — из ДЕКЛАРАЦИИ, а не из кода: «у этого сервиса
170
+ # мёртвый метод отвечает вот так». Общие улики знает сам классификатор.
171
+ endpoint_markers=self._recipe.dead_endpoint_markers,
172
+ session_markers=self._recipe.dead_session_markers,
173
+ )
174
+ if verdict.state is AccessState.SESSION_DEAD:
175
+ self._cookies = await self._acquire_access(force_mint=True)
176
+ return True
177
+ if verdict.state is AccessState.ENDPOINT_DEAD:
178
+ if self._book.bury(endpoint, why=verdict.why):
179
+ return True
180
+ # Заменять нечем. Это НЕ «сервис ответил ошибкой» и НЕ «сессия
181
+ # протухла»: отдать такой ответ наверх как обычный — значит снова
182
+ # отправить человека делать бесполезный перевход.
183
+ raise AccessUnavailable(
184
+ f"сервис {self.decl.name!r}, операция {endpoint.name!r}: живых "
185
+ f"адресов не осталось ({verdict.why}). Нужен актуальный адрес "
186
+ "метода — перевход не поможет."
187
+ )
188
+ return False
189
+
190
+
191
+ def _cookies_from_state(state: Any) -> dict[str, str]:
192
+ """``storage_state`` (форма Playwright) → ``{name: value}``."""
193
+ raw = (state or {}).get("cookies", []) or []
194
+ return {c["name"]: c.get("value", "") for c in raw if isinstance(c, dict) and c.get("name")}