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-31 22:05:25 -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 ` 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).
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 " :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
[ 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 (
2025-12-29 18:42:02 -08:00
[ 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 (
2025-12-29 18:42:02 -08:00
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 ) ]
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
" 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
2025-12-29 18:42:02 -08:00
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 ( )
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
[ " 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 :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
[ " 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 (
2025-12-29 18:42:02 -08:00
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 :
2025-12-29 18:42:02 -08:00
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
2025-12-29 18:42:02 -08:00
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
2025-12-29 18:42:02 -08:00
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 ] :
2025-12-31 22:05:25 -08:00
""" Return a requirements.txt Path if found in common locations (scripts, root, client, requirements) or via a shallow search.
2025-12-29 17:05:03 -08:00
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 .
"""
2025-12-31 22:05:25 -08:00
candidates = [
root / " scripts " / " requirements.txt " ,
root / " requirements.txt " ,
root / " client " / " requirements.txt " ,
root / " requirements " / " requirements.txt " ,
]
2025-12-29 17:05:03 -08:00
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
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
" 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 :
2025-12-29 18:42:02 -08:00
parser = argparse . ArgumentParser (
description = " Clone Hydrus into a ' hydrusnetwork ' directory. "
)
2025-12-29 17:05:03 -08:00
parser . add_argument (
" --root " ,
" -r " ,
default = " . " ,
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
" --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 (
2025-12-29 18:42:02 -08:00
" --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 ,
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
" --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 " ,
2025-12-29 18:42:02 -08:00
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 " ,
2025-12-29 18:42:02 -08:00
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 " ,
2025-12-29 18:42:02 -08:00
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 " ,
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
" --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 " ,
2025-12-29 18:42:02 -08:00
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 " ,
2025-12-29 18:42:02 -08:00
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 " ,
2025-12-29 18:42:02 -08:00
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 " ,
2025-12-29 18:42:02 -08:00
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 " ,
2025-12-29 18:42:02 -08:00
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
2025-12-29 18:42:02 -08:00
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 :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
[ 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 :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
" No requirements.txt found in %s ; nothing to install. " ,
dest
2025-12-29 17:05:03 -08:00
)
return 0
2025-12-29 18:42:02 -08:00
logging . info (
" Installing dependencies from %s into venv " ,
req
)
2025-12-29 17:05:03 -08:00
try :
subprocess . run (
2025-12-29 18:42:02 -08:00
[
str ( venv_py ) ,
" -m " ,
" pip " ,
" install " ,
" --upgrade " ,
" pip "
] ,
2025-12-29 17:05:03 -08:00
check = True ,
)
subprocess . run (
2025-12-29 18:42:02 -08:00
[
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 :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
[ 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 :
2025-12-29 18:42:02 -08:00
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 :
2025-12-29 18:42:02 -08:00
venv_dir = dest / str (
getattr ( args ,
" venv_name " ,
" .venv " )
)
2025-12-29 17:05:03 -08:00
if not venv_dir . exists ( ) :
logging . info (
2025-12-29 18:42:02 -08:00
" Creating venv at %s to perform service install " ,
venv_dir
2025-12-29 17:05:03 -08:00
)
subprocess . run (
2025-12-29 18:42:02 -08:00
[ 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 :
2025-12-29 18:42:02 -08:00
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
2025-12-29 18:42:02 -08:00
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 :
2025-12-29 18:42:02 -08:00
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
2025-12-29 18:42:02 -08:00
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 ,
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
[
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 (
2025-12-29 18:42:02 -08:00
[
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 :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
[ 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 (
2025-12-29 18:42:02 -08:00
" 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 (
2025-12-29 18:42:02 -08:00
[
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 :
2025-12-29 18:42:02 -08:00
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 \n Then 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).
2025-12-29 18:42:02 -08:00
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
2025-12-29 18:42:02 -08:00
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 :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
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 :
2025-12-29 18:42:02 -08:00
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 " )
2025-12-29 18:42:02 -08:00
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 ( )
2025-12-29 18:42:02 -08:00
kwargs . update ( {
" cwd " : str ( dest )
} )
2025-12-29 17:05:03 -08:00
subprocess . Popen ( cmd , * * kwargs )
2025-12-29 18:42:02 -08:00
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 :
2025-12-29 18:42:02 -08:00
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 (
2025-12-29 18:42:02 -08:00
" 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 ( )
2025-12-29 18:42:02 -08:00
kwargs . update ( {
" cwd " : str ( dest ) ,
" env " : env
} )
2025-12-29 17:05:03 -08:00
subprocess . Popen ( cmd , * * kwargs )
2025-12-29 18:42:02 -08:00
logging . info (
" Hydrus client launched (detached). "
)
2025-12-29 17:05:03 -08:00
except Exception as exc :
logging . exception (
2025-12-29 18:42:02 -08:00
" 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 :
2025-12-29 18:42:02 -08:00
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 = (
2025-12-29 18:42:02 -08:00
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] \n Or use the helper: %s --help \n Helper 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 (
2025-12-29 18:42:02 -08:00
" 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 (
2025-12-29 18:42:02 -08:00
" 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 ( ) )