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 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
@@ -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)