#!/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 `.//.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). 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-/ 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, /.venv, /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 (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")): 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())