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
2026-04-01 13:58:06 -07:00
import json
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
2026-01-12 14:31:57 -08:00
import re
2025-12-28 14:56:01 -08:00
from pathlib import Path
2026-01-21 22:52:52 -08:00
from typing import Optional , Sequence , Tuple
2025-12-28 14:56:01 -08:00
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
2026-01-23 03:48:41 -08:00
def _determine_service_user ( args ) -> Optional [ str ]:
user = getattr ( args , "service_user" , None )
if user :
user = user . strip ()
if not user :
user = None
if (
not user
and os . name != "nt"
and hasattr ( os , "geteuid" )
and os . geteuid () == 0
):
user = "hydrusnetwork"
return user
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 )
2026-04-01 13:58:06 -07:00
subprocess . run ([ git , "-C" , str ( dest ), "pull" , "--ff-only" ], check = True )
2025-12-28 14:56:01 -08:00
2026-01-23 15:02:19 -08:00
def _sanitize_store_name ( name : str ) -> str :
clean = "" . join ( ch for ch in name if ch . isalnum () or ch in { "-" , "_" })
return clean or "hydrus"
2026-01-12 14:31:57 -08:00
def update_medios_config ( hydrus_path : Path ) -> bool :
2026-01-22 01:53:13 -08:00
"""Attempt to update Medios-Macina root configuration with the hydrus path.
2026-01-12 14:31:57 -08:00
2026-01-23 15:02:19 -08:00
We look for an existing hydrusnetwork backend entry and attach the gitclone
path to that backend. This avoids hard-coded store names such as "hydrus" or
"hn-local" so users can pick their own alias.
2026-01-12 14:31:57 -08:00
"""
script_dir = Path ( __file__ ) . resolve () . parent
root = script_dir . parent
2026-01-22 01:53:13 -08:00
db_path = root / "medios.db"
hydrus_abs_path = str ( hydrus_path . resolve ())
2026-01-23 15:02:19 -08:00
if not db_path . exists ():
return False
2026-01-22 01:53:13 -08:00
2026-01-23 15:02:19 -08:00
try :
import sqlite3
with sqlite3 . connect ( str ( db_path ), timeout = 30.0 ) as conn :
conn . row_factory = sqlite3 . Row
cur = conn . cursor ()
cur . execute (
2026-05-03 21:20:05 -07:00
"SELECT DISTINCT item_name FROM config WHERE category='plugin' AND subtype='hydrusnetwork'"
2026-01-23 15:02:19 -08:00
)
rows = [ row [ 0 ] for row in cur . fetchall () if row [ 0 ]]
if not rows :
store_name = _sanitize_store_name ( hydrus_path . name )
cur . execute (
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)" ,
2026-05-03 21:20:05 -07:00
( 'plugin' , 'hydrusnetwork' , store_name , 'name' , store_name )
2026-01-23 15:02:19 -08:00
)
rows = [ store_name ]
for name in rows :
cur . execute (
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)" ,
2026-05-03 21:20:05 -07:00
( 'plugin' , 'hydrusnetwork' , name , 'gitclone' , hydrus_abs_path )
2026-01-23 15:02:19 -08:00
)
conn . commit ()
logging . info (
"✅ Linked Hydrus installation in medios.db for [ %s ] (gitclone= \" %s \" )" ,
rows ,
hydrus_abs_path ,
)
return True
except Exception as e :
logging . error ( "Failed to update medios.db: %s " , e )
return False
2026-01-12 14:31:57 -08:00
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
2026-01-21 22:52:52 -08:00
def maybe_reexec_under_project_venv (
root : Path ,
disable : bool = False ,
extra_argv : Sequence [ str ] | None = None ,
) -> None :
2025-12-28 16:52:47 -08:00
"""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
2026-01-21 22:52:52 -08:00
args = [
str ( py ),
str ( script_path ) if script_path is not None else sys . argv [ 0 ]
] + sys . argv [ 1 :]
if extra_argv :
args += list ( extra_argv )
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 :
2026-01-19 06:24:09 -08:00
# Use getattr for platform-specific os methods to satisfy Mypy
geteuid = getattr ( os , "geteuid" , None )
if geteuid :
return bool ( geteuid () == 0 )
return False
2025-12-28 16:52:47 -08:00
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 :
2026-01-19 06:24:09 -08:00
pw = pwd . getpwnam ( user ) # type: ignore[attr-defined]
2025-12-28 16:52:47 -08:00
uid = pw . pw_uid
2026-01-19 06:24:09 -08:00
gid = pw . pw_gid if not group else grp . getgrnam ( group ) . gr_gid # type: ignore[attr-defined]
2025-12-28 16:52:47 -08:00
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 ):
2026-01-19 06:24:09 -08:00
if hasattr ( os , "chown" ):
2025-12-28 16:52:47 -08:00
try :
2026-01-19 06:24:09 -08:00
os . chown ( root_dir , uid , gid )
2025-12-28 16:52:47 -08:00
except Exception :
pass
2026-01-19 06:24:09 -08:00
for fn in files :
fpath = os . path . join ( root_dir , fn )
if hasattr ( os , "chown" ):
try :
os . chown ( fpath , uid , gid )
except Exception :
pass
2025-12-28 16:52:47 -08:00
# 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.
"""
2026-01-12 14:19:22 -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
2026-04-01 13:58:06 -07:00
IMPORT_NAME_OVERRIDES = {
"pyyaml" : "yaml" ,
"pillow" : "PIL" ,
"python-dateutil" : "dateutil" ,
"beautifulsoup4" : "bs4" ,
"pillow-heif" : "pillow_heif" ,
"pillow-jxl-plugin" : "pillow_jxl" ,
"pyopenssl" : "OpenSSL" ,
"pysocks" : "socks" ,
"service-identity" : "service_identity" ,
"show-in-file-manager" : "showinfm" ,
"opencv-python-headless" : "cv2" ,
"mpv" : "mpv" ,
"pyside6" : "PySide6" ,
"pyside6-essentials" : "PySide6" ,
"pyside6-addons" : "PySide6" ,
}
2026-04-01 14:14:29 -07:00
def normalize_python_command ( python_cmd : Optional [ Sequence [ str ] | str ]) -> list [ str ]:
if python_cmd is None :
return [ sys . executable ]
if isinstance ( python_cmd , ( str , Path )):
return [ str ( python_cmd )]
return [ str ( part ) for part in python_cmd ]
def get_python_version_info ( python_cmd : Optional [ Sequence [ str ] | str ] = None ) -> Optional [ tuple [ int , int , int ]]:
cmd = normalize_python_command ( python_cmd )
try :
result = subprocess . run (
cmd + [
"-c" ,
"import sys; print('.'.join(str(part) for part in sys.version_info[:3]))" ,
],
stdout = subprocess . PIPE ,
stderr = subprocess . DEVNULL ,
text = True ,
check = True ,
)
version_text = ( result . stdout or "" ) . strip ()
major , minor , micro = version_text . split ( "." , 2 )
return ( int ( major ), int ( minor ), int ( micro ))
except Exception :
return None
def is_hydrus_repo ( repo_root : Path ) -> bool :
return ( repo_root / "setup_venv.py" ) . exists () and ( repo_root / "hydrus_client.py" ) . exists ()
def select_hydrus_python_command () -> tuple [ list [ str ], tuple [ int , int , int ]]:
candidates : list [ list [ str ]] = []
if os . name == "nt" and shutil . which ( "py" ):
for version in ( "3.12" , "3.11" , "3.10" , "3.13" , "3.14" ):
candidates . append ([ "py" , f "- { version } " ])
candidates . append ([ sys . executable ])
for name in ( "python3.12" , "python3.11" , "python3.10" , "python3" , "python" ):
resolved = shutil . which ( name )
if resolved :
candidates . append ([ resolved ])
seen : set [ tuple [ str , ... ]] = set ()
best_fallback : Optional [ tuple [ list [ str ], tuple [ int , int , int ]]] = None
for candidate in candidates :
normalized = tuple ( candidate )
if normalized in seen :
continue
seen . add ( normalized )
version = get_python_version_info ( candidate )
if version is None or version < ( 3 , 10 ):
continue
if version < ( 3 , 13 ):
return candidate , version
if best_fallback is None or version < best_fallback [ 1 ]:
best_fallback = ( candidate , version )
if best_fallback is not None :
return best_fallback
version = get_python_version_info ([ sys . executable ])
if version is None :
raise RuntimeError ( "Could not determine a usable Python interpreter for Hydrus" )
return [ sys . executable ], version
def build_hydrus_install_targets (
req_path : Path ,
repo_root : Path ,
python_version : tuple [ int , int , int ],
) -> list [ str ]:
if not is_hydrus_repo ( repo_root ) or python_version < ( 3 , 13 ):
return [ "-r" , str ( req_path )]
overrides = {
"pyside6" : "PySide6==6.10.1" ,
"qtpy" : "QtPy==2.4.3" ,
"opencv-python-headless" : "opencv-python-headless==4.13.0.90" ,
"numpy" : "numpy==2.4.1" ,
}
targets : list [ str ] = []
seen_packages : set [ str ] = set ()
for raw_line in req_path . read_text ( encoding = "utf-8" ) . splitlines ():
line = raw_line . strip ()
if not line or line . startswith ( "#" ):
continue
package_name = re . split ( r "[<>=!~\[]" , line , maxsplit = 1 )[ 0 ] . strip () . lower ()
if package_name in overrides :
line = overrides [ package_name ]
targets . append ( line )
seen_packages . add ( package_name )
if os . name == "nt" and "pywin32" not in seen_packages :
targets . append ( "pywin32" )
logging . info (
"Using Hydrus compatibility dependency set for Python %s . %s . %s " ,
python_version [ 0 ],
python_version [ 1 ],
python_version [ 2 ],
)
return targets
2026-04-01 13:58:06 -07:00
def ensure_repo_venv (
repo_root : Path ,
venv_name : str = ".venv" ,
recreate : bool = False ,
purpose : Optional [ str ] = None ,
) -> Path :
2026-04-01 14:14:29 -07:00
python_cmd : list [ str ]
python_version : Optional [ tuple [ int , int , int ]]
if is_hydrus_repo ( repo_root ):
python_cmd , python_version = select_hydrus_python_command ()
if python_version is not None :
logging . info (
"Using Python %s . %s . %s for Hydrus venv creation via %s " ,
python_version [ 0 ],
python_version [ 1 ],
python_version [ 2 ],
" " . join ( python_cmd ),
)
if python_version >= ( 3 , 13 ):
logging . info (
"Hydrus is running on a newer Python; installer will apply compatibility package overrides."
)
else :
python_cmd = [ sys . executable ]
2026-04-01 13:58:06 -07:00
venv_dir = repo_root / str ( venv_name )
if venv_dir . exists ():
if recreate :
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 ():
if purpose :
logging . info ( "Creating venv at %s for %s " , venv_dir , purpose )
else :
logging . info ( "Creating venv at %s " , venv_dir )
2026-04-01 14:14:29 -07:00
subprocess . run ( python_cmd + [ "-m" , "venv" , str ( venv_dir )], check = True )
2026-04-01 13:58:06 -07:00
venv_py = get_python_in_venv ( venv_dir )
if not venv_py :
raise RuntimeError ( f "Could not locate python in venv { venv_dir } " )
logging . info ( "Venv ready: %s " , venv_py )
return venv_py
def install_requirements_into_venv (
venv_py : Path ,
repo_root : Path ,
req_path : Path ,
reinstall : bool = False ,
) -> None :
2026-04-01 14:14:29 -07:00
python_version = get_python_version_info ([ str ( venv_py )])
if python_version is None :
raise RuntimeError ( f "Could not determine python version for { venv_py } " )
2026-04-01 13:58:06 -07:00
logging . info (
"Installing dependencies from %s into venv (reinstall= %s )" ,
req_path ,
bool ( reinstall ),
)
subprocess . run (
[
str ( venv_py ),
"-m" ,
"pip" ,
"install" ,
"--disable-pip-version-check" ,
"--upgrade" ,
"pip" ,
],
check = True ,
)
cmd = [
str ( venv_py ),
"-m" ,
"pip" ,
"install" ,
"--disable-pip-version-check" ,
2026-04-01 14:14:29 -07:00
"--prefer-binary" ,
2026-04-01 13:58:06 -07:00
]
if reinstall :
cmd . extend ([ "--upgrade" , "--force-reinstall" ])
2026-04-01 14:14:29 -07:00
cmd . extend ( build_hydrus_install_targets ( req_path , repo_root , python_version ))
2026-04-01 13:58:06 -07:00
subprocess . run ( cmd , cwd = str ( repo_root ), check = True )
logging . info ( "Dependencies installed successfully" )
def verify_requirements_in_venv ( venv_py : Path , req_path : Path ) -> bool :
packages = parse_requirements_file ( req_path )
if not packages :
logging . debug (
"No parseable packages found in %s for verification; skipping further checks" ,
req_path ,
)
return True
logging . info ( "Running pip consistency check inside the venv..." )
pip_check = subprocess . run (
[ str ( venv_py ), "-m" , "pip" , "check" ],
stdout = subprocess . PIPE ,
stderr = subprocess . STDOUT ,
text = True ,
)
if pip_check . returncode != 0 :
output = ( pip_check . stdout or "" ) . strip () or "Unknown dependency issue"
logging . warning ( "pip check reported issues: \n %s " , output )
seen_modules : set [ str ] = set ()
targets : list [ tuple [ str , str ]] = []
for package in packages :
module_name = IMPORT_NAME_OVERRIDES . get ( package , package )
if module_name in seen_modules :
continue
seen_modules . add ( module_name )
targets . append (( package , module_name ))
verify_script = (
"import importlib, json \n "
f "targets = { targets !r} \n "
"failures = [] \n "
"for package, module_name in targets: \n "
" try: \n "
" importlib.import_module(module_name) \n "
" except Exception as exc: \n "
" failures.append((package, module_name, f'{type(exc).__name__}: {exc} ')) \n "
"print(json.dumps(failures)) \n "
"raise SystemExit(1 if failures else 0) \n "
)
result = subprocess . run (
[ str ( venv_py ), "-c" , verify_script ],
stdout = subprocess . PIPE ,
stderr = subprocess . PIPE ,
text = True ,
)
failures : list [ tuple [ str , str , str ]] = []
raw_output = ( result . stdout or "" ) . strip ()
if raw_output :
try :
decoded = json . loads ( raw_output )
failures = [ tuple ( item ) for item in decoded ]
except json . JSONDecodeError :
logging . warning (
"Dependency import verification returned unexpected output: %s " ,
raw_output ,
)
any_missing = False
for package , module_name , error in failures :
if module_name == "mpv" :
logging . info (
"Package ' %s ' is installed, but import ' %s ' failed (likely missing system libmpv). This is usually non-critical." ,
package ,
module_name ,
)
continue
logging . warning (
"Package ' %s ' appears installed but import ' %s ' failed inside venv: %s " ,
package ,
module_name ,
error ,
)
any_missing = True
if result . returncode != 0 and not failures :
stderr_text = ( result . stderr or "" ) . strip ()
if stderr_text :
logging . warning ( "Dependency import verification failed: %s " , stderr_text )
any_missing = True
if pip_check . returncode == 0 and not any_missing :
logging . info ( "Dependency verification completed successfully" )
return True
logging . warning (
"Some dependencies may not be importable in the venv; consider running with --reinstall-deps"
)
return False
2025-12-29 17:05:03 -08:00
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
)
2026-01-12 14:31:57 -08:00
group_obtain = parser . add_mutually_exclusive_group ()
group_obtain . add_argument (
2025-12-29 17:05:03 -08:00
"--git" ,
2026-01-12 14:31:57 -08:00
dest = "git" ,
2025-12-29 17:05:03 -08:00
action = "store_true" ,
2026-01-12 14:31:57 -08:00
help = "Use git clone (shallow by default) to allow updates. This is the default." ,
2025-12-29 17:05:03 -08:00
)
2026-01-12 14:31:57 -08:00
group_obtain . add_argument (
"--no-git" ,
dest = "git" ,
action = "store_false" ,
help = "Use ZIP download instead of git clone (no git pull support)." ,
)
parser . set_defaults ( git = True )
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)" ,
)
2026-01-23 03:48:41 -08:00
parser . add_argument (
"--service-user" ,
default = None ,
help = "When installing a systemd system service as root, run it under this user" ,
)
2025-12-29 17:05:03 -08:00
parser . add_argument (
"--no-project-venv" ,
action = "store_true" ,
help = "Do not attempt to re-exec the script under a project venv (if present)" ,
)
2026-01-21 22:52:52 -08:00
parser . add_argument (
"--use-project-venv" ,
action = "store_true" ,
help = "Force using the project venv even when running interactively" ,
)
2025-12-28 14:56:01 -08:00
parser . add_argument ( "--verbose" , "-v" , action = "store_true" , help = "Verbose logging" )
2026-01-22 03:35:16 -08:00
parser . add_argument (
"--interactive" ,
action = "store_true" ,
help = "Force interactive setup even if root/name are provided or no TTY is detected" ,
)
2025-12-28 14:56:01 -08:00
args = parser . parse_args ( argv )
if args . verbose :
logging . getLogger () . setLevel ( logging . DEBUG )
2026-01-11 11:11:32 -08:00
# Interactive setup for root and name if not provided and in a TTY
# We check sys.argv directly to see if the flags were explicitly passed.
2026-01-21 22:52:52 -08:00
interactive_setup = False
2026-01-22 03:35:16 -08:00
wants_interactive = args . interactive or (
sys . stdin . isatty () and not any ( arg in sys . argv for arg in [ "--root" , "-r" , "--dest-name" , "-d" ])
)
if wants_interactive :
2026-01-11 11:11:32 -08:00
print ( " \n HydrusNetwork Setup" )
print ( "--------------------" )
# Ask for root path
2026-01-12 14:22:01 -08:00
default_root = Path . cwd ()
2026-01-11 11:11:32 -08:00
try :
2026-01-22 03:35:16 -08:00
print ( f "Current directory: { default_root } " )
2026-01-11 11:11:32 -08:00
root_input = input ( f "Enter root directory for Hydrus installation [default: { default_root } ]: " ) . strip ()
if root_input :
2026-01-21 22:52:52 -08:00
# If they typed "C:" or similar, assume they want the root "C:\"
if len ( root_input ) == 2 and root_input [ 1 ] == ":" and root_input [ 0 ] . isalpha ():
root_input += " \\ "
2026-01-11 11:11:32 -08:00
args . root = root_input
else :
args . root = str ( default_root )
# Ask for destination folder name
2026-01-19 03:14:30 -08:00
dest_input = input ( "Enter folder name for Hydrus [default: hydrusnetwork]: " ) . strip ()
2026-01-11 11:11:32 -08:00
if dest_input :
args . dest_name = dest_input
except ( EOFError , KeyboardInterrupt ):
print ( " \n Setup cancelled." )
return 0
2026-01-21 22:52:52 -08:00
interactive_setup = True
2026-01-11 11:11:32 -08:00
2026-01-12 14:38:22 -08:00
# Expand variables like $HOME or %USERPROFILE% and ~
args . root = os . path . expandvars ( args . root )
2025-12-28 14:56:01 -08:00
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 :
2026-01-21 22:52:52 -08:00
# If we are already running in a venv-like environment, we might skip re-exec.
# However, we only re-exec if the target root is the same as the project root.
disable_reexec = bool ( args . no_project_venv )
# Don't re-exec when running interactively unless explicitly requested.
if interactive_setup and not args . use_project_venv :
disable_reexec = True
current_repo_root = Path ( __file__ ) . resolve () . parent . parent
# Only re-exec if the target root folder matches the folder where THIS script lives.
# This prevents picking up Medios-Macina's .venv when installing Hydrus to a separate drive/folder.
if root != current_repo_root and not args . use_project_venv :
disable_reexec = True
maybe_reexec_under_project_venv (
root ,
disable = disable_reexec ,
extra_argv = [ "--root" , args . root , "--dest-name" , args . dest_name ],
)
2025-12-28 16:52:47 -08:00
except Exception :
pass
2026-01-12 14:38:22 -08:00
args . dest_name = os . path . expandvars ( args . dest_name )
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 )
2026-01-12 14:31:57 -08:00
update_medios_config ( dest )
2025-12-28 14:56:01 -08:00
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 :
2026-04-01 13:58:06 -07:00
venv_py = ensure_repo_venv (
dest ,
getattr ( args , "venv_name" , ".venv" ),
2025-12-29 18:42:02 -08:00
)
2025-12-29 17:05:03 -08:00
except Exception as e :
logging . error ( "Failed to prepare venv: %s " , e )
return 8
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
try :
2026-04-01 13:58:06 -07:00
install_requirements_into_venv (
venv_py ,
dest ,
req ,
reinstall = getattr ( args , "reinstall_deps" , False ),
2025-12-29 17:05:03 -08:00
)
except subprocess . CalledProcessError as e :
logging . error ( "Failed to install dependencies: %s " , e )
return 10
2026-04-01 13:58:06 -07:00
verify_requirements_in_venv ( venv_py , req )
2025-12-29 17:05:03 -08:00
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 :
2026-04-01 13:58:06 -07:00
venv_py = ensure_repo_venv (
dest ,
getattr ( args , "venv_name" , ".venv" ),
purpose = "service install" ,
2025-12-29 18:42:02 -08:00
)
2025-12-29 17:05:03 -08:00
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
# 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
2026-01-23 03:48:41 -08:00
service_user = _determine_service_user ( args )
2025-12-29 17:05:03 -08:00
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" ,
]
2026-01-23 03:48:41 -08:00
if service_user :
cmd . extend ([ "--service-user" , service_user ])
2025-12-29 17:05:03 -08:00
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 ,
2026-01-23 03:48:41 -08:00
service_user = service_user ,
2025-12-29 17:05:03 -08:00
)
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
2025-12-28 14:56:01 -08:00
2025-12-28 16:52:47 -08:00
# 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 :
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
2026-01-12 14:31:57 -08:00
# Auto-link to Medios-Macina if possible
if obtained :
update_medios_config ( dest )
2025-12-28 16:52:47 -08:00
# Post-obtain setup: create repository-local venv (unless disabled)
if not getattr ( args , "no_venv" , False ):
try :
2026-04-01 13:58:06 -07:00
venv_py = ensure_repo_venv (
dest ,
getattr ( args , "venv_name" , ".venv" ),
recreate = getattr ( args , "recreate_venv" , False ),
)
2025-12-28 16:52:47 -08:00
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 ():
2025-12-28 16:52:47 -08:00
try :
2026-04-01 13:58:06 -07:00
install_requirements_into_venv (
venv_py ,
dest ,
req ,
reinstall = getattr ( args , "reinstall_deps" , False ),
2025-12-29 17:05:03 -08:00
)
2025-12-28 16:52:47 -08:00
except subprocess . CalledProcessError as e :
logging . error ( "Failed to install dependencies: %s " , e )
return 10
2025-12-29 17:05:03 -08:00
2026-04-01 13:58:06 -07:00
if not verify_requirements_in_venv ( venv_py , req ):
logging . warning (
"To re-install and verify, run: \n %s -m pip install -r %s \n Then run the client with: \n %s %s " ,
venv_py ,
2025-12-29 17:05:03 -08:00
req ,
2026-04-01 13:58:06 -07:00
venv_py ,
dest / "hydrus_client.py" ,
2025-12-29 17:05:03 -08:00
)
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 :
2026-04-01 14:19:15 -07:00
activation_venv_dir = dest / str ( getattr ( args , "venv_name" , ".venv" ))
2025-12-28 16:52:47 -08:00
logging . info ( "Setup complete. To use the repository venv, activate it:" )
if os . name == "nt" :
2026-04-01 14:19:15 -07:00
logging . info ( " %s \\ Scripts \\ activate" , activation_venv_dir )
2025-12-28 16:52:47 -08:00
else :
2026-04-01 14:19:15 -07:00
logging . info ( " source %s /bin/activate" , activation_venv_dir )
2025-12-28 16:52:47 -08:00
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
2026-01-19 06:24:09 -08:00
2026-01-23 01:19:36 -08:00
script_dir = Path ( __file__ ) . resolve () . parent
2026-04-01 14:36:36 -07:00
installed_helper = ensure_run_client_helper ( dest , script_dir )
2026-01-23 01:19:36 -08:00
2026-01-19 06:24:09 -08:00
run_client_script = None
2025-12-29 17:05:03 -08:00
if client_found :
2026-04-01 14:36:36 -07:00
# Prefer the helper installed directly into the Hydrus repository.
helper_candidates = [ installed_helper , dest / "run_client.py" , script_dir / "run_client.py" ]
2025-12-29 17:05:03 -08:00
for cand in helper_candidates :
2026-04-01 14:36:36 -07:00
if cand and cand . exists ():
2025-12-29 17:05:03 -08:00
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 ):
2026-01-23 03:48:41 -08:00
service_user = _determine_service_user ( args )
2026-01-19 06:24:09 -08:00
if run_client_script and run_client_script . exists ():
2025-12-29 17:05:03 -08:00
cmd = [
str ( venv_py ),
str ( run_client_script ),
"--install-service" ,
"--service-name" ,
args . service_name ,
"--detached" ,
"--headless" ,
]
2026-01-23 03:48:41 -08:00
if service_user :
cmd . extend ([ "--service-user" , service_user ])
2025-12-29 17:05:03 -08:00
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 ,
2026-01-23 03:48:41 -08:00
detached = True ,
service_user = service_user
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 ):
2026-01-19 06:24:09 -08:00
if run_client_script and run_client_script . exists ():
2025-12-29 17:05:03 -08:00
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 :
if venv_py :
2026-04-01 14:36:36 -07:00
if run_client_script and run_client_script . exists () and run_client_script . resolve () . parent == dest . resolve ():
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 --no-update --headless --detached" ,
venv_py ,
dest / "hydrus_client.py" ,
run_client_script ,
run_client_script ,
run_client_script ,
)
else :
logging . info (
"To run the Hydrus client using the repo venv (no activation needed): \n %s %s [args]" ,
venv_py ,
dest / "hydrus_client.py" ,
)
2025-12-29 17:05:03 -08:00
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
2026-01-23 01:19:36 -08:00
def ensure_run_client_helper ( dest : Path , script_dir : Path ) -> Optional [ Path ]:
"""Ensure the run_client helper is installed inside the target repository."""
helper_src = script_dir / "run_client.py"
if not helper_src . exists ():
logging . debug (
"run_client helper not found in %s ; skipping copy." ,
helper_src ,
)
return None
helper_dest = dest / "run_client.py"
try :
shutil . copy2 ( helper_src , helper_dest )
if os . name != "nt" :
helper_dest . chmod ( helper_dest . stat () . st_mode | 0o111 )
2026-04-01 14:36:36 -07:00
logging . debug ( "Installed run_client helper to %s " , helper_dest )
2026-01-23 01:19:36 -08:00
return helper_dest
except Exception as exc :
logging . debug ( "Failed to copy run_client helper: %s " , exc )
return None
2025-12-28 14:56:01 -08:00
if __name__ == "__main__" :
raise SystemExit ( main ())