Add YAPF style + ignore, and format tracked Python files

This commit is contained in:
2025-12-29 18:42:02 -08:00
parent c019c00aed
commit 507946a3e4
108 changed files with 11664 additions and 6494 deletions

View File

@@ -112,7 +112,10 @@ def run_platform_bootstrap(repo_root: Path) -> int:
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)
print(
f"Bootstrap script failed with exit code {rc.returncode}",
file=sys.stderr
)
return int(rc.returncode or 0)
@@ -143,7 +146,9 @@ def _build_playwright_install_cmd(browsers: str | None) -> list[str]:
if "all" in items:
return base
allowed = {"chromium", "firefox", "webkit"}
allowed = {"chromium",
"firefox",
"webkit"}
invalid = [b for b in items if b not in allowed]
if invalid:
raise ValueError(
@@ -172,7 +177,16 @@ def _install_deno(version: str | None = None) -> int:
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])
run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
ps_cmd
]
)
else:
# POSIX: use curl + sh installer
if version:
@@ -220,7 +234,8 @@ def main() -> int:
"--browsers",
type=str,
default="chromium",
help="Comma-separated list of browsers to install: chromium,firefox,webkit or 'all' (default: chromium)",
help=
"Comma-separated list of browsers to install: chromium,firefox,webkit or 'all' (default: chromium)",
)
parser.add_argument(
"--install-editable",
@@ -234,7 +249,9 @@ def main() -> int:
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)"
"--no-deno",
action="store_true",
help="Skip installing Deno runtime (opt out)"
)
parser.add_argument(
"--deno-version",
@@ -312,7 +329,9 @@ def main() -> int:
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)...")
print(
"Installing Playwright browsers (this may download several hundred MB)..."
)
try:
cmd = _build_playwright_install_cmd(args.browsers)
except ValueError as exc:
@@ -346,7 +365,9 @@ def main() -> int:
file=sys.stderr,
)
else:
print(f"Installing Python dependencies into local venv from {req_file}...")
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:
@@ -354,7 +375,9 @@ def main() -> int:
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)...")
print(
"Installing Playwright browsers (this may download several hundred MB)..."
)
try:
cmd = _build_playwright_install_cmd(args.browsers)
except ValueError as exc:
@@ -373,7 +396,11 @@ def main() -> int:
print("Verifying top-level 'CLI' import in venv...")
try:
rc = subprocess.run(
[str(venv_python), "-c", "import importlib; importlib.import_module('CLI')"],
[
str(venv_python),
"-c",
"import importlib; importlib.import_module('CLI')"
],
check=False,
)
if rc.returncode == 0:
@@ -418,7 +445,9 @@ def main() -> int:
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}")
print(
f"Wrote .pth adding repo root to venv site-packages: {pth_file}"
)
# Re-check whether CLI can be imported now
rc2 = subprocess.run(
@@ -436,7 +465,9 @@ def main() -> int:
"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}")
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
@@ -572,9 +603,14 @@ python -m medeia_macina.cli_entry @args
"$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("\\", "\\\\"))
).format(bin=str_bin.replace("\\",
"\\\\"))
subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd], check=False
["powershell",
"-NoProfile",
"-Command",
ps_cmd],
check=False
)
except Exception:
pass
@@ -583,7 +619,10 @@ python -m medeia_macina.cli_entry @args
else:
# POSIX
user_bin = Path(os.environ.get("XDG_BIN_HOME", str(home / ".local/bin")))
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"
@@ -688,7 +727,10 @@ python -m medeia_macina.cli_entry @args
return 0
except subprocess.CalledProcessError as exc:
print(f"Error: command failed with exit {exc.returncode}: {exc}", file=sys.stderr)
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)

