Metadata-Version: 2.5
Name: s-netkit
Version: 0.0.19
Summary: СЕТЕВОЙ слой китов: транспорт (async+sync httpx), WS/RPC/GraphQL, лимиты и повторы, лестница деградации, пагинация, аплоад, form-кодек. Декларативное объявление транспорта с ЕДИНЫМ поведением лимитов и ретраев.
Author: Dmitry
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: httpx[brotli,http2,zstd]>=0.28
Requires-Dist: s-corekit>=0.0.11
Requires-Dist: stamina>=24.3
Provides-Extra: curl
Requires-Dist: curl-cffi>=0.7; extra == 'curl'
Provides-Extra: dev
Requires-Dist: curl-cffi>=0.7; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Provides-Extra: ws
Requires-Dist: websockets>=12.0; extra == 'ws'
Description-Content-Type: text/markdown

# netkit (`s-netkit`)

**Сетевой слой китов.** Всё, чем интеграция разговаривает с чужим сервером:
транспорт, темп, живучесть, деградация. Ставится и работает без оркестратора —
зависимости идут строго вниз: `netkit → corekit`.

```
     clikit        adapterkit        <- ветки-оболочки
          \           /
           librarykit                <- ОРКЕСТРАТОР (сессии, браузер, склад)
               |
            netkit                   <- СЕТЬ (этот пакет)
               |
            corekit                  <- ОСНОВАНИЕ (значения и правила)
```

## Что внутри

| модуль | ответственность |
|---|---|
| `netkit.transport` | исполнители запроса поверх httpx (async + sync близнец), choke-point `HttpClient`/`SyncHttpClient`, `RestHttpClient` к своему backend, permissive-рецепты |
| `netkit.outfit` | **НАРЯД**: единственная дверь, где персона и род запроса превращаются в то, что нужно проводу — заголовки, цель подражания, версия протокола |
| `netkit.outfit_guard` | страж этой двери: исполнитель, собравший заголовки сам, ловится проверкой, а не ревью |
| `netkit.fingerprint` | из чего наряд собран: версия браузера, платформа (персона → машина), `User-Agent`, client hints, `Sec-Fetch` по режиму запроса, кодировки ответа, порядок заголовков Chrome |
| `netkit.stream` | persistent-каналы: `StreamTransport`, WS-реализация (extra `[ws]`) |
| `netkit.rpc` | codec-слой RPC: `JsonCodec` / `PrefixedJsonCodec` / `RpcClient` |
| `netkit.graphql` | GraphQL-клиент поверх транспорта кита |
| `netkit.limit` | `RateLimiter` + token-bucket: ПРОАКТИВНЫЙ темп, а не «поймал 429 — поспал» |
| `netkit.retry` | политики повторов: header-driven (`RetryPolicy`) и фиксированная (`SimpleRetryPolicy`) |
| `netkit.ladder` | лестница деградации: чем выполнять запросы и чем добывать состояние, память ступени, события спуска |
| `netkit.pagination` / `netkit.upload` / `netkit.forms` | листание ресурса, resumable-догрузка, form-urlencoded кодек |
| `netkit.errmap` | ответ сервера → доменная ошибка (декларативная таблица) |
| `netkit.declare` | **объявление транспорта** (`http`/`ws`/`rpc`/`graphql`) с ЕДИНЫМ поведением лимитов и повторов |
| `netkit.providers` | СЛОТЫ верхнего слоя: браузерный минт, склад состояния, диагностика, egress |

## Объявить транспорт декларативно

Один и тот же лимит и одна и та же политика повторов — на любом виде транспорта.
Интеграция объявляет, а не пишет обвязку:

```python
from netkit.declare import KIND_HTTP, KIND_WS, TransportSpec, declare
from netkit.limit import LimitPolicy, LimitScope
from netkit.retry import SimpleRetryPolicy

limit = LimitPolicy(rate=5, per=1.0)          # 5 обменов в секунду
retry = SimpleRetryPolicy(backoff=(0.0, 0.0))  # 2 повтора без пауз
scope = LimitScope(service="acme")

api = declare(TransportSpec(kind=KIND_HTTP, url="https://api.acme.io",
                            limit=limit, scope=scope, retry=retry))
live = declare(TransportSpec(kind=KIND_WS, channel=my_ws_channel,
                            limit=limit, scope=scope, retry=retry))

await api.call("GET", "/v1/items")   # ждёт квоту, повторяет 429/5xx и сбои
await live.call('{"op":"ping"}')     # ТОТ ЖЕ темп и ТЕ ЖЕ повторы — без своего кода
```

