Metadata-Version: 2.4
Name: s-adapterkit
Version: 0.1.11
Summary: Переиспользуемый SDK сетевых адаптеров: Transport/Auth/RetryPolicy/HttpClient, ErrorMap, пагинация, браузер/антибот-транспорты, SessionStore, BaseAdapter + контракт плагинов. Тонкий коннектор поверх librarykit.
Author: Dmitry
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: s-corekit>=0.0.2
Requires-Dist: s-librarykit>=0.7.8
Provides-Extra: antibot
Requires-Dist: curl-cffi>=0.7; extra == 'antibot'
Provides-Extra: browser
Requires-Dist: playwright>=1.40; extra == 'browser'
Provides-Extra: codegen
Requires-Dist: datamodel-code-generator>=0.25; extra == 'codegen'
Requires-Dist: jinja2>=3.1; extra == 'codegen'
Requires-Dist: pyyaml>=6; extra == 'codegen'
Provides-Extra: dev
Requires-Dist: datamodel-code-generator>=0.25; extra == 'dev'
Requires-Dist: grimp>=3; extra == 'dev'
Requires-Dist: hypothesis>=6; extra == 'dev'
Requires-Dist: import-linter>=2.0; extra == 'dev'
Requires-Dist: jinja2>=3.1; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Provides-Extra: oauth
Requires-Dist: authlib>=1.3; extra == 'oauth'
Provides-Extra: testing
Requires-Dist: pytest>=8.3; extra == 'testing'
Description-Content-Type: text/markdown

# adapterkit