26
scripts/format_tracked.py Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Format all tracked Python files using the repo's YAPF style.
This script intentionally formats only files tracked by git to avoid touching
files that are ignored (e.g., venv, site-packages).
"""
import subprocess
import sys
try:
out = subprocess.check_output(["git", "ls-files", "*.py"]).decode("utf-8")
except subprocess.CalledProcessError as exc:
print("Failed to get tracked files from git:", exc, file=sys.stderr)
sys.exit(1)
files = [f for f in (line.strip() for line in out.splitlines()) if f]
print(f"Formatting {len(files)} tracked python files...")
if not files:
print("No tracked python files found.")
sys.exit(0)
for path in files:
print(path)
subprocess.run([sys.executable, "-m", "yapf", "-i", "--style", ".style.yapf", path], check=False)
print("Done")

View File

@@ -50,7 +50,11 @@ except Exception:
def detach_kwargs_for_platform():
kwargs = {}
if os.name == "nt":
CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
CREATE_NEW_PROCESS_GROUP = getattr(
subprocess,
"CREATE_NEW_PROCESS_GROUP",
0
)
DETACHED_PROCESS = getattr(subprocess, "DETACHED_PROCESS", 0)
flags = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
if flags:
@@ -70,7 +74,11 @@ def find_git_executable() -> Optional[str]:
# Quick sanity check
try:
subprocess.run(
[git, "--version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
[git,
"--version"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
return git
except Exception:
@@ -88,7 +96,11 @@ def is_git_repo(path: Path) -> bool:
return False
try:
subprocess.run(
[git, "-C", str(path), "rev-parse", "--is-inside-work-tree"],
[git,
"-C",
str(path),
"rev-parse",
"--is-inside-work-tree"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -99,7 +111,11 @@ def is_git_repo(path: Path) -> bool:
def run_git_clone(
git: str, repo: str, dest: Path, branch: Optional[str] = None, depth: Optional[int] = None
git: str,
repo: str,
dest: Path,
branch: Optional[str] = None,
depth: Optional[int] = None
) -> None:
# Build git clone with options before the repository argument. Support shallow clones
# via --depth when requested.
@@ -113,7 +129,12 @@ def run_git_clone(
if branch:
cmd += ["--branch", branch]
cmd += [repo, str(dest)]
logging.info("Cloning: %s -> %s (depth=%s)", repo, dest, str(depth) if depth else "full")
logging.info(
"Cloning: %s -> %s (depth=%s)",
repo,
dest,
str(depth) if depth else "full"
)
subprocess.run(cmd, check=True)
@@ -123,7 +144,11 @@ def run_git_pull(git: str, dest: Path) -> None:
def download_and_extract_zip(
repo_url: str, dest: Path, branch_candidates: Tuple[str, ...] = ("main", "master")
repo_url: str,
dest: Path,
branch_candidates: Tuple[str,
...] = ("main",
"master")
) -> None:
"""Download the GitHub repo zip and extract it into dest.
@@ -181,7 +206,10 @@ def download_and_extract_zip(
target.unlink()
shutil.move(str(entry), str(dest))
logging.info(
"Downloaded and extracted %s (branch: %s) into %s", repo_url, branch, dest
"Downloaded and extracted %s (branch: %s) into %s",
repo_url,
branch,
dest
)
return
except Exception as exc:
@@ -277,9 +305,9 @@ def maybe_reexec_under_project_venv(root: Path, disable: bool = False) -> None:
script_path = Path(sys.argv[0]).resolve()
except Exception:
script_path = None
args = [str(py), str(script_path) if script_path is not None else sys.argv[0]] + sys.argv[
1:
]
args = [str(py),
str(script_path) if script_path is not None else sys.argv[0]
] + sys.argv[1:]
logging.debug("Exec args: %s", args)
os.execvpe(str(py), args, env)
except Exception as exc:
@@ -325,12 +353,21 @@ def fix_permissions_windows(path: Path, user: Optional[str] = None) -> bool:
except Exception:
user = getpass.getuser()
logging.info("Attempting Windows ownership/ACL fix for %s (owner=%s)", path, user)
logging.info(
"Attempting Windows ownership/ACL fix for %s (owner=%s)",
path,
user
)
# Try to take ownership (best-effort)
try:
subprocess.run(
["takeown", "/F", str(path), "/R", "/D", "Y"],
["takeown",
"/F",
str(path),
"/R",
"/D",
"Y"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -341,14 +378,28 @@ def fix_permissions_windows(path: Path, user: Optional[str] = None) -> bool:
rc_setowner = 1
rc_grant = 1
try:
out = subprocess.run(["icacls", str(path), "/setowner", user, "/T", "/C"], check=False)
out = subprocess.run(
["icacls",
str(path),
"/setowner",
user,
"/T",
"/C"],
check=False
)
rc_setowner = int(out.returncode)
except Exception:
rc_setowner = 1
try:
out = subprocess.run(
["icacls", str(path), "/grant", f"{user}:(OI)(CI)F", "/T", "/C"], check=False
["icacls",
str(path),
"/grant",
f"{user}:(OI)(CI)F",
"/T",
"/C"],
check=False
)
rc_grant = int(out.returncode)
except Exception:
@@ -368,7 +419,9 @@ def fix_permissions_windows(path: Path, user: Optional[str] = None) -> bool:
def fix_permissions_unix(
path: Path, user: Optional[str] = None, group: Optional[str] = None
path: Path,
user: Optional[str] = None,
group: Optional[str] = None
) -> bool:
"""Attempt to chown/chmod recursively for a Unix-like system.
@@ -398,7 +451,13 @@ def fix_permissions_unix(
)
try:
subprocess.run(["chown", "-R", f"{user}:{group or pw.pw_gid}", str(path)], check=True)
subprocess.run(
["chown",
"-R",
f"{user}:{group or pw.pw_gid}",
str(path)],
check=True
)
except Exception:
# Best-effort fallback: chown/chmod individual entries
for root_dir, dirs, files in os.walk(path):
@@ -426,14 +485,20 @@ def fix_permissions_unix(
except Exception:
pass
logging.info("Unix permission fix attempted (some changes may require root privilege).")
logging.info(
"Unix permission fix attempted (some changes may require root privilege)."
)
return True
except Exception as exc:
logging.debug("Unix fix-permissions error: %s", exc)
return False
def fix_permissions(path: Path, user: Optional[str] = None, group: Optional[str] = None) -> bool:
def fix_permissions(
path: Path,
user: Optional[str] = None,
group: Optional[str] = None
) -> bool:
try:
if os.name == "nt":
return fix_permissions_windows(path, user=user)
@@ -554,9 +619,8 @@ def open_in_editor(path: Path) -> bool:
pass
# Linux: use xdg-open only if a display is available and xdg-open exists
if shutil.which("xdg-open") and (
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
):
if shutil.which("xdg-open") and (os.environ.get("DISPLAY")
or os.environ.get("WAYLAND_DISPLAY")):
try:
subprocess.run(["xdg-open", str(path)], check=False)
logging.info("Opened %s with default application", path)
@@ -565,7 +629,8 @@ def open_in_editor(path: Path) -> bool:
pass
logging.debug(
"No available method to open %s automatically (headless or no opener installed)", path
"No available method to open %s automatically (headless or no opener installed)",
path
)
return False
except Exception as exc:
@@ -574,12 +639,15 @@ def open_in_editor(path: Path) -> bool:
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Clone Hydrus into a 'hydrusnetwork' directory.")
parser = argparse.ArgumentParser(
description="Clone Hydrus into a 'hydrusnetwork' directory."
)
parser.add_argument(
"--root",
"-r",
default=".",
help="Root folder to create the hydrusnetwork directory in (default: current working directory)",
help=
"Root folder to create the hydrusnetwork directory in (default: current working directory)",
)
parser.add_argument(
"--dest-name",
@@ -588,7 +656,9 @@ def main(argv: Optional[list[str]] = None) -> int:
help="Name of the destination folder (default: hydrusnetwork)",
)
parser.add_argument(
"--repo", default="https://github.com/hydrusnetwork/hydrus", help="Repository URL to clone"
"--repo",
default="https://github.com/hydrusnetwork/hydrus",
help="Repository URL to clone"
)
parser.add_argument(
"--update",
@@ -602,31 +672,40 @@ def main(argv: Optional[list[str]] = None) -> int:
help="Remove existing destination directory before cloning",
)
parser.add_argument(
"--branch", "-b", default=None, help="Branch to clone (passed to git clone --branch)."
"--branch",
"-b",
default=None,
help="Branch to clone (passed to git clone --branch)."
)
parser.add_argument(
"--depth",
type=int,
default=1,
help="If set, pass --depth to git clone (default: 1 for a shallow clone). Use --full to perform a full clone instead.",
help=
"If set, pass --depth to git clone (default: 1 for a shallow clone). Use --full to perform a full clone instead.",
)
parser.add_argument(
"--full", action="store_true", help="Perform a full clone (no --depth passed to git clone)"
"--full",
action="store_true",
help="Perform a full clone (no --depth passed to git clone)"
)
parser.add_argument(
"--git",
action="store_true",
help="Use git clone instead of fetching repository ZIP (opt-in). Default: fetch ZIP (smaller).",
help=
"Use git clone instead of fetching repository ZIP (opt-in). Default: fetch ZIP (smaller).",
)
parser.add_argument(
"--no-fallback",
action="store_true",
help="If set, do not attempt to download ZIP when git is missing (only relevant with --git)",
help=
"If set, do not attempt to download ZIP when git is missing (only relevant with --git)",
)
parser.add_argument(
"--fix-permissions",
action="store_true",
help="Fix ownership/permissions on the obtained repo (OS-aware). Requires elevated privileges for some actions.",
help=
"Fix ownership/permissions on the obtained repo (OS-aware). Requires elevated privileges for some actions.",
)
parser.add_argument(
"--fix-permissions-user",
@@ -641,7 +720,8 @@ def main(argv: Optional[list[str]] = None) -> int:
parser.add_argument(
"--no-venv",
action="store_true",
help="Do not create a venv inside the cloned repo (default: create a .venv folder)",
help=
"Do not create a venv inside the cloned repo (default: create a .venv folder)",
)
parser.add_argument(
"--venv-name",
@@ -649,7 +729,9 @@ def main(argv: Optional[list[str]] = None) -> int:
help="Name of the venv directory to create inside the repo (default: .venv)",
)
parser.add_argument(
"--recreate-venv", action="store_true", help="Remove existing venv and create a fresh one"
"--recreate-venv",
action="store_true",
help="Remove existing venv and create a fresh one"
)
# By default install dependencies into the created venv; use --no-install-deps to opt out
group_install = parser.add_mutually_exclusive_group()
@@ -657,7 +739,8 @@ def main(argv: Optional[list[str]] = None) -> int:
"--install-deps",
dest="install_deps",
action="store_true",
help="Install dependencies from requirements.txt into the created venv (default).",
help=
"Install dependencies from requirements.txt into the created venv (default).",
)
group_install.add_argument(
"--no-install-deps",
@@ -669,17 +752,20 @@ def main(argv: Optional[list[str]] = None) -> int:
parser.add_argument(
"--reinstall-deps",
action="store_true",
help="If present, force re-install dependencies into the created venv using pip --force-reinstall.",
help=
"If present, force re-install dependencies into the created venv using pip --force-reinstall.",
)
parser.add_argument(
"--no-open-client",
action="store_true",
help="(ignored) installer no longer opens hydrus_client.py automatically; use the run_client helper to launch the client when ready.",
help=
"(ignored) installer no longer opens hydrus_client.py automatically; use the run_client helper to launch the client when ready.",
)
parser.add_argument(
"--run-client",
action="store_true",
help="Run hydrus_client.py using the repo-local venv's Python (if present). This runs the client in the foreground unless --run-client-detached is specified.",
help=
"Run hydrus_client.py using the repo-local venv's Python (if present). This runs the client in the foreground unless --run-client-detached is specified.",
)
parser.add_argument(
"--run-client-detached",
@@ -689,7 +775,8 @@ def main(argv: Optional[list[str]] = None) -> int:
parser.add_argument(
"--run-client-headless",
action="store_true",
help="If used with --run-client, attempt to run hydrus_client.py without showing the Qt GUI (best-effort)",
help=
"If used with --run-client, attempt to run hydrus_client.py without showing the Qt GUI (best-effort)",
)
parser.add_argument(
"--install-service",
@@ -759,7 +846,10 @@ def main(argv: Optional[list[str]] = None) -> int:
)
return 0
logging.info("Destination %s is already a git repository.", dest)
logging.info(
"Destination %s is already a git repository.",
dest
)
print("")
print("Select an action:")
print(
@@ -779,13 +869,21 @@ def main(argv: Optional[list[str]] = None) -> int:
if choice == "1":
# Install dependencies into the repository venv (create venv if needed)
try:
venv_dir = dest / str(getattr(args, "venv_name", ".venv"))
venv_dir = dest / str(
getattr(args,
"venv_name",
".venv")
)
if venv_dir.exists():
logging.info("Using existing venv at %s", venv_dir)
else:
logging.info("Creating venv at %s", venv_dir)
subprocess.run(
[sys.executable, "-m", "venv", str(venv_dir)], check=True
[sys.executable,
"-m",
"venv",
str(venv_dir)],
check=True
)
venv_py = get_python_in_venv(venv_dir)
except Exception as e:
@@ -793,24 +891,45 @@ def main(argv: Optional[list[str]] = None) -> int:
return 8
if not venv_py:
logging.error("Could not locate python in venv %s", venv_dir)
logging.error(
"Could not locate python in venv %s",
venv_dir
)
return 9
req = find_requirements(dest)
if not req:
logging.info(
"No requirements.txt found in %s; nothing to install.", dest
"No requirements.txt found in %s; nothing to install.",
dest
)
return 0
logging.info("Installing dependencies from %s into venv", req)
logging.info(
"Installing dependencies from %s into venv",
req
)
try:
subprocess.run(
[str(venv_py), "-m", "pip", "install", "--upgrade", "pip"],
[
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"
],
check=True,
)
subprocess.run(
[str(venv_py), "-m", "pip", "install", "-r", str(req)],
[
str(venv_py),
"-m",
"pip",
"install",
"-r",
str(req)
],
cwd=str(dest),
check=True,
)
@@ -822,7 +941,9 @@ def main(argv: Optional[list[str]] = None) -> int:
# Post-install verification
pkgs = parse_requirements_file(req)
if pkgs:
logging.info("Verifying installed packages inside the venv...")
logging.info(
"Verifying installed packages inside the venv..."
)
any_missing = False
import_map = {
"pyyaml": "yaml",
@@ -843,7 +964,9 @@ def main(argv: Optional[list[str]] = None) -> int:
mod = import_map.get(pkg, pkg)
try:
subprocess.run(
[str(venv_py), "-c", f"import {mod}"],
[str(venv_py),
"-c",
f"import {mod}"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -869,7 +992,9 @@ def main(argv: Optional[list[str]] = None) -> int:
elif choice == "2":
if not git:
logging.error("Git not found; cannot --update without git")
logging.error(
"Git not found; cannot --update without git"
)
return 2
try:
run_git_pull(git, dest)
@@ -882,17 +1007,29 @@ def main(argv: Optional[list[str]] = None) -> int:
elif choice == "3":
# Install a user-level service to start the hydrus client on boot
try:
venv_dir = dest / str(getattr(args, "venv_name", ".venv"))
venv_dir = dest / str(
getattr(args,
"venv_name",
".venv")
)
if not venv_dir.exists():
logging.info(
"Creating venv at %s to perform service install", venv_dir
"Creating venv at %s to perform service install",
venv_dir
)
subprocess.run(
[sys.executable, "-m", "venv", str(venv_dir)], check=True
[sys.executable,
"-m",
"venv",
str(venv_dir)],
check=True
)
venv_py = get_python_in_venv(venv_dir)
except Exception as e:
logging.error("Failed to prepare venv for service install: %s", e)
logging.error(
"Failed to prepare venv for service install: %s",
e
)
return 8
if not venv_py:
@@ -986,7 +1123,13 @@ def main(argv: Optional[list[str]] = None) -> int:
try:
# Default behavior when using git: shallow clone (depth=1) unless --full specified.
depth_to_use = None if getattr(args, "full", False) else args.depth
run_git_clone(git, args.repo, dest, branch=args.branch, depth=depth_to_use)
run_git_clone(
git,
args.repo,
dest,
branch=args.branch,
depth=depth_to_use
)
logging.info("Repository cloned into %s", dest)
obtained = True
obtained_by = "git"
@@ -1023,7 +1166,13 @@ def main(argv: Optional[list[str]] = None) -> int:
if not venv_dir.exists():
logging.info("Creating venv at %s", venv_dir)
try:
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
subprocess.run(
[sys.executable,
"-m",
"venv",
str(venv_dir)],
check=True
)
except subprocess.CalledProcessError as e:
logging.error("Failed to create venv: %s", e)
return 8
@@ -1038,17 +1187,30 @@ def main(argv: Optional[list[str]] = None) -> int:
logging.info("Venv ready: %s", venv_py)
# Optionally install or reinstall requirements.txt
if getattr(args, "install_deps", False) or getattr(args, "reinstall_deps", False):
if getattr(args,
"install_deps",
False) or getattr(args,
"reinstall_deps",
False):
req = find_requirements(dest)
if req and req.exists():
logging.info(
"Installing dependencies from %s into venv (reinstall=%s)",
req,
bool(getattr(args, "reinstall_deps", False)),
bool(getattr(args,
"reinstall_deps",
False)),
)
try:
subprocess.run(
[str(venv_py), "-m", "pip", "install", "--upgrade", "pip"],
[
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"
],
check=True,
)
if getattr(args, "reinstall_deps", False):
@@ -1068,7 +1230,14 @@ def main(argv: Optional[list[str]] = None) -> int:
)
else:
subprocess.run(
[str(venv_py), "-m", "pip", "install", "-r", str(req)],
[
str(venv_py),
"-m",
"pip",
"install",
"-r",
str(req)
],
cwd=str(dest),
check=True,
)
@@ -1080,7 +1249,9 @@ def main(argv: Optional[list[str]] = None) -> int:
# Post-install verification: ensure packages are visible inside the venv
pkgs = parse_requirements_file(req)
if pkgs:
logging.info("Verifying installed packages inside the venv...")
logging.info(
"Verifying installed packages inside the venv..."
)
any_missing = False
# Small mapping for known differences between package name and import name
import_map = {
@@ -1101,14 +1272,19 @@ def main(argv: Optional[list[str]] = None) -> int:
for pkg in pkgs:
try:
out = subprocess.run(
[str(venv_py), "-m", "pip", "show", pkg],
[str(venv_py),
"-m",
"pip",
"show",
pkg],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if out.returncode != 0 or not out.stdout.strip():
logging.warning(
"Package '%s' not found in venv (pip show failed).", pkg
"Package '%s' not found in venv (pip show failed).",
pkg
)
any_missing = True
continue
@@ -1116,7 +1292,11 @@ def main(argv: Optional[list[str]] = None) -> int:
import_name = import_map.get(pkg, pkg)
try:
subprocess.run(
[str(venv_py), "-c", f"import {import_name}"],
[
str(venv_py),
"-c",
f"import {import_name}"
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -1129,7 +1309,11 @@ def main(argv: Optional[list[str]] = None) -> int:
)
any_missing = True
except Exception as exc:
logging.debug("Verification error for package %s: %s", pkg, exc)
logging.debug(
"Verification error for package %s: %s",
pkg,
exc
)
any_missing = True
if any_missing:
@@ -1182,7 +1366,10 @@ def main(argv: Optional[list[str]] = None) -> int:
logging.info(" source %s/bin/activate", venv_dir)
# Optionally open/run hydrus_client.py in the repo for convenience (open by default if present).
client_candidates = [dest / "hydrus_client.py", dest / "client" / "hydrus_client.py"]
client_candidates = [
dest / "hydrus_client.py",
dest / "client" / "hydrus_client.py"
]
client_found = None
for p in client_candidates:
if p.exists():
@@ -1197,12 +1384,18 @@ def main(argv: Optional[list[str]] = None) -> int:
if cand.exists():
run_client_script = cand
break
if getattr(args, "install_service", False) or getattr(args, "uninstall_service", False):
if getattr(args,
"install_service",
False) or getattr(args,
"uninstall_service",
False):
if not venv_py:
venv_dir = dest / str(getattr(args, "venv_name", ".venv"))
venv_py = get_python_in_venv(venv_dir)
if not venv_py:
logging.error("Could not locate python in repo venv; cannot manage service.")
logging.error(
"Could not locate python in repo venv; cannot manage service."
)
else:
if getattr(args, "install_service", False):
if run_client_script.exists():
@@ -1224,7 +1417,11 @@ def main(argv: Optional[list[str]] = None) -> int:
else:
if install_service_auto:
ok = install_service_auto(
args.service_name, dest, venv_py, headless=True, detached=True
args.service_name,
dest,
venv_py,
headless=True,
detached=True
)
if ok:
logging.info("Service installed (user-level).")
@@ -1253,7 +1450,11 @@ def main(argv: Optional[list[str]] = None) -> int:
logging.error("Service uninstall failed: %s", e)
else:
if uninstall_service_auto:
ok = uninstall_service_auto(args.service_name, dest, venv_py)
ok = uninstall_service_auto(
args.service_name,
dest,
venv_py
)
if ok:
logging.info("Service removed.")
else:
@@ -1294,17 +1495,27 @@ def main(argv: Optional[list[str]] = None) -> int:
if getattr(args, "run_client_detached", False):
cmd.append("--detached")
logging.info("Running hydrus client via helper: %s", cmd)
logging.info(
"Running hydrus client via helper: %s",
cmd
)
try:
if getattr(args, "run_client_detached", False):
kwargs = detach_kwargs_for_platform()
kwargs.update({"cwd": str(dest)})
kwargs.update({
"cwd": str(dest)
})
subprocess.Popen(cmd, **kwargs)
logging.info("Hydrus client launched (detached).")
logging.info(
"Hydrus client launched (detached)."
)
else:
subprocess.run(cmd, cwd=str(dest))
except subprocess.CalledProcessError as e:
logging.error("run_client.py exited non-zero: %s", e)
logging.error(
"run_client.py exited non-zero: %s",
e
)
else:
# Fallback: call the client directly; support headless by setting
# QT_QPA_PLATFORM or using xvfb-run on Linux.
@@ -1327,23 +1538,34 @@ def main(argv: Optional[list[str]] = None) -> int:
)
logging.info(
"Running hydrus client with %s: %s", venv_py, client_found
"Running hydrus client with %s: %s",
venv_py,
client_found
)
if getattr(args, "run_client_detached", False):
try:
kwargs = detach_kwargs_for_platform()
kwargs.update({"cwd": str(dest), "env": env})
kwargs.update({
"cwd": str(dest),
"env": env
})
subprocess.Popen(cmd, **kwargs)
logging.info("Hydrus client launched (detached).")
logging.info(
"Hydrus client launched (detached)."
)
except Exception as exc:
logging.exception(
"Failed to launch client detached: %s", exc
"Failed to launch client detached: %s",
exc
)
else:
try:
subprocess.run(cmd, cwd=str(dest), env=env)
except subprocess.CalledProcessError as e:
logging.error("hydrus client exited non-zero: %s", e)
logging.error(
"hydrus client exited non-zero: %s",
e
)
except Exception as exc:
logging.exception("Failed to run hydrus client: %s", exc)
@@ -1360,9 +1582,9 @@ def main(argv: Optional[list[str]] = None) -> int:
# Helpful hint: show the new run_client helper and direct run example
try:
helper_to_show = (
run_client_script
if (run_client_script and run_client_script.exists())
else (script_dir / "run_client.py")
run_client_script if
(run_client_script and run_client_script.exists()) else
(script_dir / "run_client.py")
)
if venv_py:
logging.info(
@@ -1375,13 +1597,15 @@ def main(argv: Optional[list[str]] = None) -> int:
)
else:
logging.info(
"To run the Hydrus client: python %s [args]", dest / "hydrus_client.py"
"To run the Hydrus client: python %s [args]",
dest / "hydrus_client.py"
)
except Exception:
pass
else:
logging.debug(
"No hydrus_client.py found to open or run (looked in %s).", client_candidates
"No hydrus_client.py found to open or run (looked in %s).",
client_candidates
)
return 0

View File

@@ -58,7 +58,10 @@ from SYS.logger import log
# CONFIGURATION
# ============================================================================
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s")
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s: %(message)s"
)
logger = logging.getLogger(__name__)
STORAGE_PATH: Optional[Path] = None
@@ -101,7 +104,9 @@ def get_local_ip() -> Optional[str]:
def create_app():
"""Create and configure Flask app with all routes."""
if not HAS_FLASK:
raise ImportError("Flask not installed. Install with: pip install flask flask-cors")
raise ImportError(
"Flask not installed. Install with: pip install flask flask-cors"
)
from flask import Flask, request, jsonify
from flask_cors import CORS
@@ -117,11 +122,13 @@ def create_app():
"""Decorator to check API key authentication if configured."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if API_KEY:
# Get API key from header or query parameter
provided_key = request.headers.get("X-API-Key") or request.args.get("api_key")
provided_key = request.headers.get("X-API-Key"
) or request.args.get("api_key")
if not provided_key or provided_key != API_KEY:
return jsonify({"error": "Unauthorized. Invalid or missing API key."}), 401
return f(*args, **kwargs)
@@ -134,6 +141,7 @@ def create_app():
"""Decorator to ensure storage path is configured."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not STORAGE_PATH:
@@ -193,7 +201,10 @@ def create_app():
with LocalLibrarySearchOptimizer(STORAGE_PATH) as db:
results = db.search_by_name(query, limit)
tag_results = db.search_by_tag(query, limit)
all_results = {r["hash"]: r for r in (results + tag_results)}
all_results = {
r["hash"]: r
for r in (results + tag_results)
}
return (
jsonify(
@@ -386,7 +397,8 @@ def create_app():
return jsonify({"error": "File not found"}), 404
metadata = db.get_metadata(file_path)
relationships = metadata.get("relationships", {}) if metadata else {}
relationships = metadata.get("relationships",
{}) if metadata else {}
return jsonify({"hash": file_hash, "relationships": relationships}), 200
except Exception as e:
logger.error(f"Get relationships error: {e}", exc_info=True)
@@ -486,15 +498,32 @@ def main():
parser = argparse.ArgumentParser(
description="Remote Storage Server for Medios-Macina",
epilog="Example: python remote_storage_server.py --storage-path /storage/media --port 5000 --api-key mysecretkey",
epilog=
"Example: python remote_storage_server.py --storage-path /storage/media --port 5000 --api-key mysecretkey",
)
parser.add_argument("--storage-path", type=str, required=True, help="Path to storage directory")
parser.add_argument(
"--host", type=str, default="0.0.0.0", help="Server host (default: 0.0.0.0)"
"--storage-path",
type=str,
required=True,
help="Path to storage directory"
)
parser.add_argument("--port", type=int, default=5000, help="Server port (default: 5000)")
parser.add_argument(
"--api-key", type=str, default=None, help="API key for authentication (optional)"
"--host",
type=str,
default="0.0.0.0",
help="Server host (default: 0.0.0.0)"
)
parser.add_argument(
"--port",
type=int,
default=5000,
help="Server port (default: 5000)"
)
parser.add_argument(
"--api-key",
type=str,
default=None,
help="API key for authentication (optional)"
)
parser.add_argument("--debug", action="store_true", help="Enable debug mode")

View File

@@ -59,7 +59,8 @@ def find_requirements(root: Path) -> Optional[Path]:
for p in root.iterdir():
if not p.is_dir():
continue
for child in (p,):
for child in (p,
):
candidate = child / "requirements.txt"
if candidate.exists():
return candidate
@@ -68,10 +69,22 @@ def find_requirements(root: Path) -> Optional[Path]:
return None
def install_requirements(venv_py: Path, req_path: Path, reinstall: bool = False) -> bool:
def install_requirements(
venv_py: Path,
req_path: Path,
reinstall: bool = False
) -> bool:
try:
print(f"Installing {req_path} into venv ({venv_py})...")
subprocess.run([str(venv_py), "-m", "pip", "install", "--upgrade", "pip"], check=True)
subprocess.run(
[str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"],
check=True
)
install_cmd = [str(venv_py), "-m", "pip", "install", "-r", str(req_path)]
if reinstall:
install_cmd = [
@@ -138,7 +151,11 @@ def verify_imports(venv_py: Path, packages: List[str]) -> bool:
for pkg in packages:
try:
out = subprocess.run(
[str(venv_py), "-m", "pip", "show", pkg],
[str(venv_py),
"-m",
"pip",
"show",
pkg],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@@ -158,7 +175,9 @@ def verify_imports(venv_py: Path, packages: List[str]) -> bool:
import_name = import_map.get(pkg, pkg)
try:
subprocess.run(
[str(venv_py), "-c", f"import {import_name}"],
[str(venv_py),
"-c",
f"import {import_name}"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -167,7 +186,10 @@ def verify_imports(venv_py: Path, packages: List[str]) -> bool:
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
missing.append(pkg)
if missing:
print("The following packages were not importable in the venv:", ", ".join(missing))
print(
"The following packages were not importable in the venv:",
", ".join(missing)
)
return False
return True
@@ -199,7 +221,9 @@ def install_service_windows(
try:
schtasks = shutil.which("schtasks")
if not schtasks:
print("schtasks not available on this system; cannot install Windows scheduled task.")
print(
"schtasks not available on this system; cannot install Windows scheduled task."
)
return False
bat = repo_root / "run-client.bat"
@@ -238,7 +262,9 @@ def uninstall_service_windows(service_name: str) -> bool:
try:
schtasks = shutil.which("schtasks")
if not schtasks:
print("schtasks not available on this system; cannot remove scheduled task.")
print(
"schtasks not available on this system; cannot remove scheduled task."
)
return False
cmd = [schtasks, "/Delete", "/TN", service_name, "/F"]
subprocess.run(cmd, check=True)
@@ -253,13 +279,25 @@ def uninstall_service_windows(service_name: str) -> bool:
def install_service_systemd(
service_name: str, repo_root: Path, venv_py: Path, headless: bool = True, detached: bool = True
service_name: str,
repo_root: Path,
venv_py: Path,
headless: bool = True,
detached: bool = True
) -> bool:
try:
systemctl = shutil.which("systemctl")
if not systemctl:
print("systemctl not available; falling back to crontab @reboot (if present).")
return install_service_cron(service_name, repo_root, venv_py, headless, detached)
print(
"systemctl not available; falling back to crontab @reboot (if present)."
)
return install_service_cron(
service_name,
repo_root,
venv_py,
headless,
detached
)
unit_dir = Path.home() / ".config" / "systemd" / "user"
unit_dir.mkdir(parents=True, exist_ok=True)
@@ -270,7 +308,12 @@ def install_service_systemd(
unit_file.write_text(content, encoding="utf-8")
subprocess.run([systemctl, "--user", "daemon-reload"], check=True)
subprocess.run(
[systemctl, "--user", "enable", "--now", f"{service_name}.service"], check=True
[systemctl,
"--user",
"enable",
"--now",
f"{service_name}.service"],
check=True
)
print(f"systemd user service '{service_name}' installed and started.")
return True
@@ -289,9 +332,15 @@ def uninstall_service_systemd(service_name: str) -> bool:
print("systemctl not available; cannot uninstall systemd service.")
return False
subprocess.run(
[systemctl, "--user", "disable", "--now", f"{service_name}.service"], check=False
[systemctl,
"--user",
"disable",
"--now",
f"{service_name}.service"],
check=False
)
unit_file = Path.home() / ".config" / "systemd" / "user" / f"{service_name}.service"
unit_file = Path.home(
) / ".config" / "systemd" / "user" / f"{service_name}.service"
if unit_file.exists():
unit_file.unlink()
subprocess.run([systemctl, "--user", "daemon-reload"], check=True)
@@ -303,7 +352,11 @@ def uninstall_service_systemd(service_name: str) -> bool:
def install_service_cron(
service_name: str, repo_root: Path, venv_py: Path, headless: bool = True, detached: bool = True
service_name: str,
repo_root: Path,
venv_py: Path,
headless: bool = True,
detached: bool = True
) -> bool:
try:
crontab = shutil.which("crontab")
@@ -312,7 +365,11 @@ def install_service_cron(
return False
entry = f"@reboot {venv_py} {str(repo_root / 'run_client.py')} --detached {'--headless' if headless else '--gui'} # {service_name}\n"
proc = subprocess.run(
[crontab, "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
[crontab,
"-l"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
existing = proc.stdout if proc.returncode == 0 else ""
if entry.strip() in existing:
@@ -337,7 +394,11 @@ def uninstall_service_cron(service_name: str, repo_root: Path, venv_py: Path) ->
print("crontab not available; cannot remove reboot cron job.")
return False
proc = subprocess.run(
[crontab, "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
[crontab,
"-l"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if proc.returncode != 0:
print("No crontab found for user; nothing to remove.")
@@ -356,21 +417,37 @@ def uninstall_service_cron(service_name: str, repo_root: Path, venv_py: Path) ->
def install_service_auto(
service_name: str, repo_root: Path, venv_py: Path, headless: bool = True, detached: bool = True
service_name: str,
repo_root: Path,
venv_py: Path,
headless: bool = True,
detached: bool = True
) -> bool:
try:
if os.name == "nt":
return install_service_windows(
service_name, repo_root, venv_py, headless=headless, detached=detached
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached
)
else:
if shutil.which("systemctl"):
return install_service_systemd(
service_name, repo_root, venv_py, headless=headless, detached=detached
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached
)
else:
return install_service_cron(
service_name, repo_root, venv_py, headless=headless, detached=detached
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached
)
except Exception as exc:
print("install_service_auto error:", exc)
@@ -391,7 +468,11 @@ def uninstall_service_auto(service_name: str, repo_root: Path, venv_py: Path) ->
return False
def print_activation_instructions(repo_root: Path, venv_dir: Path, venv_py: Path) -> None:
def print_activation_instructions(
repo_root: Path,
venv_dir: Path,
venv_py: Path
) -> None:
print("\nActivation and run examples:")
# PowerShell
print(f" PowerShell:\n . {shlex.quote(str(venv_dir))}\\Scripts\\Activate.ps1")
@@ -417,7 +498,9 @@ def detach_kwargs_for_platform():
return kwargs
def find_venv_python(repo_root: Path, venv_arg: Optional[str], venv_name: str) -> Optional[Path]:
def find_venv_python(repo_root: Path,
venv_arg: Optional[str],
venv_name: str) -> Optional[Path]:
# venv_arg may be a python executable or a directory
if venv_arg:
p = Path(venv_arg)
@@ -454,7 +537,9 @@ def _python_can_import(python_exe: Path, modules: List[str]) -> bool:
# Build a short import test string. Use semicolons to ensure any import error results in non-zero exit.
imports = ";".join([f"import {m}" for m in modules])
out = subprocess.run(
[str(python_exe), "-c", imports],
[str(python_exe),
"-c",
imports],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
@@ -466,11 +551,17 @@ def _python_can_import(python_exe: Path, modules: List[str]) -> bool:
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(
description="Run hydrus_client.py using the repo-local venv Python (top-level helper)"
description=
"Run hydrus_client.py using the repo-local venv Python (top-level helper)"
)
p.add_argument("--venv", help="Path to venv dir or python executable (overrides default .venv)")
p.add_argument(
"--venv-name", default=".venv", help="Name of the venv folder to look for (default: .venv)"
"--venv",
help="Path to venv dir or python executable (overrides default .venv)"
)
p.add_argument(
"--venv-name",
default=".venv",
help="Name of the venv folder to look for (default: .venv)"
)
p.add_argument(
"--client",
@@ -490,22 +581,26 @@ def main(argv: Optional[List[str]] = None) -> int:
p.add_argument(
"--reinstall",
action="store_true",
help="Force re-install dependencies from requirements.txt into the venv (uses --force-reinstall)",
help=
"Force re-install dependencies from requirements.txt into the venv (uses --force-reinstall)",
)
p.add_argument(
"--verify",
action="store_true",
help="Verify that packages from requirements.txt are importable in the venv (after install)",
help=
"Verify that packages from requirements.txt are importable in the venv (after install)",
)
p.add_argument(
"--no-verify",
action="store_true",
help="Skip verification and do not prompt to install missing dependencies; proceed to run with the chosen Python",
help=
"Skip verification and do not prompt to install missing dependencies; proceed to run with the chosen Python",
)
p.add_argument(
"--headless",
action="store_true",
help="Attempt to launch the client without showing the Qt GUI (best-effort). Default for subsequent runs; first run will show GUI unless --headless is supplied",
help=
"Attempt to launch the client without showing the Qt GUI (best-effort). Default for subsequent runs; first run will show GUI unless --headless is supplied",
)
p.add_argument(
"--gui",
@@ -513,12 +608,15 @@ def main(argv: Optional[List[str]] = None) -> int:
help="Start the client with the GUI visible (overrides headless/default) ",
)
p.add_argument(
"--detached", action="store_true", help="Start the client and do not wait (detached)"
"--detached",
action="store_true",
help="Start the client and do not wait (detached)"
)
p.add_argument(
"--install-service",
action="store_true",
help="Install a user-level start-on-boot service/scheduled task for the hydrus client",
help=
"Install a user-level start-on-boot service/scheduled task for the hydrus client",
)
p.add_argument(
"--uninstall-service",
@@ -531,7 +629,9 @@ def main(argv: Optional[List[str]] = None) -> int:
help="Name of the service / scheduled task to install (default: hydrus-client)",
)
p.add_argument(
"--cwd", default=None, help="Working directory to start the client in (default: repo root)"
"--cwd",
default=None,
help="Working directory to start the client in (default: repo root)"
)
p.add_argument("--quiet", action="store_true", help="Reduce output")
p.add_argument(
@@ -557,9 +657,13 @@ def main(argv: Optional[List[str]] = None) -> int:
def _is_running_in_virtualenv() -> bool:
try:
return hasattr(sys, "real_prefix") or getattr(sys, "base_prefix", None) != getattr(
sys, "prefix", None
)
return hasattr(sys,
"real_prefix") or getattr(sys,
"base_prefix",
None
) != getattr(sys,
"prefix",
None)
except Exception:
return False
@@ -619,7 +723,9 @@ def main(argv: Optional[List[str]] = None) -> int:
"Create one with: python -m venv .venv (inside your hydrus repo) and then re-run this helper, or use the installer to create it for you."
)
print_activation_instructions(
repo_root, repo_root / args.venv_name, repo_root / args.venv_name
repo_root,
repo_root / args.venv_name,
repo_root / args.venv_name
)
return 2
@@ -645,7 +751,9 @@ def main(argv: Optional[List[str]] = None) -> int:
if pkgs:
okv = verify_imports(venv_py, pkgs)
if not okv:
print("Verification failed; see instructions above to re-run installation.")
print(
"Verification failed; see instructions above to re-run installation."
)
# If not installing but user asked to verify, do verification only
if args.verify and not (args.install_deps or args.reinstall):
@@ -698,7 +806,11 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.install_service:
ok = install_service_auto(
args.service_name, repo_root, venv_py, headless=use_headless, detached=True
args.service_name,
repo_root,
venv_py,
headless=use_headless,
detached=True
)
return 0 if ok else 6
if args.uninstall_service:
@@ -723,7 +835,11 @@ def main(argv: Optional[List[str]] = None) -> int:
env = os.environ.copy()
if headless:
if os.name == "posix" and shutil.which("xvfb-run"):
xvfb_cmd = ["xvfb-run", "--auto-servernum", "--server-args=-screen 0 1024x768x24"]
xvfb_cmd = [
"xvfb-run",
"--auto-servernum",
"--server-args=-screen 0 1024x768x24"
]
cmd = xvfb_cmd + cmd
if not args.quiet:
print("Headless: using xvfb-run to provide a virtual X server")
@@ -744,7 +860,10 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.detached:
try:
kwargs = detach_kwargs_for_platform()
kwargs.update({"cwd": str(cwd), "env": env})
kwargs.update({
"cwd": str(cwd),
"env": env
})
subprocess.Popen(cmd, **kwargs)
print("Hydrus client launched (detached).")
return 0

View File

@@ -16,39 +16,6 @@ scripts/setup.py
"""
""" 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).
When invoked without any arguments, `setup.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/setup.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.
Usage:
python ./scripts/bootstrap.py # install deps and playwright browsers (or run platform bootstrap if no args)
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
@@ -100,7 +67,16 @@ def run_platform_bootstrap(repo_root: Path) -> int:
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"]
cmd = [
exe,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(ps1),
"-Quiet"
]
elif sh_script.exists():
shell = _find_shell()
if not shell:
@@ -115,7 +91,10 @@ def run_platform_bootstrap(repo_root: Path) -> int:
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)
print(
f"Bootstrap script failed with exit code {rc.returncode}",
file=sys.stderr
)
return int(rc.returncode or 0)
@@ -145,10 +124,14 @@ def _build_playwright_install_cmd(browsers: str | None) -> list[str]:
if "all" in items:
return base
allowed = {"chromium", "firefox", "webkit"}
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'")
raise ValueError(
f"invalid browsers specified: {invalid}. Valid choices: chromium, firefox, webkit, or 'all'"
)
return base + items
@@ -171,7 +154,16 @@ def _install_deno(version: str | None = None) -> int:
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])
run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
ps_cmd
]
)
else:
# POSIX: use curl + sh installer
if version:
@@ -186,7 +178,10 @@ def _install_deno(version: str | None = None) -> int:
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)
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)
@@ -194,17 +189,58 @@ def _install_deno(version: str | None = None) -> int:
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")
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")
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
@@ -273,7 +309,9 @@ def main() -> int:
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)...")
print(
"Installing Playwright browsers (this may download several hundred MB)..."
)
try:
cmd = _build_playwright_install_cmd(args.browsers)
except ValueError as exc:
@@ -286,14 +324,30 @@ def main() -> int:
if args.upgrade_pip:
print("Upgrading pip, setuptools, and wheel in local venv...")
run([str(venv_python), "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"])
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)
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}...")
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:
@@ -301,7 +355,9 @@ def main() -> int:
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)...")
print(
"Installing Playwright browsers (this may download several hundred MB)..."
)
try:
cmd = _build_playwright_install_cmd(args.browsers)
except ValueError as exc:
@@ -321,20 +377,33 @@ def main() -> int:
print("Verifying top-level 'CLI' import in venv...")
try:
import subprocess as _sub
rc = _sub.run([str(venv_python), "-c", "import importlib; importlib.import_module('CLI')"], check=False)
rc = _sub.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"
)]
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 = _sub.check_output(cmd, text=True).strip().splitlines()
site_dir = None
for sp in out:
@@ -342,7 +411,9 @@ def main() -> int:
site_dir = Path(sp)
break
if site_dir is None:
print("Could not determine venv site-packages directory; skipping .pth fallback")
print(
"Could not determine venv site-packages directory; skipping .pth fallback"
)
else:
pth_file = site_dir / "medeia_repo.pth"
if pth_file.exists():
@@ -356,16 +427,29 @@ def main() -> int:
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}")
print(
f"Wrote .pth adding repo root to venv site-packages: {pth_file}"
)
# Re-check whether CLI can be imported now
rc2 = _sub.run([str(venv_python), "-c", "import importlib; importlib.import_module('CLI')"], check=False)
rc2 = _sub.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.")
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}")
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
@@ -436,6 +520,7 @@ python -m medeia_macina.cli_entry @args
)
try:
# non-interactive mode, which we use so "python ./scripts/bootstrap.py" just
pass
except Exception:
pass
@@ -501,8 +586,15 @@ python -m medeia_macina.cli_entry @args
"$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)
).format(bin=str_bin.replace('\\',
'\\\\'))
subprocess.run(
["powershell",
"-NoProfile",
"-Command",
ps_cmd],
check=False
)
except Exception:
pass
@@ -510,7 +602,10 @@ python -m medeia_macina.cli_entry @args
else:
# POSIX
user_bin = Path(os.environ.get("XDG_BIN_HOME", str(home / ".local/bin")))
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"
@@ -615,7 +710,10 @@ python -m medeia_macina.cli_entry @args
return 0
except subprocess.CalledProcessError as exc:
print(f"Error: command failed with exit {exc.returncode}: {exc}", file=sys.stderr)
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)