Files
Medios-Macina/scripts/hydrusnetwork.py
2025-12-31 22:05:25 -08:00

1621 lines
66 KiB
Python

#!/usr/bin/env python3
"""Create a 'hydrusnetwork' directory and clone the Hydrus repository into it.
Works on Linux and Windows. Behavior:
- By default creates ./hydrusnetwork and clones https://github.com/hydrusnetwork/hydrus there.
- If the target directory already exists:
- When run non-interactively: Use --update to run `git pull` (if it's a git repo) or --force to re-clone.
- When run interactively without flags, the script presents a numeric menu to choose actions:
1) Update definitions (attempt to update a 'definitions' subdir if present)
2) Update hydrus (git pull)
3) Re-clone (remove and re-clone)
- If `git` is not available, the script will fall back to downloading the repository ZIP and extracting it.
- By default the script will create a repository-local virtual environment `./<dest>/.venv` after cloning/extraction; use `--no-venv` to skip this. By default the script will install dependencies from `scripts/requirements.txt` into that venv (use `--no-install-deps` to skip). After setup the script will print instructions for running the client; use `--run-client` to *launch* `hydrus_client.py` using the created repo venv's Python (use `--run-client-detached` to run it in the background).
Examples:
python scripts/hydrusnetwork.py
python scripts/hydrusnetwork.py --root /opt --dest-name hydrusnetwork --force
python scripts/hydrusnetwork.py --update
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
import shlex
from pathlib import Path
from typing import Optional, Tuple
import logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
# Try to import helpers from the run_client module if available. If import fails, provide fallbacks.
try:
from hydrusnetwork.run_client import (
install_service_auto,
uninstall_service_auto,
detach_kwargs_for_platform,
)
except Exception:
install_service_auto = None
uninstall_service_auto = None
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_git_executable() -> Optional[str]:
"""Return the git executable path or None if not found."""
import shutil as _shutil
git = _shutil.which("git")
if not git:
return None
# Quick sanity check
try:
subprocess.run(
[git,
"--version"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
return git
except Exception:
return None
def is_git_repo(path: Path) -> bool:
"""Determine whether the given path is a git working tree."""
if not path.exists() or not path.is_dir():
return False
if (path / ".git").exists():
return True
git = find_git_executable()
if not git:
return False
try:
subprocess.run(
[git,
"-C",
str(path),
"rev-parse",
"--is-inside-work-tree"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return True
except Exception:
return False
def run_git_clone(
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.
cmd = [git, "clone"]
if depth is not None and depth > 0:
cmd += ["--depth", str(int(depth))]
# For performance/clarity, when doing a shallow clone of a specific branch,
# prefer --single-branch to avoid fetching other refs.
if branch:
cmd += ["--single-branch"]
if branch:
cmd += ["--branch", branch]
cmd += [repo, str(dest)]
logging.info(
"Cloning: %s -> %s (depth=%s)",
repo,
dest,
str(depth) if depth else "full"
)
subprocess.run(cmd, check=True)
def run_git_pull(git: str, dest: Path) -> None:
logging.info("Updating git repository in %s", dest)
subprocess.run([git, "-C", str(dest), "pull"], check=True)
def download_and_extract_zip(
repo_url: str,
dest: Path,
branch_candidates: Tuple[str,
...] = ("main",
"master")
) -> None:
"""Download the GitHub repo zip and extract it into dest.
This avoids requiring git to be installed.
"""
# By default, if a project virtualenv is detected (".venv" or "venv" under
# the chosen --root, or $VIRTUAL_ENV), the script will re-exec itself under
# that venv's python interpreter so subsequent operations use the project
# environment. Use --no-project-venv to opt out of this behavior.
# Parse owner/repo from URL like https://github.com/owner/repo
try:
from urllib.parse import urlparse
p = urlparse(repo_url)
parts = [p for p in p.path.split("/") if p]
if len(parts) < 2:
raise ValueError("Cannot parse owner/repo from URL")
owner, repo = parts[0], parts[1]
except Exception:
raise RuntimeError(f"Invalid repo URL: {repo_url}")
errors = []
for branch in branch_candidates:
zip_url = f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
logging.info("Attempting ZIP download: %s", zip_url)
try:
with urllib.request.urlopen(zip_url) as resp:
if resp.status != 200:
raise RuntimeError(f"HTTP {resp.status} while fetching {zip_url}")
with tempfile.TemporaryDirectory() as td:
tmpzip = Path(td) / "repo.zip"
with open(tmpzip, "wb") as fh:
fh.write(resp.read())
with zipfile.ZipFile(tmpzip, "r") as z:
z.extractall(td)
# Extracted content usually at repo-<branch>/
extracted_root = None
td_path = Path(td)
for child in td_path.iterdir():
if child.is_dir():
extracted_root = child
break
if not extracted_root:
raise RuntimeError("Broken ZIP: no extracted directory found")
# Move contents of extracted_root into dest
dest.mkdir(parents=True, exist_ok=True)
for entry in extracted_root.iterdir():
target = dest / entry.name
if target.exists():
# Try to remove before moving
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
shutil.move(str(entry), str(dest))
logging.info(
"Downloaded and extracted %s (branch: %s) into %s",
repo_url,
branch,
dest
)
return
except Exception as exc:
errors.append(str(exc))
logging.debug("ZIP download failed for branch %s: %s", branch, exc)
continue
# If we failed for all branches
raise RuntimeError(f"Failed to download zip for {repo_url}; errors: {errors}")
# --- Project venv helpers -------------------------------------------------
def get_python_in_venv(venv_dir: Path) -> Optional[Path]:
"""Return path to python executable inside a venv-like directory, or None."""
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_project_venv(root: Path) -> Optional[Path]:
"""Find a project venv directory under the given root (or VIRTUAL_ENV).
Checks, in order: $VIRTUAL_ENV, <root>/.venv, <root>/venv
Returns the Path to the venv dir if it looks valid, else None.
"""
candidates = []
try:
venv_env = os.environ.get("VIRTUAL_ENV")
if venv_env:
candidates.append(Path(venv_env))
except Exception:
pass
candidates.extend([root / ".venv", root / "venv"]) # order matters: prefer .venv
for c in candidates:
try:
if c and c.exists():
py = get_python_in_venv(c)
if py is not None:
return c
except Exception:
continue
return None
def maybe_reexec_under_project_venv(root: Path, disable: bool = False) -> None:
"""If a project venv exists and we are not already running under it, re-exec
the current script using that venv's python interpreter.
This makes the script "use the project venv by default" when present.
"""
if disable:
return
# Avoid infinite re-exec loops
if os.environ.get("HYDRUSNETWORK_REEXEC") == "1":
return
try:
venv_dir = find_project_venv(root)
if not venv_dir:
return
py = get_python_in_venv(venv_dir)
if not py:
return
current = Path(sys.executable)
try:
# If current interpreter is the same as venv's python, skip.
if current.resolve() == py.resolve():
return
except Exception:
pass
logging.info("Re-executing under project venv: %s", py)
env = os.environ.copy()
env["HYDRUSNETWORK_REEXEC"] = "1"
# Use absolute script path to avoid any relative path quirks.
try:
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:]
logging.debug("Exec args: %s", args)
os.execvpe(str(py), args, env)
except Exception as exc:
logging.debug("Failed to re-exec under project venv: %s", exc)
return
# --- Permissions helpers -------------------------------------------------
def is_elevated() -> bool:
"""Return True if the current process is elevated (Windows) or running as root (Unix)."""
try:
if os.name == "nt":
import ctypes
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
else:
try:
return os.geteuid() == 0
except Exception:
return False
except Exception:
return False
def fix_permissions_windows(path: Path, user: Optional[str] = None) -> bool:
"""Attempt to set owner and grant FullControl via icacls/takeown.
Returns True if commands report success; otherwise False. May require elevation.
"""
import getpass
import subprocess
try:
if not user:
try:
who = subprocess.check_output(["whoami"], text=True).strip()
user = who or getpass.getuser()
except Exception:
user = getpass.getuser()
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"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
rc_setowner = 1
rc_grant = 1
try:
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
)
rc_grant = int(out.returncode)
except Exception:
rc_grant = 1
success = rc_setowner == 0 or rc_grant == 0
if success:
logging.info("Windows permission fix succeeded (owner/grant applied).")
else:
logging.warning(
"Windows permission fix did not fully succeed (setowner/grant may require elevation)."
)
return success
except Exception as exc:
logging.debug("Windows fix-permissions error: %s", exc)
return False
def fix_permissions_unix(
path: Path,
user: Optional[str] = None,
group: Optional[str] = None
) -> bool:
"""Attempt to chown/chmod recursively for a Unix-like system.
Returns True if operations were attempted; may still fail for some files if not root.
"""
import getpass
import pwd
import grp
import subprocess
try:
if not user:
user = getpass.getuser()
try:
pw = pwd.getpwnam(user)
uid = pw.pw_uid
gid = pw.pw_gid if not group else grp.getgrnam(group).gr_gid
except Exception:
logging.warning("Could not resolve user/group to uid/gid; skipping chown.")
return False
logging.info(
"Attempting to chown recursively to %s:%s (may require root)...",
user,
group or pw.pw_gid,
)
try:
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):
try:
os.chown(root_dir, uid, gid)
except Exception:
pass
for fn in files:
fpath = os.path.join(root_dir, fn)
try:
os.chown(fpath, uid, gid)
except Exception:
pass
# Fix modes: directories 0o755, files 0o644 (best-effort)
for root_dir, dirs, files in os.walk(path):
for d in dirs:
try:
os.chmod(os.path.join(root_dir, d), 0o755)
except Exception:
pass
for f in files:
try:
os.chmod(os.path.join(root_dir, f), 0o644)
except Exception:
pass
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:
try:
if os.name == "nt":
return fix_permissions_windows(path, user=user)
else:
return fix_permissions_unix(path, user=user, group=group)
except Exception as exc:
logging.debug("General fix-permissions error: %s", exc)
return False
def find_requirements(root: Path) -> Optional[Path]:
"""Return a requirements.txt Path if found in common locations (scripts, root, client, requirements) or via a shallow search.
This tries a few sensible locations used by various projects and performs a shallow
two-level walk to find a requirements.txt so installation works even if the file is
not at the repository root.
"""
candidates = [
root / "scripts" / "requirements.txt",
root / "requirements.txt",
root / "client" / "requirements.txt",
root / "requirements" / "requirements.txt",
]
for c in candidates:
if c.exists():
return c
try:
# Shallow walk up to depth 2
for p in root.iterdir():
if not p.is_dir():
continue
candidate = p / "requirements.txt"
if candidate.exists():
return candidate
for child in p.iterdir():
if not child.is_dir():
continue
candidate2 = child / "requirements.txt"
if candidate2.exists():
return candidate2
except Exception:
pass
return None
def parse_requirements_file(req_path: Path) -> list[str]:
"""Return a list of canonical package names parsed from a requirements.txt file.
This is a lightweight parser intended for verification (not a full pip parser).
It ignores comments, editable and VCS references, and extracts the package name
by stripping extras and version specifiers (e.g. "requests[security]>=2.0" -> "requests").
"""
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
# Skip VCS/file direct references
if line.startswith("git+") or "://" in line or line.startswith("file:"):
continue
# Remove environment markers
line = line.split(";")[0].strip()
# Remove extras like requests[security]
line = line.split("[")[0].strip()
# Remove version specifiers
for sep in ("==", ">=", "<=", "~=", "!=", ">", "<", "==="):
if sep in line:
line = line.split(sep)[0].strip()
# Remove direct-reference forms: pkg @ url
if " @ " in line:
line = line.split(" @ ")[0].strip()
# Take the first token as the package name
token = line.split()[0].strip()
if token:
names.append(token.lower())
except Exception:
pass
return names
def open_in_editor(path: Path) -> bool:
"""Open the file using the OS default opener.
Uses:
- Windows: os.startfile
- macOS: open
- Linux: xdg-open (only if DISPLAY or WAYLAND_DISPLAY is present)
Returns True if an opener was invoked (success is best-effort).
"""
import shutil
import subprocess
import os
import sys
try:
# Windows: use os.startfile when available
if os.name == "nt":
try:
os.startfile(str(path))
logging.info("Opened %s with default application", path)
return True
except Exception:
pass
# macOS: use open
if sys.platform == "darwin":
try:
subprocess.run(["open", str(path)], check=False)
logging.info("Opened %s with default application", path)
return True
except Exception:
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")):
try:
subprocess.run(["xdg-open", str(path)], check=False)
logging.info("Opened %s with default application", path)
return True
except Exception:
pass
logging.debug(
"No available method to open %s automatically (headless or no opener installed)",
path
)
return False
except Exception as exc:
logging.debug("open_in_editor failed for %s: %s", path, exc)
return False
def main(argv: Optional[list[str]] = None) -> int:
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)",
)
parser.add_argument(
"--dest-name",
"-d",
default="hydrusnetwork",
help="Name of the destination folder (default: hydrusnetwork)",
)
parser.add_argument(
"--repo",
default="https://github.com/hydrusnetwork/hydrus",
help="Repository URL to clone"
)
parser.add_argument(
"--update",
action="store_true",
help="If dest exists and is a git repo, run git pull instead of cloning",
)
parser.add_argument(
"--force",
"-f",
action="store_true",
help="Remove existing destination directory before cloning",
)
parser.add_argument(
"--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.",
)
parser.add_argument(
"--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).",
)
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)",
)
parser.add_argument(
"--fix-permissions",
action="store_true",
help=
"Fix ownership/permissions on the obtained repo (OS-aware). Requires elevated privileges for some actions.",
)
parser.add_argument(
"--fix-permissions-user",
default=None,
help="User to set as owner when fixing permissions (defaults to current user).",
)
parser.add_argument(
"--fix-permissions-group",
default=None,
help="Group to set when fixing permissions (Unix only).",
)
parser.add_argument(
"--no-venv",
action="store_true",
help=
"Do not create a venv inside the cloned repo (default: create a .venv folder)",
)
parser.add_argument(
"--venv-name",
default=".venv",
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"
)
# By default install dependencies into the created venv; use --no-install-deps to opt out
group_install = parser.add_mutually_exclusive_group()
group_install.add_argument(
"--install-deps",
dest="install_deps",
action="store_true",
help=
"Install dependencies from requirements.txt into the created venv (default).",
)
group_install.add_argument(
"--no-install-deps",
dest="install_deps",
action="store_false",
help="Do not install dependencies from requirements.txt into the created venv.",
)
parser.set_defaults(install_deps=True)
parser.add_argument(
"--reinstall-deps",
action="store_true",
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.",
)
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.",
)
parser.add_argument(
"--run-client-detached",
action="store_true",
help="Start hydrus_client.py and do not wait for it to exit (detached).",
)
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)",
)
parser.add_argument(
"--install-service",
action="store_true",
help="Register the hydrus client to start on boot (user-level).",
)
parser.add_argument(
"--uninstall-service",
action="store_true",
help="Remove a registered start-on-boot service for the hydrus client.",
)
parser.add_argument(
"--service-name",
default="hydrus-client",
help="Name for the installed service/scheduled task (default: hydrus-client)",
)
parser.add_argument(
"--no-project-venv",
action="store_true",
help="Do not attempt to re-exec the script under a project venv (if present)",
)
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
args = parser.parse_args(argv)
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
root = Path(args.root).expanduser().resolve()
# Python executable inside the repo venv (set when we create/find the venv)
venv_py = None
# Re-exec under project venv by default when present (opt-out with --no-project-venv)
try:
maybe_reexec_under_project_venv(root, disable=bool(args.no_project_venv))
except Exception:
pass
dest = root / args.dest_name
try:
git = find_git_executable()
if dest.exists():
if args.force:
logging.info("Removing existing directory: %s", dest)
shutil.rmtree(dest)
else:
# If it's a git repo and user asked to update, do a pull
if is_git_repo(dest):
if args.update:
if not git:
logging.error("Git not found; cannot --update without git")
return 2
try:
run_git_pull(git, dest)
logging.info("Updated repository in %s", dest)
return 0
except subprocess.CalledProcessError as e:
logging.error("git pull failed: %s", e)
return 3
else:
# If running non-interactively (no TTY), preserve legacy behavior
if not sys.stdin or not sys.stdin.isatty():
logging.info(
"Destination %s is already a git repository. Use --update to pull or --force to re-clone, or run interactively for a numeric menu.",
dest,
)
return 0
logging.info(
"Destination %s is already a git repository.",
dest
)
print("")
print("Select an action:")
print(
" 1) Install dependencies (create venv if needed and install from requirements.txt)"
)
print(" 2) Update hydrus (git pull)")
print(" 3) Install service (register hydrus to start on boot)")
print(" 4) Re-clone (remove and re-clone the repository)")
print(" 0) Do nothing / exit")
try:
choice = (input("Enter choice [0-4]: ") or "").strip()
except Exception:
logging.info("No interactive input available; exiting.")
return 0
if choice == "1":
# Install dependencies into the repository venv (create venv if needed)
try:
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
)
venv_py = get_python_in_venv(venv_dir)
except Exception as e:
logging.error("Failed to prepare venv: %s", e)
return 8
if not venv_py:
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
)
return 0
logging.info(
"Installing dependencies from %s into venv",
req
)
try:
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"
],
check=True,
)
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"-r",
str(req)
],
cwd=str(dest),
check=True,
)
logging.info("Dependencies installed successfully")
except subprocess.CalledProcessError as e:
logging.error("Failed to install dependencies: %s", e)
return 10
# Post-install verification
pkgs = parse_requirements_file(req)
if pkgs:
logging.info(
"Verifying installed packages inside the venv..."
)
any_missing = False
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",
}
for pkg in pkgs:
mod = import_map.get(pkg, pkg)
try:
subprocess.run(
[str(venv_py),
"-c",
f"import {mod}"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
logging.warning(
"Package '%s' not importable inside venv (module %s)",
pkg,
mod,
)
any_missing = True
if any_missing:
logging.warning(
"Some packages failed to import inside the venv; consider running with --reinstall-deps"
)
else:
logging.info(
"All packages imported successfully inside the venv"
)
return 0
elif choice == "2":
if not git:
logging.error(
"Git not found; cannot --update without git"
)
return 2
try:
run_git_pull(git, dest)
logging.info("Updated repository in %s", dest)
return 0
except subprocess.CalledProcessError as e:
logging.error("git pull failed: %s", e)
return 3
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")
)
if not venv_dir.exists():
logging.info(
"Creating venv at %s to perform service install",
venv_dir
)
subprocess.run(
[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
)
return 8
if not venv_py:
logging.error(
"Could not locate python in venv %s; cannot manage service.",
venv_dir,
)
return 9
# Prefer the helper script inside the repo if present, else use installed helper
script_dir = Path(__file__).resolve().parent
helper_candidates = [
dest / "run_client.py",
script_dir / "run_client.py",
]
run_client_script = None
for cand in helper_candidates:
if cand.exists():
run_client_script = cand
break
if run_client_script and run_client_script.exists():
cmd = [
str(venv_py),
str(run_client_script),
"--install-service",
"--service-name",
args.service_name,
"--detached",
"--headless",
]
logging.info("Installing service via helper: %s", cmd)
try:
subprocess.run(cmd, cwd=str(dest), check=True)
logging.info("Service installed (user-level).")
return 0
except subprocess.CalledProcessError as e:
logging.error("Service install failed: %s", e)
return 11
else:
if install_service_auto:
ok = install_service_auto(
args.service_name,
dest,
venv_py,
headless=True,
detached=True,
)
if ok:
logging.info("Service installed (user-level).")
return 0
else:
logging.error("Service install failed.")
return 11
else:
logging.error(
"Service installer functions are not available in this environment. Please run '%s %s --install-service' inside the repository, or use the helper script when available.",
venv_py,
dest / "run_client.py",
)
return 11
elif choice == "4":
logging.info("Removing existing directory: %s", dest)
shutil.rmtree(dest)
# Continue execution to allow clone/fetch logic below to proceed
# (effectively like --force)
args.force = True
else:
logging.info("No valid choice selected; exiting.")
return 0
# If directory isn't a git repo and not empty, avoid overwriting
if any(dest.iterdir()):
logging.error(
"Destination %s already exists and is not empty. Use --force to overwrite.",
dest,
)
return 4
# Empty directory: attempt clone into it
# Ensure parent exists
dest.parent.mkdir(parents=True, exist_ok=True)
obtained = False
obtained_by: Optional[str] = None
# If user explicitly requested git, try that first
if args.git:
if git:
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
)
logging.info("Repository cloned into %s", dest)
obtained = True
obtained_by = "git"
except subprocess.CalledProcessError as e:
logging.error("git clone failed: %s", e)
if args.no_fallback:
return 5
logging.info("Git clone failed; falling back to ZIP download...")
else:
logging.info("Git not found; falling back to ZIP download...")
# If not obtained via git, try ZIP fetch (default behavior)
if not obtained:
try:
download_and_extract_zip(args.repo, dest)
logging.info("Repository downloaded and extracted into %s", dest)
obtained = True
obtained_by = "zip"
except Exception as exc:
logging.error("Failed to obtain repository (ZIP): %s", exc)
return 7
# Post-obtain setup: create repository-local venv (unless disabled)
if not getattr(args, "no_venv", False):
try:
venv_py = None
venv_dir = dest / str(getattr(args, "venv_name", ".venv"))
if venv_dir.exists():
if getattr(args, "recreate_venv", False):
logging.info("Removing existing venv: %s", venv_dir)
shutil.rmtree(venv_dir)
else:
logging.info("Using existing venv at %s", venv_dir)
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
)
except subprocess.CalledProcessError as e:
logging.error("Failed to create venv: %s", e)
return 8
try:
venv_py = get_python_in_venv(venv_dir)
except Exception:
venv_py = None
if not venv_py:
logging.error("Could not locate python in venv %s", venv_dir)
return 9
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):
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)),
)
try:
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"
],
check=True,
)
if getattr(args, "reinstall_deps", False):
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"--force-reinstall",
"-r",
str(req),
],
cwd=str(dest),
check=True,
)
else:
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"-r",
str(req)
],
cwd=str(dest),
check=True,
)
logging.info("Dependencies installed successfully")
except subprocess.CalledProcessError as e:
logging.error("Failed to install dependencies: %s", e)
return 10
# 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..."
)
any_missing = False
# Small mapping for known differences between package name and 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",
}
for pkg in pkgs:
try:
out = subprocess.run(
[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
)
any_missing = True
continue
# Try import test for common mappings
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,
)
except subprocess.CalledProcessError:
logging.warning(
"Package '%s' appears installed but 'import %s' failed inside venv.",
pkg,
import_name,
)
any_missing = True
except Exception as exc:
logging.debug(
"Verification error for package %s: %s",
pkg,
exc
)
any_missing = True
if any_missing:
logging.warning(
"Some packages may not be importable in the venv. To re-install and verify, run:\n %s -m pip install -r %s\nThen run the client with:\n %s %s",
venv_py,
req,
venv_py,
dest / "hydrus_client.py",
)
else:
logging.debug(
"No parseable packages found in %s for verification; skipping further checks",
req,
)
else:
logging.info(
"No requirements.txt found in common locations; skipping dependency installation"
)
except Exception as exc:
logging.exception("Unexpected error during venv setup: %s", exc)
return 99
# Optionally fix permissions
if getattr(args, "fix_permissions", False):
logging.info("Fixing ownership/permissions for %s", dest)
fp_user = getattr(args, "fix_permissions_user", None)
fp_group = getattr(args, "fix_permissions_group", None)
try:
ok_perm = fix_permissions(dest, user=fp_user, group=fp_group)
if not ok_perm:
logging.warning(
"Permission fix reported issues or lacked privileges; some files may remain inaccessible."
)
except Exception as exc:
logging.exception("Failed to fix permissions: %s", exc)
if getattr(args, "no_venv", False):
logging.info(
"Setup complete. Venv creation was skipped (use --venv-name/omit --no-venv to create one)."
)
else:
logging.info("Setup complete. To use the repository venv, activate it:")
if os.name == "nt":
logging.info(" %s\\Scripts\\activate", venv_dir)
else:
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_found = None
for p in client_candidates:
if p.exists():
client_found = p
break
if client_found:
# Prefer run_client helper located in the cloned repo; if missing, fall back to top-level scripts folder helper.
script_dir = Path(__file__).resolve().parent
helper_candidates = [dest / "run_client.py", script_dir / "run_client.py"]
run_client_script = None
for cand in helper_candidates:
if cand.exists():
run_client_script = cand
break
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."
)
else:
if getattr(args, "install_service", False):
if run_client_script.exists():
cmd = [
str(venv_py),
str(run_client_script),
"--install-service",
"--service-name",
args.service_name,
"--detached",
"--headless",
]
logging.info("Installing service via helper: %s", cmd)
try:
subprocess.run(cmd, cwd=str(dest), check=True)
logging.info("Service installed (user-level).")
except subprocess.CalledProcessError as e:
logging.error("Service install failed: %s", e)
else:
if install_service_auto:
ok = install_service_auto(
args.service_name,
dest,
venv_py,
headless=True,
detached=True
)
if ok:
logging.info("Service installed (user-level).")
else:
logging.error("Service install failed.")
else:
logging.error(
"Service installer functions are not available in this environment. Please run '%s %s --install-service' inside the repository, or use the helper script when available.",
venv_py,
dest / "run_client.py",
)
if getattr(args, "uninstall_service", False):
if run_client_script.exists():
cmd = [
str(venv_py),
str(run_client_script),
"--uninstall-service",
"--service-name",
args.service_name,
]
logging.info("Uninstalling service via helper: %s", cmd)
try:
subprocess.run(cmd, cwd=str(dest), check=True)
logging.info("Service removed.")
except subprocess.CalledProcessError as e:
logging.error("Service uninstall failed: %s", e)
else:
if uninstall_service_auto:
ok = uninstall_service_auto(
args.service_name,
dest,
venv_py
)
if ok:
logging.info("Service removed.")
else:
logging.error("Service uninstall failed.")
else:
logging.error(
"Service uninstaller functions are not available in this environment. Please run '%s %s --uninstall-service' inside the repository, or use the helper script when available.",
venv_py,
dest / "run_client.py",
)
# If user requested to run the client, prefer running it with the repo venv python.
if getattr(args, "run_client", False):
if getattr(args, "no_venv", False):
logging.error(
"--run-client requested but venv creation was skipped (use --venv-name or omit --no-venv)."
)
else:
try:
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 run client."
)
else:
# Prefer to use the repository helper script if present; it knows how to
# install/verify and support headless/detached options.
if run_client_script and run_client_script.exists():
cmd = [str(venv_py), str(run_client_script)]
if getattr(args, "reinstall_deps", False):
cmd.append("--reinstall")
elif getattr(args, "install_deps", False):
cmd.append("--install-deps")
if getattr(args, "run_client_headless", False):
cmd.append("--headless")
if getattr(args, "run_client_detached", False):
cmd.append("--detached")
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)
})
subprocess.Popen(cmd, **kwargs)
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
)
else:
# Fallback: call the client directly; support headless by setting
# QT_QPA_PLATFORM or using xvfb-run on Linux.
cmd = [str(venv_py), str(client_found)]
env = os.environ.copy()
if getattr(args, "run_client_headless", False):
if os.name == "posix" and shutil.which("xvfb-run"):
cmd = [
"xvfb-run",
"--auto-servernum",
"--server-args=-screen 0 1024x768x24",
] + cmd
logging.info(
"Headless: using xvfb-run to provide a virtual X server"
)
else:
env["QT_QPA_PLATFORM"] = "offscreen"
logging.info(
"Headless: setting QT_QPA_PLATFORM=offscreen (best-effort)"
)
logging.info(
"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
})
subprocess.Popen(cmd, **kwargs)
logging.info(
"Hydrus client launched (detached)."
)
except Exception as exc:
logging.exception(
"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
)
except Exception as exc:
logging.exception("Failed to run hydrus client: %s", exc)
# We no longer attempt to open or auto-launch the Hydrus client at the end
# because this can behave unpredictably in headless environments. Instead,
# print a short instruction for the user to run it manually.
try:
logging.info(
"Installer will not open or launch the Hydrus client automatically. To start it later, run the helper or run the client directly (see hints below)."
)
except Exception as exc:
logging.debug("Could not print run instructions: %s", exc)
# 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")
)
if venv_py:
logging.info(
"To run the Hydrus client using the repo venv (no activation needed):\n %s %s [args]\nOr use the helper: %s --help\nHelper examples:\n %s --install-deps --verify\n %s --headless --detached",
venv_py,
dest / "hydrus_client.py",
helper_to_show,
helper_to_show,
helper_to_show,
)
else:
logging.info(
"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
)
return 0
except Exception as exc: # pragma: no cover - defensive
logging.exception("Unexpected error: %s", exc)
return 99
if __name__ == "__main__":
raise SystemExit(main())