ember-dl 0.9.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.
ember/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """Ember — extract direct media links from social platforms.
2
+
3
+ An embeddable, cobalt-like (imputnet/cobalt) Python library.
4
+
5
+ Usage:
6
+ import ember
7
+ result = ember.extract("https://www.tiktok.com/@user/video/123...")
8
+ for m in result.media:
9
+ print(m.kind, m.url)
10
+
11
+ Logging: the package logs to the "ember" logger and is silent by default
12
+ (NullHandler). To see logs from your app:
13
+ import logging; logging.basicConfig(); logging.getLogger("ember").setLevel(logging.INFO)
14
+ """
15
+
16
+ import logging as _logging
17
+
18
+ # best practice: a library must not force output — silent by default
19
+ _logging.getLogger("ember").addHandler(_logging.NullHandler())
20
+
21
+ from .errors import (
22
+ EmberError,
23
+ ExtractionError,
24
+ NetworkError,
25
+ UnsupportedUrlError,
26
+ )
27
+ from .cookies import cookies_from_browser, cookies_from_file
28
+ from .download import (DownloadProgress, RateLimiter, available_qualities,
29
+ download, download_media, ffmpeg_available, probe_size)
30
+ from .models import Media, MediaVariant, Playlist, Result, Subtitle
31
+ from .router import (can_extract, extract, extract_highlights, extract_playlist,
32
+ extract_timeline, supported_services, supports_highlights,
33
+ supports_playlist, supports_timeline)
34
+
35
+ __version__ = "0.9.0"
36
+
37
+ __all__ = [
38
+ "extract",
39
+ "extract_playlist",
40
+ "extract_timeline",
41
+ "extract_highlights",
42
+ "can_extract",
43
+ "supports_playlist",
44
+ "supports_timeline",
45
+ "supports_highlights",
46
+ "supported_services",
47
+ "cookies_from_browser",
48
+ "cookies_from_file",
49
+ "download",
50
+ "download_media",
51
+ "available_qualities",
52
+ "ffmpeg_available",
53
+ "probe_size",
54
+ "DownloadProgress",
55
+ "RateLimiter",
56
+ "MediaVariant",
57
+ "Playlist",
58
+ "Result",
59
+ "Media",
60
+ "Subtitle",
61
+ "EmberError",
62
+ "UnsupportedUrlError",
63
+ "NetworkError",
64
+ "ExtractionError",
65
+ "__version__",
66
+ ]
ember/__main__.py ADDED
@@ -0,0 +1,497 @@
1
+ """Ember CLI.
2
+
3
+ Show links: ember "URL"
4
+ Download: ember -d "URL" (site-derived name, current folder)
5
+ Custom name: ember -d -o myclip "URL"
6
+ Custom folder: ember -d -p downloads "URL"
7
+ Installed entry point is `ember`; `python -m ember` works too.
8
+ """
9
+
10
+ import argparse
11
+ import re
12
+ import shutil
13
+ import sys
14
+ import time
15
+
16
+ from . import (DownloadProgress, EmberError, __version__, available_qualities,
17
+ can_extract, cookies_from_file, download, extract,
18
+ extract_highlights, extract_playlist, extract_timeline,
19
+ probe_size, supported_services, supports_playlist,
20
+ supports_timeline)
21
+ from .http import make_context
22
+
23
+ # browsers understood by --cookies-from-browser (same set as yt-dlp)
24
+ BROWSERS = ["brave", "chrome", "chromium", "edge", "firefox",
25
+ "opera", "safari", "vivaldi", "whale"]
26
+
27
+ # options that take a value (can accidentally swallow the URL) + example values
28
+ _VALUE_OPTS = {
29
+ "-o": "myfile", "--output": "myfile", "-p": "downloads", "--path": "downloads",
30
+ "-a": "links.txt", "--batch-file": "links.txt",
31
+ "--max-height": "720", "--concurrency": "6", "--timeout": "20",
32
+ "--proxy": "http://host:port", "--cookies": '"a=1; b=2"',
33
+ "--cookies-file": "cookies.txt", "--cookies-from-browser": "firefox",
34
+ "--browser-profile": "Default",
35
+ }
36
+
37
+
38
+ def _parse_cookies_arg(raw: str) -> dict:
39
+ cookies = {}
40
+ for pair in raw.split(";"):
41
+ pair = pair.strip()
42
+ if "=" in pair:
43
+ name, _, value = pair.partition("=")
44
+ cookies[name.strip()] = value.strip()
45
+ return cookies
46
+
47
+
48
+ _parse_cookies_file = cookies_from_file # библиотечный парсер Netscape
49
+
50
+
51
+ _RATE_UNITS = {"": 1, "k": 1000, "m": 1000 ** 2, "g": 1000 ** 3,
52
+ "ki": 1024, "mi": 1024 ** 2, "gi": 1024 ** 3}
53
+
54
+
55
+ def _parse_rate(raw: str) -> float:
56
+ """'500K' / '2M' / '1.5MiB' / '800' -> bytes per second."""
57
+ m = re.fullmatch(r"\s*([\d.]+)\s*([KMGkmg]i?)?B?/?[sS]?\s*", raw)
58
+ if not m:
59
+ raise ValueError(f"could not parse rate {raw!r}")
60
+ value = float(m.group(1))
61
+ unit = (m.group(2) or "").lower()
62
+ return value * _RATE_UNITS[unit]
63
+
64
+
65
+ def _bar(frac: float, width: int = 30) -> str:
66
+ filled = int(frac * width)
67
+ if filled >= width:
68
+ return "=" * width
69
+ return "=" * filled + ">" + "-" * (width - filled - 1)
70
+
71
+
72
+ def _eta(seconds: float) -> str:
73
+ s = int(seconds)
74
+ return f"{s // 60:02d}m{s % 60:02d}s"
75
+
76
+
77
+ def _enable_vt() -> None:
78
+ """Windows: turn on VT processing so \\r and \\x1b[K redraw in place."""
79
+ if sys.platform != "win32":
80
+ return
81
+ try:
82
+ import ctypes
83
+ k = ctypes.windll.kernel32
84
+ h = k.GetStdHandle(-11) # STD_OUTPUT_HANDLE
85
+ mode = ctypes.c_uint32()
86
+ if k.GetConsoleMode(h, ctypes.byref(mode)):
87
+ k.SetConsoleMode(h, mode.value | 0x0004) # VIRTUAL_TERMINAL_PROCESSING
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ def _cols() -> int:
93
+ return shutil.get_terminal_size((80, 20)).columns
94
+
95
+
96
+ def _line(text: str) -> None:
97
+ """Redraw one line in place. Skipped when stdout is not a terminal —
98
+ there \\r would pile up as new lines."""
99
+ try:
100
+ if not sys.stdout.isatty():
101
+ return
102
+ except Exception:
103
+ return
104
+ print("\r\x1b[K" + text[:_cols() - 1], end="", flush=True)
105
+
106
+
107
+ def _make_progress_printer():
108
+ """One-line progress: MiB / MiB [bar] pct speed ETA (CLI only)."""
109
+ state = {"last": 0.0}
110
+ MiB = 1048576
111
+
112
+ def cb(p: DownloadProgress):
113
+ now = time.time()
114
+ if p.stage != "download":
115
+ _line(f" {p.stage}…")
116
+ return
117
+ if now - state["last"] < 0.1:
118
+ return
119
+ state["last"] = now
120
+ dl, spd = p.downloaded / MiB, p.speed / MiB
121
+ if p.total:
122
+ frac = p.fraction or 0
123
+ # bar shrinks so the whole line fits the console
124
+ bar = _bar(frac, max(10, _cols() - 56))
125
+ _line(f" {dl:.2f} MiB / {p.total / MiB:.2f} MiB [{bar}]"
126
+ f" {frac * 100:6.2f}% {spd:.2f} MiB/s {_eta(p.eta or 0)}")
127
+ elif p.segments_total:
128
+ _line(f" segment {p.segments_done}/{p.segments_total}"
129
+ f" {dl:.2f} MiB {spd:.2f} MiB/s")
130
+ else:
131
+ _line(f" {dl:.2f} MiB {spd:.2f} MiB/s")
132
+
133
+ return cb
134
+
135
+
136
+ def _print_result(result, size=False, ctx=None) -> None:
137
+ print(f"service: {result.service}")
138
+ print(f"type: {result.kind}")
139
+ print(f"author: {result.author or '-'}")
140
+ print(f"title: {result.title or '-'}")
141
+ if result.duration:
142
+ print(f"duration: {int(result.duration // 60)}:{int(result.duration % 60):02d}")
143
+ if result.timestamp:
144
+ from datetime import datetime, timezone
145
+ print(f"date: {datetime.fromtimestamp(result.timestamp, timezone.utc):%Y-%m-%d}")
146
+ if result.view_count is not None:
147
+ print(f"views: {result.view_count}")
148
+ if result.like_count is not None:
149
+ print(f"likes: {result.like_count}")
150
+ print(f"filename: {result.filename_hint}")
151
+ if result.thumbnail:
152
+ print(f"thumb: {result.thumbnail}")
153
+ for i, m in enumerate(result.media, 1):
154
+ q = f" [{m.quality}]" if m.quality else ""
155
+ print(f" {i}. {m.kind}{q} .{m.ext}")
156
+ print(f" {m.url}")
157
+ qs = available_qualities(m) if m.variants else []
158
+ if qs:
159
+ print(f" qualities: {qs}")
160
+ if size:
161
+ b = probe_size(m, ctx)
162
+ if b:
163
+ print(f" size: {b / 1048576:.1f} MB")
164
+ if result.subtitles:
165
+ print(f"subs: {', '.join(s.lang for s in result.subtitles)}")
166
+ if result.requires_merge:
167
+ print("! video and audio are separate — downloading needs ffmpeg")
168
+
169
+
170
+ class _Parser(argparse.ArgumentParser):
171
+ """Turns confusing argparse errors into short, clear CLI messages
172
+ (especially when a value-taking option accidentally swallows the URL)."""
173
+
174
+ def _fail(self, text: str):
175
+ # короткое сообщение без простыни usage
176
+ sys.stderr.write(f"ember: error: {text}\n")
177
+ self.exit(2)
178
+
179
+ def error(self, message):
180
+ argv = sys.argv[1:]
181
+ url = next((a for a in argv if a.startswith(("http://", "https://"))), None)
182
+
183
+ # 1) ссылка «съедена» как значение опции (напр. --cookies-from-browser URL)
184
+ if url:
185
+ i = argv.index(url)
186
+ prev = argv[i - 1] if i > 0 else None
187
+ if prev in _VALUE_OPTS:
188
+ sample = _VALUE_OPTS[prev]
189
+ self._fail(
190
+ f'"{prev}" needs a value, but your link was taken as its value.\n'
191
+ f' put the value after "{prev}", e.g.: '
192
+ f'ember {prev} {sample} "{url}"\n'
193
+ f' or just show links: ember "{url}"')
194
+
195
+ # 2) опция требует значение, но его нет (напр. в конце строки)
196
+ m = re.search(r"argument ([^:]+): expected one argument", message)
197
+ if m:
198
+ opt = m.group(1).split("/")[-1].strip()
199
+ sample = _VALUE_OPTS.get(opt, "<value>")
200
+ self._fail(f'"{opt}" needs a value, e.g.: ember {opt} {sample} "URL"')
201
+
202
+ # 3) ссылка есть, но не встала как позиционный аргумент
203
+ if "required: url" in message and url:
204
+ self._fail(f'put the link as the last argument, e.g.: ember "{url}"')
205
+
206
+ super().error(message)
207
+
208
+
209
+ def _build_parser() -> argparse.ArgumentParser:
210
+ p = _Parser(prog="ember",
211
+ description="Extract and download media (a cobalt-like library)")
212
+ p.add_argument("--version", action="version", version=f"ember {__version__}")
213
+ p.add_argument("--list-services", action="store_true",
214
+ help="print supported services and exit")
215
+ p.add_argument("url", nargs="?", help="link to a post / track / video")
216
+ p.add_argument("-a", "--batch-file", metavar="FILE",
217
+ help="read links from a file (one per line, '#' comments; '-' = stdin)")
218
+ p.add_argument("--json", action="store_true", help="print metadata as JSON")
219
+ p.add_argument("-F", "--list-formats", action="store_true",
220
+ help="list available qualities and exit (no download)")
221
+ p.add_argument("-v", "--verbose", action="count", default=0,
222
+ help="log to stderr; -vv for debug")
223
+ p.add_argument("--timeout", type=float, default=15.0, metavar="SEC",
224
+ help="per-request timeout, seconds (default 15)")
225
+ # download
226
+ p.add_argument("-d", "--download", action="store_true",
227
+ help="download the media (otherwise only links are shown)")
228
+ p.add_argument("-o", "--output", metavar="NAME",
229
+ help="output file name without extension, or a template with "
230
+ "%%(title)s/%%(author)s/%%(service)s/%%(id)s; implies --download")
231
+ p.add_argument("-p", "--path", metavar="DIR",
232
+ help="target folder (default: current folder); implies --download")
233
+ p.add_argument("--max-height", type=int, metavar="N",
234
+ help="cap quality by height, e.g. 720")
235
+ p.add_argument("--audio-only", action="store_true",
236
+ help="keep audio only (needs ffmpeg); implies --download")
237
+ p.add_argument("--subs", action="store_true",
238
+ help="also download subtitle tracks; implies --download")
239
+ p.add_argument("--thumbnail", action="store_true",
240
+ help="also save the cover image; implies --download")
241
+ p.add_argument("--write-info", action="store_true", dest="write_info",
242
+ help="save a .info.json sidecar with all metadata; implies --download")
243
+ p.add_argument("--size", action="store_true",
244
+ help="print file size before downloading (extra request)")
245
+ p.add_argument("--concurrency", type=int, default=1, metavar="N",
246
+ help="parallel HLS segments, and parallel gallery items (default 1)")
247
+ p.add_argument("--skip-existing", action="store_true", dest="skip_existing",
248
+ help="do not re-download files that already exist")
249
+ p.add_argument("--rate-limit", metavar="RATE", dest="rate_limit",
250
+ help="cap download speed, e.g. 500K, 2M, 1.5MiB (default: unlimited)")
251
+ p.add_argument("--embed-metadata", "--metadata", action="store_true",
252
+ dest="embed_metadata",
253
+ help="write title/author into the file (needs ffmpeg); implies --download")
254
+ p.add_argument("--playlist", action="store_true",
255
+ help="treat as a playlist/set (SoundCloud sets)")
256
+ p.add_argument("--timeline", action="store_true",
257
+ help="treat the URL as a profile/channel and list latest posts")
258
+ p.add_argument("--highlights", action="store_true",
259
+ help="treat the URL as a profile and list its story highlights "
260
+ "(Instagram; needs cookies)")
261
+ p.add_argument("--limit", type=int, default=30, metavar="N",
262
+ help="max items for --timeline (default 30)")
263
+ p.add_argument("--proxy", metavar="URL",
264
+ help="proxy for all requests, e.g. http://host:port "
265
+ "(helps with IP-blocked sites)")
266
+ # cookies
267
+ p.add_argument("--cookies", metavar='"name=value; ..."',
268
+ help="cookies as a string (NSFW tweets, private Instagram)")
269
+ p.add_argument("--cookies-file", metavar="cookies.txt",
270
+ help="cookies.txt in Netscape format (like yt-dlp)")
271
+ p.add_argument("--cookies-from-browser", metavar="BROWSER", choices=BROWSERS,
272
+ help="read cookies from a browser; one of: " + ", ".join(BROWSERS))
273
+ p.add_argument("--browser-profile", metavar="PROFILE",
274
+ help="browser profile for --cookies-from-browser")
275
+ return p
276
+
277
+
278
+ def _report_error(msg: str, prefix: str = "error") -> None:
279
+ """Print an error and, when relevant, a hint with CLI flags (not Python API)."""
280
+ print(f"{prefix}: {msg}", file=sys.stderr)
281
+ low = msg.lower()
282
+ if "cookie" in low and "could not read cookies" not in low and "app-bound" not in low:
283
+ print('hint: pass cookies with --cookies "name=value; ..." | '
284
+ "--cookies-file cookies.txt | --cookies-from-browser firefox",
285
+ file=sys.stderr)
286
+ if "proxy" in low or "network policy" in low:
287
+ print("hint: try another IP with --proxy http://host:port", file=sys.stderr)
288
+
289
+
290
+ def _setup_logging(verbose: int) -> None:
291
+ if verbose <= 0:
292
+ return
293
+ import logging
294
+ h = logging.StreamHandler(sys.stderr)
295
+ h.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
296
+ lg = logging.getLogger("ember")
297
+ lg.addHandler(h)
298
+ lg.setLevel(logging.DEBUG if verbose >= 2 else logging.INFO)
299
+
300
+
301
+ def _read_batch(path: str) -> list:
302
+ src = sys.stdin if path == "-" else open(path, encoding="utf-8", errors="replace")
303
+ try:
304
+ urls = []
305
+ for line in src:
306
+ line = line.strip()
307
+ if line and not line.startswith("#"):
308
+ urls.append(line)
309
+ return urls
310
+ finally:
311
+ if src is not sys.stdin:
312
+ src.close()
313
+
314
+
315
+ def _render_name(template: str, result) -> str:
316
+ if "%(" not in template:
317
+ return template
318
+ fields = {"title": result.title or "", "author": result.author or "",
319
+ "uploader": result.author or "", "service": result.service,
320
+ "id": result.filename_hint or ""}
321
+ try:
322
+ return template % fields
323
+ except (KeyError, ValueError):
324
+ return template
325
+
326
+
327
+ def _list_formats(result) -> None:
328
+ print(f"# {result.service}: {result.title or result.filename_hint}")
329
+ for i, m in enumerate(result.media, 1):
330
+ qs = available_qualities(m)
331
+ label = ", ".join(f"{h}p" for h in qs) if qs else (m.quality or "single")
332
+ print(f" {i}. {m.kind} .{m.ext}: {label}")
333
+ for s in result.subtitles:
334
+ print(f" sub: {s.lang} ({s.ext})")
335
+
336
+
337
+ def main() -> int:
338
+ # Windows-консоль часто cp1251/cp866 -> не-ASCII падает/рисуется квадратами.
339
+ # Данные в str корректны; правим только вывод.
340
+ for _s in (sys.stdout, sys.stderr):
341
+ try:
342
+ _s.reconfigure(encoding="utf-8", errors="replace")
343
+ except (AttributeError, ValueError):
344
+ pass
345
+ _enable_vt()
346
+ args = _build_parser().parse_args()
347
+ _setup_logging(args.verbose)
348
+
349
+ if args.list_services:
350
+ print(", ".join(supported_services()))
351
+ return 0
352
+
353
+ urls = []
354
+ if args.url:
355
+ urls.append(args.url)
356
+ if args.batch_file:
357
+ try:
358
+ urls += _read_batch(args.batch_file)
359
+ except OSError as e:
360
+ print(f"could not read batch file: {e}", file=sys.stderr)
361
+ return 1
362
+ if not urls:
363
+ print("error: provide a URL or --batch-file FILE", file=sys.stderr)
364
+ return 2
365
+
366
+ cookies = {}
367
+ if args.cookies_file:
368
+ try:
369
+ cookies.update(_parse_cookies_file(args.cookies_file))
370
+ except OSError as e:
371
+ print(f"could not read cookies file: {e}", file=sys.stderr)
372
+ return 1
373
+ if args.cookies:
374
+ cookies.update(_parse_cookies_arg(args.cookies))
375
+
376
+ rate_limit = None
377
+ if args.rate_limit:
378
+ try:
379
+ rate_limit = _parse_rate(args.rate_limit)
380
+ except ValueError as e:
381
+ print(f"error: {e} (use e.g. 500K, 2M, 1.5MiB)", file=sys.stderr)
382
+ return 2
383
+
384
+ proxies = {"http": args.proxy, "https": args.proxy} if args.proxy else None
385
+ common = dict(timeout=args.timeout, proxies=proxies, cookies=cookies or None,
386
+ cookies_from_browser=args.cookies_from_browser,
387
+ browser_profile=args.browser_profile)
388
+
389
+ do_download = (args.download or args.output or args.path or args.audio_only
390
+ or args.embed_metadata or args.subs or args.thumbnail
391
+ or args.write_info or args.skip_existing or args.rate_limit)
392
+ dl_ctx = make_context(timeout=args.timeout, proxies=proxies) if (do_download or args.size) else None
393
+ cb = _make_progress_printer()
394
+ rc = 0
395
+
396
+ for url in urls:
397
+ # профиль/канал -> лента; авто, если ссылка не пост
398
+ is_timeline = (not args.highlights
399
+ and (args.timeline
400
+ or (supports_timeline(url) and not can_extract(url))))
401
+ try:
402
+ if args.highlights:
403
+ pl = extract_highlights(url, limit=args.limit, **common)
404
+ results = pl.entries
405
+ print(f"highlights: {pl.author or '-'} ({len(results)} collections)")
406
+ elif is_timeline:
407
+ pl = extract_timeline(url, limit=args.limit, **common)
408
+ results = pl.entries
409
+ if do_download:
410
+ results = list(reversed(results)) # старые -> новые
411
+ print(f"timeline: {pl.author or '-'} ({len(results)} items)")
412
+ elif args.playlist or supports_playlist(url):
413
+ playlist = extract_playlist(url, **common)
414
+ results = playlist.entries
415
+ print(f"playlist: {playlist.title or '-'} ({len(results)} items)")
416
+ else:
417
+ results = [extract(url, **common)]
418
+ except EmberError as e:
419
+ _report_error(str(e))
420
+ rc = 1
421
+ continue
422
+ except Exception as e: # ничего не должно долетать сюда сырым
423
+ print(f"error: {type(e).__name__}: {e}", file=sys.stderr)
424
+ rc = 1
425
+ continue
426
+
427
+ # лента/highlights без -d: компактный список, не вываливаем полные блоки
428
+ if ((is_timeline or args.highlights) and not do_download
429
+ and not args.json and not args.list_formats):
430
+ for i, r in enumerate(results, 1):
431
+ extra = (f" [{len(r.media)} items]" if args.highlights
432
+ else f" {r.source_url}")
433
+ print(f" {i:2d}. {(r.title or r.filename_hint or '')[:60]}{extra}")
434
+ continue
435
+
436
+ if args.json:
437
+ import json
438
+ payload = [r.to_dict() for r in results]
439
+ print(json.dumps(payload if len(payload) > 1 else payload[0],
440
+ ensure_ascii=False, indent=2))
441
+ continue
442
+ if args.list_formats:
443
+ for r in results:
444
+ _list_formats(r)
445
+ continue
446
+ if not do_download:
447
+ for r in results:
448
+ _print_result(r, size=args.size, ctx=dl_ctx)
449
+ if len(results) > 1 or len(urls) > 1:
450
+ print("-" * 40)
451
+ continue
452
+
453
+ out_dir = args.path or "."
454
+ single = len(results) == 1 and len(urls) == 1
455
+ if args.output and "%(" not in args.output and not single:
456
+ print(f'warning: -o "{args.output}" is a literal name but there are '
457
+ "multiple files — ignored, using site-derived names instead. "
458
+ "Use a template, e.g. -o \"%(title)s\"", file=sys.stderr)
459
+ for r in results:
460
+ print(f"downloading: {r.title or r.filename_hint}")
461
+ if args.output and "%(" in args.output:
462
+ name = _render_name(args.output, r) # шаблон — на каждый результат
463
+ elif args.output and single:
464
+ name = args.output # литеральное имя — только один файл
465
+ else:
466
+ name = None # иначе имя с сайта
467
+ try:
468
+ paths = download(r, out_dir, filename=name, ctx=dl_ctx,
469
+ max_height=args.max_height,
470
+ concurrency=args.concurrency, on_progress=cb,
471
+ audio_only=args.audio_only,
472
+ embed_metadata=args.embed_metadata,
473
+ subtitles=args.subs, thumbnail=args.thumbnail,
474
+ write_info=args.write_info,
475
+ skip_existing=args.skip_existing,
476
+ rate_limit=rate_limit)
477
+ except EmberError as e:
478
+ print()
479
+ _report_error(str(e), prefix=" error")
480
+ rc = 1
481
+ continue
482
+ _line("") # стираем строку прогресса
483
+ for p in paths:
484
+ print(f"\r saved: {p}")
485
+ return rc
486
+
487
+
488
+ def _run() -> int:
489
+ try:
490
+ return main()
491
+ except KeyboardInterrupt:
492
+ print("\ncancelled", file=sys.stderr)
493
+ return 130 # общепринятый код для SIGINT
494
+
495
+
496
+ if __name__ == "__main__":
497
+ sys.exit(_run())