1644 lines
67 KiB
Python
1644 lines
67 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)
|
|
|
|
# Interactive setup for root and name if not provided and in a TTY
|
|
# We check sys.argv directly to see if the flags were explicitly passed.
|
|
if sys.stdin.isatty() and not any(arg in sys.argv for arg in ["--root", "-r", "--dest-name", "-d"]):
|
|
print("\nHydrusNetwork Setup")
|
|
print("--------------------")
|
|
|
|
# Ask for root path
|
|
default_root = Path.home()
|
|
try:
|
|
root_input = input(f"Enter root directory for Hydrus installation [default: {default_root}]: ").strip()
|
|
if root_input:
|
|
args.root = root_input
|
|
else:
|
|
args.root = str(default_root)
|
|
|
|
# Ask for destination folder name
|
|
dest_input = input(f"Enter folder name for Hydrus [default: hydrusnetwork]: ").strip()
|
|
if dest_input:
|
|
args.dest_name = dest_input
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nSetup cancelled.")
|
|
return 0
|
|
|
|
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())
|