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.
- clientkit/__init__.py +120 -0
- clientkit/_base.py +160 -0
- clientkit/_candidates.py +150 -0
- clientkit/_limit.py +55 -0
- clientkit/builder.py +65 -0
- clientkit/declaration.py +338 -0
- clientkit/errors.py +37 -0
- clientkit/hidden.py +194 -0
- clientkit/identity.py +249 -0
- clientkit/ports.py +70 -0
- clientkit/white.py +84 -0
- s_clientkit-0.0.1.dist-info/METADATA +78 -0
- s_clientkit-0.0.1.dist-info/RECORD +15 -0
- s_clientkit-0.0.1.dist-info/WHEEL +4 -0
- s_clientkit-0.0.1.dist-info/licenses/LICENSE +21 -0
clientkit/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""clientkit — КИТ-СБОРЩИК: готовый клиент сервиса собирается, а не пишется.
|
|
2
|
+
|
|
3
|
+
ФОРМА ГРАФА::
|
|
4
|
+
|
|
5
|
+
clientkit <- СБОРЩИК (вы здесь)
|
|
6
|
+
/ \\
|
|
7
|
+
authkit-client browserkit <- доступ (кто ты) / чеканка (как войти)
|
|
8
|
+
\\ /
|
|
9
|
+
netkit <- сеть (чем ходить)
|
|
10
|
+
|
|
|
11
|
+
corekit <- основание (значения и чистые правила)
|
|
12
|
+
|
|
13
|
+
ЗАЧЕМ. Интеграция с сервисом — это каждый раз одни и те же пять решений: куда
|
|
14
|
+
ходить, чем представляться, как часто можно, какие операции есть и (если API
|
|
15
|
+
скрытый) чем держать доступ. Пока эти решения — КОД, каждый новый сервис стоит
|
|
16
|
+
файла на триста строк, который через месяц разъедется с соседним. Здесь они —
|
|
17
|
+
ДАННЫЕ (:class:`~clientkit.declaration.ServiceDecl`, читается из TOML), а код
|
|
18
|
+
один на всех.
|
|
19
|
+
|
|
20
|
+
ОДИН ВЫЗОВ НА ОБА МИРА::
|
|
21
|
+
|
|
22
|
+
from clientkit import build_from_toml
|
|
23
|
+
|
|
24
|
+
white = build_from_toml("examples/white_service.toml", secrets=os.environ.get)
|
|
25
|
+
hidden = build_from_toml("examples/hidden_service.toml", session_store=store)
|
|
26
|
+
|
|
27
|
+
await white.call("me") # белый REST с api-ключом
|
|
28
|
+
await hidden.call("me") # скрытый API с сессией и лестницей деградации
|
|
29
|
+
|
|
30
|
+
Потребитель зовёт ОДИНАКОВО и по поверхности клиента не может отличить один от
|
|
31
|
+
другого. При этом белый путь НЕ поднимает ни сессий, ни чеканки, ни антибота и
|
|
32
|
+
делает ровно один запрос — это проверяется тестом по ``sys.modules`` и по
|
|
33
|
+
счётчику запросов, а не декларируется.
|
|
34
|
+
|
|
35
|
+
ЧЕГО ЗДЕСЬ НЕТ. Своего HTTP: «чем ходить» приходит аргументом
|
|
36
|
+
(:class:`~clientkit.ports.RequestPort`), умолчание резолвится лениво. Своего
|
|
37
|
+
браузера: чеканка приходит через порт слоя доступа. Своей таксономии отказов:
|
|
38
|
+
диагноз ставит `corekit.diagnosis.access` — та же функция, что у живой пробы и у
|
|
39
|
+
реестра эндпоинтов.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import importlib
|
|
44
|
+
from typing import TYPE_CHECKING
|
|
45
|
+
|
|
46
|
+
__version__ = "0.0.1"
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING: # pragma: no cover — для тайпчекера/IDE
|
|
49
|
+
from clientkit._base import ServiceClient
|
|
50
|
+
from clientkit.builder import build_client, build_from_toml
|
|
51
|
+
from clientkit.declaration import (
|
|
52
|
+
AccessRecipe,
|
|
53
|
+
AuthDecl,
|
|
54
|
+
AuthScheme,
|
|
55
|
+
EndpointDecl,
|
|
56
|
+
LimitDecl,
|
|
57
|
+
ServiceDecl,
|
|
58
|
+
ServiceKind,
|
|
59
|
+
decl_from_data,
|
|
60
|
+
decl_to_data,
|
|
61
|
+
load_toml,
|
|
62
|
+
)
|
|
63
|
+
from clientkit.errors import AccessUnavailable, ClientBuildError, OperationFailed
|
|
64
|
+
from clientkit.hidden import HiddenApiClient
|
|
65
|
+
from clientkit.ports import RequestPort, SecretLookup
|
|
66
|
+
from clientkit.white import WhiteApiClient
|
|
67
|
+
|
|
68
|
+
#: имя -> модуль-владелец. `hidden` в этой таблице стоит РЯДОМ с `white`, но
|
|
69
|
+
#: цена у них разная: обращение к `HiddenApiClient` подтягивает слой доступа, а
|
|
70
|
+
#: к `WhiteApiClient` — нет. Ленивость здесь и защищает эту разницу.
|
|
71
|
+
_LAZY_NAMES: dict[str, str] = {
|
|
72
|
+
"build_client": "clientkit.builder",
|
|
73
|
+
"build_from_toml": "clientkit.builder",
|
|
74
|
+
"ServiceClient": "clientkit._base",
|
|
75
|
+
# ИМЯ сервиса — тоже данные, и притом нужные тем, кто базы не держит:
|
|
76
|
+
# навыку назвать свою сессию, записи трафика — положить снимок.
|
|
77
|
+
"ServiceIdentity": "clientkit.identity",
|
|
78
|
+
"Instancing": "clientkit.identity",
|
|
79
|
+
"UnknownService": "clientkit.identity",
|
|
80
|
+
"CATALOG": "clientkit.identity",
|
|
81
|
+
"canonical": "clientkit.identity",
|
|
82
|
+
"resolve_service": "clientkit.identity",
|
|
83
|
+
"suggest_service": "clientkit.identity",
|
|
84
|
+
# декларация — данные
|
|
85
|
+
"ServiceDecl": "clientkit.declaration",
|
|
86
|
+
"ServiceKind": "clientkit.declaration",
|
|
87
|
+
"AuthDecl": "clientkit.declaration",
|
|
88
|
+
"AuthScheme": "clientkit.declaration",
|
|
89
|
+
"LimitDecl": "clientkit.declaration",
|
|
90
|
+
"EndpointDecl": "clientkit.declaration",
|
|
91
|
+
"AccessRecipe": "clientkit.declaration",
|
|
92
|
+
"decl_from_data": "clientkit.declaration",
|
|
93
|
+
"decl_to_data": "clientkit.declaration",
|
|
94
|
+
"load_toml": "clientkit.declaration",
|
|
95
|
+
# порты
|
|
96
|
+
"RequestPort": "clientkit.ports",
|
|
97
|
+
"SecretLookup": "clientkit.ports",
|
|
98
|
+
# клиенты (обычно не нужны напрямую — собирает build_client)
|
|
99
|
+
"WhiteApiClient": "clientkit.white",
|
|
100
|
+
"HiddenApiClient": "clientkit.hidden",
|
|
101
|
+
# ошибки
|
|
102
|
+
"ClientBuildError": "clientkit.errors",
|
|
103
|
+
"AccessUnavailable": "clientkit.errors",
|
|
104
|
+
"OperationFailed": "clientkit.errors",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
__all__ = ["__version__", *sorted(_LAZY_NAMES)]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def __getattr__(name: str):
|
|
111
|
+
module_name = _LAZY_NAMES.get(name)
|
|
112
|
+
if module_name is None:
|
|
113
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
114
|
+
value = getattr(importlib.import_module(module_name), name)
|
|
115
|
+
globals()[name] = value
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def __dir__() -> list[str]:
|
|
120
|
+
return sorted({*globals(), *_LAZY_NAMES})
|
clientkit/_base.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Общая часть обоих клиентов: ОДИН вызов, одна сборка запроса, один разбор.
|
|
2
|
+
|
|
3
|
+
Смысл файла — в том, чего в нём нет: ни одного ``if kind == HIDDEN``. Различие
|
|
4
|
+
белого и скрытого путей вынесено в ДВЕ вещи, которые подклассы переопределяют,
|
|
5
|
+
— заголовки (:meth:`ServiceClientBase._headers`) и адрес операции
|
|
6
|
+
(:meth:`ServiceClientBase._address`). Всё остальное — подстановка параметров,
|
|
7
|
+
лимит, отправка, разбор — общее, поэтому потребителю нечем отличить один путь от
|
|
8
|
+
другого: он зовёт ``await client.call("me")`` и там, и там.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from clientkit._limit import TokenBucket
|
|
17
|
+
from clientkit.declaration import EndpointDecl, ServiceDecl
|
|
18
|
+
from clientkit.ports import RequestPort
|
|
19
|
+
|
|
20
|
+
__all__ = ["ServiceClient", "ServiceClientBase"]
|
|
21
|
+
|
|
22
|
+
#: Потолок починок на один вызов: сессия могла умереть И адрес мог смениться,
|
|
23
|
+
#: но третья попытка починить то же самое — уже круг.
|
|
24
|
+
MAX_REPAIRS = 3
|
|
25
|
+
|
|
26
|
+
#: ``{name}`` в пути операции — параметр, который уезжает В АДРЕС, а не в запрос.
|
|
27
|
+
_PLACEHOLDER = re.compile(r"\{(\w+)\}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class ServiceClient(Protocol):
|
|
32
|
+
"""ЕДИНЫЙ контракт готового клиента — то, ради чего собирался кит.
|
|
33
|
+
|
|
34
|
+
Ровно два имени. Потребитель, которому дали такой объект, НЕ МОЖЕТ узнать по
|
|
35
|
+
его поверхности, белый под ним API или скрытый: одна операция вызова и одно
|
|
36
|
+
свойство с именем сервиса.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
|
|
41
|
+
async def call(self, operation: str, **params: Any) -> Any:
|
|
42
|
+
"""Позвать операцию по ИМЕНИ и получить разобранный ответ."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServiceClientBase:
|
|
47
|
+
"""Скелет клиента: подстановка → лимит → запрос → разбор."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, decl: ServiceDecl, transport: RequestPort) -> None:
|
|
50
|
+
self.decl = decl
|
|
51
|
+
self.name = decl.name
|
|
52
|
+
self._transport = transport
|
|
53
|
+
self._bucket = (
|
|
54
|
+
TokenBucket(decl.limits.rate, decl.limits.burst) if decl.limits.rate else None
|
|
55
|
+
)
|
|
56
|
+
#: счётчик отправленных запросов — улика для тестов и для отчёта о цене
|
|
57
|
+
#: вызова («белый путь = один запрос» проверяется числом, а не верой).
|
|
58
|
+
self.requests_sent = 0
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------ #
|
|
61
|
+
# то, чем различаются белый и скрытый пути #
|
|
62
|
+
# ------------------------------------------------------------------ #
|
|
63
|
+
async def _headers(self) -> dict[str, str]:
|
|
64
|
+
"""Заголовки авторизации. Белый — ключ/токен, скрытый — cookies сессии."""
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
async def _address(self, endpoint: EndpointDecl) -> str:
|
|
68
|
+
"""Адрес операции (у скрытого — текущий живой кандидат)."""
|
|
69
|
+
return endpoint.path
|
|
70
|
+
|
|
71
|
+
async def _inspect(self, endpoint: EndpointDecl, response: Any) -> bool:
|
|
72
|
+
"""Осмотреть ЛЮБОЙ ответ. ``True`` — починили, стоит повторить вызов.
|
|
73
|
+
|
|
74
|
+
Зовётся на каждом ответе, а не только на «похожем на ошибку», и это
|
|
75
|
+
принципиально: скрытый API хоронит метод статусом 200 с телом
|
|
76
|
+
``unknown rpcid``. Гадать о виде отказа ЗДЕСЬ нельзя — таксономия одна на
|
|
77
|
+
экосистему (`corekit.diagnosis.access`), и подключает её тот клиент,
|
|
78
|
+
которому она нужна.
|
|
79
|
+
|
|
80
|
+
База не чинит ничего: у белого API отказ — это ответ сервиса, и глотать
|
|
81
|
+
его нельзя.
|
|
82
|
+
"""
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------ #
|
|
86
|
+
# общий путь вызова #
|
|
87
|
+
# ------------------------------------------------------------------ #
|
|
88
|
+
async def call(self, operation: str, **params: Any) -> Any:
|
|
89
|
+
"""Позвать операцию по имени. Одинаково для белого и скрытого сервиса."""
|
|
90
|
+
endpoint = self.decl.endpoint(operation)
|
|
91
|
+
attempts = 0
|
|
92
|
+
while True:
|
|
93
|
+
attempts += 1
|
|
94
|
+
address = await self._address(endpoint)
|
|
95
|
+
path, rest = _apply_placeholders(address, params)
|
|
96
|
+
headers = await self._headers()
|
|
97
|
+
if self._bucket is not None:
|
|
98
|
+
await self._bucket.acquire()
|
|
99
|
+
response = await self._send(endpoint.method, self._url(path), headers, rest)
|
|
100
|
+
self.requests_sent += 1
|
|
101
|
+
# Потолок повторов — страховка от бесконечного круга «починили →
|
|
102
|
+
# снова то же самое». Каждая починка ЧТО-ТО меняет (сессию либо
|
|
103
|
+
# адрес), поэтому исчерпание потолка означает, что чинить больше
|
|
104
|
+
# нечем; ответ уходит наверх как есть.
|
|
105
|
+
if attempts <= MAX_REPAIRS and await self._inspect(endpoint, response):
|
|
106
|
+
continue
|
|
107
|
+
return _parse(response)
|
|
108
|
+
|
|
109
|
+
def _url(self, path: str) -> str:
|
|
110
|
+
"""Склейка базы и пути без двойных слэшей и без потери суффикса базы."""
|
|
111
|
+
if path.startswith(("http://", "https://")):
|
|
112
|
+
return path
|
|
113
|
+
return f"{self.decl.base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
114
|
+
|
|
115
|
+
async def _send(
|
|
116
|
+
self, method: str, url: str, headers: dict[str, str], params: dict[str, Any]
|
|
117
|
+
) -> Any:
|
|
118
|
+
"""Отправить запрос через порт (sync-транспорт тоже годится)."""
|
|
119
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
120
|
+
if method in {"GET", "HEAD", "DELETE"}:
|
|
121
|
+
kwargs["params"] = params
|
|
122
|
+
else:
|
|
123
|
+
kwargs["json"] = params
|
|
124
|
+
result = self._transport.request(method, url, **kwargs)
|
|
125
|
+
if inspect.isawaitable(result):
|
|
126
|
+
result = await result
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _apply_placeholders(path: str, params: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
131
|
+
"""``/users/{id}`` + ``{"id": 7, "q": "x"}`` → ``/users/7`` + ``{"q": "x"}``.
|
|
132
|
+
|
|
133
|
+
Параметр, названный в адресе, в запрос НЕ уезжает — иначе ``id`` уходил бы и
|
|
134
|
+
в путь, и в query, и половина сервисов отвечала бы 400 на ровном месте.
|
|
135
|
+
"""
|
|
136
|
+
used: list[str] = []
|
|
137
|
+
|
|
138
|
+
def _sub(match: re.Match[str]) -> str:
|
|
139
|
+
key = match.group(1)
|
|
140
|
+
if key not in params:
|
|
141
|
+
raise KeyError(f"адрес {path!r} требует параметр {key!r}, а его не передали")
|
|
142
|
+
used.append(key)
|
|
143
|
+
return str(params[key])
|
|
144
|
+
|
|
145
|
+
resolved = _PLACEHOLDER.sub(_sub, path)
|
|
146
|
+
return resolved, {k: v for k, v in params.items() if k not in used}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse(response: Any) -> Any:
|
|
152
|
+
"""Разобрать ответ: ``.json()`` если умеет, иначе ``.text``, иначе как есть."""
|
|
153
|
+
parser = getattr(response, "json", None)
|
|
154
|
+
if callable(parser):
|
|
155
|
+
try:
|
|
156
|
+
return parser()
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
text = getattr(response, "text", None)
|
|
160
|
+
return text if text is not None else response
|
clientkit/_candidates.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""КНИГА АДРЕСОВ операции: кандидаты и их живость.
|
|
2
|
+
|
|
3
|
+
Реальный инцидент (он же — причина, по которой adapterkit завёл
|
|
4
|
+
`EndpointRegistry`): у Gemini операция «история беседы» годами звалась `rpcid`
|
|
5
|
+
``EqPOKe``, а на свежей сессии он отдаёт ``null``. Пока адрес зашит в код, эта
|
|
6
|
+
смерть выглядит как «история пустая» или «сессия протухла» — обе трактовки
|
|
7
|
+
ложные, обе гонят человека делать бесполезный ручной вход.
|
|
8
|
+
|
|
9
|
+
ДВА ИСТОЧНИКА, ОДНО ПОВЕДЕНИЕ. Если установлен `adapterkit`, книга — тонкая
|
|
10
|
+
обёртка НАД ЕГО `EndpointRegistry` (тот же порядок кандидатов, та же семантика
|
|
11
|
+
«мёртвый пропускается, непроверенный — нет»). Если нет — равнозначная реализация
|
|
12
|
+
на 30 строк. Причина держать обе: adapterkit тянет за собой оркестратор целиком
|
|
13
|
+
(`s-librarykit`, httpx), и заставлять белого потребителя ставить это ради
|
|
14
|
+
кита-сборщика было бы ровно тем, от чего кит и уводит. Совпадение поведения
|
|
15
|
+
проверяется тестом, который прогоняет ОДИН сценарий через обе ветки.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from clientkit.declaration import EndpointDecl, ServiceDecl
|
|
22
|
+
from clientkit.errors import AccessUnavailable
|
|
23
|
+
|
|
24
|
+
__all__ = ["CandidateBook"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CandidateBook:
|
|
28
|
+
"""Кандидаты операций сервиса + отметки «этот адрес доказано мёртв»."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, decl: ServiceDecl, *, registry: Any | None = None) -> None:
|
|
31
|
+
self.decl = decl
|
|
32
|
+
self._registry = registry
|
|
33
|
+
self._dead: dict[str, set[str]] = {}
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------ #
|
|
36
|
+
@classmethod
|
|
37
|
+
def for_service(cls, decl: ServiceDecl, *, prefer_adapterkit: bool = True) -> CandidateBook:
|
|
38
|
+
"""Собрать книгу: поверх adapterkit, если он есть, иначе своя.
|
|
39
|
+
|
|
40
|
+
Импорт adapterkit ЛЕНИВЫЙ и только на скрытом пути — белый клиент за него
|
|
41
|
+
не платит (проверено тестом по ``sys.modules``).
|
|
42
|
+
"""
|
|
43
|
+
if prefer_adapterkit:
|
|
44
|
+
registry = _adapterkit_registry(decl)
|
|
45
|
+
if registry is not None:
|
|
46
|
+
return cls(decl, registry=registry)
|
|
47
|
+
return cls(decl)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def backend(self) -> str:
|
|
51
|
+
"""Кто под книгой: ``"adapterkit"`` или ``"builtin"`` (улика для тестов)."""
|
|
52
|
+
return "adapterkit" if self._registry is not None else "builtin"
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ #
|
|
55
|
+
def candidates(self, endpoint: EndpointDecl) -> tuple[str, ...]:
|
|
56
|
+
"""Все адреса операции по объявленному порядку (первым — основной путь)."""
|
|
57
|
+
return endpoint.candidates or ((endpoint.path,) if endpoint.path else ())
|
|
58
|
+
|
|
59
|
+
def active(self, endpoint: EndpointDecl) -> str:
|
|
60
|
+
"""Адрес, которым звать операцию СЕЙЧАС. Все мертвы — `AccessUnavailable`."""
|
|
61
|
+
if self._registry is not None:
|
|
62
|
+
return self._registry_active(endpoint)
|
|
63
|
+
dead = self._dead.get(endpoint.name, set())
|
|
64
|
+
for candidate in self.candidates(endpoint):
|
|
65
|
+
if candidate not in dead:
|
|
66
|
+
return candidate
|
|
67
|
+
raise AccessUnavailable(_exhausted_message(self.decl.name, endpoint, sorted(dead)))
|
|
68
|
+
|
|
69
|
+
def bury(self, endpoint: EndpointDecl, *, why: str = "") -> bool:
|
|
70
|
+
"""Пометить текущий адрес мёртвым. ``True``, если есть кем его заменить.
|
|
71
|
+
|
|
72
|
+
``False`` (а не исключение) намеренно: решение «сдаться или отдать ответ
|
|
73
|
+
как есть» принимает вызывающий, а книга лишь отвечает, осталось ли чем
|
|
74
|
+
звонить.
|
|
75
|
+
"""
|
|
76
|
+
current = self.active(endpoint)
|
|
77
|
+
if self._registry is not None:
|
|
78
|
+
return self._registry_bury(endpoint, current, why)
|
|
79
|
+
self._dead.setdefault(endpoint.name, set()).add(current)
|
|
80
|
+
remaining = [c for c in self.candidates(endpoint) if c not in self._dead[endpoint.name]]
|
|
81
|
+
return bool(remaining)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
84
|
+
# ветка adapterkit #
|
|
85
|
+
# ------------------------------------------------------------------ #
|
|
86
|
+
def _registry_active(self, endpoint: EndpointDecl) -> str:
|
|
87
|
+
from adapterkit.endpoints import EndpointsExhausted
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
return self._registry.resolve(endpoint.name).id
|
|
91
|
+
except EndpointsExhausted as exc:
|
|
92
|
+
raise AccessUnavailable(str(exc)) from exc
|
|
93
|
+
|
|
94
|
+
def _registry_bury(self, endpoint: EndpointDecl, current: str, why: str) -> bool:
|
|
95
|
+
from adapterkit.endpoints import EndpointProbe, EndpointsExhausted
|
|
96
|
+
|
|
97
|
+
self._registry.record_probe(
|
|
98
|
+
EndpointProbe(
|
|
99
|
+
endpoint=endpoint.name,
|
|
100
|
+
candidate=current,
|
|
101
|
+
state=_endpoint_dead_state(),
|
|
102
|
+
why=why or "адрес не отвечает по существу",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
self._registry.resolve(endpoint.name)
|
|
107
|
+
except EndpointsExhausted:
|
|
108
|
+
return False
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _endpoint_dead_state():
|
|
113
|
+
from corekit.diagnosis.access import AccessState
|
|
114
|
+
|
|
115
|
+
return AccessState.ENDPOINT_DEAD
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _adapterkit_registry(decl: ServiceDecl):
|
|
119
|
+
"""Построить `EndpointRegistry` из ДЕКЛАРАЦИИ. ``None``, если adapterkit нет."""
|
|
120
|
+
try:
|
|
121
|
+
from adapterkit.endpoints import EndpointRegistry, endpoint_from_data
|
|
122
|
+
except Exception:
|
|
123
|
+
return None
|
|
124
|
+
endpoints = []
|
|
125
|
+
for ep in decl.endpoints:
|
|
126
|
+
ids = ep.candidates or ((ep.path,) if ep.path else ())
|
|
127
|
+
endpoints.append(
|
|
128
|
+
endpoint_from_data(
|
|
129
|
+
{
|
|
130
|
+
"name": ep.name,
|
|
131
|
+
"method": ep.method,
|
|
132
|
+
"path": ep.path,
|
|
133
|
+
"surface": "hidden",
|
|
134
|
+
# Приоритет ПО ПОРЯДКУ ОБЪЯВЛЕНИЯ: декларация читается сверху
|
|
135
|
+
# вниз, и первый кандидат обязан пойти первым.
|
|
136
|
+
"candidates": [
|
|
137
|
+
{"id": cid, "priority": 10 * (i + 1)} for i, cid in enumerate(ids)
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return EndpointRegistry(endpoints, service=decl.name)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _exhausted_message(service: str, endpoint: EndpointDecl, dead: list[str]) -> str:
|
|
146
|
+
return (
|
|
147
|
+
f"сервис {service!r}, операция {endpoint.name!r}: живых адресов не осталось "
|
|
148
|
+
f"(мертвы: {', '.join(dead) or '—'}). Нужен актуальный адрес метода — "
|
|
149
|
+
"перевход не поможет."
|
|
150
|
+
)
|
clientkit/_limit.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Гейт лимита: token bucket на монотонных часах.
|
|
2
|
+
|
|
3
|
+
Крошечный намеренно. Настоящая политика лимитов (percent-остаток аккаунта,
|
|
4
|
+
балансировка пула, health) живёт выше — здесь ровно то, что объявлено В
|
|
5
|
+
ДЕКЛАРАЦИИ сервиса: «не чаще N в секунду». Если ``rate == 0``, гейт не строится
|
|
6
|
+
вовсе — платить блокировкой за необъявленное никто не обязан.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
__all__ = ["TokenBucket"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenBucket:
|
|
17
|
+
"""``rate`` пополнений в секунду, ёмкость ``burst``.
|
|
18
|
+
|
|
19
|
+
Часы — ``time.monotonic`` (перевод системного времени не открывает шлюз и не
|
|
20
|
+
вешает вызов навсегда). Инъекция часов/сна — для тестов: настоящий ``sleep``
|
|
21
|
+
в прогоне лимитов означал бы тест, который либо врёт, либо идёт минуту.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
rate: float,
|
|
27
|
+
burst: int = 1,
|
|
28
|
+
*,
|
|
29
|
+
clock=time.monotonic,
|
|
30
|
+
sleep=asyncio.sleep,
|
|
31
|
+
) -> None:
|
|
32
|
+
if rate <= 0:
|
|
33
|
+
raise ValueError("rate обязан быть положительным (0 = гейт не нужен вовсе)")
|
|
34
|
+
self.rate = rate
|
|
35
|
+
self.burst = max(1, burst)
|
|
36
|
+
self._clock = clock
|
|
37
|
+
self._sleep = sleep
|
|
38
|
+
self._tokens = float(self.burst)
|
|
39
|
+
self._updated = clock()
|
|
40
|
+
self._lock = asyncio.Lock()
|
|
41
|
+
|
|
42
|
+
async def acquire(self) -> float:
|
|
43
|
+
"""Дождаться права на вызов. Возвращает, сколько СЕКУНД пришлось ждать."""
|
|
44
|
+
async with self._lock:
|
|
45
|
+
now = self._clock()
|
|
46
|
+
self._tokens = min(self.burst, self._tokens + (now - self._updated) * self.rate)
|
|
47
|
+
self._updated = now
|
|
48
|
+
if self._tokens >= 1.0:
|
|
49
|
+
self._tokens -= 1.0
|
|
50
|
+
return 0.0
|
|
51
|
+
wait = (1.0 - self._tokens) / self.rate
|
|
52
|
+
await self._sleep(wait)
|
|
53
|
+
self._updated = self._clock()
|
|
54
|
+
self._tokens = 0.0
|
|
55
|
+
return wait
|
clientkit/builder.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""СБОРЩИК: декларация → готовый клиент. Здесь и происходит выбор слоёв.
|
|
2
|
+
|
|
3
|
+
Ровно одна развилка на весь кит, и она смотрит на ДАННЫЕ (``decl.kind``), а не на
|
|
4
|
+
имя сервиса, не на env и не на «если это гугл, то…». За развилкой — два клиента с
|
|
5
|
+
ОДИНАКОВОЙ поверхностью (:class:`~clientkit._base.ServiceClient`), поэтому дальше
|
|
6
|
+
по коду потребителя различия нет.
|
|
7
|
+
|
|
8
|
+
ГЛАВНОЕ СВОЙСТВО: белая ветка не импортирует ни сессий, ни чеканки, ни антибота.
|
|
9
|
+
Импорт :mod:`clientkit.hidden` живёт ВНУТРИ ветки ``HIDDEN`` — не для красоты, а
|
|
10
|
+
потому что импорт на уровне модуля стоил бы каждому белому клиенту всего слоя
|
|
11
|
+
удержания доступа. Проверяется по ``sys.modules``, а не чтением.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from clientkit._base import ServiceClient
|
|
19
|
+
from clientkit.declaration import ServiceDecl, ServiceKind, load_toml
|
|
20
|
+
from clientkit.ports import RequestPort, resolve_transport
|
|
21
|
+
|
|
22
|
+
__all__ = ["build_client", "build_from_toml"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_client(
|
|
26
|
+
decl: ServiceDecl,
|
|
27
|
+
*,
|
|
28
|
+
transport: RequestPort | None = None,
|
|
29
|
+
**options: Any,
|
|
30
|
+
) -> ServiceClient:
|
|
31
|
+
"""Собрать готовый клиент по декларации.
|
|
32
|
+
|
|
33
|
+
``transport`` — «чем ходить». Не передали → умолчание резолвится ЛЕНИВО
|
|
34
|
+
(netkit → httpx): кит не тянет сетевое колесо на импорте.
|
|
35
|
+
|
|
36
|
+
``options`` уходят выбранному клиенту как есть:
|
|
37
|
+
|
|
38
|
+
* белый — ``secrets`` (``(name) -> str | None``), ``tokens``,
|
|
39
|
+
``oauth_refresher``;
|
|
40
|
+
* скрытый — ``session_store``, ``profile``, ``account_id``, ``mint``.
|
|
41
|
+
|
|
42
|
+
Декларация ВАЛИДИРУЕТСЯ здесь: белый сервис с рецептом доступа или скрытый
|
|
43
|
+
без чеканщика — опечатка, и узнать о ней надо при сборке.
|
|
44
|
+
"""
|
|
45
|
+
decl.validate()
|
|
46
|
+
port = transport if transport is not None else resolve_transport()
|
|
47
|
+
if decl.kind is ServiceKind.WHITE:
|
|
48
|
+
from clientkit.white import WhiteApiClient
|
|
49
|
+
|
|
50
|
+
return WhiteApiClient(decl, port, **options)
|
|
51
|
+
|
|
52
|
+
# Скрытый путь — и только он — поднимает слой удержания доступа.
|
|
53
|
+
from clientkit.hidden import HiddenApiClient
|
|
54
|
+
|
|
55
|
+
return HiddenApiClient(decl, port, **options)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_from_toml(
|
|
59
|
+
path: str | Path,
|
|
60
|
+
*,
|
|
61
|
+
transport: RequestPort | None = None,
|
|
62
|
+
**options: Any,
|
|
63
|
+
) -> ServiceClient:
|
|
64
|
+
"""Прочитать декларацию из TOML и собрать клиент (самая короткая форма)."""
|
|
65
|
+
return build_client(load_toml(path), transport=transport, **options)
|