df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
@@ -88,7 +88,16 @@ def run_platform_bootstrap(repo_root: Path) -> int:
|
||||
if not exe:
|
||||
print("PowerShell not found; cannot run bootstrap.ps1", file=sys.stderr)
|
||||
return 1
|
||||
cmd = [exe, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(ps1), "-Quiet"]
|
||||
cmd = [
|
||||
exe,
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(ps1),
|
||||
"-Quiet",
|
||||
]
|
||||
elif sh_script.exists():
|
||||
shell = _find_shell()
|
||||
if not shell:
|
||||
@@ -189,15 +198,23 @@ def _install_deno(version: str | None = None) -> int:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap Medios-Macina: install deps and Playwright browsers")
|
||||
parser.add_argument(
|
||||
"--skip-deps", action="store_true", help="Skip installing Python dependencies from requirements.txt"
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bootstrap Medios-Macina: install deps and Playwright browsers"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-playwright", action="store_true", help="Skip running 'playwright install' (only install packages)"
|
||||
"--skip-deps",
|
||||
action="store_true",
|
||||
help="Skip installing Python dependencies from requirements.txt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--playwright-only", action="store_true", help="Only run 'playwright install' (skips dependency installation)"
|
||||
"--no-playwright",
|
||||
action="store_true",
|
||||
help="Skip running 'playwright install' (only install packages)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--playwright-only",
|
||||
action="store_true",
|
||||
help="Only run 'playwright install' (skips dependency installation)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--browsers",
|
||||
@@ -212,12 +229,23 @@ def main() -> int:
|
||||
)
|
||||
deno_group = parser.add_mutually_exclusive_group()
|
||||
deno_group.add_argument(
|
||||
"--install-deno", action="store_true", help="Install the Deno runtime (default behavior; kept for explicitness)"
|
||||
"--install-deno",
|
||||
action="store_true",
|
||||
help="Install the Deno runtime (default behavior; kept for explicitness)",
|
||||
)
|
||||
deno_group.add_argument(
|
||||
"--no-deno", action="store_true", help="Skip installing Deno runtime (opt out)"
|
||||
)
|
||||
deno_group.add_argument("--no-deno", action="store_true", help="Skip installing Deno runtime (opt out)")
|
||||
parser.add_argument("--deno-version", type=str, default=None, help="Specific Deno version to install (e.g., v1.34.3)")
|
||||
parser.add_argument(
|
||||
"--upgrade-pip", action="store_true", help="Upgrade pip/setuptools/wheel before installing requirements"
|
||||
"--deno-version",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Specific Deno version to install (e.g., v1.34.3)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--upgrade-pip",
|
||||
action="store_true",
|
||||
help="Upgrade pip/setuptools/wheel before installing requirements",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -297,12 +325,26 @@ def main() -> int:
|
||||
|
||||
if args.upgrade_pip:
|
||||
print("Upgrading pip, setuptools, and wheel in local venv...")
|
||||
run([str(venv_python), "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"])
|
||||
run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"pip",
|
||||
"setuptools",
|
||||
"wheel",
|
||||
]
|
||||
)
|
||||
|
||||
if not args.skip_deps:
|
||||
req_file = repo_root / "requirements.txt"
|
||||
if not req_file.exists():
|
||||
print(f"requirements.txt not found at {req_file}; skipping dependency installation.", file=sys.stderr)
|
||||
print(
|
||||
f"requirements.txt not found at {req_file}; skipping dependency installation.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Installing Python dependencies into local venv from {req_file}...")
|
||||
run([str(venv_python), "-m", "pip", "install", "-r", str(req_file)])
|
||||
@@ -360,7 +402,9 @@ def main() -> int:
|
||||
site_dir = Path(sp)
|
||||
break
|
||||
if site_dir is None:
|
||||
print("Could not determine venv site-packages directory; skipping .pth fallback")
|
||||
print(
|
||||
"Could not determine venv site-packages directory; skipping .pth fallback"
|
||||
)
|
||||
else:
|
||||
pth_file = site_dir / "medeia_repo.pth"
|
||||
if pth_file.exists():
|
||||
@@ -378,7 +422,12 @@ def main() -> int:
|
||||
|
||||
# Re-check whether CLI can be imported now
|
||||
rc2 = subprocess.run(
|
||||
[str(venv_python), "-c", "import importlib; importlib.import_module('CLI')"], check=False
|
||||
[
|
||||
str(venv_python),
|
||||
"-c",
|
||||
"import importlib; importlib.import_module('CLI')",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if rc2.returncode == 0:
|
||||
print("Top-level 'CLI' import works after adding .pth")
|
||||
@@ -452,8 +501,8 @@ python -m medeia_macina.cli_entry @args
|
||||
"@echo off\r\n"
|
||||
"set SCRIPT_DIR=%~dp0\r\n"
|
||||
"set PATH=%SCRIPT_DIR%\\.venv\\Scripts;%PATH%\r\n"
|
||||
"if exist \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" \"%SCRIPT_DIR%\\.venv\\Scripts\\python.exe\" -m medeia_macina.cli_entry %*\r\n"
|
||||
"if exist \"%SCRIPT_DIR%\\CLI.py\" python \"%SCRIPT_DIR%\\CLI.py\" %*\r\n"
|
||||
'if exist "%SCRIPT_DIR%\\.venv\\Scripts\\python.exe" "%SCRIPT_DIR%\\.venv\\Scripts\\python.exe" -m medeia_macina.cli_entry %*\r\n'
|
||||
'if exist "%SCRIPT_DIR%\\CLI.py" python "%SCRIPT_DIR%\\CLI.py" %*\r\n'
|
||||
"python -m medeia_macina.cli_entry %*\r\n"
|
||||
)
|
||||
try:
|
||||
@@ -478,12 +527,12 @@ python -m medeia_macina.cli_entry @args
|
||||
cmd_text = (
|
||||
f"@echo off\r\n"
|
||||
f"set REPO={repo}\r\n"
|
||||
f"if exist \"%REPO%\\.venv\\Scripts\\mm.exe\" \"%REPO%\\.venv\\Scripts\\mm.exe\" %*\r\n"
|
||||
f'if exist "%REPO%\\.venv\\Scripts\\mm.exe" "%REPO%\\.venv\\Scripts\\mm.exe" %*\r\n'
|
||||
f"if defined MM_DEBUG (\r\n"
|
||||
f" echo MM_DEBUG: REPO=%REPO%\r\n"
|
||||
f" if exist \"%REPO%\\.venv\\Scripts\\python.exe\" \"%REPO%\\.venv\\Scripts\\python.exe\" -c \"import sys,importlib,importlib.util; print('sys.executable:', sys.executable); print('sys.path (first 8):', sys.path[:8]);\" \r\n"
|
||||
f' if exist "%REPO%\\.venv\\Scripts\\python.exe" "%REPO%\\.venv\\Scripts\\python.exe" -c "import sys,importlib,importlib.util; print(\'sys.executable:\', sys.executable); print(\'sys.path (first 8):\', sys.path[:8]);" \r\n'
|
||||
f")\r\n"
|
||||
f"if exist \"%REPO%\\.venv\\Scripts\\python.exe\" \"%REPO%\\.venv\\Scripts\\python.exe\" -m medeia_macina.cli_entry %*\r\n"
|
||||
f'if exist "%REPO%\\.venv\\Scripts\\python.exe" "%REPO%\\.venv\\Scripts\\python.exe" -m medeia_macina.cli_entry %*\r\n'
|
||||
f"python -m medeia_macina.cli_entry %*\r\n"
|
||||
)
|
||||
if mm_cmd.exists():
|
||||
@@ -495,14 +544,14 @@ python -m medeia_macina.cli_entry @args
|
||||
mm_ps1 = user_bin / "mm.ps1"
|
||||
ps1_text = (
|
||||
"Param([Parameter(ValueFromRemainingArguments=$true)] $args)\n"
|
||||
f"$repo = \"{repo}\"\n"
|
||||
f'$repo = "{repo}"\n'
|
||||
"$venv = Join-Path $repo '.venv'\n"
|
||||
"$exe = Join-Path $venv 'Scripts\\mm.exe'\n"
|
||||
"if (Test-Path $exe) { & $exe @args; exit $LASTEXITCODE }\n"
|
||||
"$py = Join-Path $venv 'Scripts\\python.exe'\n"
|
||||
"if (Test-Path $py) {\n"
|
||||
" if ($env:MM_DEBUG) {\n"
|
||||
" Write-Host \"MM_DEBUG: diagnostics\" -ForegroundColor Yellow\n"
|
||||
' Write-Host "MM_DEBUG: diagnostics" -ForegroundColor Yellow\n'
|
||||
" & $py -c \"import sys,importlib,importlib.util,traceback; print('sys.executable:', sys.executable); print('sys.path (first 8):', sys.path[:8]);\"\n"
|
||||
" }\n"
|
||||
" & $py -m medeia_macina.cli_entry @args; exit $LASTEXITCODE\n"
|
||||
@@ -524,7 +573,9 @@ python -m medeia_macina.cli_entry @args
|
||||
"$cur = [Environment]::GetEnvironmentVariable('PATH','User');"
|
||||
"if ($cur -notlike \"*$bin*\") {[Environment]::SetEnvironmentVariable('PATH', ($bin + ';' + ($cur -ne $null ? $cur : '')), 'User')}"
|
||||
).format(bin=str_bin.replace("\\", "\\\\"))
|
||||
subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], check=False)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps_cmd], check=False
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -539,64 +590,64 @@ python -m medeia_macina.cli_entry @args
|
||||
sh_text = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
"set -e\n"
|
||||
f"REPO=\"{repo}\"\n"
|
||||
f'REPO="{repo}"\n'
|
||||
"# Prefer git top-level when available to avoid embedding a parent path.\n"
|
||||
"if command -v git >/dev/null 2>&1; then\n"
|
||||
" gitroot=$(git -C \"$REPO\" rev-parse --show-toplevel 2>/dev/null || true)\n"
|
||||
" if [ -n \"$gitroot\" ]; then\n"
|
||||
" REPO=\"$gitroot\"\n"
|
||||
' gitroot=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null || true)\n'
|
||||
' if [ -n "$gitroot" ]; then\n'
|
||||
' REPO="$gitroot"\n'
|
||||
" fi\n"
|
||||
"fi\n"
|
||||
"# If git not available or didn't resolve, walk up from CWD to find a project root.\n"
|
||||
"if [ ! -f \"$REPO/CLI.py\" ] && [ ! -f \"$REPO/pyproject.toml\" ]; then\n"
|
||||
" CUR=\"$(pwd -P)\"\n"
|
||||
" while [ \"$CUR\" != \"/\" ] && [ \"$CUR\" != \"\" ]; do\n"
|
||||
" if [ -f \"$CUR/CLI.py\" ] || [ -f \"$CUR/pyproject.toml\" ]; then\n"
|
||||
" REPO=\"$CUR\"\n"
|
||||
'if [ ! -f "$REPO/CLI.py" ] && [ ! -f "$REPO/pyproject.toml" ]; then\n'
|
||||
' CUR="$(pwd -P)"\n'
|
||||
' while [ "$CUR" != "/" ] && [ "$CUR" != "" ]; do\n'
|
||||
' if [ -f "$CUR/CLI.py" ] || [ -f "$CUR/pyproject.toml" ]; then\n'
|
||||
' REPO="$CUR"\n'
|
||||
" break\n"
|
||||
" fi\n"
|
||||
" CUR=\"$(dirname \"$CUR\")\"\n"
|
||||
' CUR="$(dirname "$CUR")"\n'
|
||||
" done\n"
|
||||
"fi\n"
|
||||
"VENV=\"$REPO/.venv\"\n"
|
||||
'VENV="$REPO/.venv"\n'
|
||||
"# Debug mode: set MM_DEBUG=1 to print repository, venv, and import diagnostics\n"
|
||||
"if [ -n \"${MM_DEBUG:-}\" ]; then\n"
|
||||
" echo \"MM_DEBUG: diagnostics\" >&2\n"
|
||||
" echo \"Resolved REPO: $REPO\" >&2\n"
|
||||
" echo \"Resolved VENV: $VENV\" >&2\n"
|
||||
" echo \"VENV exists: $( [ -d \"$VENV\" ] && echo yes || echo no )\" >&2\n"
|
||||
" echo \"Candidates:\" >&2\n"
|
||||
" echo \" VENV/bin/mm: $( [ -x \"$VENV/bin/mm\" ] && echo yes || echo no )\" >&2\n"
|
||||
" echo \" VENV/bin/python3: $( [ -x \"$VENV/bin/python3\" ] && echo yes || echo no )\" >&2\n"
|
||||
" echo \" VENV/bin/python: $( [ -x \"$VENV/bin/python\" ] && echo yes || echo no )\" >&2\n"
|
||||
" echo \" system python3: $(command -v python3 || echo none)\" >&2\n"
|
||||
" echo \" system python: $(command -v python || echo none)\" >&2\n"
|
||||
" for pycmd in \"$VENV/bin/python3\" \"$VENV/bin/python\" \"$(command -v python3 2>/dev/null)\" \"$(command -v python 2>/dev/null)\"; do\n"
|
||||
" if [ -n \"$pycmd\" ] && [ -x \"$pycmd\" ]; then\n"
|
||||
" echo \"---- Testing with: $pycmd ----\" >&2\n"
|
||||
'if [ -n "${MM_DEBUG:-}" ]; then\n'
|
||||
' echo "MM_DEBUG: diagnostics" >&2\n'
|
||||
' echo "Resolved REPO: $REPO" >&2\n'
|
||||
' echo "Resolved VENV: $VENV" >&2\n'
|
||||
' echo "VENV exists: $( [ -d "$VENV" ] && echo yes || echo no )" >&2\n'
|
||||
' echo "Candidates:" >&2\n'
|
||||
' echo " VENV/bin/mm: $( [ -x "$VENV/bin/mm" ] && echo yes || echo no )" >&2\n'
|
||||
' echo " VENV/bin/python3: $( [ -x "$VENV/bin/python3" ] && echo yes || echo no )" >&2\n'
|
||||
' echo " VENV/bin/python: $( [ -x "$VENV/bin/python" ] && echo yes || echo no )" >&2\n'
|
||||
' echo " system python3: $(command -v python3 || echo none)" >&2\n'
|
||||
' echo " system python: $(command -v python || echo none)" >&2\n'
|
||||
' for pycmd in "$VENV/bin/python3" "$VENV/bin/python" "$(command -v python3 2>/dev/null)" "$(command -v python 2>/dev/null)"; do\n'
|
||||
' if [ -n "$pycmd" ] && [ -x "$pycmd" ]; then\n'
|
||||
' echo "---- Testing with: $pycmd ----" >&2\n'
|
||||
" $pycmd - <<'PY'\nimport sys, importlib, traceback, importlib.util\nprint('sys.executable:', sys.executable)\nprint('sys.path (first 8):', sys.path[:8])\nfor mod in ('CLI','medeia_macina','medeia_macina.cli_entry'):\n try:\n spec = importlib.util.find_spec(mod)\n print(mod, 'spec:', spec)\n if spec:\n m = importlib.import_module(mod)\n print(mod, 'loaded at', getattr(m, '__file__', None))\n except Exception:\n print(mod, 'import failed')\n traceback.print_exc()\nPY\n"
|
||||
" fi\n"
|
||||
" done\n"
|
||||
" echo \"MM_DEBUG: end diagnostics\" >&2\n"
|
||||
' echo "MM_DEBUG: end diagnostics" >&2\n'
|
||||
"fi\n"
|
||||
"# Packaged console script in the venv if available\n"
|
||||
"if [ -x \"$VENV/bin/mm\" ]; then\n"
|
||||
" exec \"$VENV/bin/mm\" \"$@\"\n"
|
||||
'if [ -x "$VENV/bin/mm" ]; then\n'
|
||||
' exec "$VENV/bin/mm" "$@"\n'
|
||||
"fi\n"
|
||||
"# Prefer venv's python3, then venv's python\n"
|
||||
"if [ -x \"$VENV/bin/python3\" ]; then\n"
|
||||
" exec \"$VENV/bin/python3\" -m medeia_macina.cli_entry \"$@\"\n"
|
||||
'if [ -x "$VENV/bin/python3" ]; then\n'
|
||||
' exec "$VENV/bin/python3" -m medeia_macina.cli_entry "$@"\n'
|
||||
"fi\n"
|
||||
"if [ -x \"$VENV/bin/python\" ]; then\n"
|
||||
" exec \"$VENV/bin/python\" -m medeia_macina.cli_entry \"$@\"\n"
|
||||
'if [ -x "$VENV/bin/python" ]; then\n'
|
||||
' exec "$VENV/bin/python" -m medeia_macina.cli_entry "$@"\n'
|
||||
"fi\n"
|
||||
"# Fallback to system python3, then system python (only if it's Python 3)\n"
|
||||
"if command -v python3 >/dev/null 2>&1; then\n"
|
||||
" exec python3 -m medeia_macina.cli_entry \"$@\"\n"
|
||||
' exec python3 -m medeia_macina.cli_entry "$@"\n'
|
||||
"fi\n"
|
||||
"if command -v python >/dev/null 2>&1; then\n"
|
||||
" if python -c 'import sys; sys.exit(0 if sys.version_info[0] >= 3 else 1)'; then\n"
|
||||
" exec python -m medeia_macina.cli_entry \"$@\"\n"
|
||||
' exec python -m medeia_macina.cli_entry "$@"\n'
|
||||
" fi\n"
|
||||
"fi\n"
|
||||
"echo 'Error: no suitable Python 3 interpreter found. Please install Python 3 or use the venv.' >&2\n"
|
||||
@@ -614,8 +665,8 @@ python -m medeia_macina.cli_entry @args
|
||||
profile = home / ".profile"
|
||||
snippet = (
|
||||
"# Added by Medeia-Macina setup: ensure user local bin is on PATH\n"
|
||||
"if [ -d \"$HOME/.local/bin\" ] && [[ \":$PATH:\" != *\":$HOME/.local/bin:\"* ]]; then\n"
|
||||
" PATH=\"$HOME/.local/bin:$PATH\"\n"
|
||||
'if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then\n'
|
||||
' PATH="$HOME/.local/bin:$PATH"\n'
|
||||
"fi\n"
|
||||
)
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ server and uses it as a remote storage backend through the RemoteStorageBackend.
|
||||
$ python remote_storage_server.py --storage-path /path/to/storage --port 5000
|
||||
$ python remote_storage_server.py --storage-path /path/to/storage --api-key mysecretkey
|
||||
5. Server prints connection info automatically (IP, port, API key)
|
||||
|
||||
|
||||
### On PC:
|
||||
1. Install requests: pip install requests
|
||||
2. Add to config.conf:
|
||||
@@ -58,10 +58,7 @@ from SYS.logger import log
|
||||
# CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_PATH: Optional[Path] = None
|
||||
@@ -71,6 +68,7 @@ API_KEY: Optional[str] = None # API key for authentication (None = no auth requ
|
||||
try:
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
HAS_FLASK = True
|
||||
except ImportError:
|
||||
HAS_FLASK = False
|
||||
@@ -79,9 +77,11 @@ except ImportError:
|
||||
# UTILITY FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_local_ip() -> Optional[str]:
|
||||
"""Get the local IP address that would be used for external connections."""
|
||||
import socket
|
||||
|
||||
try:
|
||||
# Create a socket to determine which interface would be used
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -92,397 +92,427 @@ def get_local_ip() -> Optional[str]:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FLASK APP FACTORY
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create and configure Flask app with all routes."""
|
||||
if not HAS_FLASK:
|
||||
raise ImportError("Flask not installed. Install with: pip install flask flask-cors")
|
||||
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# HELPER DECORATORS
|
||||
# ========================================================================
|
||||
|
||||
|
||||
def require_auth():
|
||||
"""Decorator to check API key authentication if configured."""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if API_KEY:
|
||||
# Get API key from header or query parameter
|
||||
provided_key = request.headers.get('X-API-Key') or request.args.get('api_key')
|
||||
provided_key = request.headers.get("X-API-Key") or request.args.get("api_key")
|
||||
if not provided_key or provided_key != API_KEY:
|
||||
return jsonify({"error": "Unauthorized. Invalid or missing API key."}), 401
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_storage():
|
||||
"""Decorator to ensure storage path is configured."""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not STORAGE_PATH:
|
||||
return jsonify({"error": "Storage path not configured"}), 500
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# HEALTH CHECK
|
||||
# ========================================================================
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
@require_auth()
|
||||
def health():
|
||||
"""Check server health and storage availability."""
|
||||
status = {
|
||||
"status": "ok",
|
||||
"storage_configured": STORAGE_PATH is not None,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
if STORAGE_PATH:
|
||||
status["storage_path"] = str(STORAGE_PATH)
|
||||
status["storage_exists"] = STORAGE_PATH.exists()
|
||||
try:
|
||||
from API.folder import API_folder_store
|
||||
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
status["database_accessible"] = True
|
||||
except Exception as e:
|
||||
status["database_accessible"] = False
|
||||
status["database_error"] = str(e)
|
||||
|
||||
|
||||
return jsonify(status), 200
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# FILE OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
@app.route('/files/search', methods=['GET'])
|
||||
|
||||
@app.route("/files/search", methods=["GET"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def search_files():
|
||||
"""Search for files by name or tag."""
|
||||
from API.folder import LocalLibrarySearchOptimizer
|
||||
|
||||
query = request.args.get('q', '')
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
|
||||
|
||||
query = request.args.get("q", "")
|
||||
limit = request.args.get("limit", 100, type=int)
|
||||
|
||||
if not query:
|
||||
return jsonify({"error": "Search query required"}), 400
|
||||
|
||||
|
||||
try:
|
||||
with LocalLibrarySearchOptimizer(STORAGE_PATH) as db:
|
||||
results = db.search_by_name(query, limit)
|
||||
tag_results = db.search_by_tag(query, limit)
|
||||
all_results = {r['hash']: r for r in (results + tag_results)}
|
||||
|
||||
return jsonify({
|
||||
"query": query,
|
||||
"count": len(all_results),
|
||||
"files": list(all_results.values())
|
||||
}), 200
|
||||
all_results = {r["hash"]: r for r in (results + tag_results)}
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"query": query,
|
||||
"count": len(all_results),
|
||||
"files": list(all_results.values()),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Search failed: {str(e)}"}), 500
|
||||
|
||||
@app.route('/files/<file_hash>', methods=['GET'])
|
||||
|
||||
@app.route("/files/<file_hash>", methods=["GET"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def get_file_metadata(file_hash: str):
|
||||
"""Get metadata for a specific file by hash."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
file_path = db.search_hash(file_hash)
|
||||
|
||||
|
||||
if not file_path or not file_path.exists():
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
metadata = db.get_metadata(file_path)
|
||||
tags = db.get_tags(file_path)
|
||||
|
||||
return jsonify({
|
||||
"hash": file_hash,
|
||||
"path": str(file_path),
|
||||
"size": file_path.stat().st_size,
|
||||
"metadata": metadata,
|
||||
"tag": tags
|
||||
}), 200
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"hash": file_hash,
|
||||
"path": str(file_path),
|
||||
"size": file_path.stat().st_size,
|
||||
"metadata": metadata,
|
||||
"tag": tags,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Get metadata error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed to get metadata: {str(e)}"}), 500
|
||||
|
||||
@app.route('/files/index', methods=['POST'])
|
||||
|
||||
@app.route("/files/index", methods=["POST"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def index_file():
|
||||
"""Index a new file in the storage."""
|
||||
from API.folder import API_folder_store
|
||||
from SYS.utils import sha256_file
|
||||
|
||||
|
||||
data = request.get_json() or {}
|
||||
file_path_str = data.get('path')
|
||||
tags = data.get('tag', [])
|
||||
url = data.get('url', [])
|
||||
|
||||
file_path_str = data.get("path")
|
||||
tags = data.get("tag", [])
|
||||
url = data.get("url", [])
|
||||
|
||||
if not file_path_str:
|
||||
return jsonify({"error": "File path required"}), 400
|
||||
|
||||
|
||||
try:
|
||||
file_path = Path(file_path_str)
|
||||
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({"error": "File does not exist"}), 404
|
||||
|
||||
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
db.get_or_create_file_entry(file_path)
|
||||
|
||||
|
||||
if tags:
|
||||
db.add_tags(file_path, tags)
|
||||
|
||||
|
||||
if url:
|
||||
db.add_url(file_path, url)
|
||||
|
||||
|
||||
file_hash = sha256_file(file_path)
|
||||
|
||||
return jsonify({
|
||||
"hash": file_hash,
|
||||
"path": str(file_path),
|
||||
"tags_added": len(tags),
|
||||
"url_added": len(url)
|
||||
}), 201
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"hash": file_hash,
|
||||
"path": str(file_path),
|
||||
"tags_added": len(tags),
|
||||
"url_added": len(url),
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Index error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Indexing failed: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# TAG OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
@app.route('/tags/<file_hash>', methods=['GET'])
|
||||
|
||||
@app.route("/tags/<file_hash>", methods=["GET"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def get_tags(file_hash: str):
|
||||
"""Get tags for a file."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
file_path = db.search_hash(file_hash)
|
||||
if not file_path:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
tags = db.get_tags(file_path)
|
||||
return jsonify({"hash": file_hash, "tag": tags}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Get tags error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
||||
|
||||
@app.route('/tags/<file_hash>', methods=['POST'])
|
||||
|
||||
@app.route("/tags/<file_hash>", methods=["POST"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def add_tags(file_hash: str):
|
||||
"""Add tags to a file."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
|
||||
data = request.get_json() or {}
|
||||
tags = data.get('tag', [])
|
||||
mode = data.get('mode', 'add')
|
||||
|
||||
tags = data.get("tag", [])
|
||||
mode = data.get("mode", "add")
|
||||
|
||||
if not tags:
|
||||
return jsonify({"error": "Tag required"}), 400
|
||||
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
file_path = db.search_hash(file_hash)
|
||||
if not file_path:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
if mode == 'replace':
|
||||
|
||||
if mode == "replace":
|
||||
db.remove_tags(file_path, db.get_tags(file_path))
|
||||
|
||||
|
||||
db.add_tags(file_path, tags)
|
||||
return jsonify({"hash": file_hash, "tag_added": len(tags), "mode": mode}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Add tags error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
||||
|
||||
@app.route('/tags/<file_hash>', methods=['DELETE'])
|
||||
|
||||
@app.route("/tags/<file_hash>", methods=["DELETE"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def remove_tags(file_hash: str):
|
||||
"""Remove tags from a file."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
tags_str = request.args.get('tag', '')
|
||||
|
||||
|
||||
tags_str = request.args.get("tag", "")
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
file_path = db.search_hash(file_hash)
|
||||
if not file_path:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
if tags_str:
|
||||
tags_to_remove = [t.strip() for t in tags_str.split(',')]
|
||||
tags_to_remove = [t.strip() for t in tags_str.split(",")]
|
||||
else:
|
||||
tags_to_remove = db.get_tags(file_path)
|
||||
|
||||
|
||||
db.remove_tags(file_path, tags_to_remove)
|
||||
return jsonify({"hash": file_hash, "tags_removed": len(tags_to_remove)}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Remove tags error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# RELATIONSHIP OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
@app.route('/relationships/<file_hash>', methods=['GET'])
|
||||
|
||||
@app.route("/relationships/<file_hash>", methods=["GET"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def get_relationships(file_hash: str):
|
||||
"""Get relationships for a file."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
file_path = db.search_hash(file_hash)
|
||||
if not file_path:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
metadata = db.get_metadata(file_path)
|
||||
relationships = metadata.get('relationships', {}) if metadata else {}
|
||||
relationships = metadata.get("relationships", {}) if metadata else {}
|
||||
return jsonify({"hash": file_hash, "relationships": relationships}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Get relationships error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
||||
|
||||
@app.route('/relationships', methods=['POST'])
|
||||
|
||||
@app.route("/relationships", methods=["POST"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def set_relationship():
|
||||
"""Set a relationship between two files."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
|
||||
data = request.get_json() or {}
|
||||
from_hash = data.get('from_hash')
|
||||
to_hash = data.get('to_hash')
|
||||
rel_type = data.get('type', 'alt')
|
||||
|
||||
from_hash = data.get("from_hash")
|
||||
to_hash = data.get("to_hash")
|
||||
rel_type = data.get("type", "alt")
|
||||
|
||||
if not from_hash or not to_hash:
|
||||
return jsonify({"error": "from_hash and to_hash required"}), 400
|
||||
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
from_path = db.search_hash(from_hash)
|
||||
to_path = db.search_hash(to_hash)
|
||||
|
||||
|
||||
if not from_path or not to_path:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
db.set_relationship(from_path, to_path, rel_type)
|
||||
return jsonify({"from_hash": from_hash, "to_hash": to_hash, "type": rel_type}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Set relationship error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# URL OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
@app.route('/url/<file_hash>', methods=['GET'])
|
||||
|
||||
@app.route("/url/<file_hash>", methods=["GET"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def get_url(file_hash: str):
|
||||
"""Get known url for a file."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
file_path = db.search_hash(file_hash)
|
||||
if not file_path:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
metadata = db.get_metadata(file_path)
|
||||
url = metadata.get('url', []) if metadata else []
|
||||
url = metadata.get("url", []) if metadata else []
|
||||
return jsonify({"hash": file_hash, "url": url}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Get url error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
||||
|
||||
@app.route('/url/<file_hash>', methods=['POST'])
|
||||
|
||||
@app.route("/url/<file_hash>", methods=["POST"])
|
||||
@require_auth()
|
||||
@require_storage()
|
||||
def add_url(file_hash: str):
|
||||
"""Add url to a file."""
|
||||
from API.folder import API_folder_store
|
||||
|
||||
|
||||
data = request.get_json() or {}
|
||||
url = data.get('url', [])
|
||||
|
||||
url = data.get("url", [])
|
||||
|
||||
if not url:
|
||||
return jsonify({"error": "url required"}), 400
|
||||
|
||||
|
||||
try:
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
file_path = db.search_hash(file_hash)
|
||||
if not file_path:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
db.add_url(file_path, url)
|
||||
return jsonify({"hash": file_hash, "url_added": len(url)}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Add url error: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
||||
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def main():
|
||||
if not HAS_FLASK:
|
||||
print("ERROR: Flask and flask-cors required")
|
||||
print("Install with: pip install flask flask-cors")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Remote Storage Server for Medios-Macina',
|
||||
epilog='Example: python remote_storage_server.py --storage-path /storage/media --port 5000 --api-key mysecretkey'
|
||||
description="Remote Storage Server for Medios-Macina",
|
||||
epilog="Example: python remote_storage_server.py --storage-path /storage/media --port 5000 --api-key mysecretkey",
|
||||
)
|
||||
parser.add_argument('--storage-path', type=str, required=True, help='Path to storage directory')
|
||||
parser.add_argument('--host', type=str, default='0.0.0.0', help='Server host (default: 0.0.0.0)')
|
||||
parser.add_argument('--port', type=int, default=5000, help='Server port (default: 5000)')
|
||||
parser.add_argument('--api-key', type=str, default=None, help='API key for authentication (optional)')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable debug mode')
|
||||
|
||||
parser.add_argument("--storage-path", type=str, required=True, help="Path to storage directory")
|
||||
parser.add_argument(
|
||||
"--host", type=str, default="0.0.0.0", help="Server host (default: 0.0.0.0)"
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=5000, help="Server port (default: 5000)")
|
||||
parser.add_argument(
|
||||
"--api-key", type=str, default=None, help="API key for authentication (optional)"
|
||||
)
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
global STORAGE_PATH, API_KEY
|
||||
STORAGE_PATH = Path(args.storage_path).resolve()
|
||||
API_KEY = args.api_key
|
||||
|
||||
|
||||
if not STORAGE_PATH.exists():
|
||||
print(f"ERROR: Storage path does not exist: {STORAGE_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Get local IP address
|
||||
local_ip = get_local_ip()
|
||||
if not local_ip:
|
||||
local_ip = "127.0.0.1"
|
||||
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"Remote Storage Server - Medios-Macina")
|
||||
print(f"{'='*70}")
|
||||
@@ -490,27 +520,31 @@ def main():
|
||||
print(f"Local IP: {local_ip}")
|
||||
print(f"Server URL: http://{local_ip}:{args.port}")
|
||||
print(f"Health URL: http://{local_ip}:{args.port}/health")
|
||||
print(f"API Key: {'Enabled - ' + ('***' + args.api_key[-4:]) if args.api_key else 'Disabled (no auth)'}")
|
||||
print(
|
||||
f"API Key: {'Enabled - ' + ('***' + args.api_key[-4:]) if args.api_key else 'Disabled (no auth)'}"
|
||||
)
|
||||
print(f"Debug Mode: {args.debug}")
|
||||
print("\n📋 Config for config.conf:")
|
||||
print("[store=remote]")
|
||||
print("name=\"phone\"")
|
||||
print(f"url=\"http://{local_ip}:{args.port}\"")
|
||||
print('name="phone"')
|
||||
print(f'url="http://{local_ip}:{args.port}"')
|
||||
if args.api_key:
|
||||
print(f"api_key=\"{args.api_key}\"")
|
||||
print(f'api_key="{args.api_key}"')
|
||||
print("timeout=30")
|
||||
print(f"\n{'='*70}\n")
|
||||
|
||||
|
||||
try:
|
||||
from API.folder import API_folder_store
|
||||
|
||||
with API_folder_store(STORAGE_PATH) as db:
|
||||
logger.info("Database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
app = create_app()
|
||||
app.run(host=args.host, port=args.port, debug=args.debug, use_reloader=False)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
764
scripts/run_client.py
Normal file
764
scripts/run_client.py
Normal file
@@ -0,0 +1,764 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the Hydrus client (top-level helper)
|
||||
|
||||
This standalone helper is intended to live in the project's top-level `scripts/`
|
||||
folder so it remains available even if the Hydrus repository subfolder is not
|
||||
present or its copy of this helper gets removed.
|
||||
|
||||
Features (subset of the repo helper):
|
||||
- Locate repository venv (default: <workspace>/hydrusnetwork/.venv)
|
||||
- Install or reinstall requirements.txt into the venv
|
||||
- Verify key imports
|
||||
- Launch hydrus_client.py (foreground or detached)
|
||||
- Install/uninstall simple user-level start-on-boot services (schtasks/systemd/crontab)
|
||||
|
||||
Usage examples:
|
||||
python scripts/run_client.py --verify
|
||||
python scripts/run_client.py --detached --headless
|
||||
python scripts/run_client.py --install-deps --verify
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def get_python_in_venv(venv_dir: Path) -> Optional[Path]:
|
||||
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_requirements(root: Path) -> Optional[Path]:
|
||||
candidates = [root / "requirements.txt", root / "client" / "requirements.txt"]
|
||||
for c in candidates:
|
||||
if c.exists():
|
||||
return c
|
||||
# shallow two-level search
|
||||
try:
|
||||
for p in root.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
for child in (p,):
|
||||
candidate = child / "requirements.txt"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def install_requirements(venv_py: Path, req_path: Path, reinstall: bool = False) -> bool:
|
||||
try:
|
||||
print(f"Installing {req_path} into venv ({venv_py})...")
|
||||
subprocess.run([str(venv_py), "-m", "pip", "install", "--upgrade", "pip"], check=True)
|
||||
install_cmd = [str(venv_py), "-m", "pip", "install", "-r", str(req_path)]
|
||||
if reinstall:
|
||||
install_cmd = [
|
||||
str(venv_py),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"--force-reinstall",
|
||||
"-r",
|
||||
str(req_path),
|
||||
]
|
||||
subprocess.run(install_cmd, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Failed to install requirements:", e)
|
||||
return False
|
||||
|
||||
|
||||
def parse_requirements_file(req_path: Path) -> List[str]:
|
||||
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
|
||||
if "://" in line or line.startswith("file:"):
|
||||
continue
|
||||
line = line.split(";")[0].strip()
|
||||
line = line.split("[")[0].strip()
|
||||
for sep in ("==", ">=", "<=", "~=", "!=", ">", "<", "==="):
|
||||
if sep in line:
|
||||
line = line.split(sep)[0].strip()
|
||||
if " @ " in line:
|
||||
line = line.split(" @ ")[0].strip()
|
||||
if line:
|
||||
names.append(line.split()[0].strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def verify_imports(venv_py: Path, packages: List[str]) -> bool:
|
||||
# Map some package names to import names (handle common cases where package name differs from 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",
|
||||
}
|
||||
missing = []
|
||||
for pkg in packages:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[str(venv_py), "-m", "pip", "show", pkg],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
missing.append(pkg)
|
||||
continue
|
||||
except Exception:
|
||||
missing.append(pkg)
|
||||
continue
|
||||
|
||||
if out.returncode != 0 or not out.stdout.strip():
|
||||
missing.append(pkg)
|
||||
continue
|
||||
|
||||
import_name = import_map.get(pkg, pkg)
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(venv_py), "-c", f"import {import_name}"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
missing.append(pkg)
|
||||
if missing:
|
||||
print("The following packages were not importable in the venv:", ", ".join(missing))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_first_run(repo_root: Path) -> bool:
|
||||
try:
|
||||
db_dir = repo_root / "db"
|
||||
if db_dir.exists() and any(db_dir.iterdir()):
|
||||
return False
|
||||
for f in repo_root.glob("*.db"):
|
||||
if f.exists():
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Service install/uninstall helpers -----------------------------------
|
||||
|
||||
|
||||
def install_service_windows(
|
||||
service_name: str,
|
||||
repo_root: Path,
|
||||
venv_py: Path,
|
||||
headless: bool = True,
|
||||
detached: bool = True,
|
||||
start_on: str = "logon",
|
||||
) -> bool:
|
||||
try:
|
||||
schtasks = shutil.which("schtasks")
|
||||
if not schtasks:
|
||||
print("schtasks not available on this system; cannot install Windows scheduled task.")
|
||||
return False
|
||||
|
||||
bat = repo_root / "run-client.bat"
|
||||
if not bat.exists():
|
||||
# Use escaped backslashes to avoid Python "invalid escape sequence" warnings
|
||||
content = '@echo off\n"%~dp0\\.venv\\Scripts\\python.exe" "%~dp0hydrus_client.py" %*\n'
|
||||
bat.write_text(content, encoding="utf-8")
|
||||
|
||||
tr = str(bat)
|
||||
sc = "ONLOGON" if start_on == "logon" else "ONSTART"
|
||||
cmd = [
|
||||
schtasks,
|
||||
"/Create",
|
||||
"/SC",
|
||||
sc,
|
||||
"/TN",
|
||||
service_name,
|
||||
"/TR",
|
||||
f'"{tr}"',
|
||||
"/RL",
|
||||
"LIMITED",
|
||||
"/F",
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"Scheduled task '{service_name}' created ({sc}).")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Failed to create scheduled task:", e)
|
||||
return False
|
||||
except Exception as exc:
|
||||
print("Windows install-service error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def uninstall_service_windows(service_name: str) -> bool:
|
||||
try:
|
||||
schtasks = shutil.which("schtasks")
|
||||
if not schtasks:
|
||||
print("schtasks not available on this system; cannot remove scheduled task.")
|
||||
return False
|
||||
cmd = [schtasks, "/Delete", "/TN", service_name, "/F"]
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"Scheduled task '{service_name}' removed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Failed to delete scheduled task:", e)
|
||||
return False
|
||||
except Exception as exc:
|
||||
print("Windows uninstall-service error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def install_service_systemd(
|
||||
service_name: str, repo_root: Path, venv_py: Path, headless: bool = True, detached: bool = True
|
||||
) -> bool:
|
||||
try:
|
||||
systemctl = shutil.which("systemctl")
|
||||
if not systemctl:
|
||||
print("systemctl not available; falling back to crontab @reboot (if present).")
|
||||
return install_service_cron(service_name, repo_root, venv_py, headless, detached)
|
||||
|
||||
unit_dir = Path.home() / ".config" / "systemd" / "user"
|
||||
unit_dir.mkdir(parents=True, exist_ok=True)
|
||||
unit_file = unit_dir / f"{service_name}.service"
|
||||
exec_args = f'"{venv_py}" "{str(repo_root / "run_client.py")}" --detached '
|
||||
exec_args += "--headless " if headless else "--gui "
|
||||
content = f"[Unit]\nDescription=Hydrus Client (user)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={exec_args}\nWorkingDirectory={str(repo_root)}\nRestart=on-failure\nEnvironment=PYTHONUNBUFFERED=1\n\n[Install]\nWantedBy=default.target\n"
|
||||
unit_file.write_text(content, encoding="utf-8")
|
||||
subprocess.run([systemctl, "--user", "daemon-reload"], check=True)
|
||||
subprocess.run(
|
||||
[systemctl, "--user", "enable", "--now", f"{service_name}.service"], check=True
|
||||
)
|
||||
print(f"systemd user service '{service_name}' installed and started.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Failed to create systemd user service:", e)
|
||||
return False
|
||||
except Exception as exc:
|
||||
print("systemd install error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def uninstall_service_systemd(service_name: str) -> bool:
|
||||
try:
|
||||
systemctl = shutil.which("systemctl")
|
||||
if not systemctl:
|
||||
print("systemctl not available; cannot uninstall systemd service.")
|
||||
return False
|
||||
subprocess.run(
|
||||
[systemctl, "--user", "disable", "--now", f"{service_name}.service"], check=False
|
||||
)
|
||||
unit_file = Path.home() / ".config" / "systemd" / "user" / f"{service_name}.service"
|
||||
if unit_file.exists():
|
||||
unit_file.unlink()
|
||||
subprocess.run([systemctl, "--user", "daemon-reload"], check=True)
|
||||
print(f"systemd user service '{service_name}' removed.")
|
||||
return True
|
||||
except Exception as exc:
|
||||
print("systemd uninstall error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def install_service_cron(
|
||||
service_name: str, repo_root: Path, venv_py: Path, headless: bool = True, detached: bool = True
|
||||
) -> bool:
|
||||
try:
|
||||
crontab = shutil.which("crontab")
|
||||
if not crontab:
|
||||
print("crontab not available; cannot install reboot cron job.")
|
||||
return False
|
||||
entry = f"@reboot {venv_py} {str(repo_root / 'run_client.py')} --detached {'--headless' if headless else '--gui'} # {service_name}\n"
|
||||
proc = subprocess.run(
|
||||
[crontab, "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
existing = proc.stdout if proc.returncode == 0 else ""
|
||||
if entry.strip() in existing:
|
||||
print("Crontab entry already present; skipping.")
|
||||
return True
|
||||
new = existing + "\n" + entry
|
||||
subprocess.run([crontab, "-"], input=new, text=True, check=True)
|
||||
print(f"Crontab @reboot entry added for '{service_name}'.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Failed to install crontab entry:", e)
|
||||
return False
|
||||
except Exception as exc:
|
||||
print("crontab install error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def uninstall_service_cron(service_name: str, repo_root: Path, venv_py: Path) -> bool:
|
||||
try:
|
||||
crontab = shutil.which("crontab")
|
||||
if not crontab:
|
||||
print("crontab not available; cannot remove reboot cron job.")
|
||||
return False
|
||||
proc = subprocess.run(
|
||||
[crontab, "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print("No crontab found for user; nothing to remove.")
|
||||
return True
|
||||
lines = [l for l in proc.stdout.splitlines() if f"# {service_name}" not in l]
|
||||
new = "\n".join(lines) + "\n"
|
||||
subprocess.run([crontab, "-"], input=new, text=True, check=True)
|
||||
print(f"Crontab entry for '{service_name}' removed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Failed to modify crontab:", e)
|
||||
return False
|
||||
except Exception as exc:
|
||||
print("crontab uninstall error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def install_service_auto(
|
||||
service_name: str, repo_root: Path, venv_py: Path, headless: bool = True, detached: bool = True
|
||||
) -> bool:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
return install_service_windows(
|
||||
service_name, repo_root, venv_py, headless=headless, detached=detached
|
||||
)
|
||||
else:
|
||||
if shutil.which("systemctl"):
|
||||
return install_service_systemd(
|
||||
service_name, repo_root, venv_py, headless=headless, detached=detached
|
||||
)
|
||||
else:
|
||||
return install_service_cron(
|
||||
service_name, repo_root, venv_py, headless=headless, detached=detached
|
||||
)
|
||||
except Exception as exc:
|
||||
print("install_service_auto error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def uninstall_service_auto(service_name: str, repo_root: Path, venv_py: Path) -> bool:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
return uninstall_service_windows(service_name)
|
||||
else:
|
||||
if shutil.which("systemctl"):
|
||||
return uninstall_service_systemd(service_name)
|
||||
else:
|
||||
return uninstall_service_cron(service_name, repo_root, venv_py)
|
||||
except Exception as exc:
|
||||
print("uninstall_service_auto error:", exc)
|
||||
return False
|
||||
|
||||
|
||||
def print_activation_instructions(repo_root: Path, venv_dir: Path, venv_py: Path) -> None:
|
||||
print("\nActivation and run examples:")
|
||||
# PowerShell
|
||||
print(f" PowerShell:\n . {shlex.quote(str(venv_dir))}\\Scripts\\Activate.ps1")
|
||||
# CMD
|
||||
print(f" CMD:\n {str(venv_dir)}\\Scripts\\activate.bat")
|
||||
# Bash
|
||||
print(f" Bash (Linux/macOS/WSL):\n source {str(venv_dir)}/bin/activate")
|
||||
print(
|
||||
f"\nDirect run without activating:\n {str(venv_py)} {str(repo_root/ 'hydrus_client.py')}"
|
||||
)
|
||||
|
||||
|
||||
def detach_kwargs_for_platform():
|
||||
kwargs = {}
|
||||
if os.name == "nt":
|
||||
CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
DETACHED_PROCESS = getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
flags = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
if flags:
|
||||
kwargs["creationflags"] = flags
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
return kwargs
|
||||
|
||||
|
||||
def find_venv_python(repo_root: Path, venv_arg: Optional[str], venv_name: str) -> Optional[Path]:
|
||||
# venv_arg may be a python executable or a directory
|
||||
if venv_arg:
|
||||
p = Path(venv_arg)
|
||||
if p.exists():
|
||||
if p.is_file():
|
||||
return p
|
||||
else:
|
||||
found = get_python_in_venv(p)
|
||||
if found:
|
||||
return found
|
||||
# Try repo-local venv
|
||||
dir_candidate = repo_root / venv_name
|
||||
found = get_python_in_venv(dir_candidate)
|
||||
if found:
|
||||
return found
|
||||
# Fallback: if current interpreter is inside repo venv
|
||||
try:
|
||||
cur = Path(sys.executable).resolve()
|
||||
if repo_root in cur.parents:
|
||||
return cur
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _python_can_import(python_exe: Path, modules: List[str]) -> bool:
|
||||
"""Return True if the given python executable can import all modules in the list.
|
||||
|
||||
Uses a subprocess to avoid side-effects in the current interpreter.
|
||||
"""
|
||||
if not python_exe:
|
||||
return False
|
||||
try:
|
||||
# Build a short import test string. Use semicolons to ensure any import error results in non-zero exit.
|
||||
imports = ";".join([f"import {m}" for m in modules])
|
||||
out = subprocess.run(
|
||||
[str(python_exe), "-c", imports],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
return out.returncode == 0
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
return False
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Run hydrus_client.py using the repo-local venv Python (top-level helper)"
|
||||
)
|
||||
p.add_argument("--venv", help="Path to venv dir or python executable (overrides default .venv)")
|
||||
p.add_argument(
|
||||
"--venv-name", default=".venv", help="Name of the venv folder to look for (default: .venv)"
|
||||
)
|
||||
p.add_argument(
|
||||
"--client",
|
||||
default="hydrus_client.py",
|
||||
help="Path to hydrus_client.py relative to repo root",
|
||||
)
|
||||
p.add_argument(
|
||||
"--repo-root",
|
||||
default=None,
|
||||
help="Path to the hydrus repository root (overrides auto-detection)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--install-deps",
|
||||
action="store_true",
|
||||
help="Install requirements.txt into the venv before running",
|
||||
)
|
||||
p.add_argument(
|
||||
"--reinstall",
|
||||
action="store_true",
|
||||
help="Force re-install dependencies from requirements.txt into the venv (uses --force-reinstall)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="Verify that packages from requirements.txt are importable in the venv (after install)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-verify",
|
||||
action="store_true",
|
||||
help="Skip verification and do not prompt to install missing dependencies; proceed to run with the chosen Python",
|
||||
)
|
||||
p.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
help="Attempt to launch the client without showing the Qt GUI (best-effort). Default for subsequent runs; first run will show GUI unless --headless is supplied",
|
||||
)
|
||||
p.add_argument(
|
||||
"--gui",
|
||||
action="store_true",
|
||||
help="Start the client with the GUI visible (overrides headless/default) ",
|
||||
)
|
||||
p.add_argument(
|
||||
"--detached", action="store_true", help="Start the client and do not wait (detached)"
|
||||
)
|
||||
p.add_argument(
|
||||
"--install-service",
|
||||
action="store_true",
|
||||
help="Install a user-level start-on-boot service/scheduled task for the hydrus client",
|
||||
)
|
||||
p.add_argument(
|
||||
"--uninstall-service",
|
||||
action="store_true",
|
||||
help="Remove an installed start-on-boot service/scheduled task",
|
||||
)
|
||||
p.add_argument(
|
||||
"--service-name",
|
||||
default="hydrus-client",
|
||||
help="Name of the service / scheduled task to install (default: hydrus-client)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--cwd", default=None, help="Working directory to start the client in (default: repo root)"
|
||||
)
|
||||
p.add_argument("--quiet", action="store_true", help="Reduce output")
|
||||
p.add_argument(
|
||||
"client_args",
|
||||
nargs=argparse.REMAINDER,
|
||||
help="Arguments to pass to hydrus_client.py (prefix with --)",
|
||||
)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
|
||||
workspace_root = Path(__file__).resolve().parent.parent
|
||||
# Determine default repo root: prefer <workspace>/hydrusnetwork when present
|
||||
if args.repo_root:
|
||||
repo_root = Path(args.repo_root).expanduser().resolve()
|
||||
else:
|
||||
candidate = workspace_root / "hydrusnetwork"
|
||||
if candidate.exists():
|
||||
repo_root = candidate
|
||||
else:
|
||||
repo_root = workspace_root
|
||||
|
||||
venv_py = find_venv_python(repo_root, args.venv, args.venv_name)
|
||||
|
||||
def _is_running_in_virtualenv() -> bool:
|
||||
try:
|
||||
return hasattr(sys, "real_prefix") or getattr(sys, "base_prefix", None) != getattr(
|
||||
sys, "prefix", None
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Prefer the current interpreter if the helper was invoked from a virtualenv
|
||||
# and the user did not explicitly pass --venv. This matches the user's likely
|
||||
# intent when they called: <venv_python> scripts/run_client.py ...
|
||||
cur_py = Path(sys.executable)
|
||||
if args.venv is None and _is_running_in_virtualenv() and cur_py:
|
||||
# If current interpreter looks like a venv and can import required modules,
|
||||
# prefer it immediately rather than forcing the repo venv.
|
||||
req = find_requirements(repo_root)
|
||||
pkgs = parse_requirements_file(req) if req else []
|
||||
check_pkgs = pkgs if pkgs else ["pyyaml"]
|
||||
try:
|
||||
ok_cur = verify_imports(cur_py, check_pkgs)
|
||||
except Exception:
|
||||
ok_cur = _python_can_import(cur_py, ["yaml"])
|
||||
if ok_cur:
|
||||
venv_py = cur_py
|
||||
if not args.quiet:
|
||||
print(f"Using current Python interpreter as venv: {cur_py}")
|
||||
|
||||
# If we found a repo-local venv, verify it has at least the core imports (or the
|
||||
# packages listed in requirements.txt). If not, prefer the current Python
|
||||
# interpreter when that interpreter looks more suitable (e.g. has deps installed).
|
||||
if venv_py and venv_py != cur_py:
|
||||
if not args.quiet:
|
||||
print(f"Found venv python: {venv_py}")
|
||||
req = find_requirements(repo_root)
|
||||
pkgs = parse_requirements_file(req) if req else []
|
||||
check_pkgs = pkgs if pkgs else ["pyyaml"]
|
||||
try:
|
||||
ok_venv = verify_imports(venv_py, check_pkgs)
|
||||
except Exception:
|
||||
ok_venv = _python_can_import(venv_py, ["yaml"])
|
||||
|
||||
if not ok_venv:
|
||||
try:
|
||||
ok_cur = verify_imports(cur_py, check_pkgs)
|
||||
except Exception:
|
||||
ok_cur = _python_can_import(cur_py, ["yaml"])
|
||||
|
||||
if ok_cur:
|
||||
if not args.quiet:
|
||||
print(
|
||||
f"Repository venv ({venv_py}) is missing required packages; using current Python at {cur_py} instead."
|
||||
)
|
||||
venv_py = cur_py
|
||||
else:
|
||||
print(
|
||||
"Warning: repository venv appears to be missing required packages. If the client fails to start, run this helper with --install-deps to install requirements into the repo venv, or use --venv to point to a Python that has the deps."
|
||||
)
|
||||
|
||||
if not venv_py:
|
||||
print("Could not locate a repository venv.")
|
||||
print(
|
||||
"Create one with: python -m venv .venv (inside your hydrus repo) and then re-run this helper, or use the installer to create it for you."
|
||||
)
|
||||
print_activation_instructions(
|
||||
repo_root, repo_root / args.venv_name, repo_root / args.venv_name
|
||||
)
|
||||
return 2
|
||||
|
||||
client_path = (repo_root / args.client).resolve()
|
||||
if not client_path.exists():
|
||||
print(f"Client file not found: {client_path}")
|
||||
return 3
|
||||
|
||||
cwd = Path(args.cwd).resolve() if args.cwd else repo_root
|
||||
|
||||
# Optionally install dependencies
|
||||
if args.install_deps or args.reinstall:
|
||||
req = find_requirements(repo_root)
|
||||
if not req:
|
||||
print("No requirements.txt found; skipping install")
|
||||
else:
|
||||
ok = install_requirements(venv_py, req, reinstall=args.reinstall)
|
||||
if not ok:
|
||||
print("Dependency installation failed; aborting")
|
||||
return 4
|
||||
if args.verify:
|
||||
pkgs = parse_requirements_file(req)
|
||||
if pkgs:
|
||||
okv = verify_imports(venv_py, pkgs)
|
||||
if not okv:
|
||||
print("Verification failed; see instructions above to re-run installation.")
|
||||
|
||||
# If not installing but user asked to verify, do verification only
|
||||
if args.verify and not (args.install_deps or args.reinstall):
|
||||
req = find_requirements(repo_root)
|
||||
if req:
|
||||
pkgs = parse_requirements_file(req)
|
||||
if pkgs and not verify_imports(venv_py, pkgs):
|
||||
print(
|
||||
"Verification found missing packages. Use --install-deps to install into the venv."
|
||||
)
|
||||
|
||||
# If the venv appears to be missing required packages, offer to install them interactively
|
||||
req = find_requirements(repo_root)
|
||||
pkgs = parse_requirements_file(req) if req else []
|
||||
check_pkgs = pkgs if pkgs else ["pyyaml"]
|
||||
try:
|
||||
venv_ok = verify_imports(venv_py, check_pkgs)
|
||||
except Exception:
|
||||
venv_ok = _python_can_import(venv_py, ["yaml"]) # fallback
|
||||
|
||||
if not venv_ok:
|
||||
# If user explicitly requested install, we've already attempted it above; otherwise, do not block.
|
||||
if args.install_deps or args.reinstall:
|
||||
# if we already did an install attempt and it still fails, bail
|
||||
print("Dependency verification failed after install; aborting.")
|
||||
return 4
|
||||
|
||||
# Default: print a clear warning and proceed to launch with the repository venv
|
||||
if args.no_verify:
|
||||
print(
|
||||
"Repository venv is missing required packages; proceeding without verification as requested ( --no-verify ). Client may fail to start."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Warning: repository venv appears to be missing required packages. Proceeding to launch with repository venv; the client may fail to start. Use --install-deps to install requirements into the repo venv."
|
||||
)
|
||||
|
||||
# Do not prompt to switch to another interpreter automatically; the user can
|
||||
# re-run with --venv to select a different python if desired.
|
||||
|
||||
# Service install/uninstall requests
|
||||
if args.install_service or args.uninstall_service:
|
||||
first_run = is_first_run(repo_root)
|
||||
if args.gui:
|
||||
use_headless = False
|
||||
elif args.headless:
|
||||
use_headless = True
|
||||
else:
|
||||
use_headless = not first_run
|
||||
|
||||
if args.install_service:
|
||||
ok = install_service_auto(
|
||||
args.service_name, repo_root, venv_py, headless=use_headless, detached=True
|
||||
)
|
||||
return 0 if ok else 6
|
||||
if args.uninstall_service:
|
||||
ok = uninstall_service_auto(args.service_name, repo_root, venv_py)
|
||||
return 0 if ok else 7
|
||||
|
||||
# Prepare the command
|
||||
client_args = args.client_args or []
|
||||
cmd = [str(venv_py), str(client_path)] + client_args
|
||||
|
||||
# Determine headless vs GUI
|
||||
first_run = is_first_run(repo_root)
|
||||
if args.gui:
|
||||
headless = False
|
||||
elif args.headless:
|
||||
headless = True
|
||||
else:
|
||||
headless = not first_run
|
||||
if not args.quiet and first_run:
|
||||
print("First run detected: defaulting to GUI unless --headless is specified.")
|
||||
|
||||
env = os.environ.copy()
|
||||
if headless:
|
||||
if os.name == "posix" and shutil.which("xvfb-run"):
|
||||
xvfb_cmd = ["xvfb-run", "--auto-servernum", "--server-args=-screen 0 1024x768x24"]
|
||||
cmd = xvfb_cmd + cmd
|
||||
if not args.quiet:
|
||||
print("Headless: using xvfb-run to provide a virtual X server")
|
||||
else:
|
||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
||||
if not args.quiet:
|
||||
print("Headless: setting QT_QPA_PLATFORM=offscreen (best-effort)")
|
||||
|
||||
# Inform which Python will be used
|
||||
if not args.quiet:
|
||||
try:
|
||||
print(f"Launching Hydrus client with Python: {venv_py}")
|
||||
print(f"Command: {' '.join(shlex.quote(str(c)) for c in cmd)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Launch
|
||||
if args.detached:
|
||||
try:
|
||||
kwargs = detach_kwargs_for_platform()
|
||||
kwargs.update({"cwd": str(cwd), "env": env})
|
||||
subprocess.Popen(cmd, **kwargs)
|
||||
print("Hydrus client launched (detached).")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print("Failed to launch client detached:", exc)
|
||||
return 5
|
||||
else:
|
||||
try:
|
||||
subprocess.run(cmd, cwd=str(cwd), env=env)
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("hydrus client exited non-zero:", e)
|
||||
return 5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user