2026-01-01 20:37:27 -08:00
|
|
|
|
"""Packaged CLI entrypoint used by installers and console scripts.
|
|
|
|
|
|
|
|
|
|
|
|
This module provides the `main` entrypoint for `mm`/`medeia` and supports
|
|
|
|
|
|
running from a development checkout (by importing the top-level
|
|
|
|
|
|
`CLI.MedeiaCLI`) or when running tests that inject a legacy
|
|
|
|
|
|
`medeia_entry` shim into `sys.modules`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Optional, List, Tuple
|
|
|
|
|
|
import importlib
|
|
|
|
|
|
import importlib.util
|
2026-01-09 15:41:38 -08:00
|
|
|
|
import os
|
2026-01-01 20:37:27 -08:00
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
import shlex
|
|
|
|
|
|
from types import ModuleType
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_repo_root_on_sys_path(pkg_file: Optional[Path] = None) -> Optional[Path]:
|
|
|
|
|
|
"""Ensure the repository root (where top-level modules live) is importable.
|
|
|
|
|
|
|
|
|
|
|
|
The project currently keeps key modules like `CLI.py` at the repo root.
|
|
|
|
|
|
When `mm` is invoked from a different working directory, that repo root is
|
|
|
|
|
|
not necessarily on `sys.path`, which breaks `import CLI`.
|
|
|
|
|
|
|
|
|
|
|
|
We infer the repo root by walking up from this package location and looking
|
2026-01-09 15:41:38 -08:00
|
|
|
|
for a sibling `CLI.py` or checking parent directories.
|
2026-01-01 20:37:27 -08:00
|
|
|
|
|
|
|
|
|
|
`pkg_file` exists for unit tests; production uses this module's `__file__`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
pkg_dir = (pkg_file or Path(__file__)).resolve().parent
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-01-09 15:41:38 -08:00
|
|
|
|
# Strategy 1: Look for CLI.py in parent directories (starting from scripts parent)
|
2026-01-01 20:37:27 -08:00
|
|
|
|
for parent in pkg_dir.parents:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if (parent / "CLI.py").exists():
|
|
|
|
|
|
parent_str = str(parent)
|
|
|
|
|
|
if parent_str not in sys.path:
|
|
|
|
|
|
sys.path.insert(0, parent_str)
|
|
|
|
|
|
return parent
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
2026-01-09 15:41:38 -08:00
|
|
|
|
|
|
|
|
|
|
# Strategy 2: If in a venv, check the .venv/.. path (project root)
|
|
|
|
|
|
try:
|
|
|
|
|
|
# If this file is in .../venv/lib/python3.x/site-packages/scripts/
|
|
|
|
|
|
# then we want to go up to find the project root
|
|
|
|
|
|
current = pkg_dir.resolve()
|
|
|
|
|
|
for _ in range(20): # Safety limit
|
|
|
|
|
|
current = current.parent
|
|
|
|
|
|
if (current / "CLI.py").exists():
|
|
|
|
|
|
parent_str = str(current)
|
|
|
|
|
|
if parent_str not in sys.path:
|
|
|
|
|
|
sys.path.insert(0, parent_str)
|
|
|
|
|
|
return current
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-01-01 20:37:27 -08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_mode_and_strip_args(args: List[str]) -> Tuple[Optional[str], List[str]]:
|
|
|
|
|
|
"""Parse --gui/--cli/--mode flags and return (mode, cleaned_args).
|
|
|
|
|
|
|
|
|
|
|
|
The function removes any mode flags from the argument list so the selected
|
|
|
|
|
|
runner can receive the remaining arguments untouched.
|
|
|
|
|
|
|
|
|
|
|
|
Supported forms:
|
|
|
|
|
|
--gui, -g, --gui=true
|
|
|
|
|
|
--cli, -c, --cli=true
|
|
|
|
|
|
--mode=gui|cli
|
|
|
|
|
|
--mode gui|cli
|
|
|
|
|
|
|
|
|
|
|
|
Raises ValueError on conflicting or invalid flags.
|
|
|
|
|
|
"""
|
|
|
|
|
|
mode: Optional[str] = None
|
|
|
|
|
|
out: List[str] = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(args):
|
|
|
|
|
|
a = args[i]
|
|
|
|
|
|
la = a.lower()
|
|
|
|
|
|
|
|
|
|
|
|
# --gui / -g
|
|
|
|
|
|
if la in ("--gui", "-g"):
|
|
|
|
|
|
if mode and mode != "gui":
|
|
|
|
|
|
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
|
|
|
|
mode = "gui"
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if la.startswith("--gui="):
|
|
|
|
|
|
val = la.split("=", 1)[1]
|
|
|
|
|
|
if val and val not in ("0", "false", "no", "off"):
|
|
|
|
|
|
if mode and mode != "gui":
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Conflicting mode flags: found both 'gui' and 'cli'"
|
|
|
|
|
|
)
|
|
|
|
|
|
mode = "gui"
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# --cli / -c
|
|
|
|
|
|
if la in ("--cli", "-c"):
|
|
|
|
|
|
if mode and mode != "cli":
|
|
|
|
|
|
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
|
|
|
|
mode = "cli"
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if la.startswith("--cli="):
|
|
|
|
|
|
val = la.split("=", 1)[1]
|
|
|
|
|
|
if val and val not in ("0", "false", "no", "off"):
|
|
|
|
|
|
if mode and mode != "cli":
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Conflicting mode flags: found both 'gui' and 'cli'"
|
|
|
|
|
|
)
|
|
|
|
|
|
mode = "cli"
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# --mode
|
|
|
|
|
|
if la.startswith("--mode="):
|
|
|
|
|
|
val = la.split("=", 1)[1]
|
|
|
|
|
|
val = val.lower()
|
|
|
|
|
|
if val not in ("gui", "cli"):
|
|
|
|
|
|
raise ValueError("--mode must be 'gui' or 'cli'")
|
|
|
|
|
|
if mode and mode != val:
|
|
|
|
|
|
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
|
|
|
|
mode = val
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if la == "--mode":
|
|
|
|
|
|
if i + 1 >= len(args):
|
|
|
|
|
|
raise ValueError("--mode requires a value ('gui' or 'cli')")
|
|
|
|
|
|
val = args[i + 1].lower()
|
|
|
|
|
|
if val not in ("gui", "cli"):
|
|
|
|
|
|
raise ValueError("--mode must be 'gui' or 'cli'")
|
|
|
|
|
|
if mode and mode != val:
|
|
|
|
|
|
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
|
|
|
|
mode = val
|
|
|
|
|
|
i += 2
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Not a mode flag; keep it
|
|
|
|
|
|
out.append(a)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
return mode, out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_cli(clean_args: List[str]) -> int:
|
|
|
|
|
|
"""Run the CLI runner (MedeiaCLI) with cleaned argv list.
|
|
|
|
|
|
|
|
|
|
|
|
The function supports three modes (in order):
|
|
|
|
|
|
1) If a `medeia_entry` module is present in sys.modules (for tests/legacy
|
|
|
|
|
|
scenarios), use it as the source of `MedeiaCLI`.
|
|
|
|
|
|
2) If running from a development checkout, import the top-level `CLI` and
|
|
|
|
|
|
use `MedeiaCLI` from there.
|
|
|
|
|
|
3) Otherwise fail with a helpful message suggesting an editable install.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
sys.argv[1:] = list(clean_args)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# 1) Check for an in-memory 'medeia_entry' module (tests/legacy installs)
|
|
|
|
|
|
MedeiaCLI = None
|
|
|
|
|
|
if "medeia_entry" in sys.modules:
|
|
|
|
|
|
mod = sys.modules.get("medeia_entry")
|
|
|
|
|
|
if hasattr(mod, "MedeiaCLI"):
|
|
|
|
|
|
MedeiaCLI = getattr(mod, "MedeiaCLI")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Preserve the existing error message used by tests that inject a
|
|
|
|
|
|
# dummy 'medeia_entry' without a `MedeiaCLI` attribute.
|
|
|
|
|
|
raise ImportError(
|
|
|
|
|
|
"Imported module 'medeia_entry' does not define 'MedeiaCLI' and direct import of top-level 'CLI' failed.\n"
|
|
|
|
|
|
"Remedy: ensure the top-level 'medeia_entry' module exports 'MedeiaCLI' or run from the project root/debug the checkout."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 2) If no in-memory module provided the class, try importing the repo-root CLI
|
|
|
|
|
|
if MedeiaCLI is None:
|
|
|
|
|
|
try:
|
2026-01-09 15:41:38 -08:00
|
|
|
|
repo_root = _ensure_repo_root_on_sys_path()
|
2026-01-01 20:37:27 -08:00
|
|
|
|
from CLI import MedeiaCLI as _M # type: ignore
|
|
|
|
|
|
MedeiaCLI = _M
|
2026-01-09 15:41:38 -08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
# Provide diagnostic information
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
error_msg = (
|
|
|
|
|
|
"Could not import 'MedeiaCLI'. This often means the project is not available on sys.path.\n"
|
|
|
|
|
|
"Diagnostic info:\n"
|
|
|
|
|
|
f" - sys.executable: {sys.executable}\n"
|
|
|
|
|
|
f" - sys.path (first 5): {sys.path[:5]}\n"
|
|
|
|
|
|
f" - current working directory: {Path.cwd()}\n"
|
|
|
|
|
|
f" - this file: {Path(__file__).resolve()}\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
repo = _ensure_repo_root_on_sys_path()
|
|
|
|
|
|
if repo:
|
|
|
|
|
|
error_msg += f" - detected repo root: {repo}\n"
|
|
|
|
|
|
cli_path = repo / "CLI.py"
|
|
|
|
|
|
error_msg += f" - CLI.py exists at {cli_path}: {cli_path.exists()}\n"
|
|
|
|
|
|
except:
|
|
|
|
|
|
pass
|
|
|
|
|
|
error_msg += (
|
|
|
|
|
|
"\nRemedy: Run 'pip install -e scripts' from the project root or re-run the bootstrap script.\n"
|
|
|
|
|
|
"Set MM_DEBUG=1 to enable detailed diagnostics."
|
2026-01-01 20:37:27 -08:00
|
|
|
|
)
|
2026-01-09 15:41:38 -08:00
|
|
|
|
if os.environ.get("MM_DEBUG"):
|
|
|
|
|
|
error_msg += f"\n\nTraceback:\n{traceback.format_exc()}"
|
|
|
|
|
|
raise ImportError(error_msg) from exc
|
2026-01-01 20:37:27 -08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
app = MedeiaCLI()
|
|
|
|
|
|
app.run()
|
|
|
|
|
|
return 0
|
|
|
|
|
|
except SystemExit as exc:
|
|
|
|
|
|
return int(getattr(exc, "code", 0) or 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_gui(clean_args: List[str]) -> int:
|
|
|
|
|
|
"""Run the TUI runner (PipelineHubApp).
|
|
|
|
|
|
|
|
|
|
|
|
The TUI is imported lazily; if Textual or the TUI code is unavailable we
|
|
|
|
|
|
give a helpful error message and exit non‑zero.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
repo_root = _ensure_repo_root_on_sys_path()
|
|
|
|
|
|
tui_mod: Optional[ModuleType] = None
|
|
|
|
|
|
selected_module: Optional[str] = None
|
|
|
|
|
|
last_exc: Optional[Exception] = None
|
|
|
|
|
|
|
|
|
|
|
|
def _load_from_file(path: Path) -> ModuleType:
|
|
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
|
|
|
|
"medeia_tui_entry",
|
|
|
|
|
|
path,
|
|
|
|
|
|
submodule_search_locations=[str(path.parent)],
|
|
|
|
|
|
)
|
|
|
|
|
|
if spec is None or spec.loader is None:
|
|
|
|
|
|
raise ModuleNotFoundError(f"Cannot load TUI from {path}")
|
|
|
|
|
|
module = importlib.util.module_from_spec(spec)
|
|
|
|
|
|
sys.modules[spec.name] = module
|
|
|
|
|
|
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
|
|
|
|
|
return module
|
|
|
|
|
|
|
|
|
|
|
|
if repo_root is not None:
|
|
|
|
|
|
tui_file = repo_root / "TUI.py"
|
|
|
|
|
|
if tui_file.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
tui_mod = _load_from_file(tui_file)
|
|
|
|
|
|
selected_module = str(tui_file)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
last_exc = exc
|
|
|
|
|
|
if tui_mod is None:
|
|
|
|
|
|
for candidate in ("TUI.tui", "TUI"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
tui_mod = importlib.import_module(candidate)
|
|
|
|
|
|
selected_module = candidate
|
|
|
|
|
|
break
|
|
|
|
|
|
except ModuleNotFoundError as exc:
|
|
|
|
|
|
last_exc = exc
|
|
|
|
|
|
if tui_mod is None:
|
|
|
|
|
|
raise last_exc or ModuleNotFoundError("No TUI module could be imported")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(
|
|
|
|
|
|
"Error: Unable to import TUI (Textual may not be installed):",
|
|
|
|
|
|
exc,
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
PipelineHubApp = getattr(tui_mod, "PipelineHubApp")
|
|
|
|
|
|
except AttributeError:
|
|
|
|
|
|
module_hint = selected_module or "TUI"
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"Error: '{module_hint}' does not expose 'PipelineHubApp'",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
app = PipelineHubApp()
|
|
|
|
|
|
app.run()
|
|
|
|
|
|
return 0
|
|
|
|
|
|
except SystemExit as exc:
|
|
|
|
|
|
return int(getattr(exc, "code", 0) or 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
|
|
|
|
"""Entry point for console_scripts.
|
|
|
|
|
|
|
|
|
|
|
|
Accepts an optional argv list (useful for testing). Mode flags are parsed
|
|
|
|
|
|
and removed before dispatching to the selected runner.
|
|
|
|
|
|
"""
|
|
|
|
|
|
args = list(argv) if argv is not None else list(sys.argv[1:])
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
mode, clean_args = _parse_mode_and_strip_args(args)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
print(f"Error parsing mode flags: {exc}", file=sys.stderr)
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
|
|
# Early environment sanity check to detect urllib3/urllib3-future conflicts.
|
|
|
|
|
|
# When a broken urllib3 is detected we print an actionable message and
|
|
|
|
|
|
# exit early to avoid confusing import-time errors later during startup.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from SYS.env_check import ensure_urllib3_ok
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
ensure_urllib3_ok(exit_on_error=True)
|
|
|
|
|
|
except SystemExit as exc:
|
|
|
|
|
|
# Bubble out the exit code as the CLI return value for clearer
|
|
|
|
|
|
# behavior in shell sessions and scripts.
|
|
|
|
|
|
return int(getattr(exc, "code", 2) or 2)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# If the sanity check itself cannot be imported or run, don't block
|
|
|
|
|
|
# startup; we'll continue and let normal import errors surface.
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# If GUI requested, delegate directly (GUI may decide to honor any args itself)
|
|
|
|
|
|
if mode == "gui":
|
|
|
|
|
|
return _run_gui(clean_args)
|
|
|
|
|
|
|
|
|
|
|
|
# Support quoting a pipeline (or even a single full command) on the command line.
|
|
|
|
|
|
#
|
|
|
|
|
|
# - If the user provides a single argument that contains a pipe character,
|
|
|
|
|
|
# treat it as a pipeline and rewrite the args to call the internal `pipeline`
|
|
|
|
|
|
# subcommand so existing CLI pipeline handling is used.
|
|
|
|
|
|
#
|
|
|
|
|
|
# - If the user provides a single argument that contains whitespace but no pipe,
|
|
|
|
|
|
# expand it into argv tokens (PowerShell commonly encourages quoting strings).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Examples:
|
|
|
|
|
|
# mm "download-file <url> | add-tag 'x' | add-file -store local"
|
|
|
|
|
|
# mm "download-file '<url>' -query 'format:720p' -path 'C:\\out'"
|
|
|
|
|
|
if len(clean_args) == 1:
|
|
|
|
|
|
single = clean_args[0]
|
|
|
|
|
|
if "|" in single and not single.startswith("-"):
|
|
|
|
|
|
clean_args = ["pipeline", "--pipeline", single]
|
|
|
|
|
|
elif (not single.startswith("-")) and any(ch.isspace() for ch in single):
|
|
|
|
|
|
try:
|
|
|
|
|
|
expanded = shlex.split(single, posix=True)
|
|
|
|
|
|
if expanded:
|
|
|
|
|
|
clean_args = list(expanded)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Default to CLI if --cli is requested or no explicit mode provided.
|
|
|
|
|
|
return _run_cli(clean_args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
raise SystemExit(main())
|