socdl 0.1.1__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.
socdl/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """socdl - Social media downloader CLI.
2
+
3
+ A friendly, fast command-line tool to download photos & videos from
4
+ Instagram, TikTok, YouTube, Twitter/X, Reddit, Facebook and more.
5
+ """
6
+
7
+ __version__ = "0.1.1"
8
+ __author__ = "Erzam Bayu"
9
+ __license__ = "MIT"
10
+ __url__ = "https://github.com/Erzambayu/socdl"
11
+
12
+ from .platforms import detect_platform # re-export for convenience
13
+
14
+ __all__ = ["__version__", "detect_platform"]
socdl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable `python -m socdl` invocation."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
socdl/cli.py ADDED
@@ -0,0 +1,443 @@
1
+ """socdl command-line entry point."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import click
12
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
13
+
14
+ from . import __version__, history, updater
15
+ from . import config as cfgmod
16
+ from .i18n import set_lang, t
17
+ from .platforms import detect_platform
18
+ from .router import download as route_download
19
+ from .ui import (
20
+ console,
21
+ err,
22
+ hr,
23
+ kv,
24
+ muted,
25
+ notice,
26
+ ok,
27
+ print_banner,
28
+ print_help_table,
29
+ print_history,
30
+ prompt_url,
31
+ warn,
32
+ )
33
+
34
+ URL_RE = re.compile(r"https?://\S+")
35
+
36
+ SUBCOMMANDS = {"watch", "history", "config", "update", "detect"}
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Windows UTF-8 sanity
41
+ # ---------------------------------------------------------------------------
42
+ def _fix_windows_encoding() -> None:
43
+ if sys.platform == "win32":
44
+ try:
45
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
46
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
47
+ except Exception:
48
+ pass
49
+ os.system("")
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ def _load_cfg_and_lang(lang_override: Optional[str] = None) -> cfgmod.Config:
54
+ cfg = cfgmod.load()
55
+ set_lang(lang_override or cfg.language or "en")
56
+ return cfg
57
+
58
+
59
+ def _extract_urls(text: str) -> list[str]:
60
+ return [m.group(0).rstrip(",;)]}") for m in URL_RE.finditer(text or "")]
61
+
62
+
63
+ def _read_batch_file(path: str) -> list[str]:
64
+ urls: list[str] = []
65
+ with open(path, encoding="utf-8") as f:
66
+ for line in f:
67
+ line = line.strip()
68
+ if not line or line.startswith("#"):
69
+ continue
70
+ urls.extend(_extract_urls(line) or ([line] if line.startswith("http") else []))
71
+ return urls
72
+
73
+
74
+ def _maybe_notify_update() -> None:
75
+ info = updater.is_outdated()
76
+ if info:
77
+ current, latest = info
78
+ muted(t("update_available", current=current, latest=latest))
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ def _handle_url(url: str, cfg: cfgmod.Config) -> bool:
83
+ url = url.strip().rstrip(",;")
84
+ det = detect_platform(url)
85
+
86
+ hr()
87
+ kv(t("platform"), det.label)
88
+ kv(t("kind"), det.kind)
89
+ kv(t("url"), url)
90
+
91
+ if det.platform == "unknown":
92
+ warn(t("unknown_platform"))
93
+
94
+ engine_used = ["-"]
95
+ with Progress(
96
+ SpinnerColumn(style="brand"),
97
+ TextColumn("[accent]{task.description}"),
98
+ BarColumn(bar_width=None),
99
+ TimeElapsedColumn(),
100
+ console=console,
101
+ transient=True,
102
+ ) as progress:
103
+ task = progress.add_task(f"{t('downloading')}...", start=True, total=None)
104
+
105
+ def on_engine(name: str) -> None:
106
+ engine_used[0] = name
107
+ progress.update(task, description=f"{t('downloading')} · [muted]{name}[/muted]")
108
+
109
+ result = route_download(url, det, cfg, progress_cb=on_engine)
110
+
111
+ if result.ok:
112
+ ok(t("success", path=str(result.output_dir)))
113
+ else:
114
+ err(t("fail_generic", code=result.exit_code) +
115
+ (f" {result.message}" if result.message else ""))
116
+ if det.platform == "instagram" and not cfg.instagram_login:
117
+ muted(t("ig_login_needed"))
118
+
119
+ if cfg.log_history:
120
+ history.record(
121
+ platform=det.platform,
122
+ kind=det.kind,
123
+ url=url,
124
+ engine=result.engine,
125
+ status="success" if result.ok else "failed",
126
+ output_dir=str(result.output_dir),
127
+ message=result.message,
128
+ )
129
+ return result.ok
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Interactive mode
134
+ # ---------------------------------------------------------------------------
135
+ def _interactive(cfg: cfgmod.Config) -> None:
136
+ print_banner()
137
+ kv(t("folder"), str(cfg.resolved_output_dir()))
138
+ muted(t("prompt_hint"))
139
+ console.print()
140
+
141
+ while True:
142
+ try:
143
+ raw = prompt_url()
144
+ except (EOFError, KeyboardInterrupt):
145
+ console.print()
146
+ break
147
+
148
+ if not raw:
149
+ continue
150
+
151
+ if raw.startswith("/"):
152
+ parts = raw[1:].split(maxsplit=1)
153
+ cmd = parts[0].lower() if parts else ""
154
+ arg = parts[1] if len(parts) > 1 else ""
155
+ if not _handle_command(cmd, arg, cfg):
156
+ break
157
+ continue
158
+
159
+ if raw.lower() in ("q", "quit", "exit", ":q"):
160
+ break
161
+
162
+ urls = _extract_urls(raw)
163
+ if not urls:
164
+ warn(t("no_url"))
165
+ continue
166
+ for u in urls:
167
+ _handle_url(u, cfg)
168
+
169
+ ok(t("goodbye"))
170
+
171
+
172
+ def _handle_command(cmd: str, arg: str, cfg: cfgmod.Config) -> bool:
173
+ if cmd in ("quit", "exit", "q"):
174
+ return False
175
+ if cmd == "help":
176
+ print_help_table()
177
+ return True
178
+ if cmd == "config":
179
+ _open_path(cfgmod.config_path())
180
+ notice(str(cfgmod.config_path()))
181
+ return True
182
+ if cmd == "history":
183
+ print_history(history.recent(limit=20))
184
+ return True
185
+ if cmd == "open":
186
+ _open_path(cfg.resolved_output_dir())
187
+ return True
188
+ if cmd == "paste":
189
+ try:
190
+ import pyperclip
191
+ text = pyperclip.paste()
192
+ except Exception:
193
+ text = ""
194
+ urls = _extract_urls(text)
195
+ if not urls:
196
+ warn(t("watch_no_clip"))
197
+ else:
198
+ for u in urls:
199
+ _handle_url(u, cfg)
200
+ return True
201
+ if cmd == "watch":
202
+ _run_watcher(cfg)
203
+ return True
204
+ if cmd == "lang":
205
+ lang = (arg or "").strip().lower()
206
+ if lang in ("en", "id"):
207
+ set_lang(lang)
208
+ cfg.language = lang
209
+ cfgmod.save(cfg)
210
+ ok(t("cfg_saved"))
211
+ else:
212
+ warn("Usage: /lang en | /lang id")
213
+ return True
214
+ warn(f"Unknown command: /{cmd}")
215
+ return True
216
+
217
+
218
+ def _open_path(path: Path) -> None:
219
+ if path.suffix:
220
+ path.parent.mkdir(parents=True, exist_ok=True)
221
+ else:
222
+ path.mkdir(parents=True, exist_ok=True)
223
+ try:
224
+ if sys.platform == "win32":
225
+ os.startfile(str(path)) # noqa
226
+ elif sys.platform == "darwin":
227
+ subprocess.run(["open", str(path)], check=False)
228
+ else:
229
+ subprocess.run(["xdg-open", str(path)], check=False)
230
+ except Exception:
231
+ pass
232
+
233
+
234
+ def _run_watcher(cfg: cfgmod.Config) -> None:
235
+ from .watcher import watch
236
+ ok(t("watch_started"))
237
+ muted(t("watch_hint"))
238
+ try:
239
+ watch(on_link=lambda u: (notice(t("watch_detected"), "accent"), _handle_url(u, cfg)))
240
+ except KeyboardInterrupt:
241
+ console.print()
242
+ warn(t("watch_stopped"))
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Click CLI
247
+ # ---------------------------------------------------------------------------
248
+ @click.group(
249
+ invoke_without_command=True,
250
+ context_settings={"help_option_names": ["-h", "--help"]},
251
+ )
252
+ @click.version_option(__version__, "-V", "--version", prog_name="socdl")
253
+ @click.option("--lang", type=click.Choice(["en", "id"]), default=None, help="Force UI language.")
254
+ @click.option("-o", "--output", type=click.Path(file_okay=False), default=None,
255
+ help="Override output directory.")
256
+ @click.option("-q", "--quality",
257
+ type=click.Choice(["best", "1080p", "720p", "480p", "360p", "audio"]),
258
+ default=None, help="Override quality.")
259
+ @click.option("-f", "--file", "batch_file", type=click.Path(exists=True, dir_okay=False),
260
+ default=None, help="Download URLs listed in a text file (one per line).")
261
+ @click.pass_context
262
+ def cli(ctx: click.Context, lang: Optional[str], output: Optional[str],
263
+ quality: Optional[str], batch_file: Optional[str]) -> None:
264
+ """socdl - download photos & videos from social media, fast."""
265
+ _fix_windows_encoding()
266
+ cfg = _load_cfg_and_lang(lang)
267
+ if output:
268
+ cfg.output_dir = output
269
+ if quality:
270
+ cfg.quality = quality
271
+
272
+ ctx.obj = cfg
273
+
274
+ if ctx.invoked_subcommand is None and batch_file:
275
+ print_banner()
276
+ for u in _read_batch_file(batch_file):
277
+ _handle_url(u, cfg)
278
+ if cfg.check_updates:
279
+ _maybe_notify_update()
280
+ elif ctx.invoked_subcommand is None:
281
+ _interactive(cfg)
282
+ if cfg.check_updates:
283
+ _maybe_notify_update()
284
+
285
+
286
+ # ---- subcommands ----
287
+
288
+ @cli.command("watch")
289
+ @click.pass_obj
290
+ def cmd_watch(cfg: cfgmod.Config) -> None:
291
+ """Start clipboard watcher - auto-download any supported link you copy."""
292
+ print_banner()
293
+ _run_watcher(cfg)
294
+
295
+
296
+ @cli.command("history")
297
+ @click.option("-n", "--limit", default=20, show_default=True, help="How many entries to show.")
298
+ @click.option("-p", "--platform", default=None, help="Filter by platform.")
299
+ @click.option("--clear", "do_clear", is_flag=True, help="Delete all history.")
300
+ @click.pass_obj
301
+ def cmd_history(cfg: cfgmod.Config, limit: int, platform: Optional[str], do_clear: bool) -> None:
302
+ """Show or clear download history."""
303
+ if do_clear:
304
+ n = history.clear()
305
+ ok(f"Deleted {n} entries.")
306
+ return
307
+ print_history(history.recent(limit=limit, platform=platform))
308
+
309
+
310
+ @cli.command("config")
311
+ @click.option("--show", is_flag=True, help="Print current config to stdout.")
312
+ @click.option("--reset", is_flag=True, help="Overwrite with defaults.")
313
+ @click.option("--set", "kv_pairs", multiple=True, metavar="KEY=VALUE",
314
+ help="Set a config value, e.g. --set quality=1080p")
315
+ @click.pass_obj
316
+ def cmd_config(cfg: cfgmod.Config, show: bool, reset: bool, kv_pairs: tuple[str, ...]) -> None:
317
+ """Show or edit the config file."""
318
+ if reset:
319
+ cfg = cfgmod.Config()
320
+ cfgmod.save(cfg)
321
+ ok(t("cfg_saved"))
322
+
323
+ if kv_pairs:
324
+ for pair in kv_pairs:
325
+ if "=" not in pair:
326
+ warn(f"Ignored: {pair}")
327
+ continue
328
+ k, v = pair.split("=", 1)
329
+ k = k.strip()
330
+ v = v.strip()
331
+ if not hasattr(cfg, k):
332
+ warn(f"Unknown key: {k}")
333
+ continue
334
+ current = getattr(cfg, k)
335
+ if isinstance(current, bool):
336
+ setattr(cfg, k, v.lower() in ("1", "true", "yes", "on"))
337
+ elif isinstance(current, int):
338
+ try:
339
+ setattr(cfg, k, int(v))
340
+ except ValueError:
341
+ warn(f"Not an int: {v}")
342
+ else:
343
+ setattr(cfg, k, v)
344
+ cfgmod.save(cfg)
345
+ ok(t("cfg_saved"))
346
+
347
+ if show or (not reset and not kv_pairs):
348
+ console.print(f"[muted]{cfgmod.config_path()}[/muted]")
349
+ for k, v in cfg.to_dict().items():
350
+ kv(k, str(v))
351
+
352
+
353
+ @cli.command("update")
354
+ def cmd_update() -> None:
355
+ """Upgrade underlying downloaders (yt-dlp, instaloader, gallery-dl)."""
356
+ pkgs = ["yt-dlp", "instaloader", "gallery-dl"]
357
+ notice(f"Upgrading: {', '.join(pkgs)}", "accent")
358
+ code = subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", *pkgs])
359
+ if code == 0:
360
+ ok("Downloaders upgraded.")
361
+ else:
362
+ err("Upgrade failed.")
363
+
364
+ info = updater.is_outdated()
365
+ if info:
366
+ current, latest = info
367
+ notice(t("update_available", current=current, latest=latest), "warn")
368
+ else:
369
+ ok(t("update_uptodate", current=__version__))
370
+
371
+
372
+ @cli.command("detect")
373
+ @click.argument("url")
374
+ def cmd_detect(url: str) -> None:
375
+ """Print detected platform/kind for a URL (debug helper)."""
376
+ det = detect_platform(url)
377
+ kv("platform", det.platform)
378
+ kv("kind", det.kind)
379
+ kv("target", str(det.target))
380
+ kv("label", det.label)
381
+ kv("folder", det.folder_name)
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ def _looks_like_url(s: str) -> bool:
386
+ return s.startswith(("http://", "https://"))
387
+
388
+
389
+ def _grab_flag_value(argv: list[str], flags: tuple[str, ...]) -> Optional[str]:
390
+ for i, tok in enumerate(argv):
391
+ if tok in flags and i + 1 < len(argv):
392
+ return argv[i + 1]
393
+ for f in flags:
394
+ if tok.startswith(f + "="):
395
+ return tok.split("=", 1)[1]
396
+ return None
397
+
398
+
399
+ def _quick_download_mode(argv: list[str]) -> bool:
400
+ """Handle bare `socdl <url>...` before Click sees the URL as a subcommand."""
401
+ for tok in argv:
402
+ if tok in SUBCOMMANDS or tok in ("-h", "--help", "-V", "--version"):
403
+ return False
404
+ urls = [a for a in argv if _looks_like_url(a)]
405
+ if not urls:
406
+ return False
407
+
408
+ lang = _grab_flag_value(argv, ("--lang",))
409
+ output = _grab_flag_value(argv, ("-o", "--output"))
410
+ quality = _grab_flag_value(argv, ("-q", "--quality"))
411
+ batch_file = _grab_flag_value(argv, ("-f", "--file"))
412
+
413
+ cfg = _load_cfg_and_lang(lang)
414
+ if output:
415
+ cfg.output_dir = output
416
+ if quality:
417
+ cfg.quality = quality
418
+
419
+ print_banner()
420
+ all_urls = list(urls)
421
+ if batch_file:
422
+ all_urls += _read_batch_file(batch_file)
423
+ for u in all_urls:
424
+ _handle_url(u, cfg)
425
+ if cfg.check_updates:
426
+ _maybe_notify_update()
427
+ return True
428
+
429
+
430
+ def main() -> None:
431
+ _fix_windows_encoding()
432
+ argv = sys.argv[1:]
433
+ try:
434
+ if argv and _quick_download_mode(argv):
435
+ return
436
+ cli(standalone_mode=True)
437
+ except KeyboardInterrupt:
438
+ console.print()
439
+ sys.exit(130)
440
+
441
+
442
+ if __name__ == "__main__":
443
+ main()
socdl/config.py ADDED
@@ -0,0 +1,111 @@
1
+ """Config loader/writer for socdl."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import tomli_w
10
+ from platformdirs import user_config_dir, user_downloads_dir
11
+
12
+ if sys.version_info >= (3, 11):
13
+ import tomllib
14
+ else:
15
+ import tomli as tomllib
16
+
17
+
18
+ APP_NAME = "socdl"
19
+
20
+
21
+ def config_dir() -> Path:
22
+ return Path(user_config_dir(APP_NAME, appauthor=False))
23
+
24
+
25
+ def config_path() -> Path:
26
+ return config_dir() / "config.toml"
27
+
28
+
29
+ def data_dir() -> Path:
30
+ """Where history.db, sessions, etc. live."""
31
+ d = config_dir() / "data"
32
+ d.mkdir(parents=True, exist_ok=True)
33
+ return d
34
+
35
+
36
+ def default_output_dir() -> Path:
37
+ """Default: ~/Downloads/socdl-downloads (avoid clashing with a repo folder named 'socdl')."""
38
+ return Path(user_downloads_dir()) / "socdl-downloads"
39
+
40
+
41
+ @dataclass
42
+ class Config:
43
+ # I/O
44
+ output_dir: str = "" # empty -> default_output_dir()
45
+ subfolder_per_platform: bool = True
46
+ subfolder_per_uploader: bool = True
47
+
48
+ # Behavior
49
+ language: str = "en" # "en" | "id"
50
+ quality: str = "best" # best|1080p|720p|480p|360p|audio
51
+ embed_metadata: bool = True
52
+ embed_thumbnail: bool = True
53
+ concurrent_fragments: int = 4
54
+ restrict_filenames: bool = True
55
+
56
+ # Features
57
+ check_updates: bool = True
58
+ log_history: bool = True
59
+
60
+ # Instagram
61
+ instagram_login: str = ""
62
+
63
+ # Cookies (for private / age-restricted content)
64
+ cookies_from_browser: str = "" # chrome|firefox|edge|brave|opera|vivaldi
65
+
66
+ def resolved_output_dir(self) -> Path:
67
+ return Path(self.output_dir).expanduser() if self.output_dir else default_output_dir()
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return asdict(self)
71
+
72
+
73
+ def load() -> Config:
74
+ path = config_path()
75
+ if not path.exists():
76
+ cfg = Config()
77
+ save(cfg)
78
+ return cfg
79
+ try:
80
+ with path.open("rb") as f:
81
+ data = tomllib.load(f)
82
+ return _from_dict(data)
83
+ except Exception:
84
+ return Config()
85
+
86
+
87
+ def save(cfg: Config) -> Path:
88
+ path = config_path()
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ with path.open("wb") as f:
91
+ tomli_w.dump(_to_serializable(cfg.to_dict()), f)
92
+ return path
93
+
94
+
95
+ def _from_dict(data: dict[str, Any]) -> Config:
96
+ known = set(Config.__dataclass_fields__)
97
+ filtered = {k: v for k, v in data.items() if k in known}
98
+ try:
99
+ return Config(**filtered)
100
+ except TypeError:
101
+ return Config()
102
+
103
+
104
+ def _to_serializable(d: dict[str, Any]) -> dict[str, Any]:
105
+ out = {}
106
+ for k, v in d.items():
107
+ out[k] = "" if v is None else v
108
+ return out
109
+
110
+
111
+ __all__ = ["Config", "load", "save", "config_path", "config_dir", "data_dir", "default_output_dir"]
@@ -0,0 +1,13 @@
1
+ """Engine wrappers around yt-dlp, instaloader, and gallery-dl."""
2
+ from .base import EngineResult, run_subprocess
3
+ from .gallerydl import GalleryDLEngine
4
+ from .instaloader_engine import InstaloaderEngine
5
+ from .ytdlp import YtDlpEngine
6
+
7
+ __all__ = [
8
+ "EngineResult",
9
+ "run_subprocess",
10
+ "YtDlpEngine",
11
+ "InstaloaderEngine",
12
+ "GalleryDLEngine",
13
+ ]
socdl/engines/base.py ADDED
@@ -0,0 +1,117 @@
1
+ """Shared engine helpers.
2
+
3
+ Engines can run in two modes:
4
+
5
+ * **subprocess** — when an external `yt-dlp` / `gallery-dl` / `instaloader`
6
+ executable is on PATH (or importable as a module via a real Python
7
+ interpreter). This gives users the exact upstream behavior and easy
8
+ upgrades.
9
+
10
+ * **in-process** — when socdl is frozen with PyInstaller (`sys.frozen`),
11
+ or when no external executable is available but the library is
12
+ importable. We call the library API directly instead of re-spawning
13
+ `sys.executable` (which would point at socdl itself in a frozen build).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+
26
+ def is_frozen() -> bool:
27
+ """True when running from a PyInstaller / py2exe style bundle."""
28
+ return getattr(sys, "frozen", False)
29
+
30
+
31
+ @dataclass
32
+ class EngineResult:
33
+ ok: bool
34
+ exit_code: int
35
+ engine: str
36
+ output_dir: Path
37
+ message: str = ""
38
+
39
+
40
+ MODULE_ALIAS = {
41
+ "yt-dlp": "yt_dlp",
42
+ "gallery-dl": "gallery_dl",
43
+ "instaloader": "instaloader",
44
+ }
45
+
46
+
47
+ def find_executable(name: str) -> Optional[list[str]]:
48
+ """Return `[exe]` if a standalone executable is on PATH, else None."""
49
+ exe = shutil.which(name)
50
+ if exe:
51
+ return [exe]
52
+ return None
53
+
54
+
55
+ def module_available(module: str) -> bool:
56
+ try:
57
+ __import__(module)
58
+ return True
59
+ except ImportError:
60
+ return False
61
+
62
+
63
+ def find_command(name: str) -> Optional[list[str]]:
64
+ """Return a runnable command list, or None if not available.
65
+
66
+ Never falls back to `[sys.executable, "-m", ...]` when frozen, because
67
+ `sys.executable` would be the socdl binary itself (infinite recursion).
68
+ """
69
+ exe = find_executable(name)
70
+ if exe:
71
+ return exe
72
+ if is_frozen():
73
+ return None
74
+ mod = MODULE_ALIAS.get(name)
75
+ if mod and module_available(mod):
76
+ return [sys.executable, "-m", mod]
77
+ return None
78
+
79
+
80
+ def run_subprocess(cmd: Sequence[str], *, quiet: bool = False) -> int:
81
+ """Run subprocess, streaming stdout/stderr to terminal. Returns exit code."""
82
+ try:
83
+ proc = subprocess.run(
84
+ list(cmd),
85
+ check=False,
86
+ stdout=None if not quiet else subprocess.DEVNULL,
87
+ stderr=None if not quiet else subprocess.DEVNULL,
88
+ )
89
+ return proc.returncode
90
+ except FileNotFoundError:
91
+ return 127
92
+ except KeyboardInterrupt:
93
+ return 130
94
+
95
+
96
+ class BaseEngine:
97
+ name: str = "base"
98
+ module: str = ""
99
+
100
+ # -- availability -------------------------------------------------------
101
+ def is_available(self) -> bool:
102
+ return self.find_command() is not None or self.module_available()
103
+
104
+ def find_command(self) -> Optional[list[str]]:
105
+ return find_command(self.name)
106
+
107
+ def module_available(self) -> bool:
108
+ return bool(self.module) and module_available(self.module)
109
+
110
+ def should_run_in_process(self) -> bool:
111
+ """Prefer the in-process API when we can't spawn a real subprocess."""
112
+ if self.find_command() is not None:
113
+ return False
114
+ return self.module_available()
115
+
116
+ def base_cmd(self) -> Optional[list[str]]:
117
+ return self.find_command()