Files
Medios-Macina/scripts/hydrusnetwork.py

1620 lines
66 KiB
Python
Raw Normal View History

2025-12-28 14:56:01 -08:00
#!/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:
2025-12-29 17:05:03 -08:00
- 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)
2025-12-28 14:56:01 -08:00
- If `git` is not available, the script will fall back to downloading the repository ZIP and extracting it.
2025-12-29 17:05:03 -08:00
- 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 `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).
2025-12-28 14:56:01 -08:00
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
2025-12-28 16:52:47 -08:00
import os
2025-12-28 14:56:01 -08:00
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
2025-12-29 17:05:03 -08:00
import shlex
2025-12-28 14:56:01 -08:00
from pathlib import Path
from typing import Optional, Tuple
import logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
2025-12-29 17:05:03 -08:00
# 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
)
2025-12-29 17:05:03 -08:00
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
2025-12-28 14:56:01 -08:00
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:
2025-12-29 17:05:03 -08:00
subprocess.run(
[git,
"--version"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
2025-12-29 17:05:03 -08:00
)
2025-12-28 14:56:01 -08:00
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:
2025-12-29 17:05:03 -08:00
subprocess.run(
[git,
"-C",
str(path),
"rev-parse",
"--is-inside-work-tree"],
2025-12-29 17:05:03 -08:00
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
2025-12-28 14:56:01 -08:00
return True
except Exception:
return False
2025-12-29 17:05:03 -08:00
def run_git_clone(
git: str,
repo: str,
dest: Path,
branch: Optional[str] = None,
depth: Optional[int] = None
2025-12-29 17:05:03 -08:00
) -> None:
2025-12-28 16:52:47 -08:00
# Build git clone with options before the repository argument. Support shallow clones
# via --depth when requested.
cmd = [git, "clone"]
2025-12-28 14:56:01 -08:00
if depth is not None and depth > 0:
cmd += ["--depth", str(int(depth))]
2025-12-28 16:52:47 -08:00
# 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"
)
2025-12-28 14:56:01 -08:00
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)
2025-12-29 17:05:03 -08:00
def download_and_extract_zip(
repo_url: str,
dest: Path,
branch_candidates: Tuple[str,
...] = ("main",
"master")
2025-12-29 17:05:03 -08:00
) -> None:
2025-12-28 14:56:01 -08:00
"""Download the GitHub repo zip and extract it into dest.
This avoids requiring git to be installed.
"""
2025-12-28 16:52:47 -08:00
2025-12-29 17:05:03 -08:00
# 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.
2025-12-28 14:56:01 -08:00
# 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))
2025-12-29 17:05:03 -08:00
logging.info(
"Downloaded and extracted %s (branch: %s) into %s",
repo_url,
branch,
dest
2025-12-29 17:05:03 -08:00
)
2025-12-28 14:56:01 -08:00
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}")
2025-12-28 16:52:47 -08:00
# --- Project venv helpers -------------------------------------------------
2025-12-29 17:05:03 -08:00
2025-12-28 16:52:47 -08:00
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:]
2025-12-28 16:52:47 -08:00
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 -------------------------------------------------
2025-12-29 17:05:03 -08:00
2025-12-28 16:52:47 -08:00
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
)
2025-12-28 16:52:47 -08:00
# Try to take ownership (best-effort)
try:
2025-12-29 17:05:03 -08:00
subprocess.run(
["takeown",
"/F",
str(path),
"/R",
"/D",
"Y"],
2025-12-29 17:05:03 -08:00
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
2025-12-28 16:52:47 -08:00
except Exception:
pass
rc_setowner = 1
rc_grant = 1
try:
out = subprocess.run(
["icacls",
str(path),
"/setowner",
user,
"/T",
"/C"],
check=False
)
2025-12-28 16:52:47 -08:00
rc_setowner = int(out.returncode)
except Exception:
rc_setowner = 1
try:
2025-12-29 17:05:03 -08:00
out = subprocess.run(
["icacls",
str(path),
"/grant",
f"{user}:(OI)(CI)F",
"/T",
"/C"],
check=False
2025-12-29 17:05:03 -08:00
)
2025-12-28 16:52:47 -08:00
rc_grant = int(out.returncode)
except Exception:
rc_grant = 1
2025-12-29 17:05:03 -08:00
success = rc_setowner == 0 or rc_grant == 0
2025-12-28 16:52:47 -08:00
if success:
logging.info("Windows permission fix succeeded (owner/grant applied).")
else:
2025-12-29 17:05:03 -08:00
logging.warning(
"Windows permission fix did not fully succeed (setowner/grant may require elevation)."
)
2025-12-28 16:52:47 -08:00
return success
except Exception as exc:
logging.debug("Windows fix-permissions error: %s", exc)
return False
2025-12-29 17:05:03 -08:00
def fix_permissions_unix(
path: Path,
user: Optional[str] = None,
group: Optional[str] = None
2025-12-29 17:05:03 -08:00
) -> bool:
2025-12-28 16:52:47 -08:00
"""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
2025-12-29 17:05:03 -08:00
logging.info(
"Attempting to chown recursively to %s:%s (may require root)...",
user,
group or pw.pw_gid,
)
2025-12-28 16:52:47 -08:00
try:
subprocess.run(
["chown",
"-R",
f"{user}:{group or pw.pw_gid}",
str(path)],
check=True
)
2025-12-28 16:52:47 -08:00
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)."
)
2025-12-28 16:52:47 -08:00
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:
2025-12-28 16:52:47 -08:00
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
2025-12-29 17:05:03 -08:00
def find_requirements(root: Path) -> Optional[Path]:
"""Return a requirements.txt Path if found in common locations (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 / "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")):
2025-12-29 17:05:03 -08:00
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
2025-12-29 17:05:03 -08:00
)
return False
except Exception as exc:
logging.debug("open_in_editor failed for %s: %s", path, exc)
return False
2025-12-28 14:56:01 -08:00
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(
description="Clone Hydrus into a 'hydrusnetwork' directory."
)
2025-12-29 17:05:03 -08:00
parser.add_argument(
"--root",
"-r",
default=".",
help=
"Root folder to create the hydrusnetwork directory in (default: current working directory)",
2025-12-29 17:05:03 -08:00
)
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"
2025-12-29 17:05:03 -08:00
)
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)."
2025-12-29 17:05:03 -08:00
)
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.",
2025-12-29 17:05:03 -08:00
)
parser.add_argument(
"--full",
action="store_true",
help="Perform a full clone (no --depth passed to git clone)"
2025-12-29 17:05:03 -08:00
)
parser.add_argument(
"--git",
action="store_true",
help=
"Use git clone instead of fetching repository ZIP (opt-in). Default: fetch ZIP (smaller).",
2025-12-29 17:05:03 -08:00
)
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)",
2025-12-29 17:05:03 -08:00
)
parser.add_argument(
"--fix-permissions",
action="store_true",
help=
"Fix ownership/permissions on the obtained repo (OS-aware). Requires elevated privileges for some actions.",
2025-12-29 17:05:03 -08:00
)
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)",
2025-12-29 17:05:03 -08:00
)
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"
2025-12-29 17:05:03 -08:00
)
# 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).",
2025-12-29 17:05:03 -08:00
)
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.",
2025-12-29 17:05:03 -08:00
)
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.",
2025-12-29 17:05:03 -08:00
)
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.",
2025-12-29 17:05:03 -08:00
)
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)",
2025-12-29 17:05:03 -08:00
)
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)",
)
2025-12-28 14:56:01 -08:00
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()
2025-12-29 17:05:03 -08:00
# Python executable inside the repo venv (set when we create/find the venv)
venv_py = None
2025-12-28 16:52:47 -08:00
# 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
2025-12-28 14:56:01 -08:00
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:
2025-12-29 17:05:03 -08:00
# 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
)
2025-12-29 17:05:03 -08:00
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")
)
2025-12-29 17:05:03 -08:00
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
2025-12-29 17:05:03 -08:00
)
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
)
2025-12-29 17:05:03 -08:00
return 9
req = find_requirements(dest)
if not req:
logging.info(
"No requirements.txt found in %s; nothing to install.",
dest
2025-12-29 17:05:03 -08:00
)
return 0
logging.info(
"Installing dependencies from %s into venv",
req
)
2025-12-29 17:05:03 -08:00
try:
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"
],
2025-12-29 17:05:03 -08:00
check=True,
)
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"-r",
str(req)
],
2025-12-29 17:05:03 -08:00
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..."
)
2025-12-29 17:05:03 -08:00
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}"],
2025-12-29 17:05:03 -08:00
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"
)
2025-12-29 17:05:03 -08:00
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")
)
2025-12-29 17:05:03 -08:00
if not venv_dir.exists():
logging.info(
"Creating venv at %s to perform service install",
venv_dir
2025-12-29 17:05:03 -08:00
)
subprocess.run(
[sys.executable,
"-m",
"venv",
str(venv_dir)],
check=True
2025-12-29 17:05:03 -08:00
)
venv_py = get_python_in_venv(venv_dir)
except Exception as e:
logging.error(
"Failed to prepare venv for service install: %s",
e
)
2025-12-29 17:05:03 -08:00
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
2025-12-28 14:56:01 -08:00
# If directory isn't a git repo and not empty, avoid overwriting
if any(dest.iterdir()):
2025-12-29 17:05:03 -08:00
logging.error(
"Destination %s already exists and is not empty. Use --force to overwrite.",
dest,
)
2025-12-28 14:56:01 -08:00
return 4
# Empty directory: attempt clone into it
# Ensure parent exists
dest.parent.mkdir(parents=True, exist_ok=True)
2025-12-28 16:52:47 -08:00
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
)
2025-12-28 16:52:47 -08:00
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:
2025-12-28 14:56:01 -08:00
try:
2025-12-28 16:52:47 -08:00
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
)
2025-12-28 16:52:47 -08:00
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)
2025-12-29 17:05:03 -08:00
# Optionally install or reinstall requirements.txt
if getattr(args,
"install_deps",
False) or getattr(args,
"reinstall_deps",
False):
2025-12-29 17:05:03 -08:00
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)),
2025-12-29 17:05:03 -08:00
)
2025-12-28 16:52:47 -08:00
try:
2025-12-29 17:05:03 -08:00
subprocess.run(
[
str(venv_py),
"-m",
"pip",
"install",
"--upgrade",
"pip"
],
2025-12-29 17:05:03 -08:00
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)
],
2025-12-29 17:05:03 -08:00
cwd=str(dest),
check=True,
)
2025-12-28 16:52:47 -08:00
logging.info("Dependencies installed successfully")
except subprocess.CalledProcessError as e:
logging.error("Failed to install dependencies: %s", e)
return 10
2025-12-29 17:05:03 -08:00
# 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..."
)
2025-12-29 17:05:03 -08:00
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],
2025-12-29 17:05:03 -08:00
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
2025-12-29 17:05:03 -08:00
)
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}"
],
2025-12-29 17:05:03 -08:00
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
)
2025-12-29 17:05:03 -08:00
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,
)
2025-12-28 16:52:47 -08:00
else:
2025-12-29 17:05:03 -08:00
logging.info(
"No requirements.txt found in common locations; skipping dependency installation"
)
2025-12-28 16:52:47 -08:00
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:
2025-12-29 17:05:03 -08:00
logging.warning(
"Permission fix reported issues or lacked privileges; some files may remain inaccessible."
)
2025-12-28 16:52:47 -08:00
except Exception as exc:
logging.exception("Failed to fix permissions: %s", exc)
if getattr(args, "no_venv", False):
2025-12-29 17:05:03 -08:00
logging.info(
"Setup complete. Venv creation was skipped (use --venv-name/omit --no-venv to create one)."
)
2025-12-28 16:52:47 -08:00
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)
2025-12-29 17:05:03 -08:00
# 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"
]
2025-12-29 17:05:03 -08:00
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):
2025-12-29 17:05:03 -08:00
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."
)
2025-12-29 17:05:03 -08:00
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
2025-12-29 17:05:03 -08:00
)
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
)
2025-12-29 17:05:03 -08:00
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
)
2025-12-29 17:05:03 -08:00
try:
if getattr(args, "run_client_detached", False):
kwargs = detach_kwargs_for_platform()
kwargs.update({
"cwd": str(dest)
})
2025-12-29 17:05:03 -08:00
subprocess.Popen(cmd, **kwargs)
logging.info(
"Hydrus client launched (detached)."
)
2025-12-29 17:05:03 -08:00
else:
subprocess.run(cmd, cwd=str(dest))
except subprocess.CalledProcessError as e:
logging.error(
"run_client.py exited non-zero: %s",
e
)
2025-12-29 17:05:03 -08:00
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
2025-12-29 17:05:03 -08:00
)
if getattr(args, "run_client_detached", False):
try:
kwargs = detach_kwargs_for_platform()
kwargs.update({
"cwd": str(dest),
"env": env
})
2025-12-29 17:05:03 -08:00
subprocess.Popen(cmd, **kwargs)
logging.info(
"Hydrus client launched (detached)."
)
2025-12-29 17:05:03 -08:00
except Exception as exc:
logging.exception(
"Failed to launch client detached: %s",
exc
2025-12-29 17:05:03 -08:00
)
else:
try:
subprocess.run(cmd, cwd=str(dest), env=env)
except subprocess.CalledProcessError as e:
logging.error(
"hydrus client exited non-zero: %s",
e
)
2025-12-29 17:05:03 -08:00
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")
2025-12-29 17:05:03 -08:00
)
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"
2025-12-29 17:05:03 -08:00
)
except Exception:
pass
else:
logging.debug(
"No hydrus_client.py found to open or run (looked in %s).",
client_candidates
2025-12-29 17:05:03 -08:00
)
2025-12-28 16:52:47 -08:00
return 0
2025-12-28 14:56:01 -08:00
except Exception as exc: # pragma: no cover - defensive
logging.exception("Unexpected error: %s", exc)
return 99
if __name__ == "__main__":
raise SystemExit(main())