Convert dev launcher to Python

- Replace the shell convenience script with a cross-platform Python launcher.
- Keep dev.sh as a Unix compatibility wrapper.
- Let the direct backend bind with host and port overrides.
- Update the root and webui README guidance for the new launcher.
- Preserve the backend startup behavior used by the old dev flow.
This commit is contained in:
Antti Kettunen 2026-05-02 16:37:26 +03:00
parent fac4e1ba1a
commit d8f8c6b95c
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
5 changed files with 367 additions and 235 deletions

View file

@ -307,8 +307,8 @@ Run tests separately when needed:
python -m pytest python -m pytest
``` ```
If you want a convenience launcher, `./dev.sh` starts both halves together. If you want a convenience launcher, `python dev.py` starts both halves together
It is most useful on Linux, macOS, and WSL. on any OS. `./dev.sh` remains available as a Unix shell wrapper.
--- ---

348
dev.py Executable file
View file

@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""SoulSync development launcher.
Starts the backend and Vite dev server together, restarts the backend when
backend source files change, and handles shutdown cleanly across platforms.
"""
from __future__ import annotations
import atexit
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent
LOG_DIR = ROOT_DIR / 'logs'
GUNICORN_CONFIG = ROOT_DIR / 'gunicorn.dev.conf.py'
VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
VITE_LOG_FILE = Path(os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(LOG_DIR / 'webui-vite.log')))
INCLUDED_SUFFIXES = {'.py', '.html', '.jinja', '.jinja2'}
SHUTDOWN_GRACE_SECONDS = int(os.environ.get('SOULSYNC_SHUTDOWN_GRACE_SECONDS', '10'))
FORCE_KILL_ON_SHUTDOWN = os.environ.get('SOULSYNC_FORCE_KILL_ON_SHUTDOWN', '1').lower() in {
'1',
'true',
'yes',
'on',
}
shutdown_requested = False
managed_processes: list[tuple[str, subprocess.Popen, object | None]] = []
def resolve_command(*candidates: str) -> str | None:
for candidate in candidates:
resolved = shutil.which(candidate)
if resolved:
return resolved
return None
def is_excluded(path: Path) -> bool:
try:
relative = path.relative_to(ROOT_DIR)
except ValueError:
return False
parts = relative.parts
if not parts:
return False
if any(part == '__pycache__' for part in parts):
return True
if parts[0] in {'.git', 'logs'}:
return True
if len(parts) >= 2 and parts[0] == 'webui' and parts[1] == 'node_modules':
return True
if len(parts) >= 3 and parts[0] == 'webui' and parts[1] == 'static' and parts[2] == 'dist':
return True
return False
def build_backend_env(direct_mode: bool) -> dict[str, str]:
env = os.environ.copy()
env.setdefault('SOULSYNC_WEB_DEV_NO_CACHE', '1')
env.setdefault('SOULSYNC_WEBUI_VITE_DEV', '1')
env.setdefault('SOULSYNC_WEBUI_VITE_URL', VITE_URL)
env.setdefault('SOULSYNC_WEBUI_VITE_LOG', str(VITE_LOG_FILE))
env.setdefault('SOULSYNC_CONFIG_PATH', str(ROOT_DIR / 'config' / 'config.json'))
if direct_mode:
env.setdefault('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')
env.setdefault('SOULSYNC_WEB_BIND_PORT', '8008')
return env
def start_process(label: str, cmd: list[str], *, log_file: Path | None = None, env: dict[str, str] | None = None) -> tuple[subprocess.Popen, object | None]:
log_handle = None
stdout = None
stderr = None
if log_file is not None:
log_file.parent.mkdir(parents=True, exist_ok=True)
log_handle = log_file.open('ab')
stdout = log_handle
stderr = log_handle
creationflags = 0
start_new_session = False
if os.name == 'nt':
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
else:
start_new_session = True
try:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT_DIR),
env=env,
stdin=subprocess.DEVNULL,
stdout=stdout,
stderr=stderr,
creationflags=creationflags,
start_new_session=start_new_session,
)
except Exception:
if log_handle is not None:
log_handle.close()
raise
managed_processes.append((label, proc, log_handle))
return proc, log_handle
def wait_for_exit(proc: subprocess.Popen, seconds: int) -> bool:
checks = max(1, int(seconds * 10))
for _ in range(checks):
if proc.poll() is not None:
return True
time.sleep(0.1)
return proc.poll() is not None
def stop_process(label: str, proc: subprocess.Popen, log_handle: object | None) -> None:
if proc.poll() is not None:
if log_handle is not None:
log_handle.close()
return
print(f'Stopping {label}...')
try:
if os.name == 'nt':
proc.terminate()
else:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
if not wait_for_exit(proc, SHUTDOWN_GRACE_SECONDS):
if not FORCE_KILL_ON_SHUTDOWN:
print(f'{label} did not exit in time; skipping forced kill for this test run.')
else:
print(f'{label} did not exit in time; forcing shutdown...')
if os.name == 'nt':
subprocess.run(
['taskkill', '/T', '/F', '/PID', str(proc.pid)],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
wait_for_exit(proc, 5)
if log_handle is not None:
log_handle.close()
def cleanup() -> None:
global shutdown_requested
if shutdown_requested:
return
shutdown_requested = True
for label, proc, log_handle in reversed(managed_processes):
stop_process(label, proc, log_handle)
def compute_backend_watch_state() -> str:
rows: list[str] = []
for dirpath, dirnames, filenames in os.walk(ROOT_DIR):
current_dir = Path(dirpath)
if is_excluded(current_dir):
dirnames[:] = []
continue
dirnames[:] = [
name
for name in dirnames
if not is_excluded(current_dir / name) and name != '__pycache__'
]
for filename in filenames:
path = current_dir / filename
if path.suffix not in INCLUDED_SUFFIXES:
continue
try:
stat = path.stat()
except FileNotFoundError:
continue
rows.append(f'{stat.st_mtime_ns} {path}')
return '\n'.join(sorted(rows))
def start_vite() -> subprocess.Popen:
npm = resolve_command('npm', 'npm.cmd')
if npm is None:
raise SystemExit('npm is required to run the Vite dev server.')
print(f'Starting Vite dev server at {VITE_URL}...')
vite_cmd = [
npm,
'--prefix',
str(ROOT_DIR / 'webui'),
'run',
'dev',
'--',
'--host',
'127.0.0.1',
'--port',
'5173',
]
proc, _ = start_process('Vite dev server', vite_cmd, log_file=VITE_LOG_FILE, env=os.environ.copy())
return proc
def wait_for_vite_ready(vite_proc: subprocess.Popen) -> None:
ready_url = f'{VITE_URL}/static/dist/@vite/client'
vite_ready = False
for _ in range(50):
if vite_proc.poll() is not None:
print('Warning: Vite dev server exited before it became ready.')
break
try:
with urllib.request.urlopen(ready_url, timeout=1) as response:
if response.status < 400:
vite_ready = True
break
except (urllib.error.URLError, TimeoutError, OSError):
pass
time.sleep(0.2)
if vite_ready:
print('Vite dev server is ready.')
else:
print('Warning: timed out waiting for the Vite dev server.')
print('The backend will still start, but the frontend may not hot-reload yet.')
def start_backend() -> tuple[subprocess.Popen, object | None]:
backend_mode = os.environ.get('SOULSYNC_DEV_BACKEND', '').strip().lower()
direct_mode = backend_mode == 'direct'
gunicorn_mode = backend_mode == 'gunicorn'
if not backend_mode:
if os.name == 'nt':
direct_mode = True
elif resolve_command('gunicorn') is None:
print('gunicorn not found; falling back to direct Python server.')
direct_mode = True
else:
gunicorn_mode = True
print('Starting SoulSync web server...')
if gunicorn_mode:
gunicorn = resolve_command('gunicorn')
if gunicorn is None:
raise SystemExit('gunicorn is not available but SOULSYNC_DEV_BACKEND=gunicorn was requested.')
print(f'Using Gunicorn config: {GUNICORN_CONFIG}')
cmd = [gunicorn, '-c', str(GUNICORN_CONFIG), 'wsgi:application']
else:
print('Using direct Python server for backend.')
cmd = [sys.executable, str(ROOT_DIR / 'web_server.py')]
proc, log_handle = start_process(
'SoulSync web server',
cmd,
env=build_backend_env(direct_mode),
)
return proc, log_handle
def watch_and_run_backend() -> None:
last_state = compute_backend_watch_state()
backend_proc, backend_log = start_backend()
try:
while not shutdown_requested:
time.sleep(1)
if backend_proc.poll() is not None:
print('SoulSync web server exited. Restarting...')
stop_process('SoulSync web server', backend_proc, backend_log)
managed_processes.pop()
backend_proc, backend_log = start_backend()
last_state = compute_backend_watch_state()
continue
current_state = compute_backend_watch_state()
if current_state != last_state:
print('Detected backend file changes. Restarting SoulSync web server...')
last_state = current_state
stop_process('SoulSync web server', backend_proc, backend_log)
managed_processes.pop()
backend_proc, backend_log = start_backend()
finally:
if backend_proc.poll() is None:
stop_process('SoulSync web server', backend_proc, backend_log)
if managed_processes:
managed_processes.pop()
def main() -> int:
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
print('webui/node_modules is missing.')
print('Run: cd webui && npm install')
return 1
vite_proc = start_vite()
try:
wait_for_vite_ready(vite_proc)
print(f'Vite log: {VITE_LOG_FILE}')
print('Backend file watching is enabled.')
watch_and_run_backend()
finally:
cleanup()
return 0
def _handle_signal(signum: int, _frame) -> None:
raise SystemExit(130 if signum == signal.SIGINT else 143)
signal.signal(signal.SIGINT, _handle_signal)
if hasattr(signal, 'SIGTERM'):
signal.signal(signal.SIGTERM, _handle_signal)
if hasattr(signal, 'SIGBREAK'):
signal.signal(signal.SIGBREAK, _handle_signal)
atexit.register(cleanup)
if __name__ == '__main__':
raise SystemExit(main())

237
dev.sh
View file

@ -1,235 +1,16 @@
#!/bin/bash #!/bin/sh
# SoulSync Development Launcher Script # Compatibility wrapper for the Python launcher.
# Starts the Python backend and Vite dev server together for local work. set -e
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
mkdir -p "$SCRIPT_DIR/logs"
DEV_GUNICORN_CONFIG="$SCRIPT_DIR/gunicorn.dev.conf.py" if command -v python3 >/dev/null 2>&1; then
GUNICORN_CONFIG="$DEV_GUNICORN_CONFIG" exec python3 "$SCRIPT_DIR/dev.py" "$@"
VITE_URL="${SOULSYNC_WEBUI_VITE_URL:-http://127.0.0.1:5173}"
VITE_LOG_FILE="${SOULSYNC_WEBUI_VITE_LOG:-$SCRIPT_DIR/logs/webui-vite.log}"
VITE_PID=""
SERVER_PID=""
SHUTTING_DOWN="0"
SHUTDOWN_GRACE_SECONDS="${SOULSYNC_SHUTDOWN_GRACE_SECONDS:-10}"
FORCE_KILL_ON_SHUTDOWN="${SOULSYNC_FORCE_KILL_ON_SHUTDOWN:-1}"
stop_process_group() {
local pid="$1"
local label="$2"
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
return
fi fi
echo "Stopping ${label}..." if command -v python >/dev/null 2>&1; then
kill -TERM -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true exec python "$SCRIPT_DIR/dev.py" "$@"
local max_checks=$((SHUTDOWN_GRACE_SECONDS * 10))
for _ in $(seq 1 "$max_checks"); do
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
if [[ "$FORCE_KILL_ON_SHUTDOWN" != "1" ]]; then
echo "${label} did not exit in time; skipping forced kill for this test run."
wait "$pid" 2>/dev/null || true
return
fi
echo "${label} did not exit in time; forcing shutdown..."
kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
fi fi
wait "$pid" 2>/dev/null || true echo "Python is required to run the SoulSync dev launcher."
}
cleanup() {
if [[ "$SHUTTING_DOWN" == "1" ]]; then
return
fi
SHUTTING_DOWN="1"
stop_process_group "${SERVER_PID}" "SoulSync web server"
stop_process_group "${VITE_PID}" "Vite dev server"
}
trap cleanup EXIT INT TERM
start_in_own_session() {
local pid_file="$1"
shift
local log_file=""
if [[ "${1:-}" == "--log-file" ]]; then
log_file="$2"
shift 2
fi
python3 - "$pid_file" "$log_file" "$@" <<'PY'
import subprocess
import sys
pid_file = sys.argv[1]
log_file = sys.argv[2]
cmd = sys.argv[3:]
stdout = None
stderr = None
log_handle = None
if log_file:
log_handle = open(log_file, "ab")
stdout = log_handle
stderr = log_handle
try:
process = subprocess.Popen(cmd, start_new_session=True, stdout=stdout, stderr=stderr)
with open(pid_file, "w", encoding="utf-8") as pid_handle:
pid_handle.write(str(process.pid))
finally:
if log_handle is not None:
log_handle.close()
PY
}
start_server() {
echo "Starting SoulSync web server..."
echo "Using Gunicorn config: ${GUNICORN_CONFIG}"
local pid_file
pid_file="$(mktemp "$SCRIPT_DIR/logs/.gunicorn-pid.XXXXXX")"
start_in_own_session "$pid_file" gunicorn -c "${GUNICORN_CONFIG}" wsgi:application
SERVER_PID="$(<"$pid_file")"
rm -f "$pid_file"
}
stop_server() {
stop_process_group "${SERVER_PID}" "SoulSync web server"
SERVER_PID=""
}
compute_backend_watch_state() {
python3 - "$SCRIPT_DIR" <<'PY'
import os
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
excluded_dirs = {
root / '.git',
root / 'logs',
root / 'webui' / 'node_modules',
root / 'webui' / 'static' / 'dist',
}
included_suffixes = {'.py', '.html', '.jinja', '.jinja2'}
rows = []
for dirpath, dirnames, filenames in os.walk(root):
current_dir = Path(dirpath)
if current_dir in excluded_dirs:
dirnames[:] = []
continue
if any(part == '__pycache__' for part in current_dir.parts):
dirnames[:] = []
continue
dirnames[:] = [
name
for name in dirnames
if (current_dir / name) not in excluded_dirs and name != '__pycache__'
]
for filename in filenames:
path = current_dir / filename
if any(part == '__pycache__' for part in path.parts):
continue
if path.suffix not in included_suffixes:
continue
try:
stat = path.stat()
except FileNotFoundError:
continue
rows.append(f'{stat.st_mtime_ns} {path}')
for row in sorted(rows):
print(row)
PY
}
watch_and_run_server() {
local last_state=""
local current_state=""
last_state="$(compute_backend_watch_state)"
start_server
while true; do
sleep 1
if [[ "$SHUTTING_DOWN" == "1" ]]; then
return
fi
if [[ -n "${SERVER_PID}" ]] && ! kill -0 "${SERVER_PID}" 2>/dev/null; then
echo "SoulSync web server exited. Restarting..."
start_server
last_state="$(compute_backend_watch_state)"
continue
fi
current_state="$(compute_backend_watch_state)"
if [[ "$current_state" != "$last_state" ]]; then
echo "Detected backend file changes. Restarting SoulSync web server..."
last_state="$current_state"
stop_server
start_server
fi
done
}
if [[ ! -d "$SCRIPT_DIR/webui/node_modules" ]]; then
echo "webui/node_modules is missing."
echo "Run: cd webui && npm install"
exit 1 exit 1
fi
echo "Starting Vite dev server at ${VITE_URL}..."
mkdir -p "$(dirname "$VITE_LOG_FILE")"
VITE_PID_FILE="$(mktemp "$SCRIPT_DIR/logs/.vite-pid.XXXXXX")"
start_in_own_session "$VITE_PID_FILE" --log-file "$VITE_LOG_FILE" npm --prefix "$SCRIPT_DIR/webui" run dev -- --host 127.0.0.1 --port 5173
VITE_PID="$(<"$VITE_PID_FILE")"
rm -f "$VITE_PID_FILE"
if command -v curl >/dev/null 2>&1; then
READY_URL="${VITE_URL}/static/dist/@vite/client"
vite_ready="0"
for _ in {1..50}; do
if ! kill -0 "${VITE_PID}" 2>/dev/null; then
echo "Warning: Vite dev server exited before it became ready."
break
fi
if curl -fsS "$READY_URL" >/dev/null 2>&1; then
vite_ready="1"
break
fi
sleep 0.2
done
if [[ "$vite_ready" == "1" ]]; then
echo "Vite dev server is ready."
else
echo "Warning: timed out waiting for the Vite dev server."
echo "The backend will still start, but the frontend may not hot-reload yet."
fi
else
sleep 2
fi
echo "Vite log: $VITE_LOG_FILE"
echo "Backend file watching is enabled."
watch_and_run_server

View file

@ -35896,8 +35896,11 @@ def start_runtime_services():
# Direct execution: python web_server.py (dev/Windows fallback) # Direct execution: python web_server.py (dev/Windows fallback)
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application # Production should use: gunicorn -c gunicorn.conf.py wsgi:application
if _DIRECT_RUN: if _DIRECT_RUN:
web_run_host = os.environ.get('SOULSYNC_WEB_BIND_HOST', '0.0.0.0')
web_run_port = int(os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008'))
display_host = '127.0.0.1' if web_run_host in {'0.0.0.0', '::'} else web_run_host
logger.info("Starting SoulSync Web UI Server...") logger.info("Starting SoulSync Web UI Server...")
logger.info("Open your browser and navigate to http://127.0.0.1:8008") logger.info(f"Open your browser and navigate to http://{display_host}:{web_run_port}")
logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application") logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application")
start_runtime_services() start_runtime_services()
socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) socketio.run(app, host=web_run_host, port=web_run_port, debug=False, allow_unsafe_werkzeug=True)

View file

@ -89,5 +89,5 @@ npm run check
npm run fix npm run fix
``` ```
If you want a convenience wrapper, the repo root also includes `./dev.sh`. If you want a convenience launcher, the repo root includes `python dev.py`
It starts both halves together and is most useful on Linux, macOS, and WSL. for any OS and `./dev.sh` as a Unix shell wrapper.