547 lines
19 KiB
Python
547 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ETF Portal CLI
|
|
|
|
A command-line interface for managing the ETF Portal application.
|
|
"""
|
|
|
|
import click
|
|
import subprocess
|
|
import webbrowser
|
|
import time
|
|
import threading
|
|
import os
|
|
import sys
|
|
import socket
|
|
import signal
|
|
import json
|
|
import psutil
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Tuple
|
|
from datetime import datetime
|
|
|
|
# Path configurations
|
|
WORKSPACE_PATH = Path(__file__).parent.parent
|
|
LAUNCHER_PATH = WORKSPACE_PATH / "ETF_Suite_Launcher.py"
|
|
PORTFOLIO_BUILDER_PATH = WORKSPACE_PATH / "pages" / "ETF_Portfolio_Builder.py"
|
|
ANALYZER_PATH = WORKSPACE_PATH / "pages" / "ETF_Analyzer.py"
|
|
CONFIG_DIR = WORKSPACE_PATH / "config"
|
|
CONFIG_FILE = CONFIG_DIR / "etf_suite_config.json"
|
|
LOGS_DIR = WORKSPACE_PATH / "logs"
|
|
|
|
# Default port settings
|
|
DEFAULT_PORTS = {
|
|
"launcher": 8500,
|
|
"portfolio_builder": 8501,
|
|
"analyzer": 8502
|
|
}
|
|
|
|
# Process tracking
|
|
active_processes = {}
|
|
|
|
# Setup logging
|
|
def setup_logging():
|
|
"""Set up logging configuration."""
|
|
LOGS_DIR.mkdir(exist_ok=True)
|
|
log_file = LOGS_DIR / f"etf_portal_{datetime.now().strftime('%Y%m%d')}.log"
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.FileHandler(log_file),
|
|
logging.StreamHandler()
|
|
]
|
|
)
|
|
return logging.getLogger("etf_portal")
|
|
|
|
logger = setup_logging()
|
|
|
|
def ensure_config_exists():
|
|
"""Ensure config directory and file exist."""
|
|
CONFIG_DIR.mkdir(exist_ok=True)
|
|
|
|
if not CONFIG_FILE.exists():
|
|
with open(CONFIG_FILE, 'w') as f:
|
|
json.dump({
|
|
"ports": DEFAULT_PORTS,
|
|
"streamlit_path": os.path.join(sys.prefix, "bin", "streamlit")
|
|
}, f, indent=2)
|
|
|
|
def get_config():
|
|
"""Get the configuration from the config file."""
|
|
ensure_config_exists()
|
|
with open(CONFIG_FILE, 'r') as f:
|
|
return json.load(f)
|
|
|
|
def update_config(key, value):
|
|
"""Update a specific configuration value."""
|
|
config = get_config()
|
|
|
|
# Handle nested configuration like ports.launcher
|
|
if '.' in key:
|
|
main_key, sub_key = key.split('.', 1)
|
|
if main_key not in config:
|
|
config[main_key] = {}
|
|
config[main_key][sub_key] = value
|
|
else:
|
|
config[key] = value
|
|
|
|
with open(CONFIG_FILE, 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
def cleanup_streamlit_processes():
|
|
"""Kill any existing Streamlit processes to prevent conflicts."""
|
|
click.echo("Cleaning up existing Streamlit processes...")
|
|
logger.info("Cleaning up existing Streamlit processes")
|
|
try:
|
|
config = get_config()
|
|
ports = config["ports"]
|
|
processed_pids = set() # Track PIDs we've already handled
|
|
|
|
# First, find and kill processes using our target ports
|
|
for port in ports.values():
|
|
try:
|
|
# Find process using the port
|
|
cmd = f"lsof -i :{port} | grep LISTEN | awk '{{print $2}}'"
|
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
if result.stdout.strip():
|
|
pids = result.stdout.strip().split('\n')
|
|
for pid in pids:
|
|
pid = int(pid)
|
|
if pid not in processed_pids:
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
logger.info(f"Terminated process {pid} using port {port}")
|
|
processed_pids.add(pid)
|
|
except ProcessLookupError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Error cleaning up port {port}: {e}")
|
|
|
|
# Then find and kill any remaining Streamlit processes
|
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
try:
|
|
if proc.info['pid'] not in processed_pids and 'streamlit' in ' '.join(proc.info['cmdline'] or []).lower():
|
|
proc.terminate()
|
|
logger.info(f"Terminated Streamlit process {proc.info['pid']}")
|
|
processed_pids.add(proc.info['pid'])
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
pass
|
|
|
|
# Give processes time to shut down
|
|
time.sleep(2)
|
|
|
|
# Force kill any remaining processes
|
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
try:
|
|
if proc.info['pid'] not in processed_pids and 'streamlit' in ' '.join(proc.info['cmdline'] or []).lower():
|
|
proc.kill()
|
|
logger.info(f"Force killed Streamlit process {proc.info['pid']}")
|
|
processed_pids.add(proc.info['pid'])
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during cleanup: {e}")
|
|
click.echo(f"Warning during cleanup: {e}")
|
|
|
|
def port_is_available(port):
|
|
"""Check if a port is available."""
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", port))
|
|
return True
|
|
except socket.error:
|
|
return False
|
|
|
|
def open_browser(url, delay=3):
|
|
"""Open browser after a delay to ensure app is running."""
|
|
time.sleep(delay)
|
|
click.echo(f"Opening browser to {url}")
|
|
webbrowser.open(url)
|
|
|
|
def start_component(component: str, open_browser_tab=True, background=False) -> Optional[subprocess.Popen]:
|
|
"""Start a specific component of the ETF Portal."""
|
|
config = get_config()
|
|
streamlit_path = config.get("streamlit_path", os.path.join(sys.prefix, "bin", "streamlit"))
|
|
ports = config["ports"]
|
|
|
|
# Check if streamlit exists at the specified path
|
|
if not os.path.exists(streamlit_path):
|
|
error_msg = f"ERROR: Streamlit not found at {streamlit_path}"
|
|
logger.error(error_msg)
|
|
click.echo(error_msg)
|
|
click.echo("Please install streamlit or update the streamlit_path in config")
|
|
return None
|
|
|
|
# Component-specific configurations
|
|
component_configs = {
|
|
"launcher": {
|
|
"path": LAUNCHER_PATH,
|
|
"port": ports["launcher"],
|
|
},
|
|
"portfolio_builder": {
|
|
"path": PORTFOLIO_BUILDER_PATH,
|
|
"port": ports["portfolio_builder"],
|
|
},
|
|
"analyzer": {
|
|
"path": ANALYZER_PATH,
|
|
"port": ports["analyzer"],
|
|
}
|
|
}
|
|
|
|
if component not in component_configs:
|
|
error_msg = f"Unknown component: {component}"
|
|
logger.error(error_msg)
|
|
click.echo(error_msg)
|
|
return None
|
|
|
|
component_config = component_configs[component]
|
|
port = component_config["port"]
|
|
|
|
if not port_is_available(port):
|
|
error_msg = f"ERROR: Port {port} is in use."
|
|
logger.error(error_msg)
|
|
click.echo(error_msg)
|
|
click.echo(f"Please stop existing service on port {port} or configure a different port.")
|
|
return None
|
|
|
|
log_file = None
|
|
if background:
|
|
# Create log file for background process
|
|
LOGS_DIR.mkdir(exist_ok=True)
|
|
log_file = LOGS_DIR / f"{component}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
|
logger.info(f"Starting {component} in background mode. Logs will be written to {log_file}")
|
|
|
|
click.echo(f"Starting {component} on port {port}..." + (" (background mode)" if background else ""))
|
|
|
|
# Prepare command
|
|
cmd = [
|
|
streamlit_path, "run",
|
|
str(component_config["path"]),
|
|
"--server.port", str(port),
|
|
"--server.fileWatcherType", "none" # Disable file watcher to prevent inotify issues
|
|
]
|
|
|
|
# Launch the component
|
|
if background:
|
|
# In background mode, redirect output to log file
|
|
with open(log_file, 'w') as log:
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
stdout=log,
|
|
stderr=log,
|
|
# Make the process independent of the parent
|
|
start_new_session=True
|
|
)
|
|
else:
|
|
# In foreground mode, just run normally
|
|
process = subprocess.Popen(cmd)
|
|
|
|
# Store process for tracking
|
|
active_processes[component] = {
|
|
"process": process,
|
|
"port": port,
|
|
"pid": process.pid,
|
|
"background": background
|
|
}
|
|
|
|
# Open browser pointing to the component
|
|
if open_browser_tab:
|
|
thread = threading.Thread(
|
|
target=open_browser,
|
|
args=(f"http://localhost:{port}",)
|
|
)
|
|
thread.daemon = True
|
|
thread.start()
|
|
|
|
# If running in background, we don't need to wait
|
|
if background:
|
|
logger.info(f"Started {component} in background mode (PID: {process.pid})")
|
|
# Give it a moment to start
|
|
time.sleep(1)
|
|
|
|
# Check if the process is still running
|
|
if process.poll() is not None:
|
|
error_msg = f"Error: {component} failed to start in background mode"
|
|
logger.error(error_msg)
|
|
click.echo(error_msg)
|
|
return None
|
|
|
|
return process
|
|
|
|
def get_streamlit_processes() -> List[Dict]:
|
|
"""Get a list of running Streamlit processes."""
|
|
processes = []
|
|
seen_ports = set() # Track ports we've seen to avoid duplicates
|
|
|
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'create_time']):
|
|
try:
|
|
cmdline = proc.info['cmdline']
|
|
if not cmdline or 'streamlit' not in ' '.join(cmdline).lower():
|
|
continue
|
|
|
|
port = None
|
|
component = None
|
|
|
|
# Extract port from command line
|
|
for i, arg in enumerate(cmdline):
|
|
if arg == '--server.port' and i + 1 < len(cmdline):
|
|
port = cmdline[i + 1]
|
|
# Skip if we've already seen this port (likely a duplicate)
|
|
if port in seen_ports:
|
|
continue
|
|
seen_ports.add(port)
|
|
|
|
# Identify which component based on file path
|
|
for i, arg in enumerate(cmdline):
|
|
if arg == 'run' and i + 1 < len(cmdline):
|
|
path = cmdline[i + 1]
|
|
if 'ETF_Suite_Launcher.py' in path:
|
|
component = 'launcher'
|
|
elif 'ETF_Portfolio_Builder.py' in path:
|
|
component = 'portfolio_builder'
|
|
elif 'ETF_Analyzer.py' in path:
|
|
component = 'analyzer'
|
|
|
|
# Only add processes that have a valid port
|
|
if port:
|
|
processes.append({
|
|
'pid': proc.info['pid'],
|
|
'port': port,
|
|
'component': component,
|
|
'cmdline': ' '.join(cmdline),
|
|
'create_time': proc.info['create_time']
|
|
})
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
pass
|
|
|
|
# Sort by creation time (newest first)
|
|
processes.sort(key=lambda x: x['create_time'], reverse=True)
|
|
return processes
|
|
|
|
def stop_component(component=None, pid=None):
|
|
"""Stop a specific component or Streamlit process."""
|
|
if pid:
|
|
try:
|
|
process = psutil.Process(pid)
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except psutil.TimeoutExpired:
|
|
process.kill()
|
|
click.echo(f"Stopped process with PID {pid}")
|
|
logger.info(f"Stopped process with PID {pid}")
|
|
return True
|
|
except psutil.NoSuchProcess:
|
|
click.echo(f"No process found with PID {pid}")
|
|
return False
|
|
elif component:
|
|
# Check active tracked processes first
|
|
if component in active_processes:
|
|
process_info = active_processes[component]
|
|
try:
|
|
process = process_info["process"]
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
click.echo(f"Stopped {component}")
|
|
logger.info(f"Stopped {component}")
|
|
del active_processes[component]
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Fall back to finding by component name in running processes
|
|
processes = get_streamlit_processes()
|
|
for proc in processes:
|
|
if proc['component'] == component:
|
|
return stop_component(pid=proc['pid'])
|
|
|
|
click.echo(f"No running {component} process found")
|
|
return False
|
|
|
|
@click.group()
|
|
def cli():
|
|
"""ETF Portal CLI - Manage the ETF Portal application."""
|
|
pass
|
|
|
|
@cli.command()
|
|
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
|
default='launcher', help='Component to start')
|
|
@click.option('--no-browser', is_flag=True, help="Don't open browser automatically")
|
|
@click.option('--background', is_flag=True, help="Run in background mode (daemon)")
|
|
def start(component, no_browser, background):
|
|
"""Start ETF Portal components."""
|
|
if component == 'all':
|
|
# Start launcher first, it will manage the other components
|
|
process = start_component('launcher', not no_browser, background)
|
|
if not process:
|
|
return
|
|
else:
|
|
process = start_component(component, not no_browser, background)
|
|
if not process:
|
|
return
|
|
|
|
click.echo(f"Started {component}" + (" in background mode" if background else ""))
|
|
|
|
# In background mode, we just return immediately
|
|
if background:
|
|
return
|
|
|
|
# In foreground mode, wait for the process
|
|
click.echo("Press Ctrl+C to exit")
|
|
|
|
# Keep running until interrupted
|
|
try:
|
|
if component == 'all' or component == 'launcher':
|
|
process.wait()
|
|
else:
|
|
# For individual components, we'll just exit
|
|
return
|
|
except KeyboardInterrupt:
|
|
click.echo("\nShutting down...")
|
|
if component == 'all':
|
|
stop_component('launcher')
|
|
else:
|
|
stop_component(component)
|
|
|
|
@cli.command()
|
|
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
|
default='all', help='Component to stop')
|
|
@click.option('--pid', type=int, help='Process ID to stop')
|
|
def stop(component, pid):
|
|
"""Stop ETF Portal components."""
|
|
if pid:
|
|
stop_component(pid=pid)
|
|
elif component == 'all':
|
|
cleanup_streamlit_processes()
|
|
click.echo("Stopped all ETF Portal components")
|
|
logger.info("Stopped all ETF Portal components")
|
|
else:
|
|
stop_component(component)
|
|
|
|
@cli.command()
|
|
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
|
default='all', help='Component to restart')
|
|
@click.option('--no-browser', is_flag=True, help="Don't open browser automatically")
|
|
@click.option('--background', is_flag=True, help="Run in background mode (daemon)")
|
|
def restart(component, no_browser, background):
|
|
"""Restart ETF Portal components (stop and then start)."""
|
|
# First stop the components
|
|
if component == 'all':
|
|
cleanup_streamlit_processes()
|
|
click.echo("Stopped all ETF Portal components")
|
|
logger.info("Stopped all ETF Portal components")
|
|
else:
|
|
stop_component(component)
|
|
|
|
# Give processes time to fully shut down
|
|
time.sleep(2)
|
|
|
|
# Then start them again
|
|
if component == 'all':
|
|
# Start launcher first, it will manage the other components
|
|
process = start_component('launcher', not no_browser, background)
|
|
if not process:
|
|
return
|
|
else:
|
|
process = start_component(component, not no_browser, background)
|
|
if not process:
|
|
return
|
|
|
|
click.echo(f"Restarted {component}" + (" in background mode" if background else ""))
|
|
|
|
# In background mode, we just return immediately
|
|
if background:
|
|
return
|
|
|
|
# In foreground mode, wait for the process
|
|
click.echo("Press Ctrl+C to exit")
|
|
|
|
# Keep running until interrupted
|
|
try:
|
|
if component == 'all' or component == 'launcher':
|
|
process.wait()
|
|
else:
|
|
# For individual components, we'll just exit
|
|
return
|
|
except KeyboardInterrupt:
|
|
click.echo("\nShutting down...")
|
|
if component == 'all':
|
|
stop_component('launcher')
|
|
else:
|
|
stop_component(component)
|
|
|
|
@cli.command()
|
|
def status():
|
|
"""Check status of ETF Portal components."""
|
|
processes = get_streamlit_processes()
|
|
|
|
if not processes:
|
|
click.echo("No ETF Portal processes are currently running.")
|
|
return
|
|
|
|
click.echo("Running ETF Portal processes:")
|
|
for i, proc in enumerate(processes):
|
|
component = proc['component'] or 'unknown'
|
|
port = proc['port'] or 'unknown'
|
|
click.echo(f"{i+1}. {component.upper()} (PID: {proc['pid']}, Port: {port})")
|
|
|
|
@cli.command()
|
|
@click.option('--key', required=True, help='Configuration key to update (e.g., ports.launcher)')
|
|
@click.option('--value', required=True, help='New value')
|
|
def config(key, value):
|
|
"""View or update configuration."""
|
|
try:
|
|
# Convert value to integer if possible
|
|
try:
|
|
value = int(value)
|
|
except ValueError:
|
|
pass
|
|
|
|
update_config(key, value)
|
|
click.echo(f"Updated {key} to {value}")
|
|
logger.info(f"Updated configuration: {key}={value}")
|
|
except Exception as e:
|
|
error_msg = f"Error updating configuration: {e}"
|
|
logger.error(error_msg)
|
|
click.echo(error_msg)
|
|
|
|
@cli.command()
|
|
def logs():
|
|
"""Show recent logs from ETF Portal components."""
|
|
LOGS_DIR.mkdir(exist_ok=True)
|
|
|
|
log_files = sorted(LOGS_DIR.glob("*.log"), key=os.path.getmtime, reverse=True)
|
|
|
|
if not log_files:
|
|
click.echo("No log files found.")
|
|
return
|
|
|
|
click.echo("Recent log files:")
|
|
for i, log_file in enumerate(log_files[:5]): # Show 5 most recent logs
|
|
size = os.path.getsize(log_file) / 1024 # Size in KB
|
|
modified = datetime.fromtimestamp(os.path.getmtime(log_file)).strftime('%Y-%m-%d %H:%M:%S')
|
|
click.echo(f"{i+1}. {log_file.name} ({size:.1f} KB, last modified: {modified})")
|
|
|
|
# Show most recent log contents
|
|
if log_files:
|
|
most_recent = log_files[0]
|
|
click.echo(f"\nMost recent log ({most_recent.name}):")
|
|
try:
|
|
# Show last 20 lines of the most recent log
|
|
with open(most_recent, 'r') as f:
|
|
lines = f.readlines()
|
|
for line in lines[-20:]:
|
|
click.echo(line.strip())
|
|
except Exception as e:
|
|
click.echo(f"Error reading log file: {e}")
|
|
|
|
def main():
|
|
"""Main entry point for the CLI."""
|
|
cli() |