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/identity.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""ЕДИНОЕ ИМЯ СЕРВИСА и его входы — данные, а не строка в каждом коде.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ ЭТОТ МОДУЛЬ ПОЯВИЛСЯ. Замер 2026-08-08: навыки пишут сессии под именами
|
|
4
|
+
``grok``, ``chatgpt``, ``deepseek``, ``gigachat``, ``google-flow``, ``qwen``,
|
|
5
|
+
``b24`` — и СЕМЬ ИЗ ДЕВЯТИ в реестре сервисов отсутствуют вовсе, а совпадающие
|
|
6
|
+
названы иначе (реестр знает ``chatgpt-chat``, навык пишет ``chatgpt``; реестр
|
|
7
|
+
``flow`` — навык ``google-flow``). Реестр и хранилище сессий живут в параллельных
|
|
8
|
+
вселенных, потому что сверять их нечем: имя — просто строка, которую каждый
|
|
9
|
+
пишет как помнит. Достаточно опечатки, чтобы у одного аккаунта стало две сессии.
|
|
10
|
+
|
|
11
|
+
ПОЧЕМУ ЗДЕСЬ, А НЕ РЯДОМ С ТАБЛИЦАМИ. Имя сервиса нужно ВСЕМ — в том числе тем,
|
|
12
|
+
кто базы не держит: навыку, чтобы назвать свою сессию; записи трафика, чтобы
|
|
13
|
+
положить снимок; CLI, чтобы показать список. Положив это знание к таблицам, мы
|
|
14
|
+
заставили бы каждого потребителя тянуть ORM и вендора БД ради одной строки.
|
|
15
|
+
Здесь — только значения (кит зависит от одного основания), а хранилище получает
|
|
16
|
+
их снаружи и сеет у себя.
|
|
17
|
+
|
|
18
|
+
СИНОНИМЫ — СПОСОБ ПЕРЕВЕСТИ, НЕ ЛОМАЯ. `b24` и `google-flow` не переименовать
|
|
19
|
+
одним днём: под ними уже лежат сессии и на них ссылается чужой код. Поэтому у
|
|
20
|
+
сервиса есть канонический ``code`` и список ``aliases``; резолвер сводит их к
|
|
21
|
+
одному сервису, и две сессии перестают быть двумя — без правки навыков в тот же
|
|
22
|
+
день.
|
|
23
|
+
|
|
24
|
+
ИНСТАНС — НЕ ОТДЕЛЬНЫЙ СЕРВИС. У Битрикс24 порталов много (``acme.bitrix24.ru``,
|
|
25
|
+
``beta.bitrix24.kz``), и куки у них разные — но продукт ОДИН. Заводить
|
|
26
|
+
``bitrix24-kz`` отдельным сервисом значит снова размножить имена, то есть
|
|
27
|
+
воспроизвести ровно ту болезнь, которую лечим. Поэтому ``instancing=MULTI`` и
|
|
28
|
+
адрес входа собирается из шаблона по хосту портала.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from enum import StrEnum
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"CATALOG",
|
|
37
|
+
"resolve_service",
|
|
38
|
+
"suggest_service",
|
|
39
|
+
"Instancing",
|
|
40
|
+
"ServiceIdentity",
|
|
41
|
+
"UnknownService",
|
|
42
|
+
"canonical",
|
|
43
|
+
"resolve",
|
|
44
|
+
"suggest",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnknownService(LookupError): # noqa: N818 # доменное имя контракта (не *Error)
|
|
49
|
+
"""Такого сервиса нет ни по коду, ни по синониму."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Instancing(StrEnum):
|
|
53
|
+
"""Сколько входов у сервиса.
|
|
54
|
+
|
|
55
|
+
``SINGLE`` — один адрес на всех (``grok.com``). ``MULTI`` — у каждого клиента
|
|
56
|
+
свой хост (портал Битрикса, поддомен арендатора), и адрес собирается из
|
|
57
|
+
шаблона.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
SINGLE = "single"
|
|
61
|
+
MULTI = "multi"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class ServiceIdentity:
|
|
66
|
+
"""Кто такой сервис: канон-имя, синонимы и куда вести браузер за входом."""
|
|
67
|
+
|
|
68
|
+
code: str
|
|
69
|
+
display: str = ""
|
|
70
|
+
aliases: tuple[str, ...] = ()
|
|
71
|
+
#: Полный URL входа (SINGLE) либо шаблон с ``{host}`` (MULTI).
|
|
72
|
+
auth_url: str = ""
|
|
73
|
+
instancing: Instancing = Instancing.SINGLE
|
|
74
|
+
#: Хосты, по которым сервис узнаётся в capture и в ссылках.
|
|
75
|
+
hosts: tuple[str, ...] = ()
|
|
76
|
+
note: str = ""
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def names(self) -> tuple[str, ...]:
|
|
80
|
+
"""Все имена, по которым сервис узнаётся (канон первым)."""
|
|
81
|
+
seen: list[str] = []
|
|
82
|
+
for name in (self.code, *self.aliases):
|
|
83
|
+
if name and name not in seen:
|
|
84
|
+
seen.append(name)
|
|
85
|
+
return tuple(seen)
|
|
86
|
+
|
|
87
|
+
def knows(self, name: str) -> bool:
|
|
88
|
+
"""Отзывается ли сервис на это имя (регистр не важен)."""
|
|
89
|
+
wanted = (name or "").strip().casefold()
|
|
90
|
+
return any(alias.casefold() == wanted for alias in self.names)
|
|
91
|
+
|
|
92
|
+
def login_url(self, host: str = "") -> str:
|
|
93
|
+
"""Адрес входа: для MULTI — подставив хост портала.
|
|
94
|
+
|
|
95
|
+
:raises ValueError: сервис многоинстансный, а хост не назван — вести
|
|
96
|
+
браузер «куда-нибудь» хуже, чем честно сказать, чего не хватает.
|
|
97
|
+
"""
|
|
98
|
+
if self.instancing is Instancing.SINGLE:
|
|
99
|
+
return self.auth_url
|
|
100
|
+
if not host:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"у сервиса `{self.code}` свой адрес у каждого клиента "
|
|
103
|
+
f"(например {self.hosts[0] if self.hosts else 'portal.example'}) — "
|
|
104
|
+
"назови хост инстанса"
|
|
105
|
+
)
|
|
106
|
+
return self.auth_url.format(host=host.strip().strip("/"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: КАТАЛОГ известных сервисов. Коды — те, под которыми уже лежат сессии: менять
|
|
110
|
+
#: их значило бы устроить миграцию данных ради красоты имени. Расхождения с
|
|
111
|
+
#: прежними названиями закрыты синонимами.
|
|
112
|
+
CATALOG: tuple[ServiceIdentity, ...] = (
|
|
113
|
+
ServiceIdentity(
|
|
114
|
+
code="grok",
|
|
115
|
+
display="Grok (xAI)",
|
|
116
|
+
auth_url="https://grok.com/",
|
|
117
|
+
hosts=("grok.com",),
|
|
118
|
+
),
|
|
119
|
+
ServiceIdentity(
|
|
120
|
+
code="chatgpt",
|
|
121
|
+
display="ChatGPT",
|
|
122
|
+
aliases=("chatgpt-chat",),
|
|
123
|
+
auth_url="https://chatgpt.com/",
|
|
124
|
+
hosts=("chatgpt.com", "chat.openai.com"),
|
|
125
|
+
note="`chatgpt-chat` — имя из прежнего каталога сервисов",
|
|
126
|
+
),
|
|
127
|
+
ServiceIdentity(
|
|
128
|
+
code="gemini",
|
|
129
|
+
display="Gemini",
|
|
130
|
+
aliases=("gemini-chat",),
|
|
131
|
+
auth_url="https://gemini.google.com/app",
|
|
132
|
+
hosts=("gemini.google.com",),
|
|
133
|
+
),
|
|
134
|
+
ServiceIdentity(
|
|
135
|
+
code="qwen",
|
|
136
|
+
display="Qwen",
|
|
137
|
+
auth_url="https://chat.qwen.ai/",
|
|
138
|
+
hosts=("chat.qwen.ai",),
|
|
139
|
+
),
|
|
140
|
+
ServiceIdentity(
|
|
141
|
+
code="deepseek",
|
|
142
|
+
display="DeepSeek",
|
|
143
|
+
auth_url="https://chat.deepseek.com/",
|
|
144
|
+
hosts=("chat.deepseek.com",),
|
|
145
|
+
),
|
|
146
|
+
ServiceIdentity(
|
|
147
|
+
code="gigachat",
|
|
148
|
+
display="GigaChat",
|
|
149
|
+
auth_url="https://giga.chat/",
|
|
150
|
+
hosts=("giga.chat",),
|
|
151
|
+
),
|
|
152
|
+
ServiceIdentity(
|
|
153
|
+
code="flow",
|
|
154
|
+
display="Google Flow",
|
|
155
|
+
aliases=("google-flow",),
|
|
156
|
+
auth_url="https://labs.google/fx/tools/flow",
|
|
157
|
+
hosts=("labs.google",),
|
|
158
|
+
note="`google-flow` — имя пакета навыка, под ним уже лежат сессии",
|
|
159
|
+
),
|
|
160
|
+
ServiceIdentity(
|
|
161
|
+
code="notebooklm",
|
|
162
|
+
display="NotebookLM",
|
|
163
|
+
auth_url="https://notebooklm.google.com/",
|
|
164
|
+
hosts=("notebooklm.google.com",),
|
|
165
|
+
),
|
|
166
|
+
ServiceIdentity(
|
|
167
|
+
code="bitrix24",
|
|
168
|
+
display="Битрикс24",
|
|
169
|
+
aliases=("b24",),
|
|
170
|
+
auth_url="https://{host}/auth/",
|
|
171
|
+
instancing=Instancing.MULTI,
|
|
172
|
+
hosts=("bitrix24.ru", "bitrix24.kz", "bitrix24.by", "bitrix24.com"),
|
|
173
|
+
note=(
|
|
174
|
+
"портал у каждого клиента свой, национальные домены — ИНСТАНСЫ одного "
|
|
175
|
+
"сервиса, а не разные сервисы"
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
ServiceIdentity(
|
|
179
|
+
code="telegram",
|
|
180
|
+
display="Telegram",
|
|
181
|
+
aliases=("tg",),
|
|
182
|
+
auth_url="https://web.telegram.org/",
|
|
183
|
+
hosts=("web.telegram.org", "telegram.org"),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
_BY_NAME: dict[str, ServiceIdentity] = {
|
|
188
|
+
alias.casefold(): svc for svc in CATALOG for alias in svc.names
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def resolve(name: str, catalog: tuple[ServiceIdentity, ...] = CATALOG) -> ServiceIdentity:
|
|
193
|
+
"""Имя (канон или синоним) → сервис.
|
|
194
|
+
|
|
195
|
+
:raises UnknownService: имени нет — с подсказкой, что похоже. Молча заводить
|
|
196
|
+
новый сервис нельзя: именно так рядом с ``grok`` появляется ``grok-chat``.
|
|
197
|
+
"""
|
|
198
|
+
wanted = (name or "").strip()
|
|
199
|
+
if not wanted:
|
|
200
|
+
raise UnknownService("имя сервиса пустое — нечего резолвить")
|
|
201
|
+
index = _BY_NAME if catalog is CATALOG else {
|
|
202
|
+
alias.casefold(): svc for svc in catalog for alias in svc.names
|
|
203
|
+
}
|
|
204
|
+
found = index.get(wanted.casefold())
|
|
205
|
+
if found is not None:
|
|
206
|
+
return found
|
|
207
|
+
hint = suggest(wanted, catalog)
|
|
208
|
+
tail = f" Возможно, имелось в виду: {', '.join(hint)}." if hint else ""
|
|
209
|
+
raise UnknownService(f"сервис `{wanted}` неизвестен.{tail}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def canonical(name: str, catalog: tuple[ServiceIdentity, ...] = CATALOG) -> str:
|
|
213
|
+
"""Каноническое имя сервиса по любому из его имён."""
|
|
214
|
+
return resolve(name, catalog).code
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def suggest(
|
|
218
|
+
name: str, catalog: tuple[ServiceIdentity, ...] = CATALOG, *, limit: int = 3
|
|
219
|
+
) -> tuple[str, ...]:
|
|
220
|
+
"""Похожие известные имена — чтобы отказ говорил, что делать.
|
|
221
|
+
|
|
222
|
+
Сравнение простое и объяснимое: общее начало либо вхождение одного в другое.
|
|
223
|
+
Умная метрика тут не нужна — ошибаются в дефисе и суффиксе (``grok-chat``,
|
|
224
|
+
``google-flow``), а не в буквах.
|
|
225
|
+
"""
|
|
226
|
+
wanted = (name or "").strip().casefold()
|
|
227
|
+
if not wanted:
|
|
228
|
+
return ()
|
|
229
|
+
scored: list[tuple[int, str]] = []
|
|
230
|
+
for svc in catalog:
|
|
231
|
+
for alias in svc.names:
|
|
232
|
+
low = alias.casefold()
|
|
233
|
+
if low == wanted:
|
|
234
|
+
continue
|
|
235
|
+
if wanted in low or low in wanted:
|
|
236
|
+
scored.append((0, svc.code))
|
|
237
|
+
elif low[:3] == wanted[:3]:
|
|
238
|
+
scored.append((1, svc.code))
|
|
239
|
+
seen: list[str] = []
|
|
240
|
+
for _, code in sorted(scored):
|
|
241
|
+
if code not in seen:
|
|
242
|
+
seen.append(code)
|
|
243
|
+
return tuple(seen[:limit])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
#: Псевдонимы для корня пакета: в общем пространстве имён `resolve`/`suggest`
|
|
247
|
+
#: ничего не говорят о предмете, а `resolve_service` — говорит.
|
|
248
|
+
resolve_service = resolve
|
|
249
|
+
suggest_service = suggest
|
clientkit/ports.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Порты сборщика: чем ходить в сеть и откуда брать секреты.
|
|
2
|
+
|
|
3
|
+
Сборщик СВОЕГО HTTP-клиента не имеет — и это не аскеза. Стоит зашить httpx, и
|
|
4
|
+
кит перестанет собираться там, где ходят curl_cffi (антибот), где ходит netkit с
|
|
5
|
+
своей политикой повторов, где вообще нет сети (тест). Поэтому «чем ходить»
|
|
6
|
+
приходит аргументом, а умолчание резолвится ЛЕНИВО и только если аргумент не дали.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
__all__ = ["RequestPort", "ResponseLike", "SecretLookup", "resolve_transport"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class ResponseLike(Protocol):
|
|
18
|
+
"""Утиный ответ: столько, сколько нужно и для разбора, и для диагноза."""
|
|
19
|
+
|
|
20
|
+
status_code: int
|
|
21
|
+
text: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class RequestPort(Protocol):
|
|
26
|
+
"""Одна операция: сходить и вернуть ответ. Sync либо async — сборщик дождётся."""
|
|
27
|
+
|
|
28
|
+
def request(
|
|
29
|
+
self,
|
|
30
|
+
method: str,
|
|
31
|
+
url: str,
|
|
32
|
+
*,
|
|
33
|
+
headers: Mapping[str, str] | None = None,
|
|
34
|
+
params: Mapping[str, Any] | None = None,
|
|
35
|
+
json: Any | None = None,
|
|
36
|
+
) -> Any: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class SecretLookup(Protocol):
|
|
41
|
+
"""``(name) -> секрет | None``. Реализуют env, `SecretStorePort`, keyring, vault."""
|
|
42
|
+
|
|
43
|
+
def __call__(self, name: str) -> str | None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_transport() -> RequestPort:
|
|
47
|
+
"""Умолчание «чем ходить»: netkit → httpx → внятный отказ.
|
|
48
|
+
|
|
49
|
+
Порядок не случаен: netkit — наш сетевой слой (повторы, лимиты, egress из
|
|
50
|
+
контекста), httpx — честный запасной вариант для того, кто netkit не ставил.
|
|
51
|
+
Отказ, если нет ни одного, звучит инструкцией, а не `ModuleNotFoundError`:
|
|
52
|
+
человек должен прочитать, ЧТО поставить или ЧТО передать.
|
|
53
|
+
"""
|
|
54
|
+
try: # netkit — предпочтительный: он знает про контекст и повторы
|
|
55
|
+
from netkit import HttpxTransport # type: ignore[attr-defined]
|
|
56
|
+
|
|
57
|
+
return HttpxTransport() # type: ignore[return-value]
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
try:
|
|
61
|
+
import httpx
|
|
62
|
+
|
|
63
|
+
return httpx.AsyncClient() # type: ignore[return-value]
|
|
64
|
+
except ImportError as exc: # pragma: no cover — окружение без обоих колёс
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"нечем ходить в сеть: не установлены ни s-netkit, ни httpx. "
|
|
67
|
+
"Поставь одно из них — либо передай свой транспорт "
|
|
68
|
+
"`build_client(decl, transport=...)` (годится любой объект с "
|
|
69
|
+
"методом `request(method, url, headers=, params=, json=)`)."
|
|
70
|
+
) from exc
|
clientkit/white.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""БЕЛЫЙ путь: прямые запросы. Ни сессий, ни чеканки, ни антибота.
|
|
2
|
+
|
|
3
|
+
Здесь важно не то, что написано, а то, чего НЕ написано и не импортируется. У
|
|
4
|
+
белого API есть документированный адрес и документированный способ
|
|
5
|
+
представиться — значит, весь слой удержания доступа ему не нужен НИКОГДА, и
|
|
6
|
+
платить за него импортом он не должен.
|
|
7
|
+
|
|
8
|
+
Модуль знает ровно три схемы заголовка (api-key / bearer / oauth) и ни одной
|
|
9
|
+
строки про cookies. ``oauth`` — единственное место, где подтягивается
|
|
10
|
+
`authkit_client.oauth`: пара токенов с ПРОАКТИВНЫМ обновлением по TTL (живой
|
|
11
|
+
токен = ноль лишних запросов). Импорт ленивый и точечный — модуль `oauth` не
|
|
12
|
+
тянет за собой ни хранилище сессий, ни SQLite.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from clientkit._base import ServiceClientBase
|
|
19
|
+
from clientkit.declaration import AuthScheme, ServiceDecl
|
|
20
|
+
from clientkit.errors import ClientBuildError
|
|
21
|
+
from clientkit.ports import RequestPort, SecretLookup
|
|
22
|
+
|
|
23
|
+
__all__ = ["WhiteApiClient"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WhiteApiClient(ServiceClientBase):
|
|
27
|
+
"""Клиент белого API: заголовок авторизации + прямой запрос.
|
|
28
|
+
|
|
29
|
+
``secrets`` — ``(name) -> секрет | None``: env, `SecretStorePort`, vault —
|
|
30
|
+
что угодно. Сам секрет в декларации не лежит и лежать не будет.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
decl: ServiceDecl,
|
|
36
|
+
transport: RequestPort,
|
|
37
|
+
*,
|
|
38
|
+
secrets: SecretLookup | None = None,
|
|
39
|
+
oauth_refresher: Any | None = None,
|
|
40
|
+
tokens: Any | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(decl, transport)
|
|
43
|
+
self._secrets = secrets
|
|
44
|
+
self._oauth = None
|
|
45
|
+
if decl.auth.scheme is AuthScheme.OAUTH:
|
|
46
|
+
self._oauth = self._build_oauth(tokens, oauth_refresher)
|
|
47
|
+
|
|
48
|
+
def _build_oauth(self, tokens: Any, refresher: Any):
|
|
49
|
+
"""Собрать `OAuthSession` — ленивый импорт слоя доступа, точечный."""
|
|
50
|
+
if tokens is None or refresher is None:
|
|
51
|
+
raise ClientBuildError(
|
|
52
|
+
f"сервис {self.decl.name!r} объявлен с auth.scheme = 'oauth', но "
|
|
53
|
+
"пары токенов и/или колбэка обновления не дали. Передай "
|
|
54
|
+
"`build_client(decl, tokens=..., oauth_refresher=...)`: обмен "
|
|
55
|
+
"делает потребитель, кит отвечает за ВРЕМЯ, а не за протокол."
|
|
56
|
+
)
|
|
57
|
+
from authkit_client.oauth import OAuthSession
|
|
58
|
+
|
|
59
|
+
return OAuthSession(tokens, refresher=refresher, leeway=self.decl.auth.leeway)
|
|
60
|
+
|
|
61
|
+
async def _headers(self) -> dict[str, str]:
|
|
62
|
+
"""Заголовок авторизации по объявленной схеме."""
|
|
63
|
+
auth = self.decl.auth
|
|
64
|
+
if auth.scheme is AuthScheme.NONE:
|
|
65
|
+
return {}
|
|
66
|
+
if auth.scheme is AuthScheme.OAUTH:
|
|
67
|
+
# ПРОАКТИВНО: живой токен — ноль сетевых вызовов (сравнение чисел),
|
|
68
|
+
# истекающий обновляется ДО основного запроса, а не после 401.
|
|
69
|
+
await self._oauth.maybe_refresh()
|
|
70
|
+
secret = self._oauth.tokens.access_token
|
|
71
|
+
else:
|
|
72
|
+
secret = self._lookup(auth.secret_name)
|
|
73
|
+
return {auth.header: auth.template.format(secret=secret)}
|
|
74
|
+
|
|
75
|
+
def _lookup(self, name: str) -> str:
|
|
76
|
+
"""Достать секрет по ИМЕНИ; отсутствие — внятный отказ при вызове."""
|
|
77
|
+
value = self._secrets(name) if self._secrets is not None else None
|
|
78
|
+
if not value:
|
|
79
|
+
raise ClientBuildError(
|
|
80
|
+
f"сервис {self.decl.name!r}: секрет {name!r} не найден. Декларация "
|
|
81
|
+
"хранит ИМЯ секрета, а значение приносит `secrets=` — env, "
|
|
82
|
+
"SecretStore, vault: что угодно с сигнатурой (name) -> str | None."
|
|
83
|
+
)
|
|
84
|
+
return value
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: s-clientkit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: КИТ-СБОРЩИК: по ДЕКЛАРАЦИИ сервиса (TOML/dataclass) собирает готовый клиент из слоёв — белый API прямыми запросами, скрытый с удержанием доступа. Потребитель зовёт одинаково.
|
|
5
|
+
Author: Dmitry
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: s-corekit>=0.0.2
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
14
|
+
Provides-Extra: endpoints
|
|
15
|
+
Requires-Dist: s-adapterkit>=0.1.8; extra == 'endpoints'
|
|
16
|
+
Provides-Extra: hidden
|
|
17
|
+
Requires-Dist: s-authkit-client>=0.0.2; extra == 'hidden'
|
|
18
|
+
Provides-Extra: http
|
|
19
|
+
Requires-Dist: httpx>=0.27; extra == 'http'
|
|
20
|
+
Provides-Extra: net
|
|
21
|
+
Requires-Dist: s-netkit>=0.0.2; extra == 'net'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# s-clientkit — кит-сборщик клиентов
|
|
25
|
+
|
|
26
|
+
Готовый клиент сервиса **собирается по декларации**, а не пишется заново.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
clientkit <- СБОРЩИК (этот кит)
|
|
30
|
+
/ \
|
|
31
|
+
authkit-client browserkit <- доступ (кто ты) / чеканка (как войти)
|
|
32
|
+
\ /
|
|
33
|
+
netkit <- сеть (чем ходить)
|
|
34
|
+
|
|
|
35
|
+
corekit <- основание
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Один вызов на оба мира
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from clientkit import build_from_toml
|
|
42
|
+
|
|
43
|
+
white = build_from_toml("examples/white_service.toml", secrets=os.environ.get)
|
|
44
|
+
hidden = build_from_toml("examples/hidden_service.toml", session_store=store)
|
|
45
|
+
|
|
46
|
+
await white.call("me") # белый REST с api-ключом
|
|
47
|
+
await hidden.call("me") # скрытый API с сессией и лестницей деградации
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Потребитель зовёт **одинаково** и по поверхности клиента не может отличить один
|
|
51
|
+
от другого. Разница — в цене:
|
|
52
|
+
|
|
53
|
+
| | белый | скрытый |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| поднимает сессии/чеканку/антибот | **нет** | да |
|
|
56
|
+
| запросов на `call("me")` | **ровно 1** | 1 (+чеканка, если доступа нет) |
|
|
57
|
+
| авторизация | заголовок (`api_key` / `bearer` / `oauth` с проактивным TTL) | cookies живой сессии |
|
|
58
|
+
| смерть адреса | ошибка сервиса | следующий кандидат, без релиза |
|
|
59
|
+
|
|
60
|
+
Обе половины проверяются тестом `tests/test_same_call_both_kinds.py` — по
|
|
61
|
+
`sys.modules` свежего интерпретатора и по счётчику запросов, а не декларацией.
|
|
62
|
+
|
|
63
|
+
## Декларация — данные
|
|
64
|
+
|
|
65
|
+
`ServiceDecl` (frozen dataclass) читается из TOML и валидируется при загрузке:
|
|
66
|
+
белый сервис с рецептом доступа или скрытый без чеканщика — опечатка, о которой
|
|
67
|
+
надо узнать при сборке, а не ночью в бою. Round-trip
|
|
68
|
+
`decl_to_data ∘ decl_from_data` проверяется тестом.
|
|
69
|
+
|
|
70
|
+
Смотри `examples/white_service.toml` и `examples/hidden_service.toml`.
|
|
71
|
+
|
|
72
|
+
## Чего в ките нет
|
|
73
|
+
|
|
74
|
+
* **своего HTTP** — «чем ходить» приходит аргументом (`RequestPort`), умолчание
|
|
75
|
+
(netkit → httpx) резолвится лениво;
|
|
76
|
+
* **своего браузера** — чеканка приходит через порт слоя доступа;
|
|
77
|
+
* **своей таксономии отказов** — диагноз ставит `corekit.diagnosis.access`, та же
|
|
78
|
+
функция, что у живой пробы и у реестра эндпоинтов.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
clientkit/__init__.py,sha256=iOM6nC4a6H226paTOStjLVoxNvie_FwXJqXfU6xztYY,5989
|
|
2
|
+
clientkit/_base.py,sha256=Q52yHZ8s4x5fWKzD3spitZzbJA7YX74jm1loVHgzCas,8515
|
|
3
|
+
clientkit/_candidates.py,sha256=1B6h9Z-qL_L-ROIRNRyMGUWaJkWmhM-U9Lob-AMy7lA,7611
|
|
4
|
+
clientkit/_limit.py,sha256=YG_tlJEoWGbiy9R4k1ZSVHncUSW9cEdR4gw129H3HcU,2373
|
|
5
|
+
clientkit/builder.py,sha256=b1Nt4AFWBFLUP833Bi_RQ7BCBwF9FoAhBI-G-W7VBSg,3162
|
|
6
|
+
clientkit/declaration.py,sha256=Si9D9k1v8YMDXZz4QVbTJXF5WGCasbRbJR6eelsTWHc,16556
|
|
7
|
+
clientkit/errors.py,sha256=OfK9UsUoHvltGM66ZtJY3G0Fa4dkkz2xXcX7ZP6rb-g,2118
|
|
8
|
+
clientkit/hidden.py,sha256=ph6hfo-niKLI0xrBatnOZfs_w_Rg8EZvP7U5M86_VG8,10646
|
|
9
|
+
clientkit/identity.py,sha256=ZHLJDiz461FBDU1szAtx9nWKpoqmrwwxGv-FR6CDvCo,11568
|
|
10
|
+
clientkit/ports.py,sha256=smQG7aP7lc8SWn_VOQ7zDvZetMR8-glAkOYiWn29gSQ,3268
|
|
11
|
+
clientkit/white.py,sha256=hCgupqXERArvGOOu4qjVV1vyCtqcppaoxNzFKYDt8Rg,4565
|
|
12
|
+
s_clientkit-0.0.1.dist-info/METADATA,sha256=59q1TfajZeivOVHq3maQy1-EmDmnHixjnODYZASm-3k,3977
|
|
13
|
+
s_clientkit-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
s_clientkit-0.0.1.dist-info/licenses/LICENSE,sha256=j9GKJmUNdQuKRUbKhbpv0uyMaL99xsxE6L2TDtXuaZ4,1063
|
|
15
|
+
s_clientkit-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|