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/identity.py ADDED
@@ -0,0 +1,249 @@
1
+ """ЕДИНОЕ ИМЯ СЕРВИСА и его входы — данные, а не строка в каждом коде.
2
+
3
+ ЗАЧЕМ ЭТОТ МОДУЛЬ ПОЯВИЛСЯ. Замер 2026-08-08: навыки пишут сессии под именами
4
+ ``grok``, ``chatgpt``, ``deepseek``, ``gigachat``, ``google-flow``, ``qwen``,
5
+ ``b24`` — и СЕМЬ ИЗ ДЕВЯТИ в реестре сервисов отсутствуют вовсе, а совпадающие
6
+ названы иначе (реестр знает ``chatgpt-chat``, навык пишет ``chatgpt``; реестр
7
+ ``flow`` — навык ``google-flow``). Реестр и хранилище сессий живут в параллельных
8
+ вселенных, потому что сверять их нечем: имя — просто строка, которую каждый
9
+ пишет как помнит. Достаточно опечатки, чтобы у одного аккаунта стало две сессии.
10
+
11
+ ПОЧЕМУ ЗДЕСЬ, А НЕ РЯДОМ С ТАБЛИЦАМИ. Имя сервиса нужно ВСЕМ — в том числе тем,
12
+ кто базы не держит: навыку, чтобы назвать свою сессию; записи трафика, чтобы
13
+ положить снимок; CLI, чтобы показать список. Положив это знание к таблицам, мы
14
+ заставили бы каждого потребителя тянуть ORM и вендора БД ради одной строки.
15
+ Здесь — только значения (кит зависит от одного основания), а хранилище получает
16
+ их снаружи и сеет у себя.
17
+
18
+ СИНОНИМЫ — СПОСОБ ПЕРЕВЕСТИ, НЕ ЛОМАЯ. `b24` и `google-flow` не переименовать
19
+ одним днём: под ними уже лежат сессии и на них ссылается чужой код. Поэтому у
20
+ сервиса есть канонический ``code`` и список ``aliases``; резолвер сводит их к
21
+ одному сервису, и две сессии перестают быть двумя — без правки навыков в тот же
22
+ день.
23
+
24
+ ИНСТАНС — НЕ ОТДЕЛЬНЫЙ СЕРВИС. У Битрикс24 порталов много (``acme.bitrix24.ru``,
25
+ ``beta.bitrix24.kz``), и куки у них разные — но продукт ОДИН. Заводить
26
+ ``bitrix24-kz`` отдельным сервисом значит снова размножить имена, то есть
27
+ воспроизвести ровно ту болезнь, которую лечим. Поэтому ``instancing=MULTI`` и
28
+ адрес входа собирается из шаблона по хосту портала.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from enum import StrEnum
34
+
35
+ __all__ = [
36
+ "CATALOG",
37
+ "resolve_service",
38
+ "suggest_service",
39
+ "Instancing",
40
+ "ServiceIdentity",
41
+ "UnknownService",
42
+ "canonical",
43
+ "resolve",
44
+ "suggest",
45
+ ]
46
+
47
+
48
+ class UnknownService(LookupError): # noqa: N818 # доменное имя контракта (не *Error)
49
+ """Такого сервиса нет ни по коду, ни по синониму."""
50
+
51
+
52
+ class Instancing(StrEnum):
53
+ """Сколько входов у сервиса.
54
+
55
+ ``SINGLE`` — один адрес на всех (``grok.com``). ``MULTI`` — у каждого клиента
56
+ свой хост (портал Битрикса, поддомен арендатора), и адрес собирается из
57
+ шаблона.
58
+ """
59
+
60
+ SINGLE = "single"
61
+ MULTI = "multi"
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class ServiceIdentity:
66
+ """Кто такой сервис: канон-имя, синонимы и куда вести браузер за входом."""
67
+
68
+ code: str
69
+ display: str = ""
70
+ aliases: tuple[str, ...] = ()
71
+ #: Полный URL входа (SINGLE) либо шаблон с ``{host}`` (MULTI).
72
+ auth_url: str = ""
73
+ instancing: Instancing = Instancing.SINGLE
74
+ #: Хосты, по которым сервис узнаётся в capture и в ссылках.
75
+ hosts: tuple[str, ...] = ()
76
+ note: str = ""
77
+
78
+ @property
79
+ def names(self) -> tuple[str, ...]:
80
+ """Все имена, по которым сервис узнаётся (канон первым)."""
81
+ seen: list[str] = []
82
+ for name in (self.code, *self.aliases):
83
+ if name and name not in seen:
84
+ seen.append(name)
85
+ return tuple(seen)
86
+
87
+ def knows(self, name: str) -> bool:
88
+ """Отзывается ли сервис на это имя (регистр не важен)."""
89
+ wanted = (name or "").strip().casefold()
90
+ return any(alias.casefold() == wanted for alias in self.names)
91
+
92
+ def login_url(self, host: str = "") -> str:
93
+ """Адрес входа: для MULTI — подставив хост портала.
94
+
95
+ :raises ValueError: сервис многоинстансный, а хост не назван — вести
96
+ браузер «куда-нибудь» хуже, чем честно сказать, чего не хватает.
97
+ """
98
+ if self.instancing is Instancing.SINGLE:
99
+ return self.auth_url
100
+ if not host:
101
+ raise ValueError(
102
+ f"у сервиса `{self.code}` свой адрес у каждого клиента "
103
+ f"(например {self.hosts[0] if self.hosts else 'portal.example'}) — "
104
+ "назови хост инстанса"
105
+ )
106
+ return self.auth_url.format(host=host.strip().strip("/"))
107
+
108
+
109
+ #: КАТАЛОГ известных сервисов. Коды — те, под которыми уже лежат сессии: менять
110
+ #: их значило бы устроить миграцию данных ради красоты имени. Расхождения с
111
+ #: прежними названиями закрыты синонимами.
112
+ CATALOG: tuple[ServiceIdentity, ...] = (
113
+ ServiceIdentity(
114
+ code="grok",
115
+ display="Grok (xAI)",
116
+ auth_url="https://grok.com/",
117
+ hosts=("grok.com",),
118
+ ),
119
+ ServiceIdentity(
120
+ code="chatgpt",
121
+ display="ChatGPT",
122
+ aliases=("chatgpt-chat",),
123
+ auth_url="https://chatgpt.com/",
124
+ hosts=("chatgpt.com", "chat.openai.com"),
125
+ note="`chatgpt-chat` — имя из прежнего каталога сервисов",
126
+ ),
127
+ ServiceIdentity(
128
+ code="gemini",
129
+ display="Gemini",
130
+ aliases=("gemini-chat",),
131
+ auth_url="https://gemini.google.com/app",
132
+ hosts=("gemini.google.com",),
133
+ ),
134
+ ServiceIdentity(
135
+ code="qwen",
136
+ display="Qwen",
137
+ auth_url="https://chat.qwen.ai/",
138
+ hosts=("chat.qwen.ai",),
139
+ ),
140
+ ServiceIdentity(
141
+ code="deepseek",
142
+ display="DeepSeek",
143
+ auth_url="https://chat.deepseek.com/",
144
+ hosts=("chat.deepseek.com",),
145
+ ),
146
+ ServiceIdentity(
147
+ code="gigachat",
148
+ display="GigaChat",
149
+ auth_url="https://giga.chat/",
150
+ hosts=("giga.chat",),
151
+ ),
152
+ ServiceIdentity(
153
+ code="flow",
154
+ display="Google Flow",
155
+ aliases=("google-flow",),
156
+ auth_url="https://labs.google/fx/tools/flow",
157
+ hosts=("labs.google",),
158
+ note="`google-flow` — имя пакета навыка, под ним уже лежат сессии",
159
+ ),
160
+ ServiceIdentity(
161
+ code="notebooklm",
162
+ display="NotebookLM",
163
+ auth_url="https://notebooklm.google.com/",
164
+ hosts=("notebooklm.google.com",),
165
+ ),
166
+ ServiceIdentity(
167
+ code="bitrix24",
168
+ display="Битрикс24",
169
+ aliases=("b24",),
170
+ auth_url="https://{host}/auth/",
171
+ instancing=Instancing.MULTI,
172
+ hosts=("bitrix24.ru", "bitrix24.kz", "bitrix24.by", "bitrix24.com"),
173
+ note=(
174
+ "портал у каждого клиента свой, национальные домены — ИНСТАНСЫ одного "
175
+ "сервиса, а не разные сервисы"
176
+ ),
177
+ ),
178
+ ServiceIdentity(
179
+ code="telegram",
180
+ display="Telegram",
181
+ aliases=("tg",),
182
+ auth_url="https://web.telegram.org/",
183
+ hosts=("web.telegram.org", "telegram.org"),
184
+ ),
185
+ )
186
+
187
+ _BY_NAME: dict[str, ServiceIdentity] = {
188
+ alias.casefold(): svc for svc in CATALOG for alias in svc.names
189
+ }
190
+
191
+
192
+ def resolve(name: str, catalog: tuple[ServiceIdentity, ...] = CATALOG) -> ServiceIdentity:
193
+ """Имя (канон или синоним) → сервис.
194
+
195
+ :raises UnknownService: имени нет — с подсказкой, что похоже. Молча заводить
196
+ новый сервис нельзя: именно так рядом с ``grok`` появляется ``grok-chat``.
197
+ """
198
+ wanted = (name or "").strip()
199
+ if not wanted:
200
+ raise UnknownService("имя сервиса пустое — нечего резолвить")
201
+ index = _BY_NAME if catalog is CATALOG else {
202
+ alias.casefold(): svc for svc in catalog for alias in svc.names
203
+ }
204
+ found = index.get(wanted.casefold())
205
+ if found is not None:
206
+ return found
207
+ hint = suggest(wanted, catalog)
208
+ tail = f" Возможно, имелось в виду: {', '.join(hint)}." if hint else ""
209
+ raise UnknownService(f"сервис `{wanted}` неизвестен.{tail}")
210
+
211
+
212
+ def canonical(name: str, catalog: tuple[ServiceIdentity, ...] = CATALOG) -> str:
213
+ """Каноническое имя сервиса по любому из его имён."""
214
+ return resolve(name, catalog).code
215
+
216
+
217
+ def suggest(
218
+ name: str, catalog: tuple[ServiceIdentity, ...] = CATALOG, *, limit: int = 3
219
+ ) -> tuple[str, ...]:
220
+ """Похожие известные имена — чтобы отказ говорил, что делать.
221
+
222
+ Сравнение простое и объяснимое: общее начало либо вхождение одного в другое.
223
+ Умная метрика тут не нужна — ошибаются в дефисе и суффиксе (``grok-chat``,
224
+ ``google-flow``), а не в буквах.
225
+ """
226
+ wanted = (name or "").strip().casefold()
227
+ if not wanted:
228
+ return ()
229
+ scored: list[tuple[int, str]] = []
230
+ for svc in catalog:
231
+ for alias in svc.names:
232
+ low = alias.casefold()
233
+ if low == wanted:
234
+ continue
235
+ if wanted in low or low in wanted:
236
+ scored.append((0, svc.code))
237
+ elif low[:3] == wanted[:3]:
238
+ scored.append((1, svc.code))
239
+ seen: list[str] = []
240
+ for _, code in sorted(scored):
241
+ if code not in seen:
242
+ seen.append(code)
243
+ return tuple(seen[:limit])
244
+
245
+
246
+ #: Псевдонимы для корня пакета: в общем пространстве имён `resolve`/`suggest`
247
+ #: ничего не говорят о предмете, а `resolve_service` — говорит.
248
+ resolve_service = resolve
249
+ suggest_service = suggest
clientkit/ports.py ADDED
@@ -0,0 +1,70 @@
1
+ """Порты сборщика: чем ходить в сеть и откуда брать секреты.
2
+
3
+ Сборщик СВОЕГО HTTP-клиента не имеет — и это не аскеза. Стоит зашить httpx, и
4
+ кит перестанет собираться там, где ходят curl_cffi (антибот), где ходит netkit с
5
+ своей политикой повторов, где вообще нет сети (тест). Поэтому «чем ходить»
6
+ приходит аргументом, а умолчание резолвится ЛЕНИВО и только если аргумент не дали.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+ __all__ = ["RequestPort", "ResponseLike", "SecretLookup", "resolve_transport"]
14
+
15
+
16
+ @runtime_checkable
17
+ class ResponseLike(Protocol):
18
+ """Утиный ответ: столько, сколько нужно и для разбора, и для диагноза."""
19
+
20
+ status_code: int
21
+ text: str
22
+
23
+
24
+ @runtime_checkable
25
+ class RequestPort(Protocol):
26
+ """Одна операция: сходить и вернуть ответ. Sync либо async — сборщик дождётся."""
27
+
28
+ def request(
29
+ self,
30
+ method: str,
31
+ url: str,
32
+ *,
33
+ headers: Mapping[str, str] | None = None,
34
+ params: Mapping[str, Any] | None = None,
35
+ json: Any | None = None,
36
+ ) -> Any: ...
37
+
38
+
39
+ @runtime_checkable
40
+ class SecretLookup(Protocol):
41
+ """``(name) -> секрет | None``. Реализуют env, `SecretStorePort`, keyring, vault."""
42
+
43
+ def __call__(self, name: str) -> str | None: ...
44
+
45
+
46
+ def resolve_transport() -> RequestPort:
47
+ """Умолчание «чем ходить»: netkit → httpx → внятный отказ.
48
+
49
+ Порядок не случаен: netkit — наш сетевой слой (повторы, лимиты, egress из
50
+ контекста), httpx — честный запасной вариант для того, кто netkit не ставил.
51
+ Отказ, если нет ни одного, звучит инструкцией, а не `ModuleNotFoundError`:
52
+ человек должен прочитать, ЧТО поставить или ЧТО передать.
53
+ """
54
+ try: # netkit — предпочтительный: он знает про контекст и повторы
55
+ from netkit import HttpxTransport # type: ignore[attr-defined]
56
+
57
+ return HttpxTransport() # type: ignore[return-value]
58
+ except Exception:
59
+ pass
60
+ try:
61
+ import httpx
62
+
63
+ return httpx.AsyncClient() # type: ignore[return-value]
64
+ except ImportError as exc: # pragma: no cover — окружение без обоих колёс
65
+ raise RuntimeError(
66
+ "нечем ходить в сеть: не установлены ни s-netkit, ни httpx. "
67
+ "Поставь одно из них — либо передай свой транспорт "
68
+ "`build_client(decl, transport=...)` (годится любой объект с "
69
+ "методом `request(method, url, headers=, params=, json=)`)."
70
+ ) from exc
clientkit/white.py ADDED
@@ -0,0 +1,84 @@
1
+ """БЕЛЫЙ путь: прямые запросы. Ни сессий, ни чеканки, ни антибота.
2
+
3
+ Здесь важно не то, что написано, а то, чего НЕ написано и не импортируется. У
4
+ белого API есть документированный адрес и документированный способ
5
+ представиться — значит, весь слой удержания доступа ему не нужен НИКОГДА, и
6
+ платить за него импортом он не должен.
7
+
8
+ Модуль знает ровно три схемы заголовка (api-key / bearer / oauth) и ни одной
9
+ строки про cookies. ``oauth`` — единственное место, где подтягивается
10
+ `authkit_client.oauth`: пара токенов с ПРОАКТИВНЫМ обновлением по TTL (живой
11
+ токен = ноль лишних запросов). Импорт ленивый и точечный — модуль `oauth` не
12
+ тянет за собой ни хранилище сессий, ни SQLite.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from clientkit._base import ServiceClientBase
19
+ from clientkit.declaration import AuthScheme, ServiceDecl
20
+ from clientkit.errors import ClientBuildError
21
+ from clientkit.ports import RequestPort, SecretLookup
22
+
23
+ __all__ = ["WhiteApiClient"]
24
+
25
+
26
+ class WhiteApiClient(ServiceClientBase):
27
+ """Клиент белого API: заголовок авторизации + прямой запрос.
28
+
29
+ ``secrets`` — ``(name) -> секрет | None``: env, `SecretStorePort`, vault —
30
+ что угодно. Сам секрет в декларации не лежит и лежать не будет.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ decl: ServiceDecl,
36
+ transport: RequestPort,
37
+ *,
38
+ secrets: SecretLookup | None = None,
39
+ oauth_refresher: Any | None = None,
40
+ tokens: Any | None = None,
41
+ ) -> None:
42
+ super().__init__(decl, transport)
43
+ self._secrets = secrets
44
+ self._oauth = None
45
+ if decl.auth.scheme is AuthScheme.OAUTH:
46
+ self._oauth = self._build_oauth(tokens, oauth_refresher)
47
+
48
+ def _build_oauth(self, tokens: Any, refresher: Any):
49
+ """Собрать `OAuthSession` — ленивый импорт слоя доступа, точечный."""
50
+ if tokens is None or refresher is None:
51
+ raise ClientBuildError(
52
+ f"сервис {self.decl.name!r} объявлен с auth.scheme = 'oauth', но "
53
+ "пары токенов и/или колбэка обновления не дали. Передай "
54
+ "`build_client(decl, tokens=..., oauth_refresher=...)`: обмен "
55
+ "делает потребитель, кит отвечает за ВРЕМЯ, а не за протокол."
56
+ )
57
+ from authkit_client.oauth import OAuthSession
58
+
59
+ return OAuthSession(tokens, refresher=refresher, leeway=self.decl.auth.leeway)
60
+
61
+ async def _headers(self) -> dict[str, str]:
62
+ """Заголовок авторизации по объявленной схеме."""
63
+ auth = self.decl.auth
64
+ if auth.scheme is AuthScheme.NONE:
65
+ return {}
66
+ if auth.scheme is AuthScheme.OAUTH:
67
+ # ПРОАКТИВНО: живой токен — ноль сетевых вызовов (сравнение чисел),
68
+ # истекающий обновляется ДО основного запроса, а не после 401.
69
+ await self._oauth.maybe_refresh()
70
+ secret = self._oauth.tokens.access_token
71
+ else:
72
+ secret = self._lookup(auth.secret_name)
73
+ return {auth.header: auth.template.format(secret=secret)}
74
+
75
+ def _lookup(self, name: str) -> str:
76
+ """Достать секрет по ИМЕНИ; отсутствие — внятный отказ при вызове."""
77
+ value = self._secrets(name) if self._secrets is not None else None
78
+ if not value:
79
+ raise ClientBuildError(
80
+ f"сервис {self.decl.name!r}: секрет {name!r} не найден. Декларация "
81
+ "хранит ИМЯ секрета, а значение приносит `secrets=` — env, "
82
+ "SecretStore, vault: что угодно с сигнатурой (name) -> str | None."
83
+ )
84
+ return value
@@ -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,15 @@
1
+ clientkit/__init__.py,sha256=iOM6nC4a6H226paTOStjLVoxNvie_FwXJqXfU6xztYY,5989
2
+ clientkit/_base.py,sha256=Q52yHZ8s4x5fWKzD3spitZzbJA7YX74jm1loVHgzCas,8515
3
+ clientkit/_candidates.py,sha256=1B6h9Z-qL_L-ROIRNRyMGUWaJkWmhM-U9Lob-AMy7lA,7611
4
+ clientkit/_limit.py,sha256=YG_tlJEoWGbiy9R4k1ZSVHncUSW9cEdR4gw129H3HcU,2373
5
+ clientkit/builder.py,sha256=b1Nt4AFWBFLUP833Bi_RQ7BCBwF9FoAhBI-G-W7VBSg,3162
6
+ clientkit/declaration.py,sha256=Si9D9k1v8YMDXZz4QVbTJXF5WGCasbRbJR6eelsTWHc,16556
7
+ clientkit/errors.py,sha256=OfK9UsUoHvltGM66ZtJY3G0Fa4dkkz2xXcX7ZP6rb-g,2118
8
+ clientkit/hidden.py,sha256=ph6hfo-niKLI0xrBatnOZfs_w_Rg8EZvP7U5M86_VG8,10646
9
+ clientkit/identity.py,sha256=ZHLJDiz461FBDU1szAtx9nWKpoqmrwwxGv-FR6CDvCo,11568
10
+ clientkit/ports.py,sha256=smQG7aP7lc8SWn_VOQ7zDvZetMR8-glAkOYiWn29gSQ,3268
11
+ clientkit/white.py,sha256=hCgupqXERArvGOOu4qjVV1vyCtqcppaoxNzFKYDt8Rg,4565
12
+ s_clientkit-0.0.1.dist-info/METADATA,sha256=59q1TfajZeivOVHq3maQy1-EmDmnHixjnODYZASm-3k,3977
13
+ s_clientkit-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ s_clientkit-0.0.1.dist-info/licenses/LICENSE,sha256=j9GKJmUNdQuKRUbKhbpv0uyMaL99xsxE6L2TDtXuaZ4,1063
15
+ s_clientkit-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.