Свой вид (`grpc`, `sse`, …) добавляется `register_kind(kind, builder)` и сразу
получает то же поведение.

## Наряд: «чем мы выглядим» — в одном месте

Персона (`corekit.persona.ClientPersona`) описывает ОДНОГО посетителя: браузер,
платформу, язык, часы, рукопожатие, выход. Превращается она в то, что нужно
проводу, ровно один раз — дверью `outfit_for`, а исполнители получают ГОТОВОЕ:

```python
from netkit.outfit import outfit_for

outfit = outfit_for(persona=persona, mode="page-request")
outfit.headers       # заголовки целиком, уже в браузерном ПОРЯДКЕ
outfit.http2         # версия протокола (персона о ней знает)
outfit.tls_target    # цель подражания — нужна только curl_cffi (считается лениво)
```

На практике этого не пишут вовсе: наряд собирают сами транспорты, choke-point'ы
и permissive-рецепты — достаточно передать им `persona=`:

```python
transport = HttpxTransport(persona=persona)                    # прямая ступень
transport = CurlCffiTransport(persona=persona)                 # ступень подражания
client = build_permissive_http_client(cookies=..., persona=persona)
```

**Почему одна дверь.** Пока сборка была скопирована по местам, персона доезжала
до одних исполнителей и молча терялась у других — а видно это становилось не
ошибкой, а отзывом сессии через несколько часов. Что дверь действительно одна,
держит `netkit.outfit_guard`: он метит наряд на самой двери и ловит исполнителя,
чей ушедший набор метки не несёт.

### Кто главнее: вызывающий или умолчание кита

**Вызывающий — поимённо.** Кит говорит за него только там, где тот промолчал.
Правило одно на весь кит (`fingerprint.merged_headers`) и действует на всех
путях: прямая ступень, ступень подражания, per-call мердж choke-point'а и
permissive-рецепты.

```python
client = build_permissive_http_client(
    follow_redirects=True,
    headers={"accept-language": "de-DE"},   # уедет на провод именно de-DE
)
```

**Назвавший свой `User-Agent` забирает ВСЁ заявление о себе** — и UA, и client
hints. Смешать чужой `User-Agent` с нашими `sec-ch-ua` значит собрать клиента,
который в одном заголовке одна программа, а в другом Chrome: это ловится одним
сравнением и хуже честного не-браузера.

**Почему об этом отдельный раздел.** У рецепта ДВА слоя набора — заголовки
httpx-клиента и заголовки choke-point'а, — и до `0.0.18` они соревновались
молча: названный язык принимался фабрикой и терялся на проводе, потому что
per-request у httpx заменяет клиентский одноимённый. Хуже всего было именно это
состояние: заголовок принят без возражений и потерян по дороге. Теперь оба слоя
сводятся в один (`transport.named_by_the_caller`), а `base_headers` как слой,
названный ближе к запросу, старше `headers`.

### Платформа: персона → машина → названная константа

Персоны нет — платформа не берётся из константы, а **выводится по машине** (как
и версия браузера). До этого она была зашита словом «Windows», и на Linux-ноде
клиент заявлял Windows, показывая настоящую машину всем остальным.

```python
from netkit.fingerprint import declared_platform

declared_platform().describe()          # 'Linux — x86/64 (откуда: машина)'
declared_platform(persona).describe()   # 'macOS 14.5 arm/64 (откуда: персона)'
```

Персона, называющая macOS с Linux-ноды, **не врёт**: заявленная платформа — это
устройство аккаунта, а не нода, на которой крутится процесс, и узнавание
устройства держится именно за неё. Лечится другое — платформа, которую никто не
выбирал; поэтому у неё есть `origin`, и его печатают наряд (`Outfit.platform`) и
проба (`python -m netkit.fingerprint_probe`). Написание платформы, её кусок в
`User-Agent` и версия лежат ОДНОЙ строкой таблицы `PLATFORMS` — новая платформа
добавляется строкой, а не ветвлением.

