s-clientkit 0.0.1__tar.gz

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,47 @@
1
+ # === atlas universal gitignore ===
2
+
3
+ # OS / IDE
4
+ .DS_Store
5
+ Thumbs.db
6
+ .vscode/
7
+ .idea/
8
+ *.swp
9
+ *.swo
10
+
11
+ # Sensitive
12
+ .env
13
+ .env.local
14
+ *.key
15
+ *.pem
16
+ secrets/
17
+ private/
18
+
19
+ # Python
20
+ __pycache__/
21
+ *.py[cod]
22
+ .venv/
23
+ venv/
24
+ .pytest_cache/
25
+ .ruff_cache/
26
+ *.egg-info/
27
+
28
+ # Node / JS
29
+ node_modules/
30
+ .next/
31
+ dist/
32
+ build/
33
+
34
+ # Temporary / large
35
+ *.log
36
+ *.tmp
37
+ nul
38
+ NUL
39
+ *.zip
40
+ *.rar
41
+ *.7z
42
+
43
+ # Media (selectively unignore via !path/*.ext if needed for fixtures)
44
+ *.mp4
45
+ *.mov
46
+ *.avi
47
+ *.mkv
@@ -0,0 +1,45 @@
1
+ # AGENTS.md — clientkit
2
+
3
+ > Контекст для AI-ассистентов (Claude Code, ChatGPT, Cursor и т.п.), работающих
4
+ > над этим проектом.
5
+
6
+ ## Что это
7
+
8
+ (заполнить one-line)
9
+
10
+ ## Atlas
11
+
12
+ Проект зарегистрирован в Atlas-БД (Atlas). Карточка:
13
+
14
+ ```sh
15
+ atlas project get clientkit
16
+ ```
17
+
18
+ Любые изменения метаданных (приоритет, статус, теги) — через atlas CLI:
19
+
20
+ - `atlas project update clientkit --priority P0` — поменять приоритет
21
+ - `atlas project tag add clientkit -t domain:<slug>` — добавить тег
22
+ - `atlas project move clientkit --to-type <type>` — конвертировать тип
23
+
24
+ ## Тип / Статус (на момент создания)
25
+
26
+ - type=`kit`, status=`active`, priority=`P2`
27
+
28
+ ## Правила работы
29
+
30
+ - Все исходные тексты, документы, код проекта — в этом репо.
31
+ - Чувствительные данные (`.env`, токены, ключи) — игнорируются `.gitignore`.
32
+ - AI-ассистенту разрешено: читать, генерировать, редактировать в этом репо.
33
+
34
+ ## Канонические команды
35
+
36
+ - `atlas project get clientkit` — карточка проекта
37
+ - `atlas task list --project clientkit` — задачи проекта (когда W7
38
+ волна будет реализована)
39
+
40
+ <!-- atlas:usage:start -->
41
+ ## Управление проектом — через Atlas
42
+
43
+ Этот проект ведётся в Atlas (личная PM-система портфеля). Для задач/проектов/эпиков/бэкапов
44
+ используй CLI `atlas` и вызывай навык `atlas` — вся логика и роутинг внутри навыка.
45
+ <!-- atlas:usage:end -->
@@ -0,0 +1,24 @@
1
+ # Changelog — s-clientkit
2
+
3
+ ## 0.0.1 — кит-сборщик (задача #1423)
4
+
5
+ Первый выпуск.
6
+
7
+ - `clientkit.declaration` — декларация сервиса как ДАННЫЕ (`ServiceDecl` и
8
+ соседи, frozen dataclass'ы, round-trip в обычный dict, загрузка из TOML,
9
+ валидация при загрузке).
10
+ - `clientkit.builder` — `build_client` / `build_from_toml`: единственная развилка
11
+ кита, и она смотрит на `decl.kind`.
12
+ - `clientkit.white` — белый API: заголовок авторизации (`api_key`/`bearer`/
13
+ `oauth` с проактивным TTL) + прямой запрос. Ни сессий, ни чеканки, ни антибота.
14
+ - `clientkit.hidden` — скрытый API: лестница доступа из декларации, чеканка через
15
+ порт слоя доступа, ротация адресов-кандидатов, разбор отказов через
16
+ `corekit.diagnosis.access`.
17
+ - `clientkit._candidates` — книга адресов поверх `adapterkit.EndpointRegistry`
18
+ либо равнозначная встроенная (совпадение проверено тестом).
19
+ - `clientkit.ports` — `RequestPort` / `SecretLookup`; умолчание транспорта
20
+ (netkit → httpx) резолвится лениво.
21
+
22
+ Главное свойство: потребитель зовёт `await client.call("me")` одинаково в обоих
23
+ мирах, при этом белый путь не поднимает ни сессий, ни чеканки, ни антибота и
24
+ делает ровно один запрос.
@@ -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.
@@ -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,55 @@
1
+ # s-clientkit — кит-сборщик клиентов
2
+
3
+ Готовый клиент сервиса **собирается по декларации**, а не пишется заново.
4
+
5
+ ```
6
+ clientkit <- СБОРЩИК (этот кит)
7
+ / \
8
+ authkit-client browserkit <- доступ (кто ты) / чеканка (как войти)
9
+ \ /
10
+ netkit <- сеть (чем ходить)
11
+ |
12
+ corekit <- основание
13
+ ```
14
+
15
+ ## Один вызов на оба мира
16
+
17
+ ```python
18
+ from clientkit import build_from_toml
19
+
20
+ white = build_from_toml("examples/white_service.toml", secrets=os.environ.get)
21
+ hidden = build_from_toml("examples/hidden_service.toml", session_store=store)
22
+
23
+ await white.call("me") # белый REST с api-ключом
24
+ await hidden.call("me") # скрытый API с сессией и лестницей деградации
25
+ ```
26
+
27
+ Потребитель зовёт **одинаково** и по поверхности клиента не может отличить один
28
+ от другого. Разница — в цене:
29
+
30
+ | | белый | скрытый |
31
+ | --- | --- | --- |
32
+ | поднимает сессии/чеканку/антибот | **нет** | да |
33
+ | запросов на `call("me")` | **ровно 1** | 1 (+чеканка, если доступа нет) |
34
+ | авторизация | заголовок (`api_key` / `bearer` / `oauth` с проактивным TTL) | cookies живой сессии |
35
+ | смерть адреса | ошибка сервиса | следующий кандидат, без релиза |
36
+
37
+ Обе половины проверяются тестом `tests/test_same_call_both_kinds.py` — по
38
+ `sys.modules` свежего интерпретатора и по счётчику запросов, а не декларацией.
39
+
40
+ ## Декларация — данные
41
+
42
+ `ServiceDecl` (frozen dataclass) читается из TOML и валидируется при загрузке:
43
+ белый сервис с рецептом доступа или скрытый без чеканщика — опечатка, о которой
44
+ надо узнать при сборке, а не ночью в бою. Round-trip
45
+ `decl_to_data ∘ decl_from_data` проверяется тестом.
46
+
47
+ Смотри `examples/white_service.toml` и `examples/hidden_service.toml`.
48
+
49
+ ## Чего в ките нет
50
+
51
+ * **своего HTTP** — «чем ходить» приходит аргументом (`RequestPort`), умолчание
52
+ (netkit → httpx) резолвится лениво;
53
+ * **своего браузера** — чеканка приходит через порт слоя доступа;
54
+ * **своей таксономии отказов** — диагноз ставит `corekit.diagnosis.access`, та же
55
+ функция, что у живой пробы и у реестра эндпоинтов.
@@ -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})
@@ -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