monokeys 0.2.0__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,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ cache.db
4
+ .venv/
5
+ .pytest_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ tokens.db
10
+ *.db
11
+ data/
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.5
2
+ Name: monokeys
3
+ Version: 0.2.0
4
+ Summary: Клиент «Ключей»: маленькие умные функции одним вызовом, без зависимостей
5
+ Project-URL: Homepage, https://monoblock.casa/keys/
6
+ Project-URL: Documentation, https://monoblock.casa/keys/client
7
+ Project-URL: Repository, https://github.com/monorez3/keys
8
+ Author: Monoblock
9
+ License: MIT
10
+ Keywords: api,keys,telegram,utilities
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # monokeys
20
+
21
+ Клиент «Ключей» — маленьких умных функций, которые зовутся одним вызовом.
22
+ Зависимостей нет: внутри только стандартная библиотека, один файл.
23
+
24
+ ```bash
25
+ pip install monokeys
26
+ ```
27
+
28
+ ```python
29
+ from monokeys import Keys
30
+
31
+ k = Keys() # ничего настраивать не надо
32
+ res = k.alive("@durov")
33
+
34
+ print(res.is_alive) # True
35
+ print(res.title) # Pavel Durov
36
+ ```
37
+
38
+ ## Ключ доступа заводить не нужно
39
+
40
+ Мы сделали работу за вас: публичный ключ работает у всех, **без счётчика и без
41
+ срока**. Клиент берёт его сам, поэтому `Keys()` работает сразу — ни
42
+ регистрации, ни настройки.
43
+
44
+ ```python
45
+ from monokeys import Keys
46
+ print(Keys().alive.text("@durov")) # и всё, больше ничего не нужно
47
+ ```
48
+
49
+ Свой ключ нужен только тем, кто хочет собственный рубильник: его выдаёт
50
+ владелец сервиса, он тоже бессрочный и без счётчика. Положите его в `.env`:
51
+
52
+ ```
53
+ KEYS_API_KEY=kx_...
54
+ ```
55
+
56
+ Отозвать свой ключ, если он утёк:
57
+
58
+ ```bash
59
+ curl -X POST -H "Authorization: Bearer kx_..." https://АДРЕС/token/revoke
60
+ ```
61
+
62
+ ## Три способа позвать ключ
63
+
64
+ ```python
65
+ k.alive("@durov") # весь ответ: объект с полями
66
+ k.alive.members_count("@durov") # только одно поле, уже числом
67
+ k.alive.text("@durov") # строка для человека
68
+ ```
69
+
70
+ ```
71
+ жив · channel · Pavel Durov · 11 005 185 subscribers
72
+ ```
73
+
74
+ ## Аргументы
75
+
76
+ ### `Keys(...)` — подключение
77
+
78
+ | Аргумент | По умолчанию | Что делает |
79
+ | --- | --- | --- |
80
+ | `token` | `KEYS_API_KEY`, иначе публичный ключ с сервера | ключ доступа; передаётся только заголовком |
81
+ | `base` | адрес, откуда скачан клиент | адрес сервера |
82
+ | `timeout` | `20.0` | сколько ждать ответа, секунд |
83
+ | `retries` | `1` | повторов при обрыве связи (отказ сервера не повторяется) |
84
+ | `user_agent` | `monokeys/<версия>` | как представляться серверу |
85
+
86
+ ```python
87
+ k = Keys(token="kx_...", base="https://АДРЕС", timeout=5, retries=2)
88
+ ```
89
+
90
+ ### `k.<ключ>(...)` — вызов
91
+
92
+ | Аргумент | По умолчанию | Что делает |
93
+ | --- | --- | --- |
94
+ | `value` | — | главное значение: для `alive` это ссылка, `@username` или `+hash` |
95
+ | `only` | `""` | вернуть только это поле вместо всего ответа |
96
+ | `fmt` | `"json"` | `json` — поля, `text` — строка для человека, `bool` — да/нет |
97
+ | `timeout` | как у клиента | переопределить ожидание для одного вызова |
98
+ | `**params` | — | остальные параметры ключа по именам |
99
+
100
+ ```python
101
+ k.alive("@durov", only="members_count") # 11005185
102
+ k.alive("@durov", fmt="bool") # 'true'
103
+ k.alive("@durov", timeout=3) # не ждать дольше трёх секунд
104
+ ```
105
+
106
+ ### Что можно спросить у клиента
107
+
108
+ | Вызов | Что вернёт |
109
+ | --- | --- |
110
+ | `k.names()` | имена всех доступных ключей |
111
+ | `k.fields("alive")` | какие поля возвращает ключ |
112
+ | `k.alive.fields()` | то же самое, короче |
113
+ | `k.catalog(refresh=True)` | полный каталог с описаниями, спросить заново |
114
+ | `k.call("alive", "@durov")` | позвать ключ, имя которого известно только в рантайме |
115
+
116
+ ## Ответ
117
+
118
+ `Answer` — это словарь, который умеет отвечать и как объект:
119
+
120
+ ```python
121
+ res = k.alive("@durov")
122
+ res.title == res["title"] # одно и то же
123
+ bool(res) # True, если ключ ответил утвердительно
124
+ dict(res) # обычный словарь
125
+ ```
126
+
127
+ Опечатка в имени поля не молчит:
128
+
129
+ ```python
130
+ res.tittle
131
+ # AttributeError: в ответе нет поля 'tittle'; есть: username, url, is_alive, ...
132
+
133
+ k.alive.members_cout("@durov")
134
+ # AttributeError: у ключа 'alive' нет поля 'members_cout'; есть: is_alive, kind, ...
135
+ ```
136
+
137
+ ## Ошибки
138
+
139
+ | Исключение | Когда |
140
+ | --- | --- |
141
+ | `AccessDenied` | ключ неизвестен, отозван или отправлен не по HTTPS |
142
+ | `Unavailable` | сервер занят или источник не ответил — осмысленно повторить |
143
+ | `KeysError` | всё остальное: нет такого ключа, мусор на входе |
144
+
145
+ У всех трёх есть `.status` (код ответа) и `.body` (что сказал сервер).
146
+ `AccessDenied` и `Unavailable` — потомки `KeysError`, так что можно ловить
147
+ одним `except KeysError`.
148
+
149
+ ```python
150
+ from monokeys import Keys, AccessDenied, Unavailable
151
+
152
+ try:
153
+ res = k.alive("@durov")
154
+ except AccessDenied:
155
+ print("ключ доступа не подошёл — попросите новый у владельца")
156
+ except Unavailable:
157
+ print("сейчас занято, попробую позже")
158
+ ```
159
+
160
+ ## Методы не зашиты в клиент
161
+
162
+ Список ключей и их полей приходит с сервера. Появился новый ключ — он
163
+ доступен сразу, без обновления пакета:
164
+
165
+ ```python
166
+ k.names() # ['alive', ...]
167
+ k.несуществующий # AttributeError со списком существующих
168
+ ```
169
+
170
+ ## Если ставить пакет не хочется
171
+
172
+ Тот же самый файл можно просто скачать — это буквально один исходник, из
173
+ которого собран пакет:
174
+
175
+ ```bash
176
+ curl https://АДРЕС/sdk/python > monokeys.py
177
+ ```
178
+
179
+ А можно вообще без клиента — ключ это обычная ссылка:
180
+
181
+ ```
182
+ https://АДРЕС/alive/@durov -> жив · channel · Pavel Durov · ...
183
+ ```
@@ -0,0 +1,165 @@
1
+ # monokeys
2
+
3
+ Клиент «Ключей» — маленьких умных функций, которые зовутся одним вызовом.
4
+ Зависимостей нет: внутри только стандартная библиотека, один файл.
5
+
6
+ ```bash
7
+ pip install monokeys
8
+ ```
9
+
10
+ ```python
11
+ from monokeys import Keys
12
+
13
+ k = Keys() # ничего настраивать не надо
14
+ res = k.alive("@durov")
15
+
16
+ print(res.is_alive) # True
17
+ print(res.title) # Pavel Durov
18
+ ```
19
+
20
+ ## Ключ доступа заводить не нужно
21
+
22
+ Мы сделали работу за вас: публичный ключ работает у всех, **без счётчика и без
23
+ срока**. Клиент берёт его сам, поэтому `Keys()` работает сразу — ни
24
+ регистрации, ни настройки.
25
+
26
+ ```python
27
+ from monokeys import Keys
28
+ print(Keys().alive.text("@durov")) # и всё, больше ничего не нужно
29
+ ```
30
+
31
+ Свой ключ нужен только тем, кто хочет собственный рубильник: его выдаёт
32
+ владелец сервиса, он тоже бессрочный и без счётчика. Положите его в `.env`:
33
+
34
+ ```
35
+ KEYS_API_KEY=kx_...
36
+ ```
37
+
38
+ Отозвать свой ключ, если он утёк:
39
+
40
+ ```bash
41
+ curl -X POST -H "Authorization: Bearer kx_..." https://АДРЕС/token/revoke
42
+ ```
43
+
44
+ ## Три способа позвать ключ
45
+
46
+ ```python
47
+ k.alive("@durov") # весь ответ: объект с полями
48
+ k.alive.members_count("@durov") # только одно поле, уже числом
49
+ k.alive.text("@durov") # строка для человека
50
+ ```
51
+
52
+ ```
53
+ жив · channel · Pavel Durov · 11 005 185 subscribers
54
+ ```
55
+
56
+ ## Аргументы
57
+
58
+ ### `Keys(...)` — подключение
59
+
60
+ | Аргумент | По умолчанию | Что делает |
61
+ | --- | --- | --- |
62
+ | `token` | `KEYS_API_KEY`, иначе публичный ключ с сервера | ключ доступа; передаётся только заголовком |
63
+ | `base` | адрес, откуда скачан клиент | адрес сервера |
64
+ | `timeout` | `20.0` | сколько ждать ответа, секунд |
65
+ | `retries` | `1` | повторов при обрыве связи (отказ сервера не повторяется) |
66
+ | `user_agent` | `monokeys/<версия>` | как представляться серверу |
67
+
68
+ ```python
69
+ k = Keys(token="kx_...", base="https://АДРЕС", timeout=5, retries=2)
70
+ ```
71
+
72
+ ### `k.<ключ>(...)` — вызов
73
+
74
+ | Аргумент | По умолчанию | Что делает |
75
+ | --- | --- | --- |
76
+ | `value` | — | главное значение: для `alive` это ссылка, `@username` или `+hash` |
77
+ | `only` | `""` | вернуть только это поле вместо всего ответа |
78
+ | `fmt` | `"json"` | `json` — поля, `text` — строка для человека, `bool` — да/нет |
79
+ | `timeout` | как у клиента | переопределить ожидание для одного вызова |
80
+ | `**params` | — | остальные параметры ключа по именам |
81
+
82
+ ```python
83
+ k.alive("@durov", only="members_count") # 11005185
84
+ k.alive("@durov", fmt="bool") # 'true'
85
+ k.alive("@durov", timeout=3) # не ждать дольше трёх секунд
86
+ ```
87
+
88
+ ### Что можно спросить у клиента
89
+
90
+ | Вызов | Что вернёт |
91
+ | --- | --- |
92
+ | `k.names()` | имена всех доступных ключей |
93
+ | `k.fields("alive")` | какие поля возвращает ключ |
94
+ | `k.alive.fields()` | то же самое, короче |
95
+ | `k.catalog(refresh=True)` | полный каталог с описаниями, спросить заново |
96
+ | `k.call("alive", "@durov")` | позвать ключ, имя которого известно только в рантайме |
97
+
98
+ ## Ответ
99
+
100
+ `Answer` — это словарь, который умеет отвечать и как объект:
101
+
102
+ ```python
103
+ res = k.alive("@durov")
104
+ res.title == res["title"] # одно и то же
105
+ bool(res) # True, если ключ ответил утвердительно
106
+ dict(res) # обычный словарь
107
+ ```
108
+
109
+ Опечатка в имени поля не молчит:
110
+
111
+ ```python
112
+ res.tittle
113
+ # AttributeError: в ответе нет поля 'tittle'; есть: username, url, is_alive, ...
114
+
115
+ k.alive.members_cout("@durov")
116
+ # AttributeError: у ключа 'alive' нет поля 'members_cout'; есть: is_alive, kind, ...
117
+ ```
118
+
119
+ ## Ошибки
120
+
121
+ | Исключение | Когда |
122
+ | --- | --- |
123
+ | `AccessDenied` | ключ неизвестен, отозван или отправлен не по HTTPS |
124
+ | `Unavailable` | сервер занят или источник не ответил — осмысленно повторить |
125
+ | `KeysError` | всё остальное: нет такого ключа, мусор на входе |
126
+
127
+ У всех трёх есть `.status` (код ответа) и `.body` (что сказал сервер).
128
+ `AccessDenied` и `Unavailable` — потомки `KeysError`, так что можно ловить
129
+ одним `except KeysError`.
130
+
131
+ ```python
132
+ from monokeys import Keys, AccessDenied, Unavailable
133
+
134
+ try:
135
+ res = k.alive("@durov")
136
+ except AccessDenied:
137
+ print("ключ доступа не подошёл — попросите новый у владельца")
138
+ except Unavailable:
139
+ print("сейчас занято, попробую позже")
140
+ ```
141
+
142
+ ## Методы не зашиты в клиент
143
+
144
+ Список ключей и их полей приходит с сервера. Появился новый ключ — он
145
+ доступен сразу, без обновления пакета:
146
+
147
+ ```python
148
+ k.names() # ['alive', ...]
149
+ k.несуществующий # AttributeError со списком существующих
150
+ ```
151
+
152
+ ## Если ставить пакет не хочется
153
+
154
+ Тот же самый файл можно просто скачать — это буквально один исходник, из
155
+ которого собран пакет:
156
+
157
+ ```bash
158
+ curl https://АДРЕС/sdk/python > monokeys.py
159
+ ```
160
+
161
+ А можно вообще без клиента — ключ это обычная ссылка:
162
+
163
+ ```
164
+ https://АДРЕС/alive/@durov -> жив · channel · Pavel Durov · ...
165
+ ```
@@ -0,0 +1,297 @@
1
+ """Клиент «Ключей» — один файл, ноль зависимостей.
2
+
3
+ Работает так же, как клиенты ИИ-сервисов: ключ доступа лежит в окружении,
4
+ клиент подставляет его сам, а каждый ключ — обычный метод.
5
+
6
+ # .env
7
+ KEYS_API_KEY=kx_...
8
+
9
+ from monokeys import Keys
10
+
11
+ k = Keys() # ключ берётся из KEYS_API_KEY
12
+ res = k.alive("@durov")
13
+
14
+ print(res.is_alive) # True
15
+ print(res.title) # Pavel Durov
16
+ print(k.alive.members_count("@durov")) # 11005185, уже числом
17
+ print(k.alive.text("@durov")) # жив · channel · Pavel Durov · ...
18
+
19
+ Ключа доступа заводить не нужно: если своего нет, клиент возьмёт публичный —
20
+ он напечатан открыто, работает у всех и без счётчика. Свой ключ нужен только
21
+ тем, кто хочет собственный рубильник.
22
+
23
+ Методы не перечислены в коде: их список приходит с сервера. Появился новый
24
+ ключ — он сразу доступен, обновлять клиент не нужно.
25
+
26
+ Все настройки — аргументы, ничего не прячется в глобальных переменных:
27
+
28
+ Keys(token=…, base=…, timeout=…, retries=…, user_agent=…)
29
+ k.alive(значение, only=…, fmt=…, timeout=…, **параметры)
30
+
31
+ Полное описание аргументов — в docstring каждого метода и в README пакета.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ import time
39
+ import urllib.error
40
+ import urllib.parse
41
+ import urllib.request
42
+
43
+ BASE = "https://monoblock.casa/keys" # BASE-MARKER: подменяется при отдаче с сервера
44
+ VERSION = "0.2.0"
45
+
46
+ __all__ = ["Keys", "Answer", "KeysError", "AccessDenied", "Unavailable"]
47
+
48
+
49
+ class KeysError(RuntimeError):
50
+ """Сервер отказал: нет такого ключа, мусор на входе, недоступен источник."""
51
+
52
+ def __init__(self, message: str, *, status: int | None = None, body: str = "") -> None:
53
+ super().__init__(message)
54
+ self.status = status
55
+ self.body = body
56
+
57
+
58
+ class AccessDenied(KeysError):
59
+ """Ключ доступа не подошёл: неизвестен, отозван или отправлен не по HTTPS."""
60
+
61
+
62
+ class Unavailable(KeysError):
63
+ """Сервер сейчас занят или источник не ответил. Осмысленно повторить позже."""
64
+
65
+
66
+ class Answer(dict):
67
+ """Ответ ключа. Поля доступны и как res['title'], и как res.title."""
68
+
69
+ def __getattr__(self, name: str):
70
+ try:
71
+ return self[name]
72
+ except KeyError as exc:
73
+ raise AttributeError(
74
+ f"в ответе нет поля '{name}'; есть: {', '.join(self)}"
75
+ ) from exc
76
+
77
+ def __bool__(self) -> bool:
78
+ """if res: ... — правда, когда ключ ответил утвердительно."""
79
+ for field in ("is_alive", "ok", "result"):
80
+ if field in self:
81
+ return bool(self[field])
82
+ return bool(dict(self))
83
+
84
+
85
+ class _Key:
86
+ """Один ключ как вызываемый объект.
87
+
88
+ k.alive("@durov") -> весь ответ
89
+ k.alive.members_count("@durov") -> только число, уже числом
90
+ k.alive.text("@durov") -> одной строкой для человека
91
+ k.alive.fields() -> что этот ключ умеет вернуть
92
+
93
+ Имена полей не зашиты: они приходят с сервера вместе со списком ключей,
94
+ поэтому опечатка ловится сразу и с подсказкой, а не отдаёт молча None.
95
+ """
96
+
97
+ def __init__(self, keys: "Keys", name: str) -> None:
98
+ self._keys = keys
99
+ self._name = name
100
+
101
+ def __call__(self, value: str | None = None, *, only: str = "",
102
+ fmt: str = "json", timeout: float | None = None, **params):
103
+ """Позвать ключ.
104
+
105
+ value — главное значение (для alive это ссылка или @username);
106
+ можно не давать, если передаёте параметры по именам.
107
+ only — вернуть только это поле вместо всего ответа.
108
+ fmt — 'json' (поля), 'text' (строка для человека), 'bool' (да/нет).
109
+ timeout — сколько ждать ответа, секунд; по умолчанию как у клиента.
110
+ **params — остальные параметры ключа по именам.
111
+ """
112
+ ответ = self._keys.call(
113
+ self._name, value, fmt=fmt, only=only, timeout=timeout, **params
114
+ )
115
+ if only or fmt != "json":
116
+ return ответ.get(only) if isinstance(ответ, dict) and only else ответ
117
+ return Answer(ответ)
118
+
119
+ def text(self, value: str | None = None, *, timeout: float | None = None, **params) -> str:
120
+ """Готовая человеческая строка вместо полей."""
121
+ return self._keys.call(self._name, value, fmt="text", timeout=timeout, **params)
122
+
123
+ def fields(self) -> list[str]:
124
+ """Что этот ключ вообще умеет вернуть."""
125
+ return self._keys.fields(self._name)
126
+
127
+ def __getattr__(self, field: str):
128
+ if field.startswith("_"):
129
+ raise AttributeError(field)
130
+ known = self.fields()
131
+ if known and field not in known:
132
+ raise AttributeError(
133
+ f"у ключа '{self._name}' нет поля '{field}'; есть: {', '.join(known)}"
134
+ )
135
+
136
+ def получить(value: str | None = None, *, timeout: float | None = None, **params):
137
+ ответ = self._keys.call(
138
+ self._name, value, fmt="json", only=field, timeout=timeout, **params
139
+ )
140
+ return ответ.get(field)
141
+
142
+ получить.__name__ = field
143
+ получить.__doc__ = f"Только поле '{field}' ключа '{self._name}'."
144
+ return получить
145
+
146
+ def __dir__(self):
147
+ return list(super().__dir__()) + self.fields()
148
+
149
+ def __repr__(self) -> str:
150
+ return f"<ключ {self._name}: {', '.join(self.fields())}>"
151
+
152
+
153
+ class Keys:
154
+ """Подключение к «Ключам».
155
+
156
+ token — ключ доступа. По умолчанию берётся из KEYS_API_KEY, а если и
157
+ её нет — у сервера спрашивается публичный ключ. То есть
158
+ настраивать ничего не надо: Keys() работает сразу. Свой ключ
159
+ нужен, только если хочется собственный рубильник. Ни у того,
160
+ ни у другого нет ни счётчика, ни срока.
161
+ base — адрес сервера. По умолчанию тот, с которого скачан клиент.
162
+ timeout — сколько ждать ответа, секунд. Можно переопределить в вызове.
163
+ retries — сколько раз повторить при обрыве связи (не при отказе сервера:
164
+ отказ повторять бессмысленно).
165
+ user_agent — как представляться; полезно, чтобы владелец сервиса видел,
166
+ кто ходит.
167
+ """
168
+
169
+ def __init__(self, token: str | None = None, base: str = BASE, *,
170
+ timeout: float = 20.0, retries: int = 1,
171
+ user_agent: str = f"monokeys/{VERSION}") -> None:
172
+ self.token = token if token is not None else os.environ.get("KEYS_API_KEY", "")
173
+ self.base = base.rstrip("/")
174
+ self.timeout = timeout
175
+ self.retries = max(0, retries)
176
+ self.user_agent = user_agent
177
+ self._catalog: dict | None = None
178
+ self._public: str | None = None
179
+
180
+ def _ключ(self) -> str:
181
+ """Свой ключ, а если своего нет — публичный.
182
+
183
+ Публичный ключ не зашит в пакет намеренно: его спрашивают у сервера,
184
+ поэтому смена ключа доходит до всех сразу и не требует нового релиза.
185
+ Спрашиваем один раз за время жизни объекта.
186
+ """
187
+ if self.token:
188
+ return self.token
189
+ if self._public is None:
190
+ try:
191
+ request = urllib.request.Request(
192
+ f"{self.base}/public-token", headers={"User-Agent": self.user_agent}
193
+ )
194
+ self._public = self._open(request, self.timeout).strip()
195
+ except KeysError:
196
+ self._public = "" # сервер не дал — пойдём как аноним
197
+ return self._public
198
+
199
+ # --- то, ради чего клиент существует ------------------------------- #
200
+
201
+ def call(self, name: str, value: str | None = None, *, fmt: str = "json",
202
+ only: str = "", timeout: float | None = None, **params):
203
+ """Позвать любой ключ по имени.
204
+
205
+ Возвращает словарь при fmt='json' и строку при fmt='text'/'bool'.
206
+ Обычно вызывают не это, а k.<имя ключа>(...) — но здесь ничего не
207
+ спрятано, и можно звать ключ, имя которого известно только в рантайме.
208
+ """
209
+ query = {k: v for k, v in params.items() if v is not None}
210
+ query["fmt"] = fmt
211
+ if only:
212
+ query["only"] = only
213
+
214
+ url = f"{self.base}/{urllib.parse.quote(name)}/"
215
+ if value is not None:
216
+ url += urllib.parse.quote(str(value), safe="@+")
217
+ url += "?" + urllib.parse.urlencode(query)
218
+
219
+ request = urllib.request.Request(url, headers={"User-Agent": self.user_agent})
220
+ ключ = self._ключ()
221
+ if ключ:
222
+ # только заголовком: в query-строке ключ виден в логах и в истории
223
+ request.add_header("Authorization", f"Bearer {ключ}")
224
+
225
+ body = self._open(request, timeout if timeout is not None else self.timeout)
226
+ return json.loads(body) if fmt == "json" else body
227
+
228
+ def _open(self, request, timeout: float) -> str:
229
+ последняя: Exception | None = None
230
+ for попытка in range(self.retries + 1):
231
+ try:
232
+ with urllib.request.urlopen(request, timeout=timeout) as response:
233
+ return response.read().decode("utf-8")
234
+ except urllib.error.HTTPError as exc:
235
+ detail = exc.read().decode("utf-8", "replace").strip()
236
+ if exc.code in (401, 403):
237
+ raise AccessDenied(f"{exc.code}: {detail}", status=exc.code,
238
+ body=detail) from exc
239
+ if exc.code in (502, 503):
240
+ raise Unavailable(f"{exc.code}: {detail}", status=exc.code,
241
+ body=detail) from exc
242
+ raise KeysError(f"{exc.code}: {detail}", status=exc.code, body=detail) from exc
243
+ except urllib.error.URLError as exc:
244
+ последняя = exc
245
+ if попытка < self.retries:
246
+ time.sleep(0.3)
247
+ raise Unavailable(f"не дозвонился до {self.base}: {последняя}")
248
+
249
+ # --- что вообще есть на сервере -------------------------------------- #
250
+
251
+ def catalog(self, *, refresh: bool = False) -> dict:
252
+ """Список ключей с описаниями. refresh=True — спросить заново."""
253
+ if self._catalog is None or refresh:
254
+ request = urllib.request.Request(
255
+ f"{self.base}/keys", headers={"User-Agent": self.user_agent}
256
+ )
257
+ self._catalog = json.loads(self._open(request, self.timeout))
258
+ return self._catalog
259
+
260
+ def names(self) -> list[str]:
261
+ """Имена всех доступных ключей."""
262
+ return [k["id"] for k in self.catalog()["keys"]]
263
+
264
+ def fields(self, name: str) -> list[str]:
265
+ """Поля ответа конкретного ключа — из того же каталога."""
266
+ for key in self.catalog()["keys"]:
267
+ if key["id"] == name:
268
+ return key.get("returns", [])
269
+ return []
270
+
271
+ def __getattr__(self, name: str) -> _Key:
272
+ if name.startswith("_"):
273
+ raise AttributeError(name)
274
+ try:
275
+ available = self.names()
276
+ except KeysError:
277
+ available = [] # сервер молчит — пусть падает уже на самом вызове
278
+ if available and name not in available:
279
+ raise AttributeError(f"ключа '{name}' нет; есть: {', '.join(available)}")
280
+ return _Key(self, name)
281
+
282
+ def __dir__(self):
283
+ try:
284
+ return list(super().__dir__()) + self.names()
285
+ except KeysError:
286
+ return list(super().__dir__())
287
+
288
+ def __repr__(self) -> str:
289
+ if self.token:
290
+ чем = "свой ключ"
291
+ elif self._public:
292
+ чем = "публичный ключ"
293
+ elif self._public == "":
294
+ чем = "без ключа (сервер не дал публичный)"
295
+ else:
296
+ чем = "ключ ещё не спрашивали"
297
+ return f"<Keys {self.base}, {чем}>"
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "monokeys"
7
+ version = "0.2.0"
8
+ description = "Клиент «Ключей»: маленькие умные функции одним вызовом, без зависимостей"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Monoblock" }]
13
+ keywords = ["telegram", "api", "keys", "utilities"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Utilities",
20
+ ]
21
+ # Зависимостей нет и не будет: весь клиент — обёртка над стандартной библиотекой.
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://monoblock.casa/keys/"
26
+ Documentation = "https://monoblock.casa/keys/client"
27
+ Repository = "https://github.com/monorez3/keys"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["monokeys"]