**Тонкий коннектор сетевых адаптеров поверх [s-librarykit](https://pypi.org/project/s-librarykit/).**
adapterkit отвечает на один вопрос: «как подключить сетевой адаптер к приложению».
Он даёт декларативный контракт плагина, реестр с автодискавери через entry-points и
базовый фасад адаптера — а весь сетевой движок (transport/auth/retry/errmap/
pagination/sessions/antibot/browser) **реэкспортирует из librarykit**, не дублируя
его. Пишется один раз, переиспользуется любым доменным пакетом.

```
librarykit   ← КОРЕНЬ: весь сетевой движок (transport/auth/retry/errmap/
                        pagination/sessions/antibot/browser)
   ▲
adapterkit   ← ЭТОТ КИТ: контракт NetworkAdapter + registry (entry-points) +
                        BaseAdapter + orchestration_api.
                        Остальное — тонкий реэкспорт-шим из librarykit.
   ▲
домен        ← конкретные адаптеры (endpoint-таблица + мапперы на сеть)
```

Зависимости направлены **только внутрь**: adapterkit зависит **только** от
`librarykit`, но НЕ от домена и НЕ от `clikit` (онион-граф
`librarykit <- adapterkit <- clikit`: clikit — слой ВЫШЕ, adapterkit его не
импортит). Адаптеры кодируются против
стабильных `typing.Protocol` из `adapterkit.contract` (структурный контракт, а не
наследование от домена). Композиция конкретных реализаций — единственный
composition root на приложение.

## Установка

```bash
uv add s-adapterkit
```

Имя дистрибутива — `s-adapterkit`, имя для импорта — `adapterkit`. `librarykit`
подтянется автоматически как транзитивная зависимость.

Опциональные extra:

```bash
uv add "s-adapterkit[browser]"   # Playwright — browser-login
uv add "s-adapterkit[antibot]"   # curl-cffi — JA3-impersonate
uv add "s-adapterkit[oauth]"     # authlib — OAuth2-flows
```

## Быстрый старт

Описать адаптер декларативно и зарегистрировать его в реестре:

```python
from adapterkit import BaseAdapter, Endpoint, register_adapter

class TwitterAdapter(BaseAdapter):
    api_version = 1
    endpoints = {
        "search": Endpoint(name="search", method="GET", path="/2/tweets/search/recent"),
    }

register_adapter("twitter", TwitterAdapter)
```

Либо отдать адаптер на автодискавери — объявить entry-point в своём `pyproject.toml`,
и любой потребитель adapterkit подхватит его без явного импорта:

```toml
[project.entry-points."adapterkit.adapters"]
twitter = "my_package.adapter:TwitterAdapter"
```

```python
from adapterkit import discover_adapters, get_adapter_class

discover_adapters()                       # загрузить все плагины из entry-points
cls = get_adapter_class("twitter")        # получить класс по имени сервиса
```

## Карта модулей

| Модуль | Назначение | Реализация |
|--------|-----------|------------|
| `contract.py` | граничные `Protocol` (`NetworkAdapter`/`Transport`/`Auth`/`ErrorMapper`/`Paginator`/`SessionStoreProtocol`) + DTO (`Endpoint`/`RequestSpec`/`SessionRef`/`Creds`) + `ADAPTER_API_VERSION`/`MIN_SUPPORTED_API_VERSION` | контракт коннектора |
| `registry.py` | `AdapterRegistry` + автодискавери через entry-points `adapterkit.adapters`, ленивая загрузка, ручная регистрация | код коннектора |
| `base.py` | `BaseAdapter` (описание запроса `build_request` + исполнение `execute`) + ресурс-под-сервисы (`ContentResource`/`CommentsResource`/`MetricsResource`/`SearchResource`) — Stripe-стиль фасад | код коннектора |
| `throttle.py` | ядро само троттлит плагин по его метаданным: `@ratelimit`, `ServiceThrottle`, `ManagedExecutor`, шов `ExecutionContext` | код коннектора |
| `orchestration_api.py` | тонкий registry-driven API: `onboard_all` / `health_check_all` (без импортов домена) | код коннектора |
| `onboarding_contract.py` | онбординг/health-контракты (`LoginMode`/`OnboardingProtocol`/`HealthProtocol`, api_version 2) | реэкспорт `librarykit.protocols` |
| `errors.py` | единая иерархия ошибок | реэкспорт `librarykit.errors` |
| `retry.py` | header-driven `RetryPolicy` | реэкспорт `librarykit.retry` |
| `transport.py` / `client.py` | `HttpxTransport` + choke-point `HttpClient` | реэкспорт `librarykit.transport` |
| `auth.py` | `TokenAuth`/`OAuth2Auth`/`CookieSessionAuth`/`BrowserLoginAuth` | реэкспорт `librarykit.auth` |
| `errmap.py` | декларативная карта ответ → доменная ошибка | реэкспорт `librarykit.errmap` |
| `pagination.py` | `CursorPaginator` (offset/cursor/page) | реэкспорт `librarykit.pagination` |
| `sessions.py` | envelope-шифрованный `SessionStore` | реэкспорт `librarykit.sessions` |
| `antibot.py` | выбор транспорта Tier 0-4 (curl-cffi JA3 / CDP) | **ленивый** реэкспорт `librarykit.antibot` |
| `browser.py` | warm/cold-login (требует extra `browser`) | **ленивый** реэкспорт `librarykit.browser` |

Всё, что помечено «реэкспорт», — тонкий shim: единая реализация живёт в `librarykit`,
adapterkit лишь предоставляет её под привычным именем. Собственный код коннектора —
только `contract`/`registry`/`base`/`throttle`/`orchestration_api`.

### Ядро само держит лимиты плагина

Плагин объявляет лимиты ОДНОЙ строкой метаданных и не пишет кода лимитов, ретраев и
удержания сессии — очередь запросов и exponential backoff делает ядро:

```python
from adapterkit import BaseAdapter, Endpoint, ratelimit

@ratelimit(calls=2, period=1)          # ← всё, что плагин пишет про лимиты
class ExampleAdapter(BaseAdapter):
    service = "example"
    endpoints = {"get_item": Endpoint("get_item", "GET", "/items/{item_id}",
                                      required_params=("item_id",))}
```

Полная инструкция (формы декларации, оси квот `QuotaScope`, инварианты, чек-лист для
кодогенератора) — [`docs/PLUGIN_LIMITS.md`](docs/PLUGIN_LIMITS.md). Адаптер БЕЗ
объявленных лимитов работает ровно как раньше: исполнитель ядра не собирается, запросы
уходят в клиент напрямую.

### Плагины-способности: единая группа `skillery.plugins`

Кроме ПОЛНОГО адаптера сети кит несёт вторую, независимую ось подключения —
**capability**: одна способность, один-два метода, ресурсы приходят аргументом
`ctx: ExecutionContext` (плагин не читает окружение).

```python
from adapterkit import PluginRegistry, PublisherProtocol

registry = PluginRegistry()
registry.capabilities()                              # имена БЕЗ импорта чужих пакетов
registry.plugins_supporting(PublisherProtocol)       # {capability_id: КЛАСС}, без инстанцирования
```

Формы (`PluginProtocol` / `PublisherProtocol` / `TranscriberProtocol` / `IngestProtocol` /
`AskProtocol` / `DeepResearchProtocol`),
версия-гейт `PLUGIN_API_VERSION`, мост `AdapterCapability` поверх существующего адаптера
и чек-лист публикации — [`docs/PLUGIN_CONTRACT.md`](docs/PLUGIN_CONTRACT.md). Прежние
группы (`adapterkit.adapters`, `transcribe.channels`) **не переименованы и работают как
раньше** — новая ось добавлена рядом, а не вместо.

### ask-способность в три строки + отбор по виду без импортов

Чат-навык объявляет `ask` наследованием (`AskCapability`) или фабрикой
(`ask_capability(fn, service=…)`) — `capability_id` вида `<service>_ask` и версия
контракта приезжают сами. По суффиксу имени реестр отбирает ask-способности **из
метаданных, за ноль `ep.load()`**: список «кто умеет отвечать» не стоит импорта
шести чат-пакетов с браузерными зависимостями.

```python
registry.capabilities_supporting("ask")   # ['chatgpt_ask', 'gemini_ask', …] — 0 загрузок
registry.plugins_supporting("ask")        # классы; грузятся только подошедшие по имени
```

Conformance-набор `adapterkit.testing.AskConformanceTests` ловит то, чего не ловит
форма: `ask` не реализован (унаследована заглушка), `ask` синхронный, лишний
обязательный аргумент, имя вне соглашения —
[`docs/ASK_CAPABILITY.md`](docs/ASK_CAPABILITY.md).

### Эндпоинты скрытого API — данные, а не константы в коде

Операция несёт СПИСОК адресов-кандидатов (`rpcid`/URL) с приоритетом, формой
пагинации, живостью и датой последней успешной проверки; вызов идёт по ИМЕНИ
операции. Смерть `rpcid` лечится правкой декларации.

```python
registry = EndpointRegistry.from_data(json.loads(path.read_text()))
candidate = registry.resolve("conversation_history")   # мёртвые адреса пропущены
probe = await probe_endpoint(registry.get("conversation_history"), call, session=state)
```

`probe_endpoint` судит **только на подтверждённо живой сессии** (иначе один
разлогин пометит мёртвыми все адреса разом), мёртвый адрес даёт `ENDPOINT_DEAD`,
а не «протухла сессия» — [`docs/ENDPOINT_ADVISOR.md`](docs/ENDPOINT_ADVISOR.md).

### Способность локально ИЛИ за сетью — решает реестр

Потребитель не знает, где живёт плагин: реестр по ОДНОМУ `capability_id` отдаёт
либо локальный класс, либо remote-прокси с идентичным интерфейсом.

```python
registry.bind_remote("vk_transcriber", transport, protocol=TranscriberProtocol)
# ↑ единственная строка сборки хоста; код вызова НЕ меняется:
await registry.get("vk_transcriber")().transcribe(ctx, "audio.mp3", lang="ru")
```

Дефолт — локальный (до `bind_remote` поведение реестра не отличается от прежнего).
Лимиты применяются на стороне СЕРВИСА (иначе у каждого клиента своя квота), ключ
идемпотентности едет в конверте вызова. Сериализация контекста, границы безопасности
и что осталось до реального HTTP — [`docs/REMOTE_CAPABILITY.md`](docs/REMOTE_CAPABILITY.md).

### Ядро не тянет EXTENSIONS (ленивые слои)

Антибот и браузер — тяжёлые опциональные слои (extras `antibot`/`browser`), поэтому
`import adapterkit` их **не загружает**: реэкспорт идёт через module-level
`__getattr__` (PEP 562) и срабатывает на первом обращении к имени. Практически:

```python
import adapterkit                      # librarykit.antibot / .browser НЕ загружены
adapterkit.HttpClient                  # ядро — как раньше
adapterkit.CurlCffiTransport           # ← вот здесь подгрузится librarykit.antibot
from adapterkit.browser import warm_or_autologin   # ← и здесь librarykit.browser
```

Публичный API не изменился: `from adapterkit import CurlCffiTransport`,
`from adapterkit.antibot import ...`, `from adapterkit.browser import ...`,
`from adapterkit import *` и `dir(adapterkit)` работают идентично. Инвариант
закреплён fitness-тестами в `tests/test_lazy_extensions.py` (замер `sys.modules`
в отдельном интерпретаторе).

## Архитектурные гейты

Канон держится инструментом, а не договорённостью. Онион-граф, ленивая граница
ЯДРО/РАСШИРЕНИЯ и отсутствие скрытых зависимостей проверяются одной командой:

```bash
uv run --extra dev lint-imports
```

Базовый слой — зрелый [import-linter](https://import-linter.readthedocs.io/);
два измерения, которых у него нет, добавлены его же механизмом плагинов
(`adapterkit.gates.contracts`):

- **`eager_forbidden`** — ядро не поднимает `librarykit.browser` /
  `librarykit.antibot` **в момент импорта**. Ленивый доступ (импорт внутри
  функции, PEP 562 `__getattr__`, `if TYPE_CHECKING:`) нарушением не считается —
  встроенный `forbidden` этого не различает;
- **`declared_dependencies`** — всё импортируемое объявлено в `pyproject.toml`
  (`dependencies` либо любой extra).

Рядом — conformance-наборы, которые чужой репозиторий подключает тремя строками:
`AdapterContractTests` (контракт `NetworkAdapter`) и `PluginConformanceTests`
(контракт способности: `capability_id` = имя entry-point, `ctx` первым
аргументом, лимиты метаданными, никакой инфраструктуры внутри плагина).

Полное описание, инструкция «как подключить у себя» и таблица «сработало —
что делать» — [`docs/ARCHITECTURE_GATES.md`](docs/ARCHITECTURE_GATES.md).

## Новый навык за одну команду

```bash
python -m adapterkit new-skill myskill --capability publish
```

Порождает пакет, который уже соблюдает канон: **три слоя луковицы** на каждую
способность (`mechanics/` атомы → `use_cases/` сценарий → `capabilities/`
граница плагина), `ctx`-аргумент, лимиты через `@ratelimit`, entry-point в группе
`skillery.plugins` под тем же именем, что `capability_id`, шесть гейтов в
`pyproject.toml` и готовый conformance-тест. Сгенерированный навык проходит
`pytest`, `ruff` и `lint-imports` **без единой правки** — это закреплено тестами
скаффолда.

Способности: `publish`, `transcribe`, `ingest`, `ask`, `deep_research`. Зачем
слои и какие гейты их держат (с живым примером VK-транскрибации) —
[`docs/ONION_LAYERS.md`](docs/ONION_LAYERS.md).

## Разработка

```bash
uv sync --extra dev
uv run --extra dev pytest -q
uv run --extra dev ruff check adapterkit
uv run --extra dev lint-imports
```

`librarykit` тянется из публичной группы
семейство китов. Для локальной
правки кита временно укажите path-источник в `[tool.uv.sources]` (см. комментарий
в `pyproject.toml`) и выполните `uv lock --upgrade`.

## Лицензия

MIT © 2026 Dmitry.