### Кодировки: заявляем ровно то, что разожмём

Chrome шлёт `Accept-Encoding: gzip, deflate, br, zstd` — и кит шлёт то же самое.
Списать эту строку дословно было нельзя: сервер верит и присылает `br`, а клиент
без декодера пропускает сжатое тело наверх **молча**, и падает потом разбор JSON
где-то в навыке. Поэтому строка собирается из двух фактов — что заявляет браузер
и что умеет распаковать ТОТ, КТО понесёт запрос:

```python
from netkit.fingerprint import DECODER_CURL, accept_encoding

accept_encoding()               # 'gzip, deflate, br, zstd' — распакует httpx
accept_encoding(DECODER_CURL)   # то же, но умения спрошены у сборки curl
```

Декодеры (`brotli`, `zstandard`) приезжают ОСНОВНОЙ зависимостью — через extra
самого httpx, чтобы вилки версий объявлял он, а не мы. В окружении, где их
всё-таки нет (`--no-deps`, замороженный requirements), набор **сужается сам** —
заявляем меньше, а не врём больше. У ступени подражания распаковщик свой
(кодеки, вкомпилированные в curl-impersonate), поэтому её строка не зависит от
питоновских вендоров вовсе — и наоборот.

## Шкала ступеней: по стоимости, а не по «продвинутости»

1. **прямой запрос** (`httpx`);
2. **прямой запрос с имитацией браузера** (`curl_cffi`: подделка рукопожатия) —
   цель работы в том, чтобы оставаться на этих двух: ни окна, ни процесса
   браузера здесь нет;
3. **настоящий браузер БЕЗ окна** (headless): основной движок `nodriver`,
   `camoufox` — запасной НА ТОЙ ЖЕ ступени, когда nodriver палится;
4. **настоящий браузер С ВИДИМЫМ ОКНОМ** — только вход человека; лестница сюда
   не спускается вовсе.

Третья и четвёртая различаются ВИДИМОСТЬЮ ОКНА, а не стелсом. Шкала объявлена
данными (`netkit.ladder.LADDER_RUNGS`), поэтому новая возможность обязана
сказать, что при ней происходит и сколько это стоит.

## Слоты: как netkit зовёт то, что живёт выше

Последняя ступень лестницы поднимает браузер, а браузер — чужой кит, который сам
зависит от сети. Прямой импорт дал бы цикл, поэтому направление разворачивается:
netkit **объявляет слот**, верхний слой **заполняет** его на своём импорте.

```python
from netkit.providers import SLOT_BROWSER_MINT, register_provider
register_provider(SLOT_BROWSER_MINT, my_mint_session)
```

Слоты со своим дефолтом (`json_store`, `file_lock`, `state_root`, `path_slug`,
`transport_factory`, `diagnose`, `egress_proxy`) никогда не роняют вызов — netkit
умеет их сам, верхний слой лишь уточняет. Слоты без дефолта (браузерные) при
обращении поднимают `ProviderMissing` с инструкцией: молчаливой деградации
«ступень тихо ничего не сделала» здесь нет.

Если в окружении стоит `librarykit`, его импорт заполняет все слоты сам —
отдельная регистрация не нужна.

## Совместимость

`librarykit` остаётся фасадом: `librarykit.transport`, `librarykit.ladder`,
`librarykit.limit`, `librarykit.retry`, `librarykit.pagination`,
`librarykit.upload`, `librarykit.forms`, `librarykit.rpc`, `librarykit.stream`,
`librarykit.graphql`, `librarykit.errmap` реэкспортируют ТЕ ЖЕ объекты (не
копии) — `isinstance` / `except` / `is` работают через любой из путей.

## Стоимость импорта

`import netkit` не исполняет ни одного подмодуля: ни httpx, ни stamina, ни
asyncio. Имена резолвятся по PEP 562 при первом обращении — платит тот, кому
нужно.

## Установка

```bash
pip install s-netkit          # ядро: corekit + httpx + stamina
pip install s-netkit[ws]      # + websockets для WS-транспорта
```
