telethon-cli 0.1.0__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.
- telethon_cli/__init__.py +3 -0
- telethon_cli/__main__.py +5 -0
- telethon_cli/app.py +320 -0
- telethon_cli/command_matrix.py +106 -0
- telethon_cli/config.py +195 -0
- telethon_cli/errors.py +2 -0
- telethon_cli/live_smoke.py +465 -0
- telethon_cli/registry.py +119 -0
- telethon_cli/resolver.py +39 -0
- telethon_cli/runtime.py +280 -0
- telethon_cli/serialization.py +141 -0
- telethon_cli/settings.py +81 -0
- telethon_cli-0.1.0.dist-info/METADATA +199 -0
- telethon_cli-0.1.0.dist-info/RECORD +17 -0
- telethon_cli-0.1.0.dist-info/WHEEL +5 -0
- telethon_cli-0.1.0.dist-info/entry_points.txt +2 -0
- telethon_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from .command_matrix import (
|
|
12
|
+
LIVE_MODES,
|
|
13
|
+
MODE_LIVE_DISPOSABLE,
|
|
14
|
+
MODE_LIVE_SAFE,
|
|
15
|
+
PUBLIC_COMMAND_MATRIX,
|
|
16
|
+
PUBLIC_COMMAND_MATRIX_BY_KEY,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LiveSmokeError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LiveSmokeBlocked(LiveSmokeError):
|
|
25
|
+
def __init__(self, reason, detail=""):
|
|
26
|
+
super().__init__(detail or reason)
|
|
27
|
+
self.reason = reason
|
|
28
|
+
self.detail = detail or reason
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class LiveContext:
|
|
33
|
+
repo_root: Path
|
|
34
|
+
temp_dir: Path
|
|
35
|
+
run_id: str = field(default_factory=lambda: uuid.uuid4().hex[:10])
|
|
36
|
+
primary_message_id: int = None
|
|
37
|
+
forwarded_message_id: int = None
|
|
38
|
+
file_message_id: int = None
|
|
39
|
+
cleanup_message_ids: list = field(default_factory=list)
|
|
40
|
+
temp_upload_path: Path = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
STATIC_LIVE_ARGS = {
|
|
44
|
+
("base", "connect"): ("base", "connect"),
|
|
45
|
+
("base", "is-connected"): ("base", "is-connected"),
|
|
46
|
+
("base", "disconnect"): ("base", "disconnect"),
|
|
47
|
+
("messages", "iter-messages"): ("messages", "iter-messages", "--entity", "me", "--limit", "1"),
|
|
48
|
+
("messages", "get-messages"): ("messages", "get-messages", "--entity", "me", "--limit", "1"),
|
|
49
|
+
("dialogs", "iter-dialogs"): ("dialogs", "iter-dialogs", "--limit", "3"),
|
|
50
|
+
("dialogs", "get-dialogs"): ("dialogs", "get-dialogs", "--limit", "3"),
|
|
51
|
+
("dialogs", "iter-drafts"): ("dialogs", "iter-drafts", "--entity", "me"),
|
|
52
|
+
("dialogs", "get-drafts"): ("dialogs", "get-drafts", "--entity", "me"),
|
|
53
|
+
("users", "get-me"): ("users", "get-me"),
|
|
54
|
+
("users", "is-bot"): ("users", "is-bot"),
|
|
55
|
+
("users", "is-user-authorized"): ("users", "is-user-authorized"),
|
|
56
|
+
("users", "get-entity"): ("users", "get-entity", "--entity", "me"),
|
|
57
|
+
("users", "get-input-entity"): ("users", "get-input-entity", "--peer", "me"),
|
|
58
|
+
("users", "get-peer-id"): ("users", "get-peer-id", "--peer", "me"),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
LIVE_HANDLER_KEYS = frozenset(STATIC_LIVE_ARGS) | {
|
|
62
|
+
("messages", "send-message"),
|
|
63
|
+
("messages", "forward-messages"),
|
|
64
|
+
("messages", "edit-message"),
|
|
65
|
+
("messages", "delete-messages"),
|
|
66
|
+
("messages", "send-read-acknowledge"),
|
|
67
|
+
("messages", "pin-message"),
|
|
68
|
+
("messages", "unpin-message"),
|
|
69
|
+
("uploads", "send-file"),
|
|
70
|
+
("uploads", "upload-file"),
|
|
71
|
+
("downloads", "download-profile-photo"),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _repo_root():
|
|
76
|
+
return Path(__file__).resolve().parents[2]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cli_env(repo_root):
|
|
80
|
+
env = os.environ.copy()
|
|
81
|
+
source_root = str(repo_root / "src")
|
|
82
|
+
existing = env.get("PYTHONPATH")
|
|
83
|
+
env["PYTHONPATH"] = source_root if not existing else source_root + os.pathsep + existing
|
|
84
|
+
return env
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _run_cli(repo_root, args):
|
|
88
|
+
process = subprocess.run(
|
|
89
|
+
[sys.executable, "-m", "telethon_cli", *args, "--output", "json"],
|
|
90
|
+
cwd=repo_root,
|
|
91
|
+
env=_cli_env(repo_root),
|
|
92
|
+
text=True,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
)
|
|
95
|
+
stdout = process.stdout.strip()
|
|
96
|
+
stderr = process.stderr.strip()
|
|
97
|
+
if process.returncode != 0:
|
|
98
|
+
raise LiveSmokeError(stderr or stdout or "Command failed")
|
|
99
|
+
if not stdout:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
return json.loads(stdout)
|
|
103
|
+
except json.JSONDecodeError as exc:
|
|
104
|
+
raise LiveSmokeError("Invalid JSON output for {}: {}".format(" ".join(args), exc)) from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cleanup_messages(ctx, notes):
|
|
108
|
+
if not ctx.cleanup_message_ids:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
ids = sorted(set(ctx.cleanup_message_ids))
|
|
112
|
+
try:
|
|
113
|
+
_run_cli(
|
|
114
|
+
ctx.repo_root,
|
|
115
|
+
[
|
|
116
|
+
"messages",
|
|
117
|
+
"delete-messages",
|
|
118
|
+
"--entity",
|
|
119
|
+
"me",
|
|
120
|
+
"--message-ids",
|
|
121
|
+
json.dumps(ids),
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
except LiveSmokeError as exc:
|
|
125
|
+
notes.append("cleanup: unable to delete messages {} ({})".format(ids, exc))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_static_live_command(ctx, spec):
|
|
129
|
+
return _run_cli(ctx.repo_root, list(STATIC_LIVE_ARGS[spec.key]))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _ensure_temp_upload_file(ctx):
|
|
133
|
+
if ctx.temp_upload_path is None:
|
|
134
|
+
ctx.temp_upload_path = ctx.temp_dir / "upload.txt"
|
|
135
|
+
ctx.temp_upload_path.write_text(
|
|
136
|
+
"telethon-cli smoke {}\n".format(ctx.run_id),
|
|
137
|
+
encoding="utf-8",
|
|
138
|
+
)
|
|
139
|
+
return ctx.temp_upload_path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_send_message(ctx, _spec):
|
|
143
|
+
result = _run_cli(
|
|
144
|
+
ctx.repo_root,
|
|
145
|
+
[
|
|
146
|
+
"messages",
|
|
147
|
+
"send-message",
|
|
148
|
+
"--entity",
|
|
149
|
+
"me",
|
|
150
|
+
"--message",
|
|
151
|
+
"[telethon-cli smoke {}] primary".format(ctx.run_id),
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
ctx.primary_message_id = result["id"]
|
|
155
|
+
ctx.cleanup_message_ids.append(ctx.primary_message_id)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _run_forward_messages(ctx, _spec):
|
|
160
|
+
if ctx.primary_message_id is None:
|
|
161
|
+
raise LiveSmokeError("forward-messages requires a disposable source message")
|
|
162
|
+
result = _run_cli(
|
|
163
|
+
ctx.repo_root,
|
|
164
|
+
[
|
|
165
|
+
"messages",
|
|
166
|
+
"forward-messages",
|
|
167
|
+
"--entity",
|
|
168
|
+
"me",
|
|
169
|
+
"--messages",
|
|
170
|
+
str(ctx.primary_message_id),
|
|
171
|
+
"--from-peer",
|
|
172
|
+
"me",
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
if result:
|
|
176
|
+
first = result[0]
|
|
177
|
+
ctx.forwarded_message_id = first["id"]
|
|
178
|
+
ctx.cleanup_message_ids.append(ctx.forwarded_message_id)
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _run_edit_message(ctx, _spec):
|
|
183
|
+
if ctx.primary_message_id is None:
|
|
184
|
+
raise LiveSmokeError("edit-message requires a disposable source message")
|
|
185
|
+
return _run_cli(
|
|
186
|
+
ctx.repo_root,
|
|
187
|
+
[
|
|
188
|
+
"messages",
|
|
189
|
+
"edit-message",
|
|
190
|
+
"--entity",
|
|
191
|
+
"me",
|
|
192
|
+
"--message",
|
|
193
|
+
str(ctx.primary_message_id),
|
|
194
|
+
"--text",
|
|
195
|
+
"[telethon-cli smoke {}] edited".format(ctx.run_id),
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _run_delete_messages(ctx, _spec):
|
|
201
|
+
if ctx.primary_message_id is None:
|
|
202
|
+
raise LiveSmokeError("delete-messages requires a disposable source message")
|
|
203
|
+
result = _run_cli(
|
|
204
|
+
ctx.repo_root,
|
|
205
|
+
[
|
|
206
|
+
"messages",
|
|
207
|
+
"delete-messages",
|
|
208
|
+
"--entity",
|
|
209
|
+
"me",
|
|
210
|
+
"--message-ids",
|
|
211
|
+
json.dumps([ctx.primary_message_id]),
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
ctx.cleanup_message_ids = [message_id for message_id in ctx.cleanup_message_ids if message_id != ctx.primary_message_id]
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _run_send_read_acknowledge(ctx, _spec):
|
|
219
|
+
if ctx.primary_message_id is None:
|
|
220
|
+
raise LiveSmokeError("send-read-acknowledge requires a disposable source message")
|
|
221
|
+
return _run_cli(
|
|
222
|
+
ctx.repo_root,
|
|
223
|
+
[
|
|
224
|
+
"messages",
|
|
225
|
+
"send-read-acknowledge",
|
|
226
|
+
"--entity",
|
|
227
|
+
"me",
|
|
228
|
+
"--max-id",
|
|
229
|
+
str(ctx.primary_message_id),
|
|
230
|
+
],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _run_pin_message(ctx, _spec):
|
|
235
|
+
if ctx.primary_message_id is None:
|
|
236
|
+
raise LiveSmokeError("pin-message requires a disposable source message")
|
|
237
|
+
try:
|
|
238
|
+
return _run_cli(
|
|
239
|
+
ctx.repo_root,
|
|
240
|
+
[
|
|
241
|
+
"messages",
|
|
242
|
+
"pin-message",
|
|
243
|
+
"--entity",
|
|
244
|
+
"me",
|
|
245
|
+
"--message",
|
|
246
|
+
str(ctx.primary_message_id),
|
|
247
|
+
],
|
|
248
|
+
)
|
|
249
|
+
except LiveSmokeError as exc:
|
|
250
|
+
raise LiveSmokeBlocked("blocked-by-telegram", str(exc)) from exc
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _run_unpin_message(ctx, _spec):
|
|
254
|
+
if ctx.primary_message_id is None:
|
|
255
|
+
raise LiveSmokeError("unpin-message requires a disposable source message")
|
|
256
|
+
try:
|
|
257
|
+
return _run_cli(
|
|
258
|
+
ctx.repo_root,
|
|
259
|
+
[
|
|
260
|
+
"messages",
|
|
261
|
+
"unpin-message",
|
|
262
|
+
"--entity",
|
|
263
|
+
"me",
|
|
264
|
+
"--message",
|
|
265
|
+
str(ctx.primary_message_id),
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
except LiveSmokeError as exc:
|
|
269
|
+
raise LiveSmokeBlocked("blocked-by-telegram", str(exc)) from exc
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _run_upload_file(ctx, _spec):
|
|
273
|
+
upload_path = _ensure_temp_upload_file(ctx)
|
|
274
|
+
return _run_cli(
|
|
275
|
+
ctx.repo_root,
|
|
276
|
+
[
|
|
277
|
+
"uploads",
|
|
278
|
+
"upload-file",
|
|
279
|
+
"--file",
|
|
280
|
+
str(upload_path),
|
|
281
|
+
],
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _run_send_file(ctx, _spec):
|
|
286
|
+
upload_path = _ensure_temp_upload_file(ctx)
|
|
287
|
+
result = _run_cli(
|
|
288
|
+
ctx.repo_root,
|
|
289
|
+
[
|
|
290
|
+
"uploads",
|
|
291
|
+
"send-file",
|
|
292
|
+
"--entity",
|
|
293
|
+
"me",
|
|
294
|
+
"--file",
|
|
295
|
+
str(upload_path),
|
|
296
|
+
"--caption",
|
|
297
|
+
"[telethon-cli smoke {}] file".format(ctx.run_id),
|
|
298
|
+
],
|
|
299
|
+
)
|
|
300
|
+
ctx.file_message_id = result["id"]
|
|
301
|
+
ctx.cleanup_message_ids.append(ctx.file_message_id)
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _run_download_profile_photo(ctx, _spec):
|
|
306
|
+
result = _run_cli(
|
|
307
|
+
ctx.repo_root,
|
|
308
|
+
[
|
|
309
|
+
"downloads",
|
|
310
|
+
"download-profile-photo",
|
|
311
|
+
"--entity",
|
|
312
|
+
"me",
|
|
313
|
+
"--file",
|
|
314
|
+
str(ctx.temp_dir / "profile-photo"),
|
|
315
|
+
],
|
|
316
|
+
)
|
|
317
|
+
if result in (None, ""):
|
|
318
|
+
raise LiveSmokeBlocked("blocked-no-profile-photo", "No profile photo was returned")
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
LIVE_HANDLERS = {
|
|
323
|
+
("messages", "send-message"): _run_send_message,
|
|
324
|
+
("messages", "forward-messages"): _run_forward_messages,
|
|
325
|
+
("messages", "edit-message"): _run_edit_message,
|
|
326
|
+
("messages", "delete-messages"): _run_delete_messages,
|
|
327
|
+
("messages", "send-read-acknowledge"): _run_send_read_acknowledge,
|
|
328
|
+
("messages", "pin-message"): _run_pin_message,
|
|
329
|
+
("messages", "unpin-message"): _run_unpin_message,
|
|
330
|
+
("uploads", "upload-file"): _run_upload_file,
|
|
331
|
+
("uploads", "send-file"): _run_send_file,
|
|
332
|
+
("downloads", "download-profile-photo"): _run_download_profile_photo,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _run_live_spec(ctx, spec):
|
|
337
|
+
if spec.key in STATIC_LIVE_ARGS:
|
|
338
|
+
return _run_static_live_command(ctx, spec)
|
|
339
|
+
return LIVE_HANDLERS[spec.key](ctx, spec)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _render_text_summary(summary):
|
|
343
|
+
lines = [
|
|
344
|
+
"login: {}".format(summary["login"]["status"]),
|
|
345
|
+
"passed: {}".format(summary["counts"]["passed"]),
|
|
346
|
+
"blocked: {}".format(summary["counts"]["blocked"]),
|
|
347
|
+
"skipped: {}".format(summary["counts"]["skipped"]),
|
|
348
|
+
"failed: {}".format(summary["counts"]["failed"]),
|
|
349
|
+
]
|
|
350
|
+
details = [result for result in summary["results"] if result["status"] != "passed"]
|
|
351
|
+
if details:
|
|
352
|
+
lines.append("details:")
|
|
353
|
+
for item in details:
|
|
354
|
+
line = "{} {} -> {}".format(item["group"], item["command"], item["status"])
|
|
355
|
+
if item["detail"]:
|
|
356
|
+
line += " ({})".format(item["detail"])
|
|
357
|
+
lines.append(line)
|
|
358
|
+
return "\n".join(lines)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def run_live_public_commands():
|
|
362
|
+
repo_root = _repo_root()
|
|
363
|
+
results = []
|
|
364
|
+
counts = {
|
|
365
|
+
"passed": 0,
|
|
366
|
+
"blocked": 0,
|
|
367
|
+
"skipped": 0,
|
|
368
|
+
"failed": 0,
|
|
369
|
+
}
|
|
370
|
+
notes = []
|
|
371
|
+
|
|
372
|
+
with tempfile.TemporaryDirectory(prefix="telethon-cli-smoke-", dir="/tmp") as temp_dir:
|
|
373
|
+
ctx = LiveContext(repo_root=repo_root, temp_dir=Path(temp_dir))
|
|
374
|
+
|
|
375
|
+
login_result = {"status": "passed", "detail": ""}
|
|
376
|
+
try:
|
|
377
|
+
_run_cli(repo_root, ["login"])
|
|
378
|
+
except LiveSmokeError as exc:
|
|
379
|
+
login_result = {"status": "failed", "detail": str(exc)}
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
for spec in PUBLIC_COMMAND_MATRIX:
|
|
383
|
+
if spec.mode not in LIVE_MODES:
|
|
384
|
+
results.append(
|
|
385
|
+
{
|
|
386
|
+
"group": spec.group,
|
|
387
|
+
"command": spec.cli_name,
|
|
388
|
+
"mode": spec.mode,
|
|
389
|
+
"status": "skipped",
|
|
390
|
+
"detail": spec.reason,
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
counts["skipped"] += 1
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
if login_result["status"] != "passed":
|
|
397
|
+
results.append(
|
|
398
|
+
{
|
|
399
|
+
"group": spec.group,
|
|
400
|
+
"command": spec.cli_name,
|
|
401
|
+
"mode": spec.mode,
|
|
402
|
+
"status": "failed",
|
|
403
|
+
"detail": "login prerequisite failed: {}".format(login_result["detail"]),
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
counts["failed"] += 1
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
_run_live_spec(ctx, spec)
|
|
411
|
+
except LiveSmokeBlocked as exc:
|
|
412
|
+
results.append(
|
|
413
|
+
{
|
|
414
|
+
"group": spec.group,
|
|
415
|
+
"command": spec.cli_name,
|
|
416
|
+
"mode": spec.mode,
|
|
417
|
+
"status": "blocked",
|
|
418
|
+
"detail": exc.reason,
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
counts["blocked"] += 1
|
|
422
|
+
except LiveSmokeError as exc:
|
|
423
|
+
results.append(
|
|
424
|
+
{
|
|
425
|
+
"group": spec.group,
|
|
426
|
+
"command": spec.cli_name,
|
|
427
|
+
"mode": spec.mode,
|
|
428
|
+
"status": "failed",
|
|
429
|
+
"detail": str(exc),
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
counts["failed"] += 1
|
|
433
|
+
else:
|
|
434
|
+
results.append(
|
|
435
|
+
{
|
|
436
|
+
"group": spec.group,
|
|
437
|
+
"command": spec.cli_name,
|
|
438
|
+
"mode": spec.mode,
|
|
439
|
+
"status": "passed",
|
|
440
|
+
"detail": "",
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
counts["passed"] += 1
|
|
444
|
+
finally:
|
|
445
|
+
_cleanup_messages(ctx, notes)
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
"login": login_result,
|
|
449
|
+
"counts": counts,
|
|
450
|
+
"results": results,
|
|
451
|
+
"notes": notes,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def main(argv=None):
|
|
456
|
+
parser = argparse.ArgumentParser(prog="live-public-commands")
|
|
457
|
+
parser.add_argument("--output", choices=("text", "json"), default="text")
|
|
458
|
+
args = parser.parse_args(argv)
|
|
459
|
+
|
|
460
|
+
summary = run_live_public_commands()
|
|
461
|
+
if args.output == "json":
|
|
462
|
+
print(json.dumps(summary, indent=2, sort_keys=True, ensure_ascii=False))
|
|
463
|
+
else:
|
|
464
|
+
print(_render_text_summary(summary))
|
|
465
|
+
return 0
|
telethon_cli/registry.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import importlib
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PUBLIC_GROUP_SPECS = (
|
|
11
|
+
("auth", "telethon.client.auth", "AuthMethods"),
|
|
12
|
+
("base", "telethon.client.telegrambaseclient", "TelegramBaseClient"),
|
|
13
|
+
("messages", "telethon.client.messages", "MessageMethods"),
|
|
14
|
+
("uploads", "telethon.client.uploads", "UploadMethods"),
|
|
15
|
+
("downloads", "telethon.client.downloads", "DownloadMethods"),
|
|
16
|
+
("dialogs", "telethon.client.dialogs", "DialogMethods"),
|
|
17
|
+
("users", "telethon.client.users", "UserMethods"),
|
|
18
|
+
("chats", "telethon.client.chats", "ChatMethods"),
|
|
19
|
+
("updates", "telethon.client.updates", "UpdateMethods"),
|
|
20
|
+
("bots", "telethon.client.bots", "BotMethods"),
|
|
21
|
+
("buttons", "telethon.client.buttons", "ButtonMethods"),
|
|
22
|
+
("account", "telethon.client.account", "AccountMethods"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PublicCommand:
|
|
28
|
+
group: str
|
|
29
|
+
name: str
|
|
30
|
+
cli_name: str
|
|
31
|
+
dotted_path: str
|
|
32
|
+
signature: inspect.Signature
|
|
33
|
+
summary: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CallableRecord:
|
|
38
|
+
dotted_path: str
|
|
39
|
+
kind: str
|
|
40
|
+
method_name: str = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _summary(obj):
|
|
44
|
+
doc = inspect.getdoc(obj) or ""
|
|
45
|
+
return doc.splitlines()[0] if doc else ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1)
|
|
49
|
+
def discover_public_commands():
|
|
50
|
+
commands = []
|
|
51
|
+
for group, module_name, class_name in PUBLIC_GROUP_SPECS:
|
|
52
|
+
module = importlib.import_module(module_name)
|
|
53
|
+
cls = getattr(module, class_name)
|
|
54
|
+
for name, member in cls.__dict__.items():
|
|
55
|
+
if name.startswith("_"):
|
|
56
|
+
continue
|
|
57
|
+
if isinstance(member, property):
|
|
58
|
+
continue
|
|
59
|
+
if not callable(member):
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
commands.append(
|
|
63
|
+
PublicCommand(
|
|
64
|
+
group=group,
|
|
65
|
+
name=name,
|
|
66
|
+
cli_name=name.replace("_", "-"),
|
|
67
|
+
dotted_path="{}.{}.{}".format(module_name, class_name, name),
|
|
68
|
+
signature=inspect.signature(member),
|
|
69
|
+
summary=_summary(member),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return tuple(commands)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _package_dir(package_name):
|
|
76
|
+
module = importlib.import_module(package_name)
|
|
77
|
+
return Path(module.__file__).resolve().parent
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _module_name(package_name, package_dir, file_path):
|
|
81
|
+
relative = file_path.relative_to(package_dir).with_suffix("")
|
|
82
|
+
if relative.name == "__init__":
|
|
83
|
+
parts = relative.parts[:-1]
|
|
84
|
+
else:
|
|
85
|
+
parts = relative.parts
|
|
86
|
+
if not parts:
|
|
87
|
+
return package_name
|
|
88
|
+
return ".".join((package_name,) + parts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@lru_cache(maxsize=1)
|
|
92
|
+
def discover_project_callables():
|
|
93
|
+
records = []
|
|
94
|
+
for package_name in ("telethon", "telethon_generator"):
|
|
95
|
+
if importlib.util.find_spec(package_name) is None:
|
|
96
|
+
continue
|
|
97
|
+
package_dir = _package_dir(package_name)
|
|
98
|
+
for file_path in sorted(package_dir.rglob("*.py")):
|
|
99
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8"))
|
|
100
|
+
module_name = _module_name(package_name, package_dir, file_path)
|
|
101
|
+
for node in tree.body:
|
|
102
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
103
|
+
records.append(CallableRecord(
|
|
104
|
+
dotted_path="{}.{}".format(module_name, node.name),
|
|
105
|
+
kind="function",
|
|
106
|
+
))
|
|
107
|
+
elif isinstance(node, ast.ClassDef):
|
|
108
|
+
records.append(CallableRecord(
|
|
109
|
+
dotted_path="{}.{}".format(module_name, node.name),
|
|
110
|
+
kind="class",
|
|
111
|
+
))
|
|
112
|
+
for child in node.body:
|
|
113
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
114
|
+
records.append(CallableRecord(
|
|
115
|
+
dotted_path="{}.{}".format(module_name, node.name),
|
|
116
|
+
kind="method",
|
|
117
|
+
method_name=child.name,
|
|
118
|
+
))
|
|
119
|
+
return tuple(records)
|
telethon_cli/resolver.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
from .errors import CLIError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PROJECT_ROOT_PREFIXES = ("telethon", "telethon_generator")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_project_path(path):
|
|
10
|
+
return any(path == prefix or path.startswith(prefix + ".") for prefix in PROJECT_ROOT_PREFIXES)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def import_dotted_path(path, *, project_only=False):
|
|
14
|
+
if project_only and not is_project_path(path):
|
|
15
|
+
raise CLIError(
|
|
16
|
+
"Target '{}' must live under telethon or telethon_generator".format(path)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
parts = path.split(".")
|
|
20
|
+
for index in range(len(parts), 0, -1):
|
|
21
|
+
module_name = ".".join(parts[:index])
|
|
22
|
+
try:
|
|
23
|
+
module = importlib.import_module(module_name)
|
|
24
|
+
except ModuleNotFoundError as exc:
|
|
25
|
+
if exc.name != module_name and not module_name.startswith(exc.name + "."):
|
|
26
|
+
raise CLIError("Failed to import '{}': {}".format(module_name, exc)) from exc
|
|
27
|
+
continue
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
raise CLIError("Failed to import '{}': {}".format(module_name, exc)) from exc
|
|
30
|
+
|
|
31
|
+
target = module
|
|
32
|
+
for attr in parts[index:]:
|
|
33
|
+
try:
|
|
34
|
+
target = getattr(target, attr)
|
|
35
|
+
except AttributeError as exc:
|
|
36
|
+
raise CLIError("Dotted path '{}' has no attribute '{}'".format(path, attr)) from exc
|
|
37
|
+
return target
|
|
38
|
+
|
|
39
|
+
raise CLIError("Unable to resolve dotted path '{}'".format(path))
|