df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
2025-12-29 17:05:03 -08:00
parent 226de9316a
commit c019c00aed
104 changed files with 19669 additions and 12954 deletions

764
scripts/run_client.py Normal file
View File

@@ -0,0 +1,764 @@
#!/usr/bin/env python3
"""Run the Hydrus client (top-level helper)
This standalone helper is intended to live in the project's top-level `scripts/`
folder so it remains available even if the Hydrus repository subfolder is not
present or its copy of this helper gets removed.
Features (subset of the repo helper):
- Locate repository venv (default: <workspace>/hydrusnetwork/.venv)
- Install or reinstall requirements.txt into the venv
- Verify key imports
- Launch hydrus_client.py (foreground or detached)
- Install/uninstall simple user-level start-on-boot services (schtasks/systemd/crontab)
Usage examples:
python scripts/run_client.py --verify
python scripts/run_client.py --detached --headless
python scripts/run_client.py --install-deps --verify
"""
from __future__ import annotations
import argparse
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
def get_python_in_venv(venv_dir: Path) -> Optional[Path]:
try:
v = Path(venv_dir)
# Windows
win_python = v / "Scripts" / "python.exe"
if win_python.exists():
return win_python
# Unix
unix_python = v / "bin" / "python"
if unix_python.exists():
return unix_python
unix_py3 = v / "bin" / "python3"
if unix_py3.exists():
return unix_py3
except Exception:
pass
return None
def find_requirements(root: Path) -> Optional[Path]:
candidates = [root / "requirements.txt", root / "client" / "requirements.txt"]
for c in candidates:
if c.exists():
return c
# shallow two-level search
try:
for p in root.iterdir():
if not p.is_dir():
continue
for child in (p,):
candidate = child / "requirements.txt"
if candidate.exists():
return candidate
except Exception:
pass
return None
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)
install_cmd = [str(venv_py), "-m", "pip", "install", "-r", str(req_path)]
if reinstall:
install_cmd = [
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"--force-reinstall",
"-r",
str(req_path),
]
subprocess.run(install_cmd, check=True)
return True
except subprocess.CalledProcessError as e:
print("Failed to install requirements:", e)
return False
def parse_requirements_file(req_path: Path) -> List[str]:
names: List[str] = []
try:
with req_path.open("r", encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("-e") or line.startswith("--"):
continue
if "://" in line or line.startswith("file:"):
continue
line = line.split(";")[0].strip()
line = line.split("[")[0].strip()
for sep in ("==", ">=", "<=", "~=", "!=", ">", "<", "==="):
if sep in line:
line = line.split(sep)[0].strip()
if " @ " in line:
line = line.split(" @ ")[0].strip()
if line:
names.append(line.split()[0].strip().lower())
except Exception:
pass
return names
def verify_imports(venv_py: Path, packages: List[str]) -> bool:
# Map some package names to import names (handle common cases where package name differs from import name)
import_map = {
"pyyaml": "yaml",
"pillow": "PIL",
"python-dateutil": "dateutil",
"beautifulsoup4": "bs4",
"pillow-heif": "pillow_heif",
"pillow-jxl-plugin": "pillow_jxl_plugin",
"pyopenssl": "OpenSSL",
"pysocks": "socks",
"service-identity": "service_identity",
"show-in-file-manager": "show_in_file_manager",
"opencv-python-headless": "cv2",
"mpv": "mpv",
"pyside6": "PySide6",
}
missing = []
for pkg in packages:
try:
out = subprocess.run(
[str(venv_py), "-m", "pip", "show", pkg],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except subprocess.TimeoutExpired:
missing.append(pkg)
continue
except Exception:
missing.append(pkg)
continue
if out.returncode != 0 or not out.stdout.strip():
missing.append(pkg)
continue
import_name = import_map.get(pkg, pkg)
try:
subprocess.run(
[str(venv_py), "-c", f"import {import_name}"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
missing.append(pkg)
if missing:
print("The following packages were not importable in the venv:", ", ".join(missing))
return False
return True
def is_first_run(repo_root: Path) -> bool:
try:
db_dir = repo_root / "db"
if db_dir.exists() and any(db_dir.iterdir()):
return False
for f in repo_root.glob("*.db"):
if f.exists():
return False
except Exception:
return False
return True
# --- Service install/uninstall helpers -----------------------------------
def install_service_windows(
service_name: str,
repo_root: Path,
venv_py: Path,
headless: bool = True,
detached: bool = True,
start_on: str = "logon",
) -> bool:
try:
schtasks = shutil.which("schtasks")
if not schtasks:
print("schtasks not available on this system; cannot install Windows scheduled task.")
return False
bat = repo_root / "run-client.bat"
if not bat.exists():
# Use escaped backslashes to avoid Python "invalid escape sequence" warnings
content = '@echo off\n"%~dp0\\.venv\\Scripts\\python.exe" "%~dp0hydrus_client.py" %*\n'
bat.write_text(content, encoding="utf-8")
tr = str(bat)
sc = "ONLOGON" if start_on == "logon" else "ONSTART"
cmd = [
schtasks,
"/Create",
"/SC",
sc,
"/TN",
service_name,
"/TR",
f'"{tr}"',
"/RL",
"LIMITED",
"/F",
]
subprocess.run(cmd, check=True)
print(f"Scheduled task '{service_name}' created ({sc}).")
return True
except subprocess.CalledProcessError as e:
print("Failed to create scheduled task:", e)
return False
except Exception as exc:
print("Windows install-service error:", exc)
return False
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.")
return False
cmd = [schtasks, "/Delete", "/TN", service_name, "/F"]
subprocess.run(cmd, check=True)
print(f"Scheduled task '{service_name}' removed.")
return True
except subprocess.CalledProcessError as e:
print("Failed to delete scheduled task:", e)
return False
except Exception as exc:
print("Windows uninstall-service error:", exc)
return False
def install_service_systemd(
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)
unit_dir = Path.home() / ".config" / "systemd" / "user"
unit_dir.mkdir(parents=True, exist_ok=True)
unit_file = unit_dir / f"{service_name}.service"
exec_args = f'"{venv_py}" "{str(repo_root / "run_client.py")}" --detached '
exec_args += "--headless " if headless else "--gui "
content = f"[Unit]\nDescription=Hydrus Client (user)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={exec_args}\nWorkingDirectory={str(repo_root)}\nRestart=on-failure\nEnvironment=PYTHONUNBUFFERED=1\n\n[Install]\nWantedBy=default.target\n"
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
)
print(f"systemd user service '{service_name}' installed and started.")
return True
except subprocess.CalledProcessError as e:
print("Failed to create systemd user service:", e)
return False
except Exception as exc:
print("systemd install error:", exc)
return False
def uninstall_service_systemd(service_name: str) -> bool:
try:
systemctl = shutil.which("systemctl")
if not systemctl:
print("systemctl not available; cannot uninstall systemd service.")
return False
subprocess.run(
[systemctl, "--user", "disable", "--now", f"{service_name}.service"], check=False
)
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)
print(f"systemd user service '{service_name}' removed.")
return True
except Exception as exc:
print("systemd uninstall error:", exc)
return False
def install_service_cron(
service_name: str, repo_root: Path, venv_py: Path, headless: bool = True, detached: bool = True
) -> bool:
try:
crontab = shutil.which("crontab")
if not crontab:
print("crontab not available; cannot install reboot cron job.")
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
)
existing = proc.stdout if proc.returncode == 0 else ""
if entry.strip() in existing:
print("Crontab entry already present; skipping.")
return True
new = existing + "\n" + entry
subprocess.run([crontab, "-"], input=new, text=True, check=True)
print(f"Crontab @reboot entry added for '{service_name}'.")
return True
except subprocess.CalledProcessError as e:
print("Failed to install crontab entry:", e)
return False
except Exception as exc:
print("crontab install error:", exc)
return False
def uninstall_service_cron(service_name: str, repo_root: Path, venv_py: Path) -> bool:
try:
crontab = shutil.which("crontab")
if not crontab:
print("crontab not available; cannot remove reboot cron job.")
return False
proc = subprocess.run(
[crontab, "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if proc.returncode != 0:
print("No crontab found for user; nothing to remove.")
return True
lines = [l for l in proc.stdout.splitlines() if f"# {service_name}" not in l]
new = "\n".join(lines) + "\n"
subprocess.run([crontab, "-"], input=new, text=True, check=True)
print(f"Crontab entry for '{service_name}' removed.")
return True
except subprocess.CalledProcessError as e:
print("Failed to modify crontab:", e)
return False
except Exception as exc:
print("crontab uninstall error:", exc)
return False
def install_service_auto(
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
)
else:
if shutil.which("systemctl"):
return install_service_systemd(
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
)
except Exception as exc:
print("install_service_auto error:", exc)
return False
def uninstall_service_auto(service_name: str, repo_root: Path, venv_py: Path) -> bool:
try:
if os.name == "nt":
return uninstall_service_windows(service_name)
else:
if shutil.which("systemctl"):
return uninstall_service_systemd(service_name)
else:
return uninstall_service_cron(service_name, repo_root, venv_py)
except Exception as exc:
print("uninstall_service_auto error:", exc)
return False
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")
# CMD
print(f" CMD:\n {str(venv_dir)}\\Scripts\\activate.bat")
# Bash
print(f" Bash (Linux/macOS/WSL):\n source {str(venv_dir)}/bin/activate")
print(
f"\nDirect run without activating:\n {str(venv_py)} {str(repo_root/ 'hydrus_client.py')}"
)
def detach_kwargs_for_platform():
kwargs = {}
if os.name == "nt":
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:
kwargs["creationflags"] = flags
else:
kwargs["start_new_session"] = True
return kwargs
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)
if p.exists():
if p.is_file():
return p
else:
found = get_python_in_venv(p)
if found:
return found
# Try repo-local venv
dir_candidate = repo_root / venv_name
found = get_python_in_venv(dir_candidate)
if found:
return found
# Fallback: if current interpreter is inside repo venv
try:
cur = Path(sys.executable).resolve()
if repo_root in cur.parents:
return cur
except Exception:
pass
return None
def _python_can_import(python_exe: Path, modules: List[str]) -> bool:
"""Return True if the given python executable can import all modules in the list.
Uses a subprocess to avoid side-effects in the current interpreter.
"""
if not python_exe:
return False
try:
# 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],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
)
return out.returncode == 0
except (subprocess.TimeoutExpired, Exception):
return False
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)"
)
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)"
)
p.add_argument(
"--client",
default="hydrus_client.py",
help="Path to hydrus_client.py relative to repo root",
)
p.add_argument(
"--repo-root",
default=None,
help="Path to the hydrus repository root (overrides auto-detection)",
)
p.add_argument(
"--install-deps",
action="store_true",
help="Install requirements.txt into the venv before running",
)
p.add_argument(
"--reinstall",
action="store_true",
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)",
)
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",
)
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",
)
p.add_argument(
"--gui",
action="store_true",
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)"
)
p.add_argument(
"--install-service",
action="store_true",
help="Install a user-level start-on-boot service/scheduled task for the hydrus client",
)
p.add_argument(
"--uninstall-service",
action="store_true",
help="Remove an installed start-on-boot service/scheduled task",
)
p.add_argument(
"--service-name",
default="hydrus-client",
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)"
)
p.add_argument("--quiet", action="store_true", help="Reduce output")
p.add_argument(
"client_args",
nargs=argparse.REMAINDER,
help="Arguments to pass to hydrus_client.py (prefix with --)",
)
args = p.parse_args(argv)
workspace_root = Path(__file__).resolve().parent.parent
# Determine default repo root: prefer <workspace>/hydrusnetwork when present
if args.repo_root:
repo_root = Path(args.repo_root).expanduser().resolve()
else:
candidate = workspace_root / "hydrusnetwork"
if candidate.exists():
repo_root = candidate
else:
repo_root = workspace_root
venv_py = find_venv_python(repo_root, args.venv, args.venv_name)
def _is_running_in_virtualenv() -> bool:
try:
return hasattr(sys, "real_prefix") or getattr(sys, "base_prefix", None) != getattr(
sys, "prefix", None
)
except Exception:
return False
# Prefer the current interpreter if the helper was invoked from a virtualenv
# and the user did not explicitly pass --venv. This matches the user's likely
# intent when they called: <venv_python> scripts/run_client.py ...
cur_py = Path(sys.executable)
if args.venv is None and _is_running_in_virtualenv() and cur_py:
# If current interpreter looks like a venv and can import required modules,
# prefer it immediately rather than forcing the repo venv.
req = find_requirements(repo_root)
pkgs = parse_requirements_file(req) if req else []
check_pkgs = pkgs if pkgs else ["pyyaml"]
try:
ok_cur = verify_imports(cur_py, check_pkgs)
except Exception:
ok_cur = _python_can_import(cur_py, ["yaml"])
if ok_cur:
venv_py = cur_py
if not args.quiet:
print(f"Using current Python interpreter as venv: {cur_py}")
# If we found a repo-local venv, verify it has at least the core imports (or the
# packages listed in requirements.txt). If not, prefer the current Python
# interpreter when that interpreter looks more suitable (e.g. has deps installed).
if venv_py and venv_py != cur_py:
if not args.quiet:
print(f"Found venv python: {venv_py}")
req = find_requirements(repo_root)
pkgs = parse_requirements_file(req) if req else []
check_pkgs = pkgs if pkgs else ["pyyaml"]
try:
ok_venv = verify_imports(venv_py, check_pkgs)
except Exception:
ok_venv = _python_can_import(venv_py, ["yaml"])
if not ok_venv:
try:
ok_cur = verify_imports(cur_py, check_pkgs)
except Exception:
ok_cur = _python_can_import(cur_py, ["yaml"])
if ok_cur:
if not args.quiet:
print(
f"Repository venv ({venv_py}) is missing required packages; using current Python at {cur_py} instead."
)
venv_py = cur_py
else:
print(
"Warning: repository venv appears to be missing required packages. If the client fails to start, run this helper with --install-deps to install requirements into the repo venv, or use --venv to point to a Python that has the deps."
)
if not venv_py:
print("Could not locate a repository venv.")
print(
"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
)
return 2
client_path = (repo_root / args.client).resolve()
if not client_path.exists():
print(f"Client file not found: {client_path}")
return 3
cwd = Path(args.cwd).resolve() if args.cwd else repo_root
# Optionally install dependencies
if args.install_deps or args.reinstall:
req = find_requirements(repo_root)
if not req:
print("No requirements.txt found; skipping install")
else:
ok = install_requirements(venv_py, req, reinstall=args.reinstall)
if not ok:
print("Dependency installation failed; aborting")
return 4
if args.verify:
pkgs = parse_requirements_file(req)
if pkgs:
okv = verify_imports(venv_py, pkgs)
if not okv:
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):
req = find_requirements(repo_root)
if req:
pkgs = parse_requirements_file(req)
if pkgs and not verify_imports(venv_py, pkgs):
print(
"Verification found missing packages. Use --install-deps to install into the venv."
)
# If the venv appears to be missing required packages, offer to install them interactively
req = find_requirements(repo_root)
pkgs = parse_requirements_file(req) if req else []
check_pkgs = pkgs if pkgs else ["pyyaml"]
try:
venv_ok = verify_imports(venv_py, check_pkgs)
except Exception:
venv_ok = _python_can_import(venv_py, ["yaml"]) # fallback
if not venv_ok:
# If user explicitly requested install, we've already attempted it above; otherwise, do not block.
if args.install_deps or args.reinstall:
# if we already did an install attempt and it still fails, bail
print("Dependency verification failed after install; aborting.")
return 4
# Default: print a clear warning and proceed to launch with the repository venv
if args.no_verify:
print(
"Repository venv is missing required packages; proceeding without verification as requested ( --no-verify ). Client may fail to start."
)
else:
print(
"Warning: repository venv appears to be missing required packages. Proceeding to launch with repository venv; the client may fail to start. Use --install-deps to install requirements into the repo venv."
)
# Do not prompt to switch to another interpreter automatically; the user can
# re-run with --venv to select a different python if desired.
# Service install/uninstall requests
if args.install_service or args.uninstall_service:
first_run = is_first_run(repo_root)
if args.gui:
use_headless = False
elif args.headless:
use_headless = True
else:
use_headless = not first_run
if args.install_service:
ok = install_service_auto(
args.service_name, repo_root, venv_py, headless=use_headless, detached=True
)
return 0 if ok else 6
if args.uninstall_service:
ok = uninstall_service_auto(args.service_name, repo_root, venv_py)
return 0 if ok else 7
# Prepare the command
client_args = args.client_args or []
cmd = [str(venv_py), str(client_path)] + client_args
# Determine headless vs GUI
first_run = is_first_run(repo_root)
if args.gui:
headless = False
elif args.headless:
headless = True
else:
headless = not first_run
if not args.quiet and first_run:
print("First run detected: defaulting to GUI unless --headless is specified.")
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"]
cmd = xvfb_cmd + cmd
if not args.quiet:
print("Headless: using xvfb-run to provide a virtual X server")
else:
env["QT_QPA_PLATFORM"] = "offscreen"
if not args.quiet:
print("Headless: setting QT_QPA_PLATFORM=offscreen (best-effort)")
# Inform which Python will be used
if not args.quiet:
try:
print(f"Launching Hydrus client with Python: {venv_py}")
print(f"Command: {' '.join(shlex.quote(str(c)) for c in cmd)}")
except Exception:
pass
# Launch
if args.detached:
try:
kwargs = detach_kwargs_for_platform()
kwargs.update({"cwd": str(cwd), "env": env})
subprocess.Popen(cmd, **kwargs)
print("Hydrus client launched (detached).")
return 0
except Exception as exc:
print("Failed to launch client detached:", exc)
return 5
else:
try:
subprocess.run(cmd, cwd=str(cwd), env=env)
return 0
except subprocess.CalledProcessError as e:
print("hydrus client exited non-zero:", e)
return 5
if __name__ == "__main__":
raise SystemExit(main())