refactor: improve CLI and package structure - Move CLI code to ETF_Portal package - Add proper package setup with setup.py - Update README with installation and usage instructions - Improve process management and cleanup
This commit is contained in:
parent
fd623ac6b9
commit
4fc9452c98
7
ETF_Portal/__init__.py
Normal file
7
ETF_Portal/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
ETF Portal
|
||||||
|
|
||||||
|
A comprehensive tool for ETF portfolio management and analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
547
ETF_Portal/cli.py
Normal file
547
ETF_Portal/cli.py
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
#!/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()
|
||||||
137
README.md
137
README.md
@ -1,6 +1,82 @@
|
|||||||
# ETF Dividend Portfolio Builder
|
# ETF Portal
|
||||||
|
|
||||||
A comprehensive tool for discovering, analyzing, and building dividend-focused ETF portfolios using real-time market data.
|
A comprehensive tool for ETF portfolio management and analysis.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd ETF_Portal
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create and activate a virtual environment:
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # On Linux/Mac
|
||||||
|
# or
|
||||||
|
.\venv\Scripts\activate # On Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install the package in development mode:
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The ETF Portal provides a command-line interface for managing the application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the launcher
|
||||||
|
etf-portal start
|
||||||
|
|
||||||
|
# Start a specific component
|
||||||
|
etf-portal start --component portfolio_builder
|
||||||
|
|
||||||
|
# Start in background mode
|
||||||
|
etf-portal start --component launcher --background
|
||||||
|
|
||||||
|
# Stop all components
|
||||||
|
etf-portal stop
|
||||||
|
|
||||||
|
# Stop a specific component
|
||||||
|
etf-portal stop --component analyzer
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
etf-portal status
|
||||||
|
|
||||||
|
# Restart components
|
||||||
|
etf-portal restart
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
etf-portal logs
|
||||||
|
|
||||||
|
# Update configuration
|
||||||
|
etf-portal config --key ports.launcher --value 8500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **Launcher**: The main entry point for the ETF Portal (port 8500)
|
||||||
|
- **Portfolio Builder**: Tool for building and managing ETF portfolios (port 8501)
|
||||||
|
- **Analyzer**: Tool for analyzing ETF portfolios (port 8502)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The configuration file is located at `config/etf_suite_config.json`. You can modify it directly or use the `config` command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
etf-portal config --key ports.launcher --value 8500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Logs are stored in the `logs` directory. You can view recent logs using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
etf-portal logs
|
||||||
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -47,49 +123,40 @@ The ETF Suite CLI provides a convenient way to manage the different components o
|
|||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```
|
```bash
|
||||||
|
# Install the package
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
sudo mkdir -p /var/run/etf-portal
|
||||||
|
sudo mkdir -p /var/log/etf-portal
|
||||||
|
sudo chown -R $USER:$USER /var/run/etf-portal
|
||||||
|
sudo chown -R $USER:$USER /var/log/etf-portal
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the ETF Portal application
|
||||||
|
etf-portal start
|
||||||
|
|
||||||
|
# Stop the ETF Portal application
|
||||||
|
etf-portal stop
|
||||||
|
|
||||||
|
# Restart the ETF Portal application
|
||||||
|
etf-portal restart
|
||||||
|
|
||||||
|
# Check the status of the ETF Portal application
|
||||||
|
etf-portal status
|
||||||
```
|
```
|
||||||
# Start the entire ETF Suite (Launcher, Portfolio Builder, Analyzer)
|
|
||||||
etf-suite start --component all
|
|
||||||
|
|
||||||
# Start just the ETF Analyzer
|
## Logs
|
||||||
etf-suite start --component analyzer
|
|
||||||
|
|
||||||
# Start without opening browser automatically
|
Logs are stored in `/var/log/etf-portal/cli_manager.log`
|
||||||
etf-suite start --component launcher --no-browser
|
|
||||||
|
|
||||||
# Start in background mode (daemon)
|
## PID File
|
||||||
etf-suite start --component all --background
|
|
||||||
|
|
||||||
# Stop all running ETF Suite components
|
The PID file is stored in `/var/run/etf-portal/etf_portal.pid`
|
||||||
etf-suite stop
|
|
||||||
|
|
||||||
# Stop a specific component
|
|
||||||
etf-suite stop --component portfolio_builder
|
|
||||||
|
|
||||||
# Restart all components
|
|
||||||
etf-suite restart
|
|
||||||
|
|
||||||
# Restart a specific component
|
|
||||||
etf-suite restart --component analyzer
|
|
||||||
|
|
||||||
# Restart in background mode
|
|
||||||
etf-suite restart --background
|
|
||||||
|
|
||||||
# Check status of running components
|
|
||||||
etf-suite status
|
|
||||||
|
|
||||||
# View recent logs
|
|
||||||
etf-suite logs
|
|
||||||
|
|
||||||
# Update configuration
|
|
||||||
etf-suite config --key ports.launcher --value 8600
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
265
cli_manager.py
Executable file
265
cli_manager.py
Executable file
@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import logging
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
class CLIManager:
|
||||||
|
"""CLI Manager for ETF Portal application.
|
||||||
|
|
||||||
|
Provides commands to:
|
||||||
|
- start: Launch the application
|
||||||
|
- stop: Gracefully stop the application
|
||||||
|
- restart: Stop and start the application
|
||||||
|
- status: Check if the application is running
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.app_name = "ETF_Portal"
|
||||||
|
self.pid_file = Path("/var/run/etf-portal/etf_portal.pid")
|
||||||
|
self.log_file = Path("/var/log/etf-portal/cli_manager.log")
|
||||||
|
self._setup_logging()
|
||||||
|
|
||||||
|
def _setup_logging(self):
|
||||||
|
"""Configure logging for the CLI manager."""
|
||||||
|
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(self.log_file),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.logger = logging.getLogger("CLIManager")
|
||||||
|
|
||||||
|
def _get_pid(self) -> Optional[int]:
|
||||||
|
"""Get the PID from the PID file if it exists."""
|
||||||
|
try:
|
||||||
|
if self.pid_file.exists():
|
||||||
|
with open(self.pid_file, 'r') as f:
|
||||||
|
pid = int(f.read().strip())
|
||||||
|
return pid
|
||||||
|
except (ValueError, IOError) as e:
|
||||||
|
self.logger.error(f"Error reading PID file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save_pid(self, pid: int):
|
||||||
|
"""Save the PID to the PID file."""
|
||||||
|
try:
|
||||||
|
self.pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self.pid_file, 'w') as f:
|
||||||
|
f.write(str(pid))
|
||||||
|
except IOError as e:
|
||||||
|
self.logger.error(f"Error saving PID file: {e}")
|
||||||
|
|
||||||
|
def _is_process_running(self, pid: int) -> bool:
|
||||||
|
"""Check if a process with the given PID is running."""
|
||||||
|
try:
|
||||||
|
process = psutil.Process(pid)
|
||||||
|
# Check if it's a Python process running Streamlit
|
||||||
|
is_running = process.is_running()
|
||||||
|
is_streamlit = any('streamlit' in cmd.lower() for cmd in process.cmdline())
|
||||||
|
is_root = process.uids().real == 0 # Check if process is running as root
|
||||||
|
|
||||||
|
if is_running and is_streamlit:
|
||||||
|
if is_root:
|
||||||
|
self.logger.warning(f"Process {pid} is running as root")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
return False
|
||||||
|
except psutil.AccessDenied:
|
||||||
|
self.logger.warning(f"Access denied to process {pid}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _find_streamlit_process(self) -> Optional[int]:
|
||||||
|
"""Find the main Streamlit process."""
|
||||||
|
for proc in psutil.process_iter(['pid', 'cmdline', 'uids']):
|
||||||
|
try:
|
||||||
|
if any('streamlit' in cmd.lower() for cmd in proc.info['cmdline']):
|
||||||
|
if proc.info['uids'].real == 0:
|
||||||
|
self.logger.warning(f"Found Streamlit process {proc.info['pid']} running as root")
|
||||||
|
return proc.info['pid']
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the ETF Portal application."""
|
||||||
|
# Check if Streamlit is already running
|
||||||
|
existing_pid = self._find_streamlit_process()
|
||||||
|
if existing_pid:
|
||||||
|
self.logger.info(f"{self.app_name} is already running with PID {existing_pid}")
|
||||||
|
self._save_pid(existing_pid)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the full path to streamlit in the virtual environment
|
||||||
|
venv_streamlit = os.path.join(os.path.dirname(sys.executable), "streamlit")
|
||||||
|
if not os.path.exists(venv_streamlit):
|
||||||
|
raise Exception(f"Streamlit not found at {venv_streamlit}")
|
||||||
|
|
||||||
|
# Ensure we're using the virtual environment's Python
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PATH"] = os.path.dirname(sys.executable) + ":" + env["PATH"]
|
||||||
|
|
||||||
|
# Start the application using streamlit
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[venv_streamlit, "run", "ETF_Suite_Launcher.py", "--server.port=8500"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True,
|
||||||
|
env=env,
|
||||||
|
preexec_fn=os.setsid # Create a new session
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a moment for Streamlit to start
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Find the actual Streamlit process
|
||||||
|
streamlit_pid = self._find_streamlit_process()
|
||||||
|
if streamlit_pid:
|
||||||
|
# Verify the process is running with correct permissions
|
||||||
|
try:
|
||||||
|
proc = psutil.Process(streamlit_pid)
|
||||||
|
if proc.uids().real == 0:
|
||||||
|
self.logger.error("Process started as root. Stopping...")
|
||||||
|
self.stop()
|
||||||
|
raise Exception("Process started with incorrect permissions")
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._save_pid(streamlit_pid)
|
||||||
|
self.logger.info(f"{self.app_name} started with PID {streamlit_pid}")
|
||||||
|
else:
|
||||||
|
raise Exception("Streamlit process not found after startup")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to start {self.app_name}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the ETF Portal application."""
|
||||||
|
# First check the PID file
|
||||||
|
pid = self._get_pid()
|
||||||
|
if not pid:
|
||||||
|
# If no PID file, try to find running Streamlit process
|
||||||
|
pid = self._find_streamlit_process()
|
||||||
|
if not pid:
|
||||||
|
self.logger.info(f"{self.app_name} is not running")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._is_process_running(pid):
|
||||||
|
# Get the process using psutil
|
||||||
|
process = psutil.Process(pid)
|
||||||
|
|
||||||
|
# Check if process is running as root
|
||||||
|
if process.uids().real == 0:
|
||||||
|
self.logger.error(f"Cannot stop process {pid} - it is running as root")
|
||||||
|
self.logger.info("Please use sudo to stop the process:")
|
||||||
|
self.logger.info(f"sudo kill {pid}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try to terminate the process and its children
|
||||||
|
try:
|
||||||
|
# Get all child processes
|
||||||
|
children = process.children(recursive=True)
|
||||||
|
|
||||||
|
# Terminate children first
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
child.terminate()
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Terminate the main process
|
||||||
|
process.terminate()
|
||||||
|
|
||||||
|
# Wait for processes to terminate
|
||||||
|
gone, alive = psutil.wait_procs([process] + children, timeout=10)
|
||||||
|
|
||||||
|
# If any processes are still alive, force kill them
|
||||||
|
for p in alive:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.logger.info(f"{self.app_name} stopped")
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
self.logger.info(f"{self.app_name} is not running")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"{self.app_name} is not running")
|
||||||
|
|
||||||
|
# Remove PID file
|
||||||
|
if self.pid_file.exists():
|
||||||
|
self.pid_file.unlink()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error stopping {self.app_name}: {e}")
|
||||||
|
# Don't exit with error, just log it
|
||||||
|
self.logger.info("Attempting to clean up PID file...")
|
||||||
|
if self.pid_file.exists():
|
||||||
|
self.pid_file.unlink()
|
||||||
|
|
||||||
|
def restart(self):
|
||||||
|
"""Restart the ETF Portal application."""
|
||||||
|
self.logger.info("Restarting ETF Portal...")
|
||||||
|
self.stop()
|
||||||
|
time.sleep(2) # Wait for processes to fully stop
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
"""Check the status of the ETF Portal application."""
|
||||||
|
# First check the PID file
|
||||||
|
pid = self._get_pid()
|
||||||
|
if not pid:
|
||||||
|
# If no PID file, try to find running Streamlit process
|
||||||
|
pid = self._find_streamlit_process()
|
||||||
|
if not pid:
|
||||||
|
print(f"{self.app_name} is not running")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._is_process_running(pid):
|
||||||
|
print(f"{self.app_name} is running with PID {pid}")
|
||||||
|
else:
|
||||||
|
print(f"{self.app_name} is not running (stale PID file)")
|
||||||
|
if self.pid_file.exists():
|
||||||
|
self.pid_file.unlink()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the CLI."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="ETF Portal CLI Manager",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
etf-portal start # Start the ETF Portal application
|
||||||
|
etf-portal stop # Stop the ETF Portal application
|
||||||
|
etf-portal restart # Restart the ETF Portal application
|
||||||
|
etf-portal status # Check the status of the ETF Portal application
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"command",
|
||||||
|
choices=["start", "stop", "restart", "status"],
|
||||||
|
help="Command to execute"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
manager = CLIManager()
|
||||||
|
|
||||||
|
# Execute the requested command
|
||||||
|
getattr(manager, args.command)()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
8
config/etf_suite_config.json
Normal file
8
config/etf_suite_config.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ports": {
|
||||||
|
"launcher": 8500,
|
||||||
|
"portfolio_builder": 8501,
|
||||||
|
"analyzer": 8502
|
||||||
|
},
|
||||||
|
"streamlit_path": "/home/pascal/Dev/ETF_Portal/venv/bin/streamlit"
|
||||||
|
}
|
||||||
369
etf_suite_cli.py
Normal file → Executable file
369
etf_suite_cli.py
Normal file → Executable file
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ETF Suite CLI
|
ETF Suite CLI
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ from datetime import datetime
|
|||||||
# Path configurations
|
# Path configurations
|
||||||
WORKSPACE_PATH = Path(__file__).parent
|
WORKSPACE_PATH = Path(__file__).parent
|
||||||
LAUNCHER_PATH = WORKSPACE_PATH / "ETF_Suite_Launcher.py"
|
LAUNCHER_PATH = WORKSPACE_PATH / "ETF_Suite_Launcher.py"
|
||||||
PORTFOLIO_BUILDER_PATH = WORKSPACE_PATH / "pages" / "ETF_Dividend_Portfolio_Builder.py"
|
PORTFOLIO_BUILDER_PATH = WORKSPACE_PATH / "pages" / "ETF_Portfolio_Builder.py"
|
||||||
ANALYZER_PATH = WORKSPACE_PATH / "pages" / "ETF_Analyzer.py"
|
ANALYZER_PATH = WORKSPACE_PATH / "pages" / "ETF_Analyzer.py"
|
||||||
CONFIG_DIR = WORKSPACE_PATH / "config"
|
CONFIG_DIR = WORKSPACE_PATH / "config"
|
||||||
CONFIG_FILE = CONFIG_DIR / "etf_suite_config.json"
|
CONFIG_FILE = CONFIG_DIR / "etf_suite_config.json"
|
||||||
@ -38,7 +38,7 @@ DEFAULT_PORTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Full path to streamlit executable - may need to be adjusted
|
# Full path to streamlit executable - may need to be adjusted
|
||||||
STREAMLIT_PATH = "/home/pascal/.local/bin/streamlit"
|
STREAMLIT_PATH = "/home/pascal/Dev/ETF_Portal/venv/bin/streamlit"
|
||||||
|
|
||||||
# Process tracking
|
# Process tracking
|
||||||
active_processes = {}
|
active_processes = {}
|
||||||
@ -72,12 +72,14 @@ def ensure_config_exists():
|
|||||||
"streamlit_path": STREAMLIT_PATH
|
"streamlit_path": STREAMLIT_PATH
|
||||||
}, f, indent=2)
|
}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def get_config():
|
def get_config():
|
||||||
"""Get the configuration from the config file."""
|
"""Get the configuration from the config file."""
|
||||||
ensure_config_exists()
|
ensure_config_exists()
|
||||||
with open(CONFIG_FILE, 'r') as f:
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def update_config(key, value):
|
def update_config(key, value):
|
||||||
"""Update a specific configuration value."""
|
"""Update a specific configuration value."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@ -94,6 +96,7 @@ def update_config(key, value):
|
|||||||
with open(CONFIG_FILE, 'w') as f:
|
with open(CONFIG_FILE, 'w') as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_streamlit_processes():
|
def cleanup_streamlit_processes():
|
||||||
"""Kill any existing Streamlit processes to prevent conflicts."""
|
"""Kill any existing Streamlit processes to prevent conflicts."""
|
||||||
click.echo("Cleaning up existing Streamlit processes...")
|
click.echo("Cleaning up existing Streamlit processes...")
|
||||||
@ -101,22 +104,56 @@ def cleanup_streamlit_processes():
|
|||||||
try:
|
try:
|
||||||
config = get_config()
|
config = get_config()
|
||||||
ports = config["ports"]
|
ports = config["ports"]
|
||||||
|
processed_pids = set() # Track PIDs we've already handled
|
||||||
|
|
||||||
# Find processes using our target ports
|
# First, find and kill processes using our target ports
|
||||||
for port in ports.values():
|
for port in ports.values():
|
||||||
cmd = f"lsof -i :{port} | grep LISTEN | awk '{{print $2}}' | xargs kill -9 2>/dev/null || true"
|
try:
|
||||||
subprocess.run(cmd, shell=True)
|
# 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}")
|
||||||
|
|
||||||
# Find and kill any lingering Streamlit processes
|
# Then find and kill any remaining Streamlit processes
|
||||||
cmd = "pkill -f streamlit || true"
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
||||||
subprocess.run(cmd, shell=True)
|
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
|
# Give processes time to shut down
|
||||||
time.sleep(1)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error during cleanup: {e}")
|
logger.error(f"Error during cleanup: {e}")
|
||||||
click.echo(f"Warning during cleanup: {e}")
|
click.echo(f"Warning during cleanup: {e}")
|
||||||
|
|
||||||
|
|
||||||
def port_is_available(port):
|
def port_is_available(port):
|
||||||
"""Check if a port is available."""
|
"""Check if a port is available."""
|
||||||
try:
|
try:
|
||||||
@ -126,12 +163,14 @@ def port_is_available(port):
|
|||||||
except socket.error:
|
except socket.error:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def open_browser(url, delay=3):
|
def open_browser(url, delay=3):
|
||||||
"""Open browser after a delay to ensure app is running."""
|
"""Open browser after a delay to ensure app is running."""
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
click.echo(f"Opening browser to {url}")
|
click.echo(f"Opening browser to {url}")
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
def start_component(component: str, open_browser_tab=True, background=False) -> Optional[subprocess.Popen]:
|
def start_component(component: str, open_browser_tab=True, background=False) -> Optional[subprocess.Popen]:
|
||||||
"""Start a specific component of the ETF Suite."""
|
"""Start a specific component of the ETF Suite."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@ -192,95 +231,146 @@ def start_component(component: str, open_browser_tab=True, background=False) ->
|
|||||||
streamlit_path, "run",
|
streamlit_path, "run",
|
||||||
str(component_config["path"]),
|
str(component_config["path"]),
|
||||||
"--server.port", str(port),
|
"--server.port", str(port),
|
||||||
"--server.address", "0.0.0.0",
|
"--server.fileWatcherType", "none" # Disable file watcher to prevent inotify issues
|
||||||
"--server.headless", "true",
|
|
||||||
"--browser.gatherUsageStats", "false"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add component-specific options
|
# Launch the component
|
||||||
if component == "portfolio_builder":
|
|
||||||
cmd.extend(["--server.baseUrlPath", "/portfolio"])
|
|
||||||
elif component == "analyzer":
|
|
||||||
cmd.extend(["--server.baseUrlPath", "/analyzer"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
if background:
|
if background:
|
||||||
with open(log_file, 'w') as f:
|
# In background mode, redirect output to log file
|
||||||
|
with open(log_file, 'w') as log:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=f,
|
stdout=log,
|
||||||
stderr=f,
|
stderr=log,
|
||||||
|
# Make the process independent of the parent
|
||||||
start_new_session=True
|
start_new_session=True
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
process = subprocess.Popen(
|
# In foreground mode, just run normally
|
||||||
cmd,
|
process = subprocess.Popen(cmd)
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
# Store process for tracking
|
||||||
universal_newlines=True
|
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()
|
||||||
|
|
||||||
active_processes[component] = process
|
# 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)
|
||||||
|
|
||||||
if open_browser_tab and not background:
|
# Check if the process is still running
|
||||||
url = f"http://localhost:{port}"
|
if process.poll() is not None:
|
||||||
threading.Thread(target=open_browser, args=(url,)).start()
|
error_msg = f"Error: {component} failed to start in background mode"
|
||||||
|
|
||||||
return process
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error starting {component}: {e}"
|
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
click.echo(error_msg)
|
click.echo(error_msg)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
|
||||||
def get_streamlit_processes() -> List[Dict]:
|
def get_streamlit_processes() -> List[Dict]:
|
||||||
"""Get information about running Streamlit processes."""
|
"""Get a list of running Streamlit processes."""
|
||||||
processes = []
|
processes = []
|
||||||
|
|
||||||
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
||||||
try:
|
try:
|
||||||
if 'streamlit' in ' '.join(proc.info['cmdline'] or []):
|
cmdline = proc.info['cmdline']
|
||||||
|
if cmdline and 'streamlit' in ' '.join(cmdline):
|
||||||
|
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]
|
||||||
|
|
||||||
|
# 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'
|
||||||
|
|
||||||
processes.append({
|
processes.append({
|
||||||
'pid': proc.info['pid'],
|
'pid': proc.info['pid'],
|
||||||
'name': proc.info['name'],
|
'port': port,
|
||||||
'cmdline': ' '.join(proc.info['cmdline'] or [])
|
'component': component,
|
||||||
|
'cmdline': ' '.join(cmdline if cmdline else [])
|
||||||
})
|
})
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return processes
|
return processes
|
||||||
|
|
||||||
|
|
||||||
def stop_component(component=None, pid=None):
|
def stop_component(component=None, pid=None):
|
||||||
"""Stop a specific component or process."""
|
"""Stop a specific component or Streamlit process."""
|
||||||
if pid:
|
if pid:
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
process = psutil.Process(pid)
|
||||||
click.echo(f"Stopped process {pid}")
|
|
||||||
return
|
|
||||||
except ProcessLookupError:
|
|
||||||
click.echo(f"Process {pid} not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
if component == "all":
|
|
||||||
cleanup_streamlit_processes()
|
|
||||||
return
|
|
||||||
|
|
||||||
if component in active_processes:
|
|
||||||
process = active_processes[component]
|
|
||||||
try:
|
|
||||||
process.terminate()
|
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)
|
process.wait(timeout=5)
|
||||||
click.echo(f"Stopped {component}")
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.kill()
|
process.kill()
|
||||||
click.echo(f"Force killed {component}")
|
click.echo(f"Stopped {component}")
|
||||||
|
logger.info(f"Stopped {component}")
|
||||||
del active_processes[component]
|
del active_processes[component]
|
||||||
else:
|
return True
|
||||||
click.echo(f"No active process found for {component}")
|
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()
|
@click.group()
|
||||||
def cli():
|
def cli():
|
||||||
"""ETF Suite CLI - Manage your ETF Suite applications."""
|
"""ETF Suite CLI - Manage the ETF Suite application."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
||||||
default='launcher', help='Component to start')
|
default='launcher', help='Component to start')
|
||||||
@ -288,11 +378,39 @@ def cli():
|
|||||||
@click.option('--background', is_flag=True, help="Run in background mode (daemon)")
|
@click.option('--background', is_flag=True, help="Run in background mode (daemon)")
|
||||||
def start(component, no_browser, background):
|
def start(component, no_browser, background):
|
||||||
"""Start ETF Suite components."""
|
"""Start ETF Suite components."""
|
||||||
if component == "all":
|
if component == 'all':
|
||||||
for comp in ['launcher', 'portfolio_builder', 'analyzer']:
|
# Start launcher first, it will manage the other components
|
||||||
start_component(comp, not no_browser, background)
|
process = start_component('launcher', not no_browser, background)
|
||||||
|
if not process:
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
start_component(component, not no_browser, background)
|
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()
|
@cli.command()
|
||||||
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
||||||
@ -300,7 +418,15 @@ def start(component, no_browser, background):
|
|||||||
@click.option('--pid', type=int, help='Process ID to stop')
|
@click.option('--pid', type=int, help='Process ID to stop')
|
||||||
def stop(component, pid):
|
def stop(component, pid):
|
||||||
"""Stop ETF Suite components."""
|
"""Stop ETF Suite components."""
|
||||||
stop_component(component, pid)
|
if pid:
|
||||||
|
stop_component(pid=pid)
|
||||||
|
elif component == 'all':
|
||||||
|
cleanup_streamlit_processes()
|
||||||
|
click.echo("Stopped all ETF Suite components")
|
||||||
|
logger.info("Stopped all ETF Suite components")
|
||||||
|
else:
|
||||||
|
stop_component(component)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
@click.option('--component', type=click.Choice(['launcher', 'portfolio_builder', 'analyzer', 'all']),
|
||||||
@ -308,67 +434,120 @@ def stop(component, pid):
|
|||||||
@click.option('--no-browser', is_flag=True, help="Don't open browser automatically")
|
@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)")
|
@click.option('--background', is_flag=True, help="Run in background mode (daemon)")
|
||||||
def restart(component, no_browser, background):
|
def restart(component, no_browser, background):
|
||||||
"""Restart ETF Suite components."""
|
"""Restart ETF Suite components (stop and then start)."""
|
||||||
if component == "all":
|
# First stop the components
|
||||||
stop_component("all")
|
if component == 'all':
|
||||||
time.sleep(2)
|
cleanup_streamlit_processes()
|
||||||
for comp in ['launcher', 'portfolio_builder', 'analyzer']:
|
click.echo("Stopped all ETF Suite components")
|
||||||
start_component(comp, not no_browser, background)
|
logger.info("Stopped all ETF Suite components")
|
||||||
else:
|
else:
|
||||||
stop_component(component)
|
stop_component(component)
|
||||||
|
|
||||||
|
# Give processes time to fully shut down
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
start_component(component, not no_browser, background)
|
|
||||||
|
# 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()
|
@cli.command()
|
||||||
def status():
|
def status():
|
||||||
"""Show status of ETF Suite components."""
|
"""Check status of ETF Suite components."""
|
||||||
processes = get_streamlit_processes()
|
processes = get_streamlit_processes()
|
||||||
|
|
||||||
if not processes:
|
if not processes:
|
||||||
click.echo("No ETF Suite components are running")
|
click.echo("No ETF Suite processes are currently running.")
|
||||||
return
|
return
|
||||||
|
|
||||||
click.echo("Running components:")
|
click.echo("Running ETF Suite processes:")
|
||||||
for proc in processes:
|
for i, proc in enumerate(processes):
|
||||||
click.echo(f"PID: {proc['pid']}")
|
component = proc['component'] or 'unknown'
|
||||||
click.echo(f"Command: {proc['cmdline']}")
|
port = proc['port'] or 'unknown'
|
||||||
click.echo("---")
|
click.echo(f"{i+1}. {component.upper()} (PID: {proc['pid']}, Port: {port})")
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option('--key', required=True, help='Configuration key to update (e.g., ports.launcher)')
|
@click.option('--key', required=True, help='Configuration key to update (e.g., ports.launcher)')
|
||||||
@click.option('--value', required=True, help='New value')
|
@click.option('--value', required=True, help='New value')
|
||||||
def config(key, value):
|
def config(key, value):
|
||||||
"""Update ETF Suite configuration."""
|
"""View or update configuration."""
|
||||||
|
try:
|
||||||
|
# Convert value to integer if possible
|
||||||
try:
|
try:
|
||||||
# Convert value to appropriate type
|
|
||||||
if value.isdigit():
|
|
||||||
value = int(value)
|
value = int(value)
|
||||||
elif value.lower() in ('true', 'false'):
|
except ValueError:
|
||||||
value = value.lower() == 'true'
|
pass
|
||||||
|
|
||||||
update_config(key, value)
|
update_config(key, value)
|
||||||
click.echo(f"Updated {key} to {value}")
|
click.echo(f"Updated {key} to {value}")
|
||||||
|
logger.info(f"Updated configuration: {key}={value}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"Error updating config: {e}")
|
error_msg = f"Error updating configuration: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
click.echo(error_msg)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
def logs():
|
def logs():
|
||||||
"""Show recent logs."""
|
"""Show recent logs from ETF Suite components."""
|
||||||
try:
|
LOGS_DIR.mkdir(exist_ok=True)
|
||||||
log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda x: x.stat().st_mtime, reverse=True)
|
|
||||||
|
log_files = sorted(LOGS_DIR.glob("*.log"), key=os.path.getmtime, reverse=True)
|
||||||
|
|
||||||
if not log_files:
|
if not log_files:
|
||||||
click.echo("No log files found")
|
click.echo("No log files found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
latest_log = log_files[0]
|
click.echo("Recent log files:")
|
||||||
click.echo(f"Showing last 20 lines of {latest_log.name}:")
|
for i, log_file in enumerate(log_files[:5]): # Show 5 most recent logs
|
||||||
click.echo("---")
|
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})")
|
||||||
|
|
||||||
with open(latest_log) as f:
|
# 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()
|
lines = f.readlines()
|
||||||
for line in lines[-20:]:
|
for line in lines[-20:]:
|
||||||
click.echo(line.strip())
|
click.echo(line.strip())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"Error reading logs: {e}")
|
click.echo(f"Error reading log file: {e}")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
235
pages/cache_manager.py
Normal file
235
pages/cache_manager.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
import hashlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
log_dir = Path("logs")
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
log_file = log_dir / f"cache_manager_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||||
|
|
||||||
|
# Remove any existing handlers to avoid duplicate logs
|
||||||
|
for handler in logging.root.handlers[:]:
|
||||||
|
logging.root.removeHandler(handler)
|
||||||
|
|
||||||
|
# Create a formatter
|
||||||
|
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
# Create file handler
|
||||||
|
file_handler = logging.FileHandler(log_file, mode='a')
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Create console handler
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
class CacheManager:
|
||||||
|
"""
|
||||||
|
Manages caching of ETF data to reduce API calls and improve performance.
|
||||||
|
Implements a time-based cache expiration system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cache_dir: str = "cache", cache_duration_hours: int = 24):
|
||||||
|
"""
|
||||||
|
Initialize the cache manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_dir: Directory to store cache files
|
||||||
|
cache_duration_hours: Number of hours before cache expires
|
||||||
|
"""
|
||||||
|
self.cache_dir = Path(cache_dir)
|
||||||
|
self.cache_duration = timedelta(hours=cache_duration_hours)
|
||||||
|
|
||||||
|
# Create cache directory if it doesn't exist
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Create formatter
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create file handler
|
||||||
|
log_file = Path("logs") / f"cache_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||||
|
log_file.parent.mkdir(exist_ok=True)
|
||||||
|
file_handler = logging.FileHandler(log_file)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
self.logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
self.logger.info(f"CacheManager initialized with directory: {self.cache_dir}")
|
||||||
|
self.logger.info(f"Cache duration: {cache_duration_hours} hours")
|
||||||
|
|
||||||
|
def _generate_cache_key(self, source: str, ticker: str, data_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unique cache key for the data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Data source (e.g., 'fmp', 'yf')
|
||||||
|
ticker: ETF ticker symbol
|
||||||
|
data_type: Type of data (e.g., 'profile', 'history')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cache key string
|
||||||
|
"""
|
||||||
|
return f"{source}_{ticker}_{data_type}.json"
|
||||||
|
|
||||||
|
def _get_cache_path(self, cache_key: str) -> Path:
|
||||||
|
"""
|
||||||
|
Get the full path for a cache file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_key: Cache key string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path object for the cache file
|
||||||
|
"""
|
||||||
|
return self.cache_dir / cache_key
|
||||||
|
|
||||||
|
def _is_cache_valid(self, cache_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a cache file is still valid based on its age.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_path: Path to the cache file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cache is valid, False otherwise
|
||||||
|
"""
|
||||||
|
if not cache_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_age = datetime.now() - datetime.fromtimestamp(cache_path.stat().st_mtime)
|
||||||
|
is_valid = file_age < self.cache_duration
|
||||||
|
|
||||||
|
self.logger.debug(f"Cache file {cache_path} age: {file_age}, valid: {is_valid}")
|
||||||
|
return is_valid
|
||||||
|
|
||||||
|
def save_to_cache(self, cache_key: str, data: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Save data to cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_key: Cache key string
|
||||||
|
data: Data to cache
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if save was successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cache_path = self._get_cache_path(cache_key)
|
||||||
|
|
||||||
|
# Create cache directory if it doesn't exist
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save data to JSON file
|
||||||
|
with open(cache_path, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
self.logger.info(f"Data saved to cache: {cache_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error saving to cache: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_from_cache(self, cache_key: str) -> Tuple[Optional[Any], bool]:
|
||||||
|
"""
|
||||||
|
Load data from cache if it exists and is valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_key: Cache key string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (cached data, is_valid)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cache_path = self._get_cache_path(cache_key)
|
||||||
|
|
||||||
|
if not cache_path.exists():
|
||||||
|
self.logger.debug(f"Cache miss: {cache_path}")
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
if not self._is_cache_valid(cache_path):
|
||||||
|
self.logger.info(f"Cache expired: {cache_path}")
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
# Load data from JSON file
|
||||||
|
with open(cache_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
self.logger.info(f"Data loaded from cache: {cache_path}")
|
||||||
|
return data, True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error loading from cache: {str(e)}")
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
def clear_expired_cache(self) -> int:
|
||||||
|
"""
|
||||||
|
Clear all expired cache files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of files cleared
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cleared_count = 0
|
||||||
|
for cache_file in self.cache_dir.glob("*.json"):
|
||||||
|
if not self._is_cache_valid(cache_file):
|
||||||
|
cache_file.unlink()
|
||||||
|
cleared_count += 1
|
||||||
|
self.logger.info(f"Cleared expired cache: {cache_file}")
|
||||||
|
|
||||||
|
self.logger.info(f"Cleared {cleared_count} expired cache files")
|
||||||
|
return cleared_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error clearing expired cache: {str(e)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_cache_stats(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get statistics about the cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cache statistics
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
total_files = 0
|
||||||
|
expired_files = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
for cache_file in self.cache_dir.glob("*.json"):
|
||||||
|
total_files += 1
|
||||||
|
total_size += cache_file.stat().st_size
|
||||||
|
if not self._is_cache_valid(cache_file):
|
||||||
|
expired_files += 1
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_files": total_files,
|
||||||
|
"expired_files": expired_files,
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"cache_dir": str(self.cache_dir),
|
||||||
|
"cache_duration_hours": self.cache_duration.total_seconds() / 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info(f"Cache statistics: {stats}")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error getting cache stats: {str(e)}")
|
||||||
|
return {}
|
||||||
29
portfolios/Testing1.json
Normal file
29
portfolios/Testing1.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "Testing1",
|
||||||
|
"created_at": "2025-05-25T15:29:13.669593",
|
||||||
|
"mode": "Income Target",
|
||||||
|
"target": 1000.0,
|
||||||
|
"allocations": [
|
||||||
|
{
|
||||||
|
"ticker": "MSTY",
|
||||||
|
"allocation": 70.0,
|
||||||
|
"yield": 142.7343086361491,
|
||||||
|
"price": 21.19,
|
||||||
|
"risk_level": "High"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "ULTY",
|
||||||
|
"allocation": 20.0,
|
||||||
|
"yield": 45.937467700258395,
|
||||||
|
"price": 19.35,
|
||||||
|
"risk_level": "Medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticker": "CONY",
|
||||||
|
"allocation": 10.0,
|
||||||
|
"yield": 67.58371269600406,
|
||||||
|
"price": 19.77,
|
||||||
|
"risk_level": "Medium"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
24
setup.py
24
setup.py
@ -2,17 +2,23 @@
|
|||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="etf-suite-cli",
|
name="etf-portal",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
description="Command-line interface for ETF Suite",
|
packages=find_packages(),
|
||||||
author="Pascal",
|
include_package_data=True,
|
||||||
py_modules=["etf_suite_cli"],
|
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"Click",
|
"click",
|
||||||
"psutil",
|
"psutil",
|
||||||
|
"streamlit",
|
||||||
],
|
],
|
||||||
entry_points="""
|
entry_points={
|
||||||
[console_scripts]
|
"console_scripts": [
|
||||||
etf-suite=etf_suite_cli:cli
|
"etf-portal=ETF_Portal.cli:main",
|
||||||
""",
|
],
|
||||||
|
},
|
||||||
|
python_requires=">=3.8",
|
||||||
|
author="Pascal",
|
||||||
|
description="ETF Portal CLI tool",
|
||||||
|
long_description=open("README.md").read() if open("README.md").read() else "",
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
)
|
)
|
||||||
Loading…
Reference in New Issue
Block a user