Files
Medios-Macina/scripts/bootstrap.py
2025-12-31 22:58:54 -08:00

995 lines
42 KiB
Python

#!/usr/bin/env python3
"""scripts/bootstrap.py
Unified project bootstrap helper (Python-only).
This script installs Python dependencies from `scripts/requirements.txt` and then
downloads Playwright browser binaries by running `python -m playwright install`.
By default this script installs **Chromium** only to conserve space; pass
`--browsers all` to install all supported engines (chromium, firefox, webkit).
Note: This Python script is the canonical installer for the project — prefer
running `python ./scripts/bootstrap.py` locally. The platform scripts
(`scripts/bootstrap.ps1` and `scripts/bootstrap.sh`) are now thin wrappers
that delegate to this script (they call it with `--no-delegate -q`).
When invoked without any arguments, `bootstrap.py` will automatically select and
run the platform-specific bootstrap helper (`scripts/bootstrap.ps1` on Windows
or `scripts/bootstrap.sh` on POSIX) in **non-interactive (quiet)** mode so a
single `python ./scripts/bootstrap.py` call does the usual bootstrap on your OS.
The platform bootstrap scripts also attempt (best-effort) to install `mpv` if
it is not found on your PATH, since some workflows use it.
This file replaces the old `scripts/setup.py` to ensure the repository only has
one `setup.py` (at the repository root) for packaging.
Usage:
python ./scripts/bootstrap.py # install deps and playwright browsers (or run platform bootstrap if no args)
python ./scripts/bootstrap.py --skip-deps
python ./scripts/bootstrap.py --playwright-only
Optional flags:
--skip-deps Skip `pip install -r scripts/requirements.txt` step
--no-playwright Skip running `python -m playwright install` (still installs deps)
--playwright-only Install only Playwright browsers (installs playwright package if missing)
--browsers Comma-separated list of Playwright browsers to install (default: chromium)
--install-editable Install the project in editable mode (pip install -e scripts) for running tests
--install-deno Install the Deno runtime using the official installer
--no-deno Skip installing the Deno runtime
--deno-version Pin a specific Deno version to install (e.g., v1.34.3)
--upgrade-pip Upgrade pip, setuptools, and wheel before installing deps
"""
from __future__ import annotations
import argparse
import os
import platform
from pathlib import Path
import shutil
import subprocess
import sys
import time
def run(cmd: list[str]) -> None:
print(f"> {' '.join(cmd)}")
subprocess.check_call(cmd)
# Helpers to find shell executables and to run the platform-specific
# bootstrap script (scripts/bootstrap.sh or scripts/bootstrap.ps1).
def _find_powershell() -> str | None:
for name in ("pwsh", "powershell"):
p = shutil.which(name)
if p:
return p
return None
def _find_shell() -> str | None:
for name in ("bash", "sh"):
p = shutil.which(name)
if p:
return p
return None
def run_platform_bootstrap(repo_root: Path) -> int:
"""Run the platform bootstrap script in quiet/non-interactive mode if present.
Returns the script exit code (0 on success). If no script is present this is a
no-op and returns 0.
"""
ps1 = repo_root / "scripts" / "bootstrap.ps1"
sh_script = repo_root / "scripts" / "bootstrap.sh"
system = platform.system().lower()
if system == "windows" and ps1.exists():
exe = _find_powershell()
if not exe:
print("PowerShell not found; cannot run bootstrap.ps1", file=sys.stderr)
return 1
cmd = [
exe,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(ps1),
"-Quiet",
]
elif sh_script.exists():
shell = _find_shell()
if not shell:
print("Shell not found; cannot run bootstrap.sh", file=sys.stderr)
return 1
# Use -q (quiet) to skip interactive prompts when supported.
cmd = [shell, str(sh_script), "-q"]
else:
# Nothing to run
return 0
print("Running platform bootstrap script:", " ".join(cmd))
rc = subprocess.run(cmd, cwd=str(repo_root))
if rc.returncode != 0:
print(
f"Bootstrap script failed with exit code {rc.returncode}",
file=sys.stderr
)
return int(rc.returncode or 0)
def playwright_package_installed() -> bool:
try:
import playwright # type: ignore
return True
except Exception:
return False
def _build_playwright_install_cmd(browsers: str | None) -> list[str]:
"""Return the command to install Playwright browsers.
- If browsers is None or empty: default to install Chromium only.
- If browsers contains 'all': install all engines by running 'playwright install' with no extra args.
- Otherwise, validate entries and return a command that installs the named engines.
"""
base = [sys.executable, "-m", "playwright", "install"]
if not browsers:
return base + ["chromium"]
items = [b.strip().lower() for b in browsers.split(",") if b.strip()]
if not items:
return base + ["chromium"]
if "all" in items:
return base
allowed = {"chromium",
"firefox",
"webkit"}
invalid = [b for b in items if b not in allowed]
if invalid:
raise ValueError(
f"invalid browsers specified: {invalid}. Valid choices: chromium, firefox, webkit, or 'all'"
)
return base + items
def _install_deno(version: str | None = None) -> int:
"""Install Deno runtime for the current platform.
Uses the official Deno install scripts:
- Unix/macOS: curl -fsSL https://deno.land/x/install/install.sh | sh [-s <version>]
- Windows: powershell iwr https://deno.land/x/install/install.ps1 -useb | iex; Install-Deno [-Version <version>]
Returns exit code 0 on success, non-zero otherwise.
"""
system = platform.system().lower()
try:
if system == "windows":
# Use official PowerShell installer
if version:
ver = version if version.startswith("v") else f"v{version}"
ps_cmd = f"iwr https://deno.land/x/install/install.ps1 -useb | iex; Install-Deno -Version {ver}"
else:
ps_cmd = "iwr https://deno.land/x/install/install.ps1 -useb | iex"
run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
ps_cmd
]
)
else:
# POSIX: use curl + sh installer
if version:
ver = version if version.startswith("v") else f"v{version}"
cmd = f"curl -fsSL https://deno.land/x/install/install.sh | sh -s {ver}"
else:
cmd = "curl -fsSL https://deno.land/x/install/install.sh | sh"
run(["sh", "-c", cmd])
# Check that 'deno' is now available in PATH
if shutil.which("deno"):
print(f"Deno installed at: {shutil.which('deno')}")
return 0
print(
"Deno installation completed but 'deno' not found in PATH. You may need to add Deno's bin directory to your PATH manually.",
file=sys.stderr,
)
return 1
except subprocess.CalledProcessError as exc:
print(f"Deno install failed: {exc}", file=sys.stderr)
return int(exc.returncode or 1)
def main() -> int:
parser = argparse.ArgumentParser(
description="Bootstrap Medios-Macina: install deps and Playwright browsers"
)
parser.add_argument(
"--skip-deps",
action="store_true",
help="Skip installing Python dependencies from scripts/requirements.txt",
)
parser.add_argument(
"--no-playwright",
action="store_true",
help="Skip running 'playwright install' (only install packages)",
)
parser.add_argument(
"--playwright-only",
action="store_true",
help="Only run 'playwright install' (skips dependency installation)",
)
parser.add_argument(
"--no-delegate",
action="store_true",
help="Do not delegate to platform bootstrap scripts; run the Python bootstrap directly.",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Quiet mode: minimize informational output (useful when called from platform wrappers)",
)
parser.add_argument(
"--browsers",
type=str,
default="chromium",
help=
"Comma-separated list of browsers to install: chromium,firefox,webkit or 'all' (default: chromium)",
)
parser.add_argument(
"--install-editable",
action="store_true",
help="Install the project in editable mode (pip install -e scripts) for running tests",
)
deno_group = parser.add_mutually_exclusive_group()
deno_group.add_argument(
"--install-deno",
action="store_true",
help="Install the Deno runtime (default behavior; kept for explicitness)",
)
deno_group.add_argument(
"--no-deno",
action="store_true",
help="Skip installing Deno runtime (opt out)"
)
parser.add_argument(
"--deno-version",
type=str,
default=None,
help="Specific Deno version to install (e.g., v1.34.3)",
)
parser.add_argument(
"--upgrade-pip",
action="store_true",
help="Upgrade pip/setuptools/wheel before installing requirements",
)
parser.add_argument(
"--uninstall",
action="store_true",
help="Uninstall local .venv and user shims (non-interactive)",
)
parser.add_argument(
"-y",
"--yes",
action="store_true",
help="Assume yes for confirmation prompts during uninstall",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parent.parent
# Helpers for interactive menu and uninstall detection
def _venv_python_path(p: Path) -> Path | None:
"""Return the path to a python executable inside a venv directory if present."""
if (p / "Scripts" / "python.exe").exists():
return p / "Scripts" / "python.exe"
if (p / "bin" / "python").exists():
return p / "bin" / "python"
return None
def _is_installed() -> bool:
"""Return True if the project appears installed into the local .venv."""
vdir = repo_root / ".venv"
py = _venv_python_path(vdir)
if py is None:
return False
try:
rc = subprocess.run([str(py), "-m", "pip", "show", "medeia-macina"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
return rc.returncode == 0
except Exception:
return False
def _do_uninstall() -> int:
"""Attempt to remove the local venv and any shims written to the user's bin.
If this script is running using the Python inside the local `.venv`, we
attempt to re-run the uninstall using a Python interpreter outside the
venv (so files can be removed on Windows). If no suitable external
interpreter can be found, the user is asked to deactivate the venv and
re-run the uninstall.
"""
vdir = repo_root / ".venv"
if not vdir.exists():
if not args.quiet:
print("No local .venv found; nothing to uninstall.")
return 0
# If the current interpreter is the one inside the local venv, try to
# run the uninstall via a Python outside the venv so files (including
# the interpreter binary) can be removed on Windows.
try:
current_exe = Path(sys.executable).resolve()
in_venv = str(current_exe).lower().startswith(str(vdir.resolve()).lower())
except Exception:
in_venv = False
if in_venv:
if not args.quiet:
print(f"Detected local venv Python in use: {current_exe}")
if not args.yes:
try:
resp = input("Uninstall will be attempted using a system Python outside the .venv. Continue? [Y/n]: ")
except EOFError:
print("Non-interactive environment; pass --uninstall --yes to uninstall without prompts.", file=sys.stderr)
return 2
if resp.strip().lower() in ("n", "no"):
print("Uninstall aborted.")
return 1
def _find_external_python() -> list[str] | None:
"""Return a command (list) for a Python interpreter outside the venv, or None."""
try:
base = Path(sys.base_prefix)
candidates: list[Path | str] = []
if platform.system().lower() == "windows":
candidates.append(base / "python.exe")
else:
candidates.extend([base / "bin" / "python3", base / "bin" / "python"])
for name in ("python3", "python"):
p = shutil.which(name)
if p:
candidates.append(Path(p))
# Special-case the Windows py launcher: ensure it resolves
# to a Python outside the venv before returning ['py','-3']
if platform.system().lower() == "windows":
py_launcher = shutil.which("py")
if py_launcher:
try:
out = subprocess.check_output(["py", "-3", "-c", "import sys; print(sys.executable)"], text=True).strip()
if out and not str(Path(out).resolve()).lower().startswith(str(vdir.resolve()).lower()):
return ["py", "-3"]
except Exception:
pass
for c in candidates:
try:
if isinstance(c, Path) and c.exists():
c_resolved = Path(c).resolve()
if not str(c_resolved).lower().startswith(str(vdir.resolve()).lower()) and c_resolved != current_exe:
return [str(c_resolved)]
except Exception:
continue
except Exception:
pass
return None
ext = _find_external_python()
if ext:
cmd = ext + [str(repo_root / "scripts" / "bootstrap.py"), "--uninstall", "--yes"]
if not args.quiet:
print("Attempting uninstall using external Python:", " ".join(cmd))
rc = subprocess.run(cmd)
if rc.returncode != 0:
print(
f"External uninstall exited with {rc.returncode}; ensure no processes are using files in {vdir} and try again.",
file=sys.stderr,
)
return int(rc.returncode or 0)
print(
"Could not find a Python interpreter outside the local .venv. Please deactivate your venv (run 'deactivate') or run the uninstall from a system Python:\n python ./scripts/bootstrap.py --uninstall --yes",
file=sys.stderr,
)
return 2
# Normal (non-venv) uninstall flow: confirm and remove launchers, shims, and venv
if not args.yes:
try:
prompt = input(f"Remove local virtualenv at {vdir} and installed user shims? [y/N]: ")
except EOFError:
print("Non-interactive environment; pass --uninstall --yes to uninstall without prompts.", file=sys.stderr)
return 2
if prompt.strip().lower() not in ("y", "yes"):
print("Uninstall aborted.")
return 1
# Remove repo-local launchers
def _remove_launcher(path: Path) -> None:
if path.exists():
try:
path.unlink()
if not args.quiet:
print(f"Removed local launcher: {path}")
except Exception as exc:
print(f"Warning: failed to remove {path}: {exc}", file=sys.stderr)
scripts_launcher = repo_root / "scripts" / "mm.ps1"
_remove_launcher(scripts_launcher)
for legacy in ("mm", "mm.ps1", "mm.bat"):
_remove_launcher(repo_root / legacy)
# Remove user shims that the installer may have written
try:
system = platform.system().lower()
if system == "windows":
user_bin = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "bin"
if user_bin.exists():
for name in ("mm.ps1",):
p = user_bin / name
if p.exists():
try:
p.unlink()
if not args.quiet:
print(f"Removed user shim: {p}")
except Exception as exc:
print(f"Warning: failed to remove {p}: {exc}", file=sys.stderr)
else:
user_bin = Path(os.environ.get("XDG_BIN_HOME", str(Path.home() / ".local/bin")))
if user_bin.exists():
p = user_bin / "mm"
if p.exists():
p.unlink()
if not args.quiet:
print(f"Removed user shim: {p}")
except Exception as exc:
print(f"Warning: failed to remove user shims: {exc}", file=sys.stderr)
# Remove .venv directory
try:
shutil.rmtree(vdir)
if not args.quiet:
print(f"Removed local virtualenv: {vdir}")
except Exception as exc:
print(f"Failed to remove venv: {exc}", file=sys.stderr)
return 1
return 0
def _interactive_menu() -> str | int:
"""Show a simple interactive menu to choose install/uninstall or delegate."""
try:
installed = _is_installed()
while True:
print("\nMedeia-Macina bootstrap - interactive menu")
if installed:
print("1) Install / Reinstall")
print("2) Uninstall")
print("3) Status")
print("q) Quit")
choice = input("Choose an option: ").strip().lower()
if not choice or choice in ("1", "install", "reinstall"):
return "install"
if choice in ("2", "uninstall"):
return "uninstall"
if choice in ("3", "status"):
print("Installation detected." if installed else "Not installed.")
continue
if choice in ("q", "quit", "exit"):
return 0
else:
print("1) Install")
print("q) Quit")
choice = input("Choose an option: ").strip().lower()
if not choice or choice in ("1", "install"):
return "install"
if choice in ("q", "quit", "exit"):
return 0
except EOFError:
# Non-interactive, fall back to delegating to platform helper
return "delegate"
# If the user passed --uninstall explicitly, perform non-interactive uninstall and exit
if args.uninstall:
return _do_uninstall()
# If invoked without any arguments and not asked to skip delegation, prefer
# the interactive menu when running in a TTY; otherwise delegate to the
# platform-specific bootstrap helper (non-interactive).
if len(sys.argv) == 1 and not args.no_delegate:
if sys.stdin.isatty() and not args.quiet:
sel = _interactive_menu()
if sel == "install":
# user chose to install/reinstall; set defaults and continue
args.skip_deps = False
args.install_editable = True
args.no_playwright = False
elif sel == "uninstall":
return _do_uninstall()
elif sel == "delegate":
rc = run_platform_bootstrap(repo_root)
if rc != 0:
return rc
if not args.quiet:
print("Platform bootstrap completed successfully.")
return 0
else:
return int(sel or 0)
else:
rc = run_platform_bootstrap(repo_root)
if rc != 0:
return rc
if not args.quiet:
print("Platform bootstrap completed successfully.")
return 0
if sys.version_info < (3, 8):
print("Warning: Python 3.8+ is recommended.", file=sys.stderr)
# Opinionated: always create or use a local venv at the project root (.venv)
venv_dir = repo_root / ".venv"
def _venv_python(p: Path) -> Path:
if platform.system().lower() == "windows":
return p / "Scripts" / "python.exe"
return p / "bin" / "python"
def _ensure_local_venv() -> Path:
"""Create (if missing) and return the path to the venv's python executable."""
try:
if not venv_dir.exists():
if not args.quiet:
print(f"Creating local virtualenv at: {venv_dir}")
run([sys.executable, "-m", "venv", str(venv_dir)])
else:
if not args.quiet:
print(f"Using existing virtualenv at: {venv_dir}")
py = _venv_python(venv_dir)
if not py.exists():
# Try recreating venv if python is missing
if not args.quiet:
print(f"Local venv python not found at {py}; recreating venv")
run([sys.executable, "-m", "venv", str(venv_dir)])
py = _venv_python(venv_dir)
if not py.exists():
raise RuntimeError(f"Unable to locate venv python at {py}")
return py
except subprocess.CalledProcessError as exc:
print(f"Failed to create or prepare local venv: {exc}", file=sys.stderr)
raise
def _ensure_pip_available(python_path: Path) -> None:
"""Ensure pip is available inside the venv; fall back to ensurepip if needed."""
try:
subprocess.run(
[str(python_path), "-m", "pip", "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
return
except Exception:
pass
if not args.quiet:
print("Bootstrapping pip inside the local virtualenv...")
try:
run([str(python_path), "-m", "ensurepip", "--upgrade"])
except subprocess.CalledProcessError as exc:
print(
"Failed to install pip inside the local virtualenv via ensurepip; ensure your Python build includes ensurepip and retry.",
file=sys.stderr,
)
raise
# Ensure a local venv is present and use it for subsequent installs.
venv_python = _ensure_local_venv()
if not args.quiet:
print(f"Using venv python: {venv_python}")
_ensure_pip_available(venv_python)
# Enforce opinionated behavior: install deps, playwright, deno, and install project in editable mode.
# Ignore `--skip-deps` and `--install-editable` flags to keep the setup deterministic.
args.skip_deps = False
args.install_editable = True
args.no_playwright = False
try:
if args.playwright_only:
if not playwright_package_installed():
if not args.quiet:
print("'playwright' package not found; installing it via pip...")
run([sys.executable, "-m", "pip", "install", "playwright"])
if not args.quiet:
print(
"Installing Playwright browsers (this may download several hundred MB)..."
)
try:
cmd = _build_playwright_install_cmd(args.browsers)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
run(cmd)
if not args.quiet:
print("Playwright browsers installed successfully.")
return 0
if args.upgrade_pip:
if not args.quiet:
print("Upgrading pip, setuptools, and wheel in local venv...")
run(
[
str(venv_python),
"-m",
"pip",
"install",
"--upgrade",
"pip",
"setuptools",
"wheel",
]
)
if not args.skip_deps:
req_file = repo_root / "scripts" / "requirements.txt"
if not req_file.exists():
print(
f"requirements.txt not found at {req_file}; skipping dependency installation.",
file=sys.stderr,
)
else:
if not args.quiet:
print(
f"Installing Python dependencies into local venv from {req_file}..."
)
run([str(venv_python), "-m", "pip", "install", "-r", str(req_file)])
if not args.no_playwright:
if not playwright_package_installed():
if not args.quiet:
print("'playwright' package not installed in venv; installing it...")
run([str(venv_python), "-m", "pip", "install", "playwright"])
if not args.quiet:
print(
"Installing Playwright browsers (this may download several hundred MB)..."
)
try:
cmd = _build_playwright_install_cmd(args.browsers)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
# Run Playwright install using the venv's python so binaries are available in venv
cmd[0] = str(venv_python)
run(cmd)
# Install the project into the local venv (editable mode is the default, opinionated)
if not args.quiet:
print("Installing project into local venv (editable mode)")
run([str(venv_python), "-m", "pip", "install", "-e", str(repo_root / "scripts")])
# Verify top-level 'CLI' import and, if missing, attempt to make it available
if not args.quiet:
print("Verifying top-level 'CLI' import in venv...")
try:
rc = subprocess.run(
[
str(venv_python),
"-c",
"import importlib; importlib.import_module('CLI')"
],
check=False,
)
if rc.returncode == 0:
print("OK: top-level 'CLI' is importable in the venv.")
else:
print(
"Top-level 'CLI' not importable; attempting to add repo path to venv site-packages via a .pth file..."
)
cmd = [
str(venv_python),
"-c",
(
"import site, sysconfig\n"
"out=[]\n"
"try:\n out.extend(site.getsitepackages())\nexcept Exception:\n pass\n"
"try:\n p = sysconfig.get_paths().get('purelib')\n if p:\n out.append(p)\nexcept Exception:\n pass\n"
"seen=[]; res=[]\n"
"for x in out:\n if x and x not in seen:\n seen.append(x); res.append(x)\n"
"for s in res:\n print(s)\n"
),
]
out = subprocess.check_output(cmd, text=True).strip().splitlines()
site_dir: Path | None = None
for sp in out:
if sp and Path(sp).exists():
site_dir = Path(sp)
break
if site_dir is None:
print(
"Could not determine venv site-packages directory; skipping .pth fallback"
)
else:
pth_file = site_dir / "medeia_repo.pth"
if pth_file.exists():
txt = pth_file.read_text(encoding="utf-8")
if str(repo_root) in txt:
print(f".pth already contains repo root: {pth_file}")
else:
with pth_file.open("a", encoding="utf-8") as fh:
fh.write(str(repo_root) + "\n")
print(f"Appended repo root to existing .pth: {pth_file}")
else:
with pth_file.open("w", encoding="utf-8") as fh:
fh.write(str(repo_root) + "\n")
print(
f"Wrote .pth adding repo root to venv site-packages: {pth_file}"
)
# Re-check whether CLI can be imported now
rc2 = subprocess.run(
[
str(venv_python),
"-c",
"import importlib; importlib.import_module('CLI')",
],
check=False,
)
if rc2.returncode == 0:
print("Top-level 'CLI' import works after adding .pth")
else:
print(
"Adding .pth did not make top-level 'CLI' importable; consider creating an egg-link or checking the venv."
)
except Exception as exc:
print(
f"Warning: failed to verify or modify site-packages for top-level CLI: {exc}"
)
# Optional: install Deno runtime (default: install unless --no-deno is passed)
install_deno_requested = True
if getattr(args, "no_deno", False):
install_deno_requested = False
elif getattr(args, "install_deno", False):
install_deno_requested = True
if install_deno_requested:
if not args.quiet:
print("Installing Deno runtime (local/system)...")
rc = _install_deno(args.deno_version)
if rc != 0:
print("Deno installation failed.", file=sys.stderr)
return rc
# Write project-local launcher script under scripts/ to keep the repo root uncluttered.
def _write_launchers() -> None:
launcher_dir = repo_root / "scripts"
launcher_dir.mkdir(parents=True, exist_ok=True)
ps1 = launcher_dir / "mm.ps1"
ps1_text = r"""Param([Parameter(ValueFromRemainingArguments=$true)] $args)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repo = (Resolve-Path (Join-Path $scriptDir "..")).Path
$venv = Join-Path $repo '.venv'
# Ensure venv Scripts dir is on PATH for provider discovery
$venvScripts = Join-Path $venv 'Scripts'
if (Test-Path $venvScripts) { $env:PATH = $venvScripts + ';' + $env:PATH }
$py = Join-Path $venv 'Scripts\python.exe'
$cli = Join-Path $repo 'CLI.py'
if (Test-Path $py) { & $py -m medeia_macina.cli_entry @args; exit $LASTEXITCODE }
if (Test-Path $cli) { & python $cli @args; exit $LASTEXITCODE }
# fallback
python -m medeia_macina.cli_entry @args
"""
try:
ps1.write_text(ps1_text, encoding="utf-8")
except Exception:
pass
_write_launchers()
# Install user-global shims so `mm` can be executed from any shell session.
def _install_user_shims(repo: Path) -> None:
try:
home = Path.home()
system = platform.system().lower()
if system == "windows":
user_bin = Path(os.environ.get("USERPROFILE", str(home))) / "bin"
user_bin.mkdir(parents=True, exist_ok=True)
# Write mm.ps1 (PowerShell shim)
mm_ps1 = user_bin / "mm.ps1"
ps1_text = (
"Param([Parameter(ValueFromRemainingArguments=$true)] $args)\n"
f'$repo = "{repo}"\n'
"$venv = Join-Path $repo '.venv'\n"
"$exe = Join-Path $venv 'Scripts\\mm.exe'\n"
"if (Test-Path $exe) { & $exe @args; exit $LASTEXITCODE }\n"
"$py = Join-Path $venv 'Scripts\\python.exe'\n"
"if (Test-Path $py) {\n"
" if ($env:MM_DEBUG) {\n"
' Write-Host "MM_DEBUG: diagnostics" -ForegroundColor Yellow\n'
" & $py -c \"import sys,importlib,importlib.util,traceback; print('sys.executable:', sys.executable); print('sys.path (first 8):', sys.path[:8]);\"\n"
" }\n"
" & $py -m medeia_macina.cli_entry @args; exit $LASTEXITCODE\n"
"}\n"
"python -m medeia_macina.cli_entry @args\n"
)
if mm_ps1.exists():
bak = mm_ps1.with_suffix(f".bak{int(time.time())}")
mm_ps1.replace(bak)
mm_ps1.write_text(ps1_text, encoding="utf-8")
# Attempt to add user_bin to the user's PATH if it's not present.
try:
cur = os.environ.get("PATH", "")
str_bin = str(user_bin)
if str_bin not in cur:
ps_cmd = (
"$bin = '{bin}';"
"$cur = [Environment]::GetEnvironmentVariable('PATH','User');"
"if ($cur -notlike \"*$bin*\") {[Environment]::SetEnvironmentVariable('PATH', ($bin + ';' + ($cur -ne $null ? $cur : '')), 'User')}"
).format(bin=str_bin.replace("\\",
"\\\\"))
subprocess.run(
["powershell",
"-NoProfile",
"-Command",
ps_cmd],
check=False
)
except Exception:
pass
if not args.quiet:
print(f"Installed global launchers to: {user_bin}")
else:
# POSIX
user_bin = Path(
os.environ.get("XDG_BIN_HOME",
str(home / ".local/bin"))
)
user_bin.mkdir(parents=True, exist_ok=True)
mm_sh = user_bin / "mm"
sh_text = (
"#!/usr/bin/env bash\n"
"set -e\n"
f'REPO="{repo}"\n'
"# Prefer git top-level when available to avoid embedding a parent path.\n"
"if command -v git >/dev/null 2>&1; then\n"
' gitroot=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null || true)\n'
' if [ -n "$gitroot" ]; then\n'
' REPO="$gitroot"\n'
" fi\n"
"fi\n"
"# If git not available or didn't resolve, walk up from CWD to find a project root.\n"
'if [ ! -f "$REPO/CLI.py" ] && [ ! -f "$REPO/pyproject.toml" ] && [ ! -f "$REPO/scripts/pyproject.toml" ]; then\n'
' CUR="$(pwd -P)"\n'
' while [ "$CUR" != "/" ] && [ "$CUR" != "" ]; do\n'
' if [ -f "$CUR/CLI.py" ] || [ -f "$CUR/pyproject.toml" ] || [ -f "$CUR/scripts/pyproject.toml" ]; then\n'
' REPO="$CUR"\n'
" break\n"
" fi\n"
' CUR="$(dirname "$CUR")"\n'
" done\n"
"fi\n"
'VENV="$REPO/.venv"\n'
"# Debug mode: set MM_DEBUG=1 to print repository, venv, and import diagnostics\n"
'if [ -n "${MM_DEBUG:-}" ]; then\n'
' echo "MM_DEBUG: diagnostics" >&2\n'
' echo "Resolved REPO: $REPO" >&2\n'
' echo "Resolved VENV: $VENV" >&2\n'
' echo "VENV exists: $( [ -d "$VENV" ] && echo yes || echo no )" >&2\n'
' echo "Candidates:" >&2\n'
' echo " VENV/bin/mm: $( [ -x "$VENV/bin/mm" ] && echo yes || echo no )" >&2\n'
' echo " VENV/bin/python3: $( [ -x "$VENV/bin/python3" ] && echo yes || echo no )" >&2\n'
' echo " VENV/bin/python: $( [ -x "$VENV/bin/python" ] && echo yes || echo no )" >&2\n'
' echo " system python3: $(command -v python3 || echo none)" >&2\n'
' echo " system python: $(command -v python || echo none)" >&2\n'
' for pycmd in "$VENV/bin/python3" "$VENV/bin/python" "$(command -v python3 2>/dev/null)" "$(command -v python 2>/dev/null)"; do\n'
' if [ -n "$pycmd" ] && [ -x "$pycmd" ]; then\n'
' echo "---- Testing with: $pycmd ----" >&2\n'
" $pycmd - <<'PY'\nimport sys, importlib, traceback, importlib.util\nprint('sys.executable:', sys.executable)\nprint('sys.path (first 8):', sys.path[:8])\nfor mod in ('CLI','medeia_macina','medeia_macina.cli_entry'):\n try:\n spec = importlib.util.find_spec(mod)\n print(mod, 'spec:', spec)\n if spec:\n m = importlib.import_module(mod)\n print(mod, 'loaded at', getattr(m, '__file__', None))\n except Exception:\n print(mod, 'import failed')\n traceback.print_exc()\nPY\n"
" fi\n"
" done\n"
' echo "MM_DEBUG: end diagnostics" >&2\n'
"fi\n"
"# Packaged console script in the venv if available\n"
'if [ -x "$VENV/bin/mm" ]; then\n'
' exec "$VENV/bin/mm" "$@"\n'
"fi\n"
"# Prefer venv's python3, then venv's python\n"
'if [ -x "$VENV/bin/python3" ]; then\n'
' exec "$VENV/bin/python3" -m medeia_macina.cli_entry "$@"\n'
"fi\n"
'if [ -x "$VENV/bin/python" ]; then\n'
' exec "$VENV/bin/python" -m medeia_macina.cli_entry "$@"\n'
"fi\n"
"# Fallback to system python3, then system python (only if it's Python 3)\n"
"if command -v python3 >/dev/null 2>&1; then\n"
' exec python3 -m medeia_macina.cli_entry "$@"\n'
"fi\n"
"if command -v python >/dev/null 2>&1; then\n"
" if python -c 'import sys; sys.exit(0 if sys.version_info[0] >= 3 else 1)'; then\n"
' exec python -m medeia_macina.cli_entry "$@"\n'
" fi\n"
"fi\n"
"echo 'Error: no suitable Python 3 interpreter found. Please install Python 3 or use the venv.' >&2\n"
"exit 127\n"
)
if mm_sh.exists():
bak = mm_sh.with_suffix(f".bak{int(time.time())}")
mm_sh.replace(bak)
mm_sh.write_text(sh_text, encoding="utf-8")
mm_sh.chmod(mm_sh.stat().st_mode | 0o111)
# Ensure the user's bin is on PATH for future sessions by adding to ~/.profile
cur_path = os.environ.get("PATH", "")
if str(user_bin) not in cur_path:
profile = home / ".profile"
snippet = (
"# Added by Medeia-Macina setup: ensure user local bin is on PATH\n"
'if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then\n'
' PATH="$HOME/.local/bin:$PATH"\n'
"fi\n"
)
try:
txt = profile.read_text() if profile.exists() else ""
if snippet.strip() not in txt:
with profile.open("a", encoding="utf-8") as fh:
fh.write("\n" + snippet)
except Exception:
pass
if not args.quiet:
print(f"Installed global launcher to: {mm_sh}")
except Exception as exc: # pragma: no cover - best effort
print(f"Failed to install global shims: {exc}", file=sys.stderr)
_install_user_shims(repo_root)
print("Setup complete.")
return 0
except subprocess.CalledProcessError as exc:
print(
f"Error: command failed with exit {exc.returncode}: {exc}",
file=sys.stderr
)
return int(exc.returncode or 1)
except Exception as exc: # pragma: no cover - defensive
print(f"Unexpected error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())