Files
Medios-Macina/scripts/run_client.py
2026-01-23 03:02:21 -08:00

1121 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 scripts/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 / "scripts" / "requirements.txt", 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:
# Suppression flag for Windows
kwargs = {}
if os.name == "nt":
kwargs["creationflags"] = 0x08000000
print(f"Installing {req_path} into venv ({venv_py})...")
subprocess.run(
[str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"],
check=True,
**kwargs
)
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, **kwargs)
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:
# Skip mpv check as it is problematic to install and causes slow startups
packages = [p for p in packages if p.lower() != "mpv"]
# 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",
"pyopenssl": "OpenSSL",
"pysocks": "socks",
"service-identity": "service_identity",
"show-in-file-manager": "showinfm",
"opencv-python-headless": "cv2",
"pyside6": "PySide6",
}
# Helper for silent subprocess execution on Windows
def _run_silent(cmd, **kwargs):
if os.name == "nt":
# 0x08000000 = CREATE_NO_WINDOW
kwargs["creationflags"] = kwargs.get("creationflags", 0) | 0x08000000
return subprocess.run(cmd, **kwargs)
missing = []
for pkg in packages:
try:
out = _run_silent(
[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:
_run_silent(
[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",
pull: bool = False,
workspace_root: Optional[Path] = None,
) -> bool:
try:
schtasks = shutil.which("schtasks")
if not schtasks:
print(
"schtasks not available on this system; cannot install Windows scheduled task."
)
return False
# Use the repository root for the service wrapper script
bat = repo_root / "run-client.bat"
# If there's a local copy of run_client.py in the target repo, use that instead
# of the one from Medios-Macina to keep the service independent.
local_helper = repo_root / "run_client.py"
if not local_helper.exists():
local_helper = repo_root / "scripts" / "run_client.py"
target_script = local_helper if local_helper.exists() else Path(__file__).resolve()
python_exe = venv_py
# Use pythonw.exe for windowless execution on Windows
pythonw_exe = python_exe.parent / "pythonw.exe"
if not pythonw_exe.exists():
pythonw_exe = python_exe
if not bat.exists():
# The .bat remains using python.exe for manual/interactive runs
content = f'@echo off\n"{python_exe}" "{target_script}" %*\n'
bat.write_text(content, encoding="utf-8")
sc = "ONLOGON" if start_on == "logon" else "ONSTART"
# When running as a service, we DO NOT use --detached.
# This keeps the run_client.py process alive as a monitor for the task scheduler,
# preventing it from thinking the task finished/crashed and trying to restart it.
task_args = ""
if headless: task_args += "--headless "
if pull: task_args += "--pull "
# Force the correct repo root for the service
task_args += f'--repo-root "{repo_root}" '
# Use pythonw for the task to avoid console window
tr_command = f'"{pythonw_exe}" "{target_script}" {task_args.strip()}'
cmd = [
schtasks,
"/Create",
"/SC",
sc,
"/TN",
service_name,
"/TR",
tr_command,
"/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,
pull: bool = False,
workspace_root: Optional[Path] = None,
) -> bool:
try:
helper_path = Path(__file__).resolve()
print(f"Installing systemd user service via {helper_path}...")
print("systemctl env:", {
"DBUS_SESSION_BUS_ADDRESS": os.environ.get("DBUS_SESSION_BUS_ADDRESS"),
"XDG_RUNTIME_DIR": os.environ.get("XDG_RUNTIME_DIR"),
"HOME": os.environ.get("HOME"),
})
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,
pull=pull,
workspace_root=workspace_root
)
if (
not os.environ.get("DBUS_SESSION_BUS_ADDRESS")
or not os.environ.get("XDG_RUNTIME_DIR")
):
print(
"DBUS_SESSION_BUS_ADDRESS/XDG_RUNTIME_DIR not set; skipping systemd user install"
)
return install_service_cron(
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached,
pull=pull,
workspace_root=workspace_root,
)
if os.name != "nt" and hasattr(os, "geteuid") and os.geteuid() == 0:
print(
"Running as root; systemd user services arent available. Falling back to cron @reboot."
)
return install_service_cron(
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached,
pull=pull,
workspace_root=workspace_root,
)
unit_dir = Path.home() / ".config" / "systemd" / "user"
unit_dir.mkdir(parents=True, exist_ok=True)
unit_file = unit_dir / f"{service_name}.service"
# Prefer local helper if it exists
local_helper = repo_root / "run_client.py"
if not local_helper.exists():
local_helper = repo_root / "scripts" / "run_client.py"
target_script = local_helper if local_helper.exists() else Path(__file__).resolve()
exec_args = f'"{venv_py}" "{target_script}" --detached '
if headless: exec_args += "--headless "
if pull: exec_args += "--pull "
exec_args += f'--repo-root "{repo_root}" '
content = f"[Unit]\nDescription=Medios-Macina Client\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")
def _run_systemctl(cmd: List[str]) -> subprocess.CompletedProcess:
try:
return subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
)
except Exception as exc:
raise
failed = False
for cmd in [
[systemctl, "--user", "daemon-reload"],
[systemctl,
"--user",
"enable",
"--now",
f"{service_name}.service"],
]:
result = _run_systemctl(cmd)
if result.returncode != 0:
stderr = (result.stderr or "").strip()
stdout = (result.stdout or "").strip()
err_lines = "\n".join([l for l in [stderr, stdout] if l])
print("Failed to run systemctl", cmd, "exit", result.returncode)
if err_lines:
print(err_lines)
if "user scope bus" in err_lines.lower() or "xdg_runtime_dir" in err_lines.lower():
print("systemd user bus unavailable; falling back to cron @reboot install.")
return install_service_cron(
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached,
pull=pull,
workspace_root=workspace_root,
)
failed = True
break
if failed:
return False
print(f"systemd user service '{service_name}' installed and started.")
return True
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,
pull: bool = False,
workspace_root: Optional[Path] = None,
) -> bool:
try:
crontab = shutil.which("crontab")
if not crontab:
print("crontab not available; cannot install reboot cron job.")
return False
# Prefer local helper if it exists
local_helper = repo_root / "run_client.py"
if not local_helper.exists():
local_helper = repo_root / "scripts" / "run_client.py"
target_script = local_helper if local_helper.exists() else Path(__file__).resolve()
exec_args = f'"{venv_py}" "{target_script}" --detached '
if headless: exec_args += "--headless "
if pull: exec_args += "--pull "
exec_args += f'--repo-root "{repo_root}" '
entry = f"@reboot {exec_args} # {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,
pull: bool = False,
workspace_root: Optional[Path] = None,
) -> bool:
try:
if os.name == "nt":
return install_service_windows(
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached,
pull=pull,
workspace_root=workspace_root
)
else:
if shutil.which("systemctl"):
return install_service_systemd(
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached,
pull=pull,
workspace_root=workspace_root
)
else:
return install_service_cron(
service_name,
repo_root,
venv_py,
headless=headless,
detached=detached,
pull=pull,
workspace_root=workspace_root
)
except Exception as exc:
print("install_service_auto error:", exc)
return False
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":
# Flags to ensure the process is detached and has NO console window
CREATE_NEW_PROCESS_GROUP = 0x00000200
DETACHED_PROCESS = 0x00000008
CREATE_NO_WINDOW = 0x08000000
kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW
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(
"--pull",
action="store_true",
help="Run 'git pull' before starting the client",
)
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)
script_dir = Path(__file__).resolve().parent
if (script_dir / "hydrus_client.py").exists():
workspace_root = script_dir
else:
workspace_root = script_dir.parent
if args.repo_root:
repo_root = Path(args.repo_root).expanduser().resolve()
else:
if (workspace_root / "hydrus_client.py").exists():
repo_root = workspace_root
else:
candidate = workspace_root / "hydrusnetwork"
if candidate.exists():
repo_root = candidate
else:
repo_root = workspace_root
# Handle git pull update if requested
# Skip execution during service install/uninstall; it will run when the service starts
if args.pull and not (args.install_service or args.uninstall_service):
if shutil.which("git"):
if (repo_root / ".git").exists():
if not args.quiet:
print(f"Updating repository via 'git pull' in {repo_root}...")
try:
# Use creationflags to hide the window on Windows
k = {}
if os.name == "nt":
k["creationflags"] = 0x08000000
subprocess.run(["git", "pull"], cwd=str(repo_root), check=False, **k)
except Exception as e:
print(f"Warning: git pull failed: {e}")
else:
if not args.quiet:
print("Skipping 'git pull': directory is not a git repository.")
else:
if not args.quiet:
print("Skipping 'git pull': 'git' not found on PATH.")
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
# Skip heavy verification if we are just installing/uninstalling a service
do_verify = args.verify or (not args.no_verify and not (args.install_service or args.uninstall_service))
# 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)
# However, if we've already found a repo-local venv and the current Python
# is external to the repository, we do NOT prefer it yet - we'll verify the
# repo-local one first. This prevents tools like Medios-Macina from
# accidentally installing their own venv into the repo's services.
cur_is_external = True
try:
if repo_root in cur_py.resolve().parents:
cur_is_external = False
except Exception:
pass
if args.venv is None and _is_running_in_virtualenv() and cur_py and (not venv_py or not cur_is_external):
# 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 and do_verify else []
check_pkgs = pkgs if pkgs else ["pyyaml"]
ok_cur = False
if do_verify:
try:
ok_cur = verify_imports(cur_py, check_pkgs)
except Exception:
ok_cur = _python_can_import(cur_py, ["yaml"])
else:
# If skipping verification, assume current is OK if it's the right version
ok_cur = True
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 and do_verify:
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"])
# ... logic continues below
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
if do_verify:
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."
)
# 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,
pull=args.pull,
workspace_root=workspace_root
)
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
# Determine headless vs GUI
if args.gui:
headless = False
elif args.headless:
headless = True
else:
# Default to GUI for the client launcher
headless = False
# On Windows, if we are headless, use pythonw.exe for the client too to avoid a console.
if os.name == "nt" and headless:
pw = venv_py.parent / "pythonw.exe"
if pw.exists():
venv_py = pw
# Prepare the command
client_args = args.client_args or []
cmd = [str(venv_py), str(client_path)] + client_args
if not args.quiet and is_first_run(repo_root):
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:
p = None
try:
# On Windows, if we are already using pythonw.exe, we don't need CREATE_NO_WINDOW
# as the process already has no console. Avoiding extra flags helps with
# service termination mapping.
kwargs = {}
if os.name == "nt" and headless and "pythonw.exe" not in str(venv_py).lower():
kwargs["creationflags"] = 0x08000000
p = subprocess.Popen(cmd, cwd=str(cwd), env=env, **kwargs)
p.wait()
return p.returncode
except Exception as e:
if not args.quiet:
print(f"Hydrus client error: {e}")
return 5
finally:
# Ensure the child process is terminated if the monitor process is killed
if p and p.poll() is None:
try:
p.terminate()
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())