Files
polymarket-insider-tracker/src/polymarket_insider_tracker/__main__.py
T
Patrick SelamyandClaude Opus 4.5 a18fc3493a feat: add graceful shutdown handler with signal trapping (#56)
Implement graceful shutdown handling for the async pipeline with proper
signal trapping and cleanup coordination.

Features:
- GracefulShutdown class with SIGTERM/SIGINT signal trapping
- Async event-based shutdown coordination
- Configurable shutdown timeout (default: 30 seconds)
- Cleanup callback registration (sync and async)
- Force exit on second signal
- Async context manager support
- Platform-specific signal handling (Unix/Windows)

Integration:
- Updated __main__.py to use GracefulShutdown wrapper
- Pipeline cleanup registered as shutdown callback
- Proper logging of shutdown stages

Tests: 25 new tests covering signal handling, timeouts, cleanup callbacks,
and context manager behavior.

Closes #56

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:54:38 -05:00

303 lines
8.5 KiB
Python

"""CLI entry point for Polymarket Insider Tracker.
This module provides the main entry point for running the tracker
from the command line.
Usage:
python -m polymarket_insider_tracker [options]
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import logging.config
import sys
from typing import NoReturn
from pydantic import ValidationError
from polymarket_insider_tracker import __version__
from polymarket_insider_tracker.config import Settings, clear_settings_cache, get_settings
from polymarket_insider_tracker.pipeline import Pipeline
from polymarket_insider_tracker.shutdown import GracefulShutdown
# Application info
APP_NAME = "Polymarket Insider Tracker"
APP_VERSION = __version__
# Exit codes
EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_CONFIG_ERROR = 2
EXIT_INTERRUPTED = 130
def create_parser() -> argparse.ArgumentParser:
"""Create the argument parser for the CLI.
Returns:
Configured ArgumentParser instance.
"""
parser = argparse.ArgumentParser(
prog="polymarket-insider-tracker",
description="Detect insider trading activity on Polymarket prediction markets.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python -m polymarket_insider_tracker Run full pipeline
python -m polymarket_insider_tracker --config-check Validate config and exit
python -m polymarket_insider_tracker --dry-run Run without sending alerts
python -m polymarket_insider_tracker --log-level DEBUG Enable debug logging
""",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {APP_VERSION}",
)
parser.add_argument(
"--config-check",
action="store_true",
help="Validate configuration and exit without running pipeline",
)
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
default=None,
help="Override logging level (default: from settings)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Run pipeline but don't send alerts",
)
parser.add_argument(
"--health-port",
type=int,
default=None,
help="Override health check port (default: from settings)",
)
return parser
def configure_logging(level: str) -> None:
"""Configure structured logging for the application.
Args:
level: Logging level string (DEBUG, INFO, etc.)
"""
config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"detailed": {
"format": (
"%(asctime)s [%(levelname)s] %(name)s (%(filename)s:%(lineno)d): %(message)s"
),
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": level,
"formatter": "detailed" if level == "DEBUG" else "standard",
"stream": "ext://sys.stdout",
},
},
"root": {
"level": level,
"handlers": ["console"],
},
# Quieter logging for noisy libraries
"loggers": {
"httpx": {"level": "WARNING"},
"httpcore": {"level": "WARNING"},
"websockets": {"level": "WARNING"},
"web3": {"level": "WARNING"},
"urllib3": {"level": "WARNING"},
},
}
logging.config.dictConfig(config)
def print_banner() -> None:
"""Print the application startup banner."""
banner = f"""
╔══════════════════════════════════════════════════════════════╗
║ ║
{APP_NAME:^56}
{"v" + APP_VERSION:^56}
║ ║
╚══════════════════════════════════════════════════════════════╝
"""
print(banner)
def print_config_summary(settings: Settings, dry_run: bool) -> None:
"""Print a summary of the configuration.
Args:
settings: Application settings.
dry_run: Whether dry-run mode is enabled.
"""
summary = settings.redacted_summary()
print("Configuration:")
print(f" Database: {summary['database_url']}")
print(f" Redis: {summary['redis_url']}")
print(f" Log Level: {summary['log_level']}")
print(f" Health Port: {summary['health_port']}")
print(f" Dry Run: {dry_run}")
print(f" Discord: {'enabled' if summary['discord_enabled'] == 'True' else 'disabled'}")
print(f" Telegram: {'enabled' if summary['telegram_enabled'] == 'True' else 'disabled'}")
print()
def validate_config() -> Settings | None:
"""Validate and load configuration.
Returns:
Settings instance if valid, None if invalid.
"""
try:
# Clear cache to force reload
clear_settings_cache()
return get_settings()
except ValidationError as e:
print("Configuration validation failed:", file=sys.stderr)
for error in e.errors():
field = ".".join(str(loc) for loc in error["loc"])
msg = error["msg"]
print(f" {field}: {msg}", file=sys.stderr)
return None
def run_config_check(settings: Settings) -> int:
"""Run configuration check and exit.
Args:
settings: Validated settings.
Returns:
Exit code (0 for success).
"""
print("Configuration is valid!")
print()
print_config_summary(settings, dry_run=False)
# Test component availability
print("Checking component availability...")
# Check Discord
if settings.discord.enabled:
print(" Discord: configured")
else:
print(" Discord: not configured")
# Check Telegram
if settings.telegram.enabled:
print(" Telegram: configured")
else:
print(" Telegram: not configured")
print()
print("All checks passed. Ready to run.")
return EXIT_SUCCESS
async def run_pipeline(
settings: Settings,
dry_run: bool,
shutdown_timeout: float = 30.0,
) -> int:
"""Run the main pipeline with graceful shutdown handling.
Args:
settings: Application settings.
dry_run: Whether to skip sending alerts.
shutdown_timeout: Maximum time to wait for graceful shutdown.
Returns:
Exit code.
"""
logger = logging.getLogger(__name__)
shutdown = GracefulShutdown(timeout=shutdown_timeout)
try:
async with shutdown:
pipeline = Pipeline(settings, dry_run=dry_run)
# Register pipeline cleanup
shutdown.register_cleanup(pipeline.stop)
logger.info("Starting pipeline...")
await pipeline.start()
logger.info("Pipeline running. Press Ctrl+C to stop.")
# Wait for shutdown signal
await shutdown.wait()
logger.info("Shutdown signal received, stopping pipeline...")
await pipeline.stop()
return EXIT_SUCCESS
except KeyboardInterrupt:
logger.info("Interrupted by user")
return EXIT_INTERRUPTED
except Exception as e:
logger.exception("Pipeline failed: %s", e)
return EXIT_ERROR
def main(argv: list[str] | None = None) -> NoReturn:
"""Main entry point for the CLI.
Args:
argv: Command line arguments (defaults to sys.argv[1:]).
"""
parser = create_parser()
args = parser.parse_args(argv)
# Validate configuration first
settings = validate_config()
if settings is None:
sys.exit(EXIT_CONFIG_ERROR)
# Determine effective log level
log_level = args.log_level or settings.log_level
configure_logging(log_level)
# Print banner
print_banner()
# Config check mode
if args.config_check:
sys.exit(run_config_check(settings))
# Determine dry-run mode
dry_run = args.dry_run or settings.dry_run
# Print config summary
print_config_summary(settings, dry_run)
# Run pipeline
exit_code = asyncio.run(run_pipeline(settings, dry_run))
sys.exit(exit_code)
if __name__ == "__main__":
main()