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.
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,5 @@
1
+ from .app import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
telethon_cli/app.py ADDED
@@ -0,0 +1,320 @@
1
+ import argparse
2
+ import inspect
3
+ import sys
4
+
5
+ from .config import add_client_arguments
6
+ from .errors import CLIError
7
+ from .registry import discover_project_callables, discover_public_commands
8
+ from .resolver import import_dotted_path
9
+ from .runtime import login, logout, run_project_call, run_public_command
10
+ from .serialization import parse_cli_value, parse_json_input, render_output
11
+ from .settings import (
12
+ DEVELOPER_MODE_CONFIRMATION,
13
+ DEVELOPER_MODE_WARNING,
14
+ ProjectSettings,
15
+ ensure_developer_mode_enabled,
16
+ find_project_root,
17
+ get_settings_path,
18
+ load_project_settings,
19
+ save_project_settings,
20
+ )
21
+
22
+
23
+ MISSING = object()
24
+
25
+
26
+ def _add_output_argument(parser):
27
+ parser.add_argument(
28
+ "--output",
29
+ choices=("text", "json"),
30
+ default="text",
31
+ help="Output format",
32
+ )
33
+
34
+
35
+ def _add_json_arguments(parser, flag_prefix="", dest_prefix=None):
36
+ dest_prefix = flag_prefix if dest_prefix is None else dest_prefix
37
+ parser.add_argument(
38
+ "--{}args-json".format(flag_prefix),
39
+ dest="{}args_json".format(dest_prefix),
40
+ )
41
+ parser.add_argument(
42
+ "--{}args-file".format(flag_prefix),
43
+ dest="{}args_file".format(dest_prefix),
44
+ )
45
+ parser.add_argument(
46
+ "--{}kwargs-json".format(flag_prefix),
47
+ dest="{}kwargs_json".format(dest_prefix),
48
+ )
49
+ parser.add_argument(
50
+ "--{}kwargs-file".format(flag_prefix),
51
+ dest="{}kwargs_file".format(dest_prefix),
52
+ )
53
+
54
+
55
+ def _load_call_arguments(namespace, prefix=""):
56
+ args = parse_json_input(
57
+ getattr(namespace, "{}args_json".format(prefix)),
58
+ getattr(namespace, "{}args_file".format(prefix)),
59
+ default=[],
60
+ )
61
+ kwargs = parse_json_input(
62
+ getattr(namespace, "{}kwargs_json".format(prefix)),
63
+ getattr(namespace, "{}kwargs_file".format(prefix)),
64
+ default={},
65
+ )
66
+
67
+ if not isinstance(args, list):
68
+ raise CLIError("{}args payload must decode to a list".format(prefix))
69
+ if not isinstance(kwargs, dict):
70
+ raise CLIError("{}kwargs payload must decode to an object".format(prefix))
71
+ return args, kwargs
72
+
73
+
74
+ def _iter_signature_parameters(signature):
75
+ for param in signature.parameters.values():
76
+ if param.name in ("self", "cls"):
77
+ continue
78
+ if param.kind not in (
79
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
80
+ inspect.Parameter.KEYWORD_ONLY,
81
+ ):
82
+ continue
83
+ yield param
84
+
85
+
86
+ def _merge_flag_arguments(namespace, kwargs, parameter_names):
87
+ for name in parameter_names:
88
+ value = getattr(namespace, "param_{}".format(name))
89
+ if value is not MISSING:
90
+ kwargs[name] = value
91
+
92
+
93
+ def _handle_public_command(namespace):
94
+ args, kwargs = _load_call_arguments(namespace)
95
+ _merge_flag_arguments(namespace, kwargs, namespace.parameter_names)
96
+ return run_public_command(namespace, namespace.public_command, args, kwargs)
97
+
98
+
99
+ def _handle_call(namespace):
100
+ ensure_developer_mode_enabled("call")
101
+ target = import_dotted_path(namespace.target, project_only=True)
102
+ args, kwargs = _load_call_arguments(namespace)
103
+ init_args, init_kwargs = _load_call_arguments(namespace, prefix="init_")
104
+ return run_project_call(
105
+ namespace,
106
+ target,
107
+ method_name=namespace.method,
108
+ init_args=init_args,
109
+ init_kwargs=init_kwargs,
110
+ args=args,
111
+ kwargs=kwargs,
112
+ client_invoke=namespace.client_invoke,
113
+ )
114
+
115
+
116
+ def _handle_registry_public(_namespace):
117
+ return [
118
+ {
119
+ "group": command.group,
120
+ "command": command.cli_name,
121
+ "method": command.name,
122
+ "path": command.dotted_path,
123
+ }
124
+ for command in discover_public_commands()
125
+ ]
126
+
127
+
128
+ def _handle_auth_login(namespace):
129
+ return login(namespace)
130
+
131
+
132
+ def _handle_auth_logout(namespace):
133
+ return logout(namespace)
134
+
135
+
136
+ def _project_settings_payload(settings):
137
+ return {
138
+ "project_root": str(find_project_root()),
139
+ "settings_path": str(get_settings_path()),
140
+ "developer_mode": settings.developer_mode,
141
+ }
142
+
143
+
144
+ def _confirm_developer_mode_enable(namespace):
145
+ if namespace.accept_dangerous_features:
146
+ return
147
+
148
+ if not sys.stdin.isatty():
149
+ raise CLIError(
150
+ "Enabling developer mode requires explicit confirmation with "
151
+ "--accept-dangerous-features. {}".format(DEVELOPER_MODE_WARNING)
152
+ )
153
+
154
+ print(DEVELOPER_MODE_WARNING, file=sys.stderr)
155
+ print(
156
+ "Type '{}' to continue: ".format(DEVELOPER_MODE_CONFIRMATION),
157
+ file=sys.stderr,
158
+ end="",
159
+ )
160
+ response = sys.stdin.readline().strip()
161
+ if response != DEVELOPER_MODE_CONFIRMATION:
162
+ raise CLIError("Developer mode was not enabled")
163
+
164
+
165
+ def _handle_settings_show(_namespace):
166
+ return _project_settings_payload(load_project_settings())
167
+
168
+
169
+ def _handle_settings_developer_mode(namespace):
170
+ current = load_project_settings()
171
+ if namespace.enable and current.developer_mode:
172
+ return _project_settings_payload(current)
173
+
174
+ if namespace.enable:
175
+ _confirm_developer_mode_enable(namespace)
176
+ settings = ProjectSettings(developer_mode=True)
177
+ else:
178
+ settings = ProjectSettings(developer_mode=False)
179
+
180
+ save_project_settings(settings)
181
+ return _project_settings_payload(settings)
182
+
183
+
184
+ def _handle_registry_project(namespace):
185
+ ensure_developer_mode_enabled("registry project")
186
+ records = discover_project_callables()
187
+ if namespace.prefix:
188
+ records = [record for record in records if record.dotted_path.startswith(namespace.prefix)]
189
+ output = []
190
+ for record in records:
191
+ item = {
192
+ "path": record.dotted_path,
193
+ "kind": record.kind,
194
+ }
195
+ if record.method_name:
196
+ item["method"] = record.method_name
197
+ output.append(item)
198
+ return output
199
+
200
+
201
+ def build_parser():
202
+ parser = argparse.ArgumentParser(prog="telethon-cli")
203
+ subparsers = parser.add_subparsers(dest="command")
204
+
205
+ public_commands = discover_public_commands()
206
+ grouped = {}
207
+ for command in public_commands:
208
+ grouped.setdefault(command.group, []).append(command)
209
+
210
+ for group, commands in grouped.items():
211
+ group_parser = subparsers.add_parser(group, help="{} commands".format(group))
212
+ group_subparsers = group_parser.add_subparsers(dest="{}_command".format(group))
213
+ for command in commands:
214
+ command_parser = group_subparsers.add_parser(
215
+ command.cli_name,
216
+ help=command.summary or command.name,
217
+ description=command.summary or command.dotted_path,
218
+ )
219
+ add_client_arguments(command_parser)
220
+ _add_output_argument(command_parser)
221
+ _add_json_arguments(command_parser)
222
+
223
+ parameter_names = []
224
+ for param in _iter_signature_parameters(command.signature):
225
+ parameter_names.append(param.name)
226
+ command_parser.add_argument(
227
+ "--{}".format(param.name.replace("_", "-")),
228
+ dest="param_{}".format(param.name),
229
+ default=MISSING,
230
+ type=parse_cli_value,
231
+ )
232
+
233
+ command_parser.set_defaults(
234
+ handler=_handle_public_command,
235
+ public_command=command,
236
+ parameter_names=tuple(parameter_names),
237
+ )
238
+
239
+ call_parser = subparsers.add_parser("call", help="Invoke any telethon/telethon_generator callable")
240
+ call_parser.add_argument("target", help="Dotted path under telethon or telethon_generator")
241
+ call_parser.add_argument("--method", help="Method name when the target is a class")
242
+ call_parser.add_argument(
243
+ "--client-invoke",
244
+ action="store_true",
245
+ help="Instantiate/call the target and invoke the result through TelegramClient",
246
+ )
247
+ add_client_arguments(call_parser)
248
+ _add_output_argument(call_parser)
249
+ _add_json_arguments(call_parser)
250
+ _add_json_arguments(call_parser, flag_prefix="init-", dest_prefix="init_")
251
+ call_parser.set_defaults(handler=_handle_call)
252
+
253
+ registry_parser = subparsers.add_parser("registry", help="Inspect discovered CLI coverage")
254
+ _add_output_argument(registry_parser)
255
+ registry_subparsers = registry_parser.add_subparsers(dest="registry_command")
256
+
257
+ public_parser = registry_subparsers.add_parser("public", help="List public TelegramClient commands")
258
+ _add_output_argument(public_parser)
259
+ public_parser.set_defaults(handler=_handle_registry_public)
260
+
261
+ project_parser = registry_subparsers.add_parser("project", help="List discovered project callables")
262
+ _add_output_argument(project_parser)
263
+ project_parser.add_argument("--prefix", help="Filter dotted paths by prefix")
264
+ project_parser.set_defaults(handler=_handle_registry_project)
265
+
266
+ login_parser = subparsers.add_parser("login", help="Create or refresh a Telegram session")
267
+ add_client_arguments(login_parser)
268
+ _add_output_argument(login_parser)
269
+ login_parser.set_defaults(handler=_handle_auth_login)
270
+
271
+ logout_parser = subparsers.add_parser("logout", help="Log out and remove the saved Telegram session")
272
+ add_client_arguments(logout_parser)
273
+ _add_output_argument(logout_parser)
274
+ logout_parser.set_defaults(handler=_handle_auth_logout)
275
+
276
+ settings_parser = subparsers.add_parser("settings", help="Manage project settings")
277
+ settings_subparsers = settings_parser.add_subparsers(dest="settings_command")
278
+
279
+ settings_show_parser = settings_subparsers.add_parser("show", help="Show project settings")
280
+ _add_output_argument(settings_show_parser)
281
+ settings_show_parser.set_defaults(handler=_handle_settings_show)
282
+
283
+ developer_mode_parser = settings_subparsers.add_parser(
284
+ "developer-mode",
285
+ help="Enable or disable project-wide developer mode",
286
+ )
287
+ _add_output_argument(developer_mode_parser)
288
+ action_group = developer_mode_parser.add_mutually_exclusive_group(required=True)
289
+ action_group.add_argument("--enable", action="store_true", help="Enable developer mode")
290
+ action_group.add_argument("--disable", action="store_true", help="Disable developer mode")
291
+ developer_mode_parser.add_argument(
292
+ "--accept-dangerous-features",
293
+ action="store_true",
294
+ help="Confirm that you understand developer mode enables dangerous features",
295
+ )
296
+ developer_mode_parser.set_defaults(handler=_handle_settings_developer_mode)
297
+
298
+ return parser
299
+
300
+
301
+ def main(argv=None):
302
+ parser = build_parser()
303
+ namespace = parser.parse_args(argv)
304
+ if not hasattr(namespace, "handler"):
305
+ parser.print_help(sys.stderr)
306
+ return 1
307
+
308
+ try:
309
+ result = namespace.handler(namespace)
310
+ except CLIError as exc:
311
+ print(str(exc), file=sys.stderr)
312
+ return 2
313
+ except KeyboardInterrupt:
314
+ print("Interrupted", file=sys.stderr)
315
+ return 130
316
+
317
+ output = render_output(result, getattr(namespace, "output", "text"))
318
+ if output:
319
+ print(output)
320
+ return 0
@@ -0,0 +1,106 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ MODE_LIVE_SAFE = "live-safe"
5
+ MODE_LIVE_DISPOSABLE = "live-disposable"
6
+ MODE_MOCKED = "mocked"
7
+ MODE_EXCLUDED_LIVE = "excluded-live"
8
+ LIVE_MODES = frozenset({MODE_LIVE_SAFE, MODE_LIVE_DISPOSABLE})
9
+ NON_LIVE_MODES = frozenset({MODE_MOCKED, MODE_EXCLUDED_LIVE})
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CommandTestSpec:
14
+ group: str
15
+ cli_name: str
16
+ mode: str
17
+ fixture: str
18
+ cleanup: str
19
+ reason: str = ""
20
+
21
+ @property
22
+ def key(self):
23
+ return (self.group, self.cli_name)
24
+
25
+ @property
26
+ def method_name(self):
27
+ return self.cli_name.replace("-", "_")
28
+
29
+
30
+ def _spec(group, cli_name, mode, fixture, cleanup, reason=""):
31
+ return CommandTestSpec(
32
+ group=group,
33
+ cli_name=cli_name,
34
+ mode=mode,
35
+ fixture=fixture,
36
+ cleanup=cleanup,
37
+ reason=reason,
38
+ )
39
+
40
+
41
+ PUBLIC_COMMAND_MATRIX = (
42
+ _spec("auth", "start", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
43
+ _spec("auth", "sign-in", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
44
+ _spec("auth", "sign-up", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
45
+ _spec("auth", "send-code-request", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
46
+ _spec("auth", "qr-login", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
47
+ _spec("auth", "log-out", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
48
+ _spec("auth", "edit-2fa", MODE_EXCLUDED_LIVE, "none", "none", "security-state-change"),
49
+ _spec("base", "connect", MODE_LIVE_SAFE, "none", "none"),
50
+ _spec("base", "is-connected", MODE_LIVE_SAFE, "none", "none"),
51
+ _spec("base", "disconnect", MODE_LIVE_SAFE, "none", "none"),
52
+ _spec("base", "set-proxy", MODE_MOCKED, "none", "none", "local-network-config"),
53
+ _spec("messages", "iter-messages", MODE_LIVE_SAFE, "me", "none"),
54
+ _spec("messages", "get-messages", MODE_LIVE_SAFE, "me", "none"),
55
+ _spec("messages", "send-message", MODE_LIVE_DISPOSABLE, "me", "delete-created-message"),
56
+ _spec("messages", "forward-messages", MODE_LIVE_DISPOSABLE, "disposable-message", "delete-created-message"),
57
+ _spec("messages", "edit-message", MODE_LIVE_DISPOSABLE, "disposable-message", "none"),
58
+ _spec("messages", "delete-messages", MODE_LIVE_DISPOSABLE, "disposable-message", "handled-in-command"),
59
+ _spec("messages", "send-read-acknowledge", MODE_LIVE_DISPOSABLE, "disposable-message", "none"),
60
+ _spec("messages", "pin-message", MODE_LIVE_DISPOSABLE, "disposable-message", "restore-unpinned-state"),
61
+ _spec("messages", "unpin-message", MODE_LIVE_DISPOSABLE, "disposable-message", "none"),
62
+ _spec("uploads", "send-file", MODE_LIVE_DISPOSABLE, "temp-file", "delete-created-message"),
63
+ _spec("uploads", "upload-file", MODE_LIVE_DISPOSABLE, "temp-file", "delete-temp-file"),
64
+ _spec("downloads", "download-profile-photo", MODE_LIVE_DISPOSABLE, "me", "delete-temp-file"),
65
+ _spec("downloads", "download-media", MODE_MOCKED, "none", "none", "requires-message-object"),
66
+ _spec("downloads", "download-file", MODE_MOCKED, "none", "none", "requires-input-location"),
67
+ _spec("downloads", "iter-download", MODE_MOCKED, "none", "none", "requires-input-location"),
68
+ _spec("dialogs", "iter-dialogs", MODE_LIVE_SAFE, "none", "none"),
69
+ _spec("dialogs", "get-dialogs", MODE_LIVE_SAFE, "none", "none"),
70
+ _spec("dialogs", "iter-drafts", MODE_LIVE_SAFE, "me", "none"),
71
+ _spec("dialogs", "get-drafts", MODE_LIVE_SAFE, "me", "none"),
72
+ _spec("dialogs", "edit-folder", MODE_EXCLUDED_LIVE, "none", "none", "mutates-dialog-state"),
73
+ _spec("dialogs", "delete-dialog", MODE_EXCLUDED_LIVE, "none", "none", "destructive-dialog-state"),
74
+ _spec("dialogs", "conversation", MODE_MOCKED, "none", "none", "interactive-conversation-loop"),
75
+ _spec("users", "get-me", MODE_LIVE_SAFE, "none", "none"),
76
+ _spec("users", "is-bot", MODE_LIVE_SAFE, "none", "none"),
77
+ _spec("users", "is-user-authorized", MODE_LIVE_SAFE, "none", "none"),
78
+ _spec("users", "get-entity", MODE_LIVE_SAFE, "me", "none"),
79
+ _spec("users", "get-input-entity", MODE_LIVE_SAFE, "me", "none"),
80
+ _spec("users", "get-peer-id", MODE_LIVE_SAFE, "me", "none"),
81
+ _spec("chats", "iter-participants", MODE_MOCKED, "none", "none", "requires-chat-fixture"),
82
+ _spec("chats", "get-participants", MODE_MOCKED, "none", "none", "requires-chat-fixture"),
83
+ _spec("chats", "iter-admin-log", MODE_MOCKED, "none", "none", "requires-admin-chat"),
84
+ _spec("chats", "get-admin-log", MODE_MOCKED, "none", "none", "requires-admin-chat"),
85
+ _spec("chats", "iter-profile-photos", MODE_MOCKED, "none", "none", "requires-peer-fixture"),
86
+ _spec("chats", "get-profile-photos", MODE_MOCKED, "none", "none", "requires-peer-fixture"),
87
+ _spec("chats", "action", MODE_MOCKED, "none", "none", "ephemeral-side-effect"),
88
+ _spec("chats", "edit-admin", MODE_EXCLUDED_LIVE, "none", "none", "mutates-admin-state"),
89
+ _spec("chats", "edit-permissions", MODE_EXCLUDED_LIVE, "none", "none", "mutates-permissions-state"),
90
+ _spec("chats", "kick-participant", MODE_EXCLUDED_LIVE, "none", "none", "destructive-membership-state"),
91
+ _spec("chats", "get-permissions", MODE_MOCKED, "none", "none", "requires-chat-fixture"),
92
+ _spec("chats", "get-stats", MODE_MOCKED, "none", "none", "requires-channel-stats"),
93
+ _spec("updates", "set-receive-updates", MODE_MOCKED, "none", "none", "mutates-session-update-state"),
94
+ _spec("updates", "run-until-disconnected", MODE_MOCKED, "none", "none", "long-running-loop"),
95
+ _spec("updates", "on", MODE_MOCKED, "none", "none", "callback-registration"),
96
+ _spec("updates", "add-event-handler", MODE_MOCKED, "none", "none", "callback-registration"),
97
+ _spec("updates", "remove-event-handler", MODE_MOCKED, "none", "none", "callback-registration"),
98
+ _spec("updates", "list-event-handlers", MODE_MOCKED, "none", "none", "callback-registration"),
99
+ _spec("updates", "catch-up", MODE_MOCKED, "none", "none", "requires-update-queue"),
100
+ _spec("bots", "inline-query", MODE_MOCKED, "none", "none", "requires-inline-bot"),
101
+ _spec("buttons", "build-reply-markup", MODE_MOCKED, "none", "none", "pure-builder"),
102
+ _spec("account", "takeout", MODE_EXCLUDED_LIVE, "none", "none", "account-export-state"),
103
+ _spec("account", "end-takeout", MODE_EXCLUDED_LIVE, "none", "none", "account-export-state"),
104
+ )
105
+
106
+ PUBLIC_COMMAND_MATRIX_BY_KEY = {spec.key: spec for spec in PUBLIC_COMMAND_MATRIX}
telethon_cli/config.py ADDED
@@ -0,0 +1,195 @@
1
+ from dataclasses import dataclass
2
+ import os
3
+ from pathlib import Path
4
+ import shlex
5
+
6
+ from .errors import CLIError
7
+ from .settings import find_project_root
8
+
9
+
10
+ ENV_API_ID = "TELETHON_API_ID"
11
+ ENV_API_HASH = "TELETHON_API_HASH"
12
+ ENV_SESSION = "TELETHON_SESSION"
13
+ ENV_PASSWORD = "TELETHON_PASSWORD"
14
+ SUPPORTED_ENV_KEYS = (ENV_API_ID, ENV_API_HASH, ENV_SESSION, ENV_PASSWORD)
15
+ DEFAULT_ENV_FILE_NAME = ".env"
16
+ DEFAULT_SESSION_DIR_NAME = ".telethon-cli"
17
+ DEFAULT_SESSION_FILE_NAME = "session"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ClientConfig:
22
+ api_id: int
23
+ api_hash: str
24
+ session: str
25
+ password: str = None
26
+
27
+
28
+ def add_client_arguments(parser):
29
+ parser.add_argument("--api-id", dest="api_id", type=int, help="Telegram API ID")
30
+ parser.add_argument("--api-hash", dest="api_hash", help="Telegram API hash")
31
+ parser.add_argument("--session", dest="session", help="Telethon session name or path")
32
+ parser.add_argument("--2fa-password", dest="password", help="Telegram 2FA password")
33
+ parser.add_argument(
34
+ "--env-file",
35
+ dest="env_file",
36
+ help="Path to a dotenv file with Telethon credentials",
37
+ )
38
+
39
+
40
+ def _strip_inline_comment(raw_value):
41
+ in_single = False
42
+ in_double = False
43
+ escaped = False
44
+
45
+ for index, char in enumerate(raw_value):
46
+ if escaped:
47
+ escaped = False
48
+ continue
49
+ if char == "\\":
50
+ escaped = True
51
+ continue
52
+ if char == "'" and not in_double:
53
+ in_single = not in_single
54
+ continue
55
+ if char == '"' and not in_single:
56
+ in_double = not in_double
57
+ continue
58
+ if char == "#" and not in_single and not in_double:
59
+ if index == 0 or raw_value[index - 1].isspace():
60
+ return raw_value[:index].rstrip()
61
+ return raw_value.strip()
62
+
63
+
64
+ def _parse_dotenv_value(raw_value, path, line_number):
65
+ value = _strip_inline_comment(raw_value).strip()
66
+ if not value:
67
+ return ""
68
+
69
+ if value[0] in ("'", '"'):
70
+ try:
71
+ parsed = shlex.split(value, posix=True)
72
+ except ValueError as exc:
73
+ raise CLIError(
74
+ "Invalid dotenv value for line {} in '{}': {}".format(line_number, path, exc)
75
+ ) from exc
76
+ if len(parsed) != 1:
77
+ raise CLIError(
78
+ "Invalid dotenv value for line {} in '{}': {}".format(
79
+ line_number,
80
+ path,
81
+ raw_value.strip(),
82
+ )
83
+ )
84
+ return parsed[0]
85
+
86
+ return value
87
+
88
+
89
+ def _load_dotenv_file(path):
90
+ values = {}
91
+ try:
92
+ content = path.read_text(encoding="utf-8")
93
+ except OSError as exc:
94
+ raise CLIError("Unable to read env file '{}': {}".format(path, exc)) from exc
95
+
96
+ for line_number, raw_line in enumerate(content.splitlines(), start=1):
97
+ line = raw_line.strip()
98
+ if not line or line.startswith("#"):
99
+ continue
100
+ if "=" not in raw_line:
101
+ raise CLIError("Invalid dotenv line {} in '{}': {}".format(line_number, path, raw_line))
102
+
103
+ key, raw_value = raw_line.split("=", 1)
104
+ key = key.strip()
105
+ if key.startswith("export "):
106
+ key = key[len("export "):].strip()
107
+ if not key:
108
+ raise CLIError("Invalid dotenv line {} in '{}': {}".format(line_number, path, raw_line))
109
+ if key not in SUPPORTED_ENV_KEYS:
110
+ continue
111
+ values[key] = _parse_dotenv_value(raw_value, path, line_number)
112
+
113
+ return values
114
+
115
+
116
+ def _resolve_env_file(namespace):
117
+ explicit_path = getattr(namespace, "env_file", None)
118
+ if explicit_path:
119
+ path = Path(explicit_path).expanduser()
120
+ if not path.is_absolute():
121
+ path = Path.cwd() / path
122
+ path = path.resolve()
123
+ if not path.exists():
124
+ raise CLIError("Env file '{}' does not exist".format(path))
125
+ if path.is_dir():
126
+ raise CLIError("Env file '{}' must be a file".format(path))
127
+ return path
128
+
129
+ candidates = []
130
+ cwd_path = (Path.cwd() / DEFAULT_ENV_FILE_NAME).resolve()
131
+ candidates.append(cwd_path)
132
+
133
+ project_root_path = (find_project_root() / DEFAULT_ENV_FILE_NAME).resolve()
134
+ if project_root_path not in candidates:
135
+ candidates.append(project_root_path)
136
+
137
+ for candidate in candidates:
138
+ if candidate.exists() and candidate.is_file():
139
+ return candidate
140
+ return None
141
+
142
+
143
+ def _merge_environment(environ, dotenv_values, dotenv_path):
144
+ merged = dict(environ)
145
+ conflicts = []
146
+
147
+ for key, dotenv_value in dotenv_values.items():
148
+ env_value = environ.get(key)
149
+ if env_value is None:
150
+ merged[key] = dotenv_value
151
+ continue
152
+ if env_value != dotenv_value:
153
+ conflicts.append("{} (environment vs {})".format(key, dotenv_path))
154
+
155
+ if conflicts:
156
+ raise CLIError(
157
+ "Conflicting Telethon client config values: {}".format(", ".join(conflicts))
158
+ )
159
+
160
+ return merged
161
+
162
+
163
+ def _default_session_path(start_path=None):
164
+ project_root = find_project_root(start_path)
165
+ return project_root / DEFAULT_SESSION_DIR_NAME / DEFAULT_SESSION_FILE_NAME
166
+
167
+
168
+ def load_client_config(namespace, environ=None):
169
+ environ = os.environ if environ is None else environ
170
+ dotenv_path = _resolve_env_file(namespace)
171
+ if dotenv_path is not None:
172
+ environ = _merge_environment(environ, _load_dotenv_file(dotenv_path), dotenv_path)
173
+
174
+ api_id = namespace.api_id if getattr(namespace, "api_id", None) is not None else environ.get(ENV_API_ID)
175
+ api_hash = namespace.api_hash if getattr(namespace, "api_hash", None) is not None else environ.get(ENV_API_HASH)
176
+ session = namespace.session if getattr(namespace, "session", None) is not None else environ.get(ENV_SESSION)
177
+ password = namespace.password if getattr(namespace, "password", None) is not None else environ.get(ENV_PASSWORD)
178
+
179
+ missing = []
180
+ if api_id in (None, ""):
181
+ missing.append(ENV_API_ID)
182
+ if not api_hash:
183
+ missing.append(ENV_API_HASH)
184
+ if missing:
185
+ raise CLIError("Missing Telethon client config: {}".format(", ".join(missing)))
186
+
187
+ try:
188
+ api_id = int(api_id)
189
+ except (TypeError, ValueError) as exc:
190
+ raise CLIError("{} must be an integer".format(ENV_API_ID)) from exc
191
+
192
+ if not session:
193
+ session = str(_default_session_path())
194
+
195
+ return ClientConfig(api_id=api_id, api_hash=api_hash, session=session, password=password)
telethon_cli/errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class CLIError(Exception):
2
+ """Raised for recoverable CLI errors."""