419 lines
18 KiB
Python
419 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""scripts/setup.py
|
|
|
|
Unified project setup helper (Python-only).
|
|
|
|
This script installs Python dependencies from `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).
|
|
|
|
Usage:
|
|
python ./scripts/setup.py # install deps and playwright browsers
|
|
python ./scripts/setup.py --skip-deps
|
|
python ./scripts/setup.py --playwright-only
|
|
|
|
Optional flags:
|
|
--skip-deps Skip `pip install -r 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 .) for running tests
|
|
--install-deno Install the Deno runtime using the official installer
|
|
--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 subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
import platform
|
|
import shutil
|
|
import os
|
|
import time
|
|
|
|
|
|
def run(cmd: list[str]) -> None:
|
|
print(f"> {' '.join(cmd)}")
|
|
subprocess.check_call(cmd)
|
|
|
|
|
|
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
|
|
else:
|
|
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="Setup Medios-Macina: install deps and Playwright browsers")
|
|
parser.add_argument("--skip-deps", action="store_true", help="Skip installing Python dependencies from 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("--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 .) 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")
|
|
args = parser.parse_args()
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
|
|
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.
|
|
|
|
This is intentionally opinionated: we keep a venv at `./.venv` in the repo root
|
|
and use that for all package operations to keep developer environments reproducible.
|
|
"""
|
|
try:
|
|
if not venv_dir.exists():
|
|
print(f"Creating local virtualenv at: {venv_dir}")
|
|
run([sys.executable, "-m", "venv", str(venv_dir)])
|
|
else:
|
|
print(f"Using existing virtualenv at: {venv_dir}")
|
|
|
|
py = _venv_python(venv_dir)
|
|
if not py.exists():
|
|
# Try recreating venv if python is missing
|
|
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
|
|
|
|
# Ensure a local venv is present and use it for subsequent installs.
|
|
venv_python = _ensure_local_venv()
|
|
print(f"Using venv python: {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():
|
|
print("'playwright' package not found; installing it via pip...")
|
|
run([sys.executable, "-m", "pip", "install", "playwright"])
|
|
|
|
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)
|
|
print("Playwright browsers installed successfully.")
|
|
return 0
|
|
|
|
if args.upgrade_pip:
|
|
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 / "requirements.txt"
|
|
if not req_file.exists():
|
|
print(f"requirements.txt not found at {req_file}; skipping dependency installation.", file=sys.stderr)
|
|
else:
|
|
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():
|
|
print("'playwright' package not installed in venv; installing it...")
|
|
run([str(venv_python), "-m", "pip", "install", "playwright"])
|
|
|
|
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)
|
|
|
|
# Optional: install the project in editable mode so tests can import the package
|
|
# Install the project into the local venv (editable mode is the default, opinionated)
|
|
print("Installing project into local venv (editable mode)")
|
|
run([str(venv_python), "-m", "pip", "install", "-e", "."])
|
|
|
|
# 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:
|
|
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 scripts (project root) that prefer the local .venv
|
|
def _write_launchers():
|
|
sh = repo_root / "mm"
|
|
ps1 = repo_root / "mm.ps1"
|
|
bat = repo_root / "mm.bat"
|
|
|
|
sh_text = """#!/usr/bin/env bash
|
|
set -e
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO="$SCRIPT_DIR"
|
|
VENV="$REPO/.venv"
|
|
PY="$VENV/bin/python"
|
|
CLI_SCRIPT="$REPO/CLI.py"
|
|
if [ -x "$PY" ]; then
|
|
exec "$PY" "$CLI_SCRIPT" "$@"
|
|
else
|
|
exec python "$CLI_SCRIPT" "$@"
|
|
fi
|
|
"""
|
|
try:
|
|
sh.write_text(sh_text, encoding="utf-8")
|
|
sh.chmod(sh.stat().st_mode | 0o111)
|
|
except Exception:
|
|
pass
|
|
|
|
ps1_text = r"""Param([Parameter(ValueFromRemainingArguments=$true)] $args)
|
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$repo = $scriptDir
|
|
$venv = Join-Path $repo '.venv'
|
|
$py = Join-Path $venv 'Scripts\python.exe'
|
|
$cli = Join-Path $repo 'CLI.py'
|
|
if (Test-Path $py) { & $py $cli @args; exit $LASTEXITCODE }
|
|
if (Test-Path $cli) { & $py $cli @args; exit $LASTEXITCODE }
|
|
# fallback
|
|
python $cli @args
|
|
"""
|
|
try:
|
|
ps1.write_text(ps1_text, encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
|
|
bat_text = (
|
|
"@echo off\r\n"
|
|
"set SCRIPT_DIR=%~dp0\r\n"
|
|
"if exist \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" \"%SCRIPT_DIR%\\CLI.py\" %*\r\n"
|
|
"if exist \"%SCRIPT_DIR%\\CLI.py\" python \"%SCRIPT_DIR%\\CLI.py\" %*\r\n"
|
|
"python -m medeia_macina.cli_entry %*\r\n"
|
|
)
|
|
try:
|
|
bat.write_text(bat_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.cmd (CMD shim)
|
|
mm_cmd = user_bin / "mm.cmd"
|
|
cmd_text = (
|
|
f"@echo off\r\n"
|
|
f"set REPO={repo}\r\n"
|
|
f"if exist \"%REPO%\\.venv\\Scripts\\mm.exe\" \"%REPO%\\.venv\\Scripts\\mm.exe\" %*\r\n"
|
|
f"if exist \"%REPO%\\.venv\\Scripts\\python.exe\" \"%REPO%\\.venv\\Scripts\\python.exe\" -m medeia_entry %*\r\n"
|
|
f"python -m medeia_entry %*\r\n"
|
|
)
|
|
if mm_cmd.exists():
|
|
bak = mm_cmd.with_suffix(f".bak{int(time.time())}")
|
|
mm_cmd.replace(bak)
|
|
mm_cmd.write_text(cmd_text, encoding="utf-8")
|
|
|
|
# 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) { & $py -m medeia_entry @args; exit $LASTEXITCODE }\n"
|
|
"python -m medeia_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
|
|
|
|
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"
|
|
f"REPO=\"{repo}\"\n"
|
|
"VENV=\"$REPO/.venv\"\n"
|
|
"if [ -x \"$VENV/bin/mm\" ]; then\n"
|
|
" exec \"$VENV/bin/mm\" \"$@\"\n"
|
|
"elif [ -x \"$VENV/bin/python\" ]; then\n"
|
|
" exec \"$VENV/bin/python\" -m medeia_entry \"$@\"\n"
|
|
"else\n"
|
|
" exec python -m medeia_entry \"$@\"\n"
|
|
"fi\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
|
|
|
|
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())
|