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.
- monokeys-0.2.0/.gitignore +11 -0
- monokeys-0.2.0/PKG-INFO +183 -0
- monokeys-0.2.0/README.md +165 -0
- monokeys-0.2.0/monokeys/__init__.py +297 -0
- monokeys-0.2.0/pyproject.toml +30 -0
monokeys-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
monokeys-0.2.0/README.md
ADDED
|
@@ -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"]
|