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.
- s_clientkit-0.0.1/.gitignore +47 -0
- s_clientkit-0.0.1/AGENTS.md +45 -0
- s_clientkit-0.0.1/CHANGELOG.md +24 -0
- s_clientkit-0.0.1/LICENSE +21 -0
- s_clientkit-0.0.1/PKG-INFO +78 -0
- s_clientkit-0.0.1/README.md +55 -0
- s_clientkit-0.0.1/clientkit/__init__.py +120 -0
- s_clientkit-0.0.1/clientkit/_base.py +160 -0
- s_clientkit-0.0.1/clientkit/_candidates.py +150 -0
- s_clientkit-0.0.1/clientkit/_limit.py +55 -0
- s_clientkit-0.0.1/clientkit/builder.py +65 -0
- s_clientkit-0.0.1/clientkit/declaration.py +338 -0
- s_clientkit-0.0.1/clientkit/errors.py +37 -0
- s_clientkit-0.0.1/clientkit/hidden.py +194 -0
- s_clientkit-0.0.1/clientkit/identity.py +249 -0
- s_clientkit-0.0.1/clientkit/ports.py +70 -0
- s_clientkit-0.0.1/clientkit/white.py +84 -0
- s_clientkit-0.0.1/docs/adr/0000-template.md +20 -0
- s_clientkit-0.0.1/docs/adr/README.md +30 -0
- s_clientkit-0.0.1/examples/hidden_service.toml +50 -0
- s_clientkit-0.0.1/examples/white_service.toml +35 -0
- s_clientkit-0.0.1/pyproject.toml +61 -0
- s_clientkit-0.0.1/tests/__init__.py +6 -0
- s_clientkit-0.0.1/tests/test_declaration.py +142 -0
- s_clientkit-0.0.1/tests/test_hidden_holds_access.py +264 -0
- s_clientkit-0.0.1/tests/test_identity.py +155 -0
- s_clientkit-0.0.1/tests/test_same_call_both_kinds.py +292 -0
- s_clientkit-0.0.1/uv.lock +847 -0
|
@@ -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
|