df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
2025-12-29 17:05:03 -08:00
parent 226de9316a
commit c019c00aed
104 changed files with 19669 additions and 12954 deletions

View File

@@ -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

View File

@@ -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
View 